feat: integrations

This commit is contained in:
Philipinho
2026-02-22 20:14:33 +00:00
parent c172d3bd5e
commit aeb30ad096
49 changed files with 2780 additions and 4 deletions
+3
View File
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import { IntegrationModule } from './integration/integration.module';
@Module({
imports: [
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
ShareModule,
NotificationModule,
WatcherModule,
IntegrationModule,
],
})
export class CoreModule implements NestModule {
@@ -45,6 +47,7 @@ export class CoreModule implements NestModule {
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
)
.forRoutes('*');
}
@@ -0,0 +1,7 @@
export enum IntegrationType {
SLACK = 'slack',
GITHUB = 'github',
GITLAB = 'gitlab',
JIRA = 'jira',
LINEAR = 'linear',
}
@@ -0,0 +1,36 @@
import * as crypto from 'crypto';
function deriveEncryptionKey(appSecret: string): Buffer {
return crypto.createHash('sha256').update(appSecret).digest();
}
export function encryptToken(token: string, appSecret: string): string {
const algorithm = 'aes-256-gcm';
const key = deriveEncryptionKey(appSecret);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(token, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
export function decryptToken(encryptedToken: string, appSecret: string): string {
const algorithm = 'aes-256-gcm';
const key = deriveEncryptionKey(appSecret);
const parts = encryptedToken.split(':');
const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const slackSettingsSchema = z.object({
channelId: z.string().min(1),
channelName: z.string().optional(),
notifyOn: z
.array(z.enum(['page.created', 'page.updated', 'page.deleted']))
.default(['page.created']),
});
export const githubSettingsSchema = z.object({
baseUrl: z.string().url().optional(),
org: z.string().optional(),
defaultRepo: z.string().optional(),
});
export const gitlabSettingsSchema = z.object({
baseUrl: z.string().url().optional(),
group: z.string().optional(),
defaultProject: z.string().optional(),
});
export const jiraSettingsSchema = z.object({
baseUrl: z.string().url().optional(),
cloudId: z.string().optional(),
siteName: z.string().optional(),
});
export const linearSettingsSchema = z.object({
teamId: z.string().optional(),
});
const integrationSettingsSchemas: Record<string, z.ZodType> = {
slack: slackSettingsSchema,
github: githubSettingsSchema,
gitlab: gitlabSettingsSchema,
jira: jiraSettingsSchema,
linear: linearSettingsSchema,
};
export function validateIntegrationSettings(
type: string,
settings: unknown,
): { success: true; data: Record<string, any> } | { success: false; error: string } {
const schema = integrationSettingsSchemas[type];
if (!schema) {
if (settings && typeof settings === 'object') {
return { success: true, data: settings as Record<string, any> };
}
return { success: true, data: {} };
}
const result = schema.safeParse(settings);
if (!result.success) {
const messages = result.error.issues.map(
(i) => `${i.path.join('.')}: ${i.message}`,
);
return { success: false, error: messages.join(', ') };
}
return { success: true, data: result.data };
}
export type SlackSettings = z.infer<typeof slackSettingsSchema>;
export type GithubSettings = z.infer<typeof githubSettingsSchema>;
export type GitlabSettings = z.infer<typeof gitlabSettingsSchema>;
export type JiraSettings = z.infer<typeof jiraSettingsSchema>;
export type LinearSettings = z.infer<typeof linearSettingsSchema>;
@@ -0,0 +1,51 @@
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
export class InstallIntegrationDto {
@IsNotEmpty()
@IsString()
type: string;
}
export class UninstallIntegrationDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
export class UpdateIntegrationDto {
@IsNotEmpty()
@IsString()
integrationId: string;
@IsOptional()
@IsObject()
settings?: Record<string, any>;
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
}
export class IntegrationIdDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
export class UnfurlDto {
@IsNotEmpty()
@IsString()
url: string;
}
export class OAuthAuthorizeDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
export class OAuthDisconnectDto {
@IsNotEmpty()
@IsString()
integrationId: string;
}
@@ -0,0 +1,68 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
@Injectable()
export class IntegrationConnectionService {
constructor(
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly integrationRepo: IntegrationRepo,
) {}
async getConnectionStatus(
integrationId: string,
userId: string,
workspaceId: string,
): Promise<{ connected: boolean; providerUserId?: string }> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const connection = await this.connectionRepo.findByIntegrationAndUser(
integrationId,
userId,
);
return {
connected: !!connection,
providerUserId: connection?.providerUserId ?? undefined,
};
}
async findByIntegrationAndUser(
integrationId: string,
userId: string,
): Promise<IntegrationConnection | undefined> {
return this.connectionRepo.findByIntegrationAndUser(integrationId, userId);
}
async findByWorkspaceTypeAndUser(
workspaceId: string,
integrationType: string,
userId: string,
): Promise<IntegrationConnection | undefined> {
return this.connectionRepo.findByWorkspaceTypeAndUser(
workspaceId,
integrationType,
userId,
);
}
async disconnect(
integrationId: string,
userId: string,
workspaceId: string,
): Promise<void> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
await this.connectionRepo.deleteByIntegrationAndUser(
integrationId,
userId,
);
}
}
@@ -0,0 +1,133 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { IntegrationService } from './integration.service';
import { IntegrationConnectionService } from './integration-connection.service';
import {
InstallIntegrationDto,
UninstallIntegrationDto,
UpdateIntegrationDto,
IntegrationIdDto,
} from './dto/integration.dto';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
@Controller('integrations')
export class IntegrationController {
constructor(
private readonly integrationService: IntegrationService,
private readonly connectionService: IntegrationConnectionService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('available')
async getAvailableIntegrations() {
return this.integrationService.getAvailableIntegrations();
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('list')
async getInstalledIntegrations(
@AuthWorkspace() workspace: Workspace,
) {
return this.integrationService.getInstalledIntegrations(workspace.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('install')
async install(
@Body() dto: InstallIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
return this.integrationService.install(dto.type, workspace.id, user.id);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('uninstall')
async uninstall(
@Body() dto: UninstallIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
await this.integrationService.uninstall(dto.integrationId, workspace.id);
return { success: true };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() dto: UpdateIntegrationDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
return this.integrationService.update(dto.integrationId, workspace.id, {
settings: dto.settings,
isEnabled: dto.isEnabled,
});
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('connection/status')
async getConnectionStatus(
@Body() dto: IntegrationIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.connectionService.getConnectionStatus(
dto.integrationId,
user.id,
workspace.id,
);
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
import { EventName } from '../../common/events/event.contants';
@Injectable()
export class IntegrationListener {
constructor(
@InjectQueue(QueueName.INTEGRATION_QUEUE)
private readonly integrationQueue: Queue,
) {}
@OnEvent(EventName.PAGE_CREATED)
async onPageCreated(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
eventName: EventName.PAGE_CREATED,
...payload,
});
}
@OnEvent(EventName.PAGE_UPDATED)
async onPageUpdated(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
eventName: EventName.PAGE_UPDATED,
...payload,
});
}
@OnEvent(EventName.PAGE_DELETED)
async onPageDeleted(payload: any) {
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
eventName: EventName.PAGE_DELETED,
...payload,
});
}
}
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { IntegrationRegistry } from './registry/integration-registry';
import { IntegrationService } from './integration.service';
import { IntegrationConnectionService } from './integration-connection.service';
import { IntegrationController } from './integration.controller';
import { OAuthController } from './oauth/oauth.controller';
import { OAuthService } from './oauth/oauth.service';
import { UnfurlController } from './unfurl/unfurl.controller';
import { UnfurlService } from './unfurl/unfurl.service';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
import { IntegrationListener } from './integration.listener';
import { IntegrationProcessor } from './integration.processor';
@Module({
controllers: [IntegrationController, OAuthController, UnfurlController],
providers: [
IntegrationRegistry,
IntegrationService,
IntegrationConnectionService,
OAuthService,
UnfurlService,
IntegrationRepo,
IntegrationConnectionRepo,
IntegrationWebhookRepo,
IntegrationListener,
IntegrationProcessor,
],
exports: [
IntegrationRegistry,
IntegrationService,
IntegrationConnectionService,
OAuthService,
IntegrationRepo,
IntegrationConnectionRepo,
],
})
export class IntegrationModule {}
@@ -0,0 +1,80 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
import { IntegrationRegistry } from './registry/integration-registry';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
import { OAuthService } from './oauth/oauth.service';
@Processor(QueueName.INTEGRATION_QUEUE)
export class IntegrationProcessor extends WorkerHost {
private readonly logger = new Logger(IntegrationProcessor.name);
constructor(
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly oauthService: OAuthService,
) {
super();
}
async process(job: Job): Promise<void> {
switch (job.name) {
case QueueJob.INTEGRATION_EVENT:
await this.handleIntegrationEvent(job);
break;
default:
this.logger.warn(`Unknown job: ${job.name}`);
}
}
private async handleIntegrationEvent(job: Job): Promise<void> {
const { eventName, workspaceId, ...payload } = job.data;
if (!workspaceId) {
return;
}
const integrations =
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
for (const integration of integrations) {
const provider = this.registry.getProvider(integration.type);
if (!provider?.handleEvent) {
continue;
}
try {
const connections = await this.connectionRepo.findByIntegration(
integration.id,
);
const connection = connections[0];
let accessToken: string | undefined;
if (connection) {
accessToken = await this.oauthService.getValidAccessToken(connection);
}
await provider.handleEvent({
eventName,
payload,
integration: {
id: integration.id,
type: integration.type,
settings: integration.settings as Record<string, any> | null,
},
connection: connection
? { accessToken, userId: connection.userId }
: undefined,
});
} catch (err) {
this.logger.error(
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
);
}
}
}
}
@@ -0,0 +1,91 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { IntegrationRepo } from './repos/integration.repo';
import { IntegrationRegistry } from './registry/integration-registry';
import { Integration } from '@docmost/db/types/entity.types';
import { validateIntegrationSettings } from './dto/integration-settings.schema';
@Injectable()
export class IntegrationService {
constructor(
private readonly integrationRepo: IntegrationRepo,
private readonly registry: IntegrationRegistry,
) {}
async getAvailableIntegrations() {
return this.registry.getAvailableIntegrations();
}
async getInstalledIntegrations(workspaceId: string): Promise<Integration[]> {
return this.integrationRepo.findAllByWorkspace(workspaceId);
}
async findById(integrationId: string): Promise<Integration | undefined> {
return this.integrationRepo.findById(integrationId);
}
async install(
type: string,
workspaceId: string,
userId: string,
): Promise<Integration> {
const provider = this.registry.getProvider(type);
if (!provider) {
throw new BadRequestException(`Unknown integration type: ${type}`);
}
const existing = await this.integrationRepo.findByWorkspaceAndType(
workspaceId,
type,
);
if (existing) {
throw new BadRequestException(
`Integration "${type}" is already installed`,
);
}
return this.integrationRepo.insertOrRestore({
type,
workspaceId,
installedById: userId,
});
}
async uninstall(integrationId: string, workspaceId: string): Promise<void> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
await this.integrationRepo.softDelete(integrationId);
}
async update(
integrationId: string,
workspaceId: string,
data: { settings?: Record<string, any>; isEnabled?: boolean },
): Promise<Integration> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
if (data.settings !== undefined) {
const validation = validateIntegrationSettings(
integration.type,
data.settings,
);
if (validation.success === false) {
throw new BadRequestException(`Invalid settings: ${validation.error}`);
}
data.settings = validation.data;
}
return this.integrationRepo.update(integrationId, {
...(data.settings !== undefined && { settings: data.settings }),
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
});
}
}
@@ -0,0 +1,101 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { OAuthService } from './oauth.service';
import { OAuthAuthorizeDto, OAuthDisconnectDto } from '../dto/integration.dto';
import { IntegrationConnectionService } from '../integration-connection.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Controller('integrations/oauth')
export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
constructor(
private readonly oauthService: OAuthService,
private readonly connectionService: IntegrationConnectionService,
private readonly environmentService: EnvironmentService,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('authorize')
async authorize(
@Body() dto: OAuthAuthorizeDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const { authorizationUrl } = await this.oauthService.getAuthorizationUrl(
dto.integrationId,
workspace.id,
user.id,
);
return { authorizationUrl };
}
@Get(':type/callback')
async callback(
@Param('type') type: string,
@Query('code') code: string,
@Query('state') state: string,
@Res() res: FastifyReply,
) {
if (!code || !state) {
throw new BadRequestException('Missing code or state parameter');
}
const statePayload = this.oauthService.verifySignedState(state);
if (!statePayload) {
throw new BadRequestException('Invalid or expired OAuth state');
}
try {
await this.oauthService.exchangeCodeForTokens(
type,
code,
statePayload.integrationId,
statePayload.userId,
statePayload.workspaceId,
);
const appUrl = this.environmentService.getAppUrl();
return res.redirect(`${appUrl}/settings/integrations`);
} catch (err) {
this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`);
const appUrl = this.environmentService.getAppUrl();
return res.redirect(`${appUrl}/settings/integrations?error=oauth_failed`);
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('disconnect')
async disconnect(
@Body() dto: OAuthDisconnectDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
await this.connectionService.disconnect(
dto.integrationId,
user.id,
workspace.id,
);
return { success: true };
}
}
@@ -0,0 +1,321 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { IntegrationRegistry } from '../registry/integration-registry';
import { IntegrationRepo } from '../repos/integration.repo';
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
import { encryptToken, decryptToken } from '../crypto/token-crypto';
import { IntegrationConnection } from '@docmost/db/types/entity.types';
import { OAuthConfig } from '../registry/integration-provider.interface';
import * as crypto from 'crypto';
type OAuthTokenResponse = {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
scope?: string;
};
export type OAuthStatePayload = {
integrationId: string;
userId: string;
workspaceId: string;
exp: number;
};
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
private readonly environmentService: EnvironmentService,
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
) {}
async getAuthorizationUrl(
integrationId: string,
workspaceId: string,
userId: string,
): Promise<{ authorizationUrl: string }> {
const integration = await this.integrationRepo.findById(integrationId);
if (!integration || integration.workspaceId !== workspaceId) {
throw new NotFoundException('Integration not found');
}
const provider = this.registry.getProvider(integration.type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
: provider.definition.oauth;
const callbackUrl = this.buildCallbackUrl(integration.type);
const state = this.createSignedState({
integrationId,
userId,
workspaceId,
exp: Date.now() + 10 * 60 * 1000,
});
const params = new URLSearchParams({
client_id: this.getClientId(integration.type),
redirect_uri: callbackUrl,
response_type: 'code',
state,
});
const scope = oauthConfig.scopes
.map((s) => encodeURIComponent(s))
.join('%20');
return {
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
};
}
verifySignedState(state: string): OAuthStatePayload | null {
const dotIndex = state.lastIndexOf('.');
if (dotIndex === -1) return null;
const data = state.substring(0, dotIndex);
const signature = state.substring(dotIndex + 1);
const secret = this.environmentService.getAppSecret();
const expected = crypto
.createHmac('sha256', secret)
.update(data)
.digest('base64url');
if (signature !== expected) return null;
try {
const payload: OAuthStatePayload = JSON.parse(
Buffer.from(data, 'base64url').toString(),
);
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
async exchangeCodeForTokens(
type: string,
code: string,
integrationId: string,
userId: string,
workspaceId: string,
): Promise<IntegrationConnection> {
const provider = this.registry.getProvider(type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const integration = await this.integrationRepo.findById(integrationId);
const settings = (integration?.settings as Record<string, any>) ?? {};
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig(settings)
: provider.definition.oauth;
const tokenResponse = await this.requestTokens(
oauthConfig,
type,
code,
);
const appSecret = this.environmentService.getAppSecret();
const encryptedAccessToken = encryptToken(
tokenResponse.access_token,
appSecret,
);
const encryptedRefreshToken = tokenResponse.refresh_token
? encryptToken(tokenResponse.refresh_token, appSecret)
: null;
const tokenExpiresAt = tokenResponse.expires_in
? new Date(Date.now() + tokenResponse.expires_in * 1000)
: null;
const connection = await this.connectionRepo.upsert({
integrationId,
userId,
workspaceId,
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
scopes: tokenResponse.scope ?? null,
});
if (provider.onConnected) {
await provider.onConnected({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
providerUserId: '',
metadata: {},
});
}
return connection;
}
async getValidAccessToken(
connection: IntegrationConnection,
): Promise<string> {
const appSecret = this.environmentService.getAppSecret();
const accessToken = decryptToken(connection.accessToken, appSecret);
const needsRefresh =
connection.tokenExpiresAt &&
connection.refreshToken &&
new Date(connection.tokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000;
if (!needsRefresh) {
return accessToken;
}
return this.refreshAccessToken(connection);
}
private async refreshAccessToken(
connection: IntegrationConnection,
): Promise<string> {
const appSecret = this.environmentService.getAppSecret();
const refreshToken = decryptToken(connection.refreshToken, appSecret);
const integration = await this.integrationRepo.findById(
connection.integrationId,
);
if (!integration) {
throw new NotFoundException('Integration not found');
}
const provider = this.registry.getProvider(integration.type);
if (!provider || !provider.definition.oauth) {
throw new BadRequestException('Integration does not support OAuth');
}
const oauthConfig = provider.getOAuthConfig
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
: provider.definition.oauth;
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.getClientId(integration.type),
client_secret: this.getClientSecret(integration.type),
refresh_token: refreshToken,
});
try {
const response = await fetch(oauthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
});
if (!response.ok) {
this.logger.error(
`Token refresh failed for ${integration.type}: ${response.status}`,
);
throw new BadRequestException('Token refresh failed');
}
const data: OAuthTokenResponse = await response.json();
const encryptedAccessToken = encryptToken(data.access_token, appSecret);
const encryptedRefreshToken = data.refresh_token
? encryptToken(data.refresh_token, appSecret)
: connection.refreshToken;
const tokenExpiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000)
: null;
await this.connectionRepo.update(connection.id, {
accessToken: encryptedAccessToken,
refreshToken: encryptedRefreshToken,
tokenExpiresAt,
});
return data.access_token;
} catch (err) {
this.logger.error(`Token refresh error: ${(err as Error).message}`);
throw new BadRequestException('Failed to refresh token');
}
}
private async requestTokens(
oauthConfig: OAuthConfig,
type: string,
code: string,
): Promise<OAuthTokenResponse> {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.getClientId(type),
client_secret: this.getClientSecret(type),
code,
redirect_uri: this.buildCallbackUrl(type),
});
const response = await fetch(oauthConfig.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: params.toString(),
});
if (!response.ok) {
const body = await response.text();
this.logger.error(`Token exchange failed for ${type}: ${response.status} ${body}`);
throw new BadRequestException('OAuth token exchange failed');
}
return response.json();
}
buildCallbackUrl(type: string): string {
const appUrl = this.environmentService.getAppUrl();
return `${appUrl}/api/integrations/oauth/${type}/callback`;
}
private createSignedState(payload: OAuthStatePayload): string {
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const secret = this.environmentService.getAppSecret();
const signature = crypto
.createHmac('sha256', secret)
.update(data)
.digest('base64url');
return `${data}.${signature}`;
}
private getClientId(type: string): string {
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_ID`;
const value = process.env[envKey];
if (!value) {
throw new BadRequestException(
`Missing environment variable: ${envKey}`,
);
}
return value;
}
private getClientSecret(type: string): string {
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_SECRET`;
const value = process.env[envKey];
if (!value) {
throw new BadRequestException(
`Missing environment variable: ${envKey}`,
);
}
return value;
}
}
@@ -0,0 +1,81 @@
export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions' | 'webhooks';
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
};
export type UnfurlPattern = {
regex: RegExp;
type: string;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
unfurlPatterns?: UnfurlPattern[];
};
export type ConnectedEvent = {
accessToken: string;
refreshToken?: string;
providerUserId: string;
metadata: Record<string, any>;
};
export type HandleEventOpts = {
eventName: string;
payload: Record<string, any>;
integration: {
id: string;
type: string;
settings: Record<string, any> | null;
};
connection?: {
accessToken: string;
userId: string;
};
};
export type UnfurlOpts = {
url: string;
accessToken: string;
match: RegExpMatchArray;
patternType: string;
};
export abstract class IntegrationProvider {
abstract definition: IntegrationDefinition;
getOAuthConfig?(
workspaceSettings: Record<string, any>,
): OAuthConfig;
getUnfurlPatterns?(
workspaceSettings: Record<string, any>,
): UnfurlPattern[];
onConnected?(opts: ConnectedEvent): Promise<void>;
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
handleEvent?(opts: HandleEventOpts): Promise<void>;
}
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import {
IntegrationDefinition,
IntegrationProvider,
} from './integration-provider.interface';
@Injectable()
export class IntegrationRegistry {
private providers = new Map<string, IntegrationProvider>();
register(provider: IntegrationProvider): void {
this.providers.set(provider.definition.type, provider);
}
getProvider(type: string): IntegrationProvider | undefined {
return this.providers.get(type);
}
getAllProviders(): IntegrationProvider[] {
return Array.from(this.providers.values());
}
getAvailableIntegrations(): IntegrationDefinition[] {
return this.getAllProviders().map((p) => p.definition);
}
findUnfurlProvider(
url: string,
): {
provider: IntegrationProvider;
match: RegExpMatchArray;
patternType: string;
} | null {
for (const provider of this.providers.values()) {
if (!provider.definition.unfurlPatterns) continue;
for (const pattern of provider.definition.unfurlPatterns) {
const match = url.match(pattern.regex);
if (match) {
return { provider, match, patternType: pattern.type };
}
}
}
return null;
}
}
@@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
IntegrationConnection,
InsertableIntegrationConnection,
UpdatableIntegrationConnection,
} from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class IntegrationConnectionRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
connectionId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.selectAll()
.where('id', '=', connectionId)
.executeTakeFirst();
}
async findByIntegrationAndUser(
integrationId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.selectAll()
.where('integrationId', '=', integrationId)
.where('userId', '=', userId)
.executeTakeFirst();
}
async findByWorkspaceTypeAndUser(
workspaceId: string,
integrationType: string,
userId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.innerJoin(
'integrations',
'integrations.id',
'integrationConnections.integrationId',
)
.selectAll('integrationConnections')
.where('integrations.workspaceId', '=', workspaceId)
.where('integrations.type', '=', integrationType)
.where('integrations.deletedAt', 'is', null)
.where('integrationConnections.userId', '=', userId)
.executeTakeFirst();
}
async findByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<IntegrationConnection[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationConnections')
.selectAll()
.where('integrationId', '=', integrationId)
.execute();
}
async upsert(
connection: InsertableIntegrationConnection,
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrationConnections')
.values(connection)
.onConflict((oc) =>
oc.columns(['integrationId', 'userId']).doUpdateSet({
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
tokenExpiresAt: connection.tokenExpiresAt,
scopes: connection.scopes,
providerUserId: connection.providerUserId,
metadata: connection.metadata,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
}
async update(
connectionId: string,
data: UpdatableIntegrationConnection,
trx?: KyselyTransaction,
): Promise<IntegrationConnection> {
const db = dbOrTx(this.db, trx);
return db
.updateTable('integrationConnections')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', connectionId)
.returningAll()
.executeTakeFirstOrThrow();
}
async deleteByIntegrationAndUser(
integrationId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationConnections')
.where('integrationId', '=', integrationId)
.where('userId', '=', userId)
.execute();
}
async deleteByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationConnections')
.where('integrationId', '=', integrationId)
.execute();
}
}
@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
IntegrationWebhook,
InsertableIntegrationWebhook,
UpdatableIntegrationWebhook,
} from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class IntegrationWebhookRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
webhookId: string,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationWebhooks')
.selectAll()
.where('id', '=', webhookId)
.executeTakeFirst();
}
async findByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationWebhooks')
.selectAll()
.where('integrationId', '=', integrationId)
.execute();
}
async findEnabledByEvent(
workspaceId: string,
eventType: string,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrationWebhooks')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('eventType', '=', eventType)
.where('isEnabled', '=', true)
.execute();
}
async insert(
webhook: InsertableIntegrationWebhook,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrationWebhooks')
.values(webhook)
.returningAll()
.executeTakeFirstOrThrow();
}
async update(
webhookId: string,
data: UpdatableIntegrationWebhook,
trx?: KyselyTransaction,
): Promise<IntegrationWebhook> {
const db = dbOrTx(this.db, trx);
return db
.updateTable('integrationWebhooks')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', webhookId)
.returningAll()
.executeTakeFirstOrThrow();
}
async delete(
webhookId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationWebhooks')
.where('id', '=', webhookId)
.execute();
}
async deleteByIntegration(
integrationId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('integrationWebhooks')
.where('integrationId', '=', integrationId)
.execute();
}
}
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
Integration,
InsertableIntegration,
UpdatableIntegration,
} from '@docmost/db/types/entity.types';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class IntegrationRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
integrationId: string,
trx?: KyselyTransaction,
): Promise<Integration | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrations')
.selectAll()
.where('id', '=', integrationId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async findByWorkspaceAndType(
workspaceId: string,
type: string,
trx?: KyselyTransaction,
): Promise<Integration | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrations')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('type', '=', type)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async findEnabledByWorkspace(
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Integration[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrations')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('isEnabled', '=', true)
.where('deletedAt', 'is', null)
.execute();
}
async findAllByWorkspace(
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Integration[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('integrations')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.execute();
}
async insert(
integration: InsertableIntegration,
trx?: KyselyTransaction,
): Promise<Integration> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrations')
.values(integration)
.returningAll()
.executeTakeFirstOrThrow();
}
async insertOrRestore(
integration: InsertableIntegration,
trx?: KyselyTransaction,
): Promise<Integration> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('integrations')
.values(integration)
.onConflict((oc) =>
oc.columns(['type', 'workspaceId']).doUpdateSet({
deletedAt: null,
isEnabled: true,
installedById: integration.installedById,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirstOrThrow();
}
async update(
integrationId: string,
data: UpdatableIntegration,
trx?: KyselyTransaction,
): Promise<Integration> {
const db = dbOrTx(this.db, trx);
return db
.updateTable('integrations')
.set({ ...data, updatedAt: new Date() })
.where('id', '=', integrationId)
.returningAll()
.executeTakeFirstOrThrow();
}
async softDelete(
integrationId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('integrations')
.set({ deletedAt: new Date() })
.where('id', '=', integrationId)
.execute();
}
}
@@ -0,0 +1,35 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { UnfurlService } from './unfurl.service';
import { UnfurlDto } from '../dto/integration.dto';
@Controller('integrations')
export class UnfurlController {
constructor(private readonly unfurlService: UnfurlService) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('unfurl')
async unfurl(
@Body() dto: UnfurlDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const result = await this.unfurlService.unfurl(
dto.url,
user.id,
workspace.id,
);
return { data: result };
}
}
@@ -0,0 +1,129 @@
import { Injectable, Logger } from '@nestjs/common';
import { IntegrationRegistry } from '../registry/integration-registry';
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
import { IntegrationRepo } from '../repos/integration.repo';
import { OAuthService } from '../oauth/oauth.service';
import { UnfurlResult, IntegrationProvider } from '../registry/integration-provider.interface';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import * as crypto from 'crypto';
const UNFURL_CACHE_TTL = 300; // 5 minutes
const UNFURL_CACHE_PREFIX = 'unfurl:';
@Injectable()
export class UnfurlService {
private readonly logger = new Logger(UnfurlService.name);
private readonly redis: Redis;
constructor(
private readonly registry: IntegrationRegistry,
private readonly integrationRepo: IntegrationRepo,
private readonly connectionRepo: IntegrationConnectionRepo,
private readonly oauthService: OAuthService,
private readonly redisService: RedisService,
) {
this.redis = this.redisService.getOrThrow();
}
async unfurl(
url: string,
userId: string,
workspaceId: string,
): Promise<UnfurlResult | null> {
const cacheKey = this.buildCacheKey(workspaceId, url);
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const resolved = await this.resolveProvider(url, workspaceId);
if (!resolved) {
return null;
}
const { provider, match, patternType, integration } = resolved;
if (!provider.unfurl) {
return null;
}
const connection = await this.connectionRepo.findByIntegrationAndUser(
integration.id,
userId,
);
if (!connection) {
return null;
}
try {
const accessToken = await this.oauthService.getValidAccessToken(connection);
const unfurlResult = await provider.unfurl({
url,
accessToken,
match,
patternType,
});
await this.redis.set(
cacheKey,
JSON.stringify(unfurlResult),
'EX',
UNFURL_CACHE_TTL,
);
return unfurlResult;
} catch (err) {
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
return null;
}
}
private async resolveProvider(
url: string,
workspaceId: string,
): Promise<{
provider: IntegrationProvider;
match: RegExpMatchArray;
patternType: string;
integration: { id: string; isEnabled: boolean; type: string };
} | null> {
const staticResult = this.registry.findUnfurlProvider(url);
if (staticResult) {
const integration = await this.integrationRepo.findByWorkspaceAndType(
workspaceId,
staticResult.provider.definition.type,
);
if (integration && integration.isEnabled) {
return { ...staticResult, integration };
}
}
const integrations =
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
for (const integration of integrations) {
const provider = this.registry.getProvider(integration.type);
if (!provider?.getUnfurlPatterns || !provider.unfurl) continue;
const settings = (integration.settings as Record<string, any>) ?? {};
const patterns = provider.getUnfurlPatterns(settings);
for (const pattern of patterns) {
const match = url.match(pattern.regex);
if (match) {
return { provider, match, patternType: pattern.type, integration };
}
}
}
return null;
}
private buildCacheKey(workspaceId: string, url: string): string {
const hash = crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
return `${UNFURL_CACHE_PREFIX}${workspaceId}:${hash}`;
}
}
@@ -0,0 +1,97 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('integrations')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('settings', 'jsonb')
.addColumn('installed_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz')
.addUniqueConstraint('uq_integrations_workspace_type', [
'workspace_id',
'type',
])
.execute();
await db.schema
.createTable('integration_connections')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('integration_id', 'uuid', (col) =>
col.references('integrations.id').onDelete('cascade').notNull(),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('provider_user_id', 'text')
.addColumn('access_token', 'text', (col) => col.notNull())
.addColumn('refresh_token', 'text')
.addColumn('token_expires_at', 'timestamptz')
.addColumn('scopes', 'text')
.addColumn('metadata', 'jsonb')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('uq_integration_connections_integration_user', [
'integration_id',
'user_id',
])
.execute();
await db.schema
.createTable('integration_webhooks')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('integration_id', 'uuid', (col) =>
col.references('integrations.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('event_type', 'text', (col) => col.notNull())
.addColumn('webhook_url', 'text')
.addColumn('secret', 'text')
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_integration_webhooks_integration_event')
.on('integration_webhooks')
.columns(['integration_id', 'event_type'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('integration_webhooks').execute();
await db.schema.dropTable('integration_connections').execute();
await db.schema.dropTable('integrations').execute();
}
+42
View File
@@ -390,6 +390,45 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface Integrations {
id: Generated<string>;
workspaceId: string;
type: string;
isEnabled: Generated<boolean>;
settings: Json | null;
installedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface IntegrationConnections {
id: Generated<string>;
integrationId: string;
userId: string;
workspaceId: string;
providerUserId: string | null;
accessToken: string;
refreshToken: string | null;
tokenExpiresAt: Timestamp | null;
scopes: string | null;
metadata: Json | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface IntegrationWebhooks {
id: Generated<string>;
integrationId: string;
workspaceId: string;
eventType: string;
webhookUrl: string | null;
secret: string | null;
isEnabled: Generated<boolean>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -401,6 +440,9 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
integrationConnections: IntegrationConnections;
integrationWebhooks: IntegrationWebhooks;
integrations: Integrations;
notifications: Notifications;
pageHistory: PageHistory;
pages: Pages;
@@ -1,6 +1,14 @@
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import {
Integrations,
IntegrationConnections,
IntegrationWebhooks,
} from '@docmost/db/types/db';
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
integrations: Integrations;
integrationConnections: IntegrationConnections;
integrationWebhooks: IntegrationWebhooks;
}
@@ -3,6 +3,9 @@ import {
Attachments,
Comments,
Groups,
Integrations as _Integrations,
IntegrationConnections as _IntegrationConnections,
IntegrationWebhooks as _IntegrationWebhooks,
Notifications,
Pages,
Spaces,
@@ -143,3 +146,23 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Integration
export type Integration = Selectable<_Integrations>;
export type InsertableIntegration = Insertable<_Integrations>;
export type UpdatableIntegration = Updateable<Omit<_Integrations, 'id'>>;
// Integration Connection
export type IntegrationConnection = Selectable<_IntegrationConnections>;
export type InsertableIntegrationConnection =
Insertable<_IntegrationConnections>;
export type UpdatableIntegrationConnection = Updateable<
Omit<_IntegrationConnections, 'id'>
>;
// Integration Webhook
export type IntegrationWebhook = Selectable<_IntegrationWebhooks>;
export type InsertableIntegrationWebhook = Insertable<_IntegrationWebhooks>;
export type UpdatableIntegrationWebhook = Updateable<
Omit<_IntegrationWebhooks, 'id'>
>;
@@ -8,6 +8,7 @@ export enum QueueName {
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}',
INTEGRATION_QUEUE = '{integration-queue}',
}
export enum QueueJob {
@@ -67,4 +68,6 @@ export enum QueueJob {
COMMENT_NOTIFICATION = 'comment-notification',
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
INTEGRATION_EVENT = 'integration-event',
}
@@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.INTEGRATION_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: { count: 50 },
attempts: 3,
},
}),
],
exports: [BullModule],
providers: [GeneralQueueProcessor],
+1
View File
@@ -67,6 +67,7 @@ async function bootstrap() {
'/api/sso/google',
'/api/workspace/create',
'/api/workspace/joined',
'/api/integrations/oauth'
];
if (