token refresh, and filter inactive connections

This commit is contained in:
Philipinho
2026-02-23 02:48:42 +00:00
parent 5c3e715a10
commit 8a0217527f
6 changed files with 70 additions and 17 deletions
@@ -6,8 +6,7 @@ import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = { type ConnectionRowProps = {
definition: IntegrationDefinition; definition: IntegrationDefinition;
connection?: UserConnection; connection?: UserConnection;
installed: boolean; onConnect: (type: string) => void;
onConnect: (integrationId: string) => void;
onDisconnect: (integrationId: string) => void; onDisconnect: (integrationId: string) => void;
isDisconnecting?: boolean; isDisconnecting?: boolean;
}; };
@@ -15,14 +14,11 @@ type ConnectionRowProps = {
export default function ConnectionRow({ export default function ConnectionRow({
definition, definition,
connection, connection,
installed,
onConnect, onConnect,
onDisconnect, onDisconnect,
isDisconnecting, isDisconnecting,
}: ConnectionRowProps) { }: ConnectionRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isConnected = !!connection;
const isAvailable = installed && (connection?.isEnabled ?? true);
return ( return (
<Box <Box
@@ -30,7 +26,6 @@ export default function ConnectionRow({
px="xs" px="xs"
style={{ style={{
borderBottom: "1px solid var(--mantine-color-default-border)", borderBottom: "1px solid var(--mantine-color-default-border)",
opacity: isAvailable || isConnected ? 1 : 0.5,
}} }}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
@@ -47,7 +42,7 @@ export default function ConnectionRow({
</Group> </Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}> <Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isConnected ? ( {connection ? (
<> <>
<Text size="xs" c="green"> <Text size="xs" c="green">
{t("Connected")} {t("Connected")}
@@ -63,7 +58,7 @@ export default function ConnectionRow({
{t("Disconnect")} {t("Disconnect")}
</Button> </Button>
</> </>
) : isAvailable ? ( ) : (
<Button <Button
size="xs" size="xs"
variant="light" variant="light"
@@ -71,10 +66,6 @@ export default function ConnectionRow({
> >
{t("Connect")} {t("Connect")}
</Button> </Button>
) : (
<Text size="xs" c="dimmed">
{t("Not available")}
</Text>
)} )}
</Group> </Group>
</Group> </Group>
@@ -80,9 +80,12 @@ export default function Connections() {
) : ( ) : (
<Stack gap={0}> <Stack gap={0}>
{available {available
.filter((def) => def.capabilities.includes("oauth")) .filter((def) => {
.map((def) => { if (!def.capabilities.includes("oauth")) return false;
const installation = installed?.find((i) => i.type === def.type); const installation = installed?.find((i) => i.type === def.type);
return installation?.isEnabled;
})
.map((def) => {
const connection = myConnections?.find( const connection = myConnections?.find(
(c) => c.type === def.type, (c) => c.type === def.type,
); );
@@ -92,7 +95,6 @@ export default function Connections() {
key={def.type} key={def.type}
definition={def} definition={def}
connection={connection} connection={connection}
installed={!!installation}
onConnect={handleConnect} onConnect={handleConnect}
onDisconnect={handleDisconnect} onDisconnect={handleDisconnect}
isDisconnecting={disconnectMutation.isPending} isDisconnecting={disconnectMutation.isPending}
@@ -1,17 +1,34 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
import { EventName } from '../../common/events/event.contants'; import { EventName } from '../../common/events/event.contants';
const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler';
const TOKEN_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
@Injectable() @Injectable()
export class IntegrationListener { export class IntegrationListener implements OnApplicationBootstrap {
private readonly logger = new Logger(IntegrationListener.name);
constructor( constructor(
@InjectQueue(QueueName.INTEGRATION_QUEUE) @InjectQueue(QueueName.INTEGRATION_QUEUE)
private readonly integrationQueue: Queue, private readonly integrationQueue: Queue,
) {} ) {}
async onApplicationBootstrap() {
await this.integrationQueue.upsertJobScheduler(
TOKEN_REFRESH_SCHEDULER_ID,
{ every: TOKEN_REFRESH_INTERVAL_MS },
{
name: QueueJob.INTEGRATION_TOKEN_REFRESH,
data: {},
},
);
this.logger.debug('Integration token refresh scheduler created');
}
@OnEvent(EventName.PAGE_CREATED) @OnEvent(EventName.PAGE_CREATED)
async onPageCreated(payload: any) { async onPageCreated(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, { await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
@@ -7,6 +7,8 @@ import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo'; import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { OAuthService } from './oauth/oauth.service'; import { OAuthService } from './oauth/oauth.service';
const TOKEN_REFRESH_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
@Processor(QueueName.INTEGRATION_QUEUE) @Processor(QueueName.INTEGRATION_QUEUE)
export class IntegrationProcessor extends WorkerHost { export class IntegrationProcessor extends WorkerHost {
private readonly logger = new Logger(IntegrationProcessor.name); private readonly logger = new Logger(IntegrationProcessor.name);
@@ -25,11 +27,38 @@ export class IntegrationProcessor extends WorkerHost {
case QueueJob.INTEGRATION_EVENT: case QueueJob.INTEGRATION_EVENT:
await this.handleIntegrationEvent(job); await this.handleIntegrationEvent(job);
break; break;
case QueueJob.INTEGRATION_TOKEN_REFRESH:
await this.handleTokenRefresh();
break;
default: default:
this.logger.warn(`Unknown job: ${job.name}`); this.logger.warn(`Unknown job: ${job.name}`);
} }
} }
private async handleTokenRefresh(): Promise<void> {
const connections = await this.connectionRepo.findExpiringTokens(
TOKEN_REFRESH_WINDOW_MS,
);
if (connections.length === 0) {
return;
}
this.logger.log(
`Refreshing tokens for ${connections.length} connection(s)`,
);
for (const connection of connections) {
try {
await this.oauthService.getValidAccessToken(connection);
} catch (err) {
this.logger.error(
`Token refresh failed for connection ${connection.id}: ${(err as Error).message}`,
);
}
}
}
private async handleIntegrationEvent(job: Job): Promise<void> { private async handleIntegrationEvent(job: Job): Promise<void> {
const { eventName, workspaceId, ...payload } = job.data; const { eventName, workspaceId, ...payload } = job.data;
@@ -148,6 +148,19 @@ export class IntegrationConnectionRepo {
.execute(); .execute();
} }
async findExpiringTokens(
expiresBeforeMs: number,
): Promise<IntegrationConnection[]> {
const threshold = new Date(Date.now() + expiresBeforeMs);
return this.db
.selectFrom('integrationConnections')
.selectAll()
.where('refreshToken', 'is not', null)
.where('tokenExpiresAt', 'is not', null)
.where('tokenExpiresAt', '<', threshold)
.execute();
}
async deleteByIntegration( async deleteByIntegration(
integrationId: string, integrationId: string,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -70,4 +70,5 @@ export enum QueueJob {
PAGE_MENTION_NOTIFICATION = 'page-mention-notification', PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
INTEGRATION_EVENT = 'integration-event', INTEGRATION_EVENT = 'integration-event',
INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh',
} }