From 63c1241125fdb89ae17bdadf6b7a9ba90ff73067 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 15 May 2026 01:36:05 +0100 Subject: [PATCH] feat(webhooks): scaffold feature flag, CASL subject, queue, and migration --- apps/client/src/ee/features.ts | 1 + apps/server/src/common/features.ts | 1 + .../abilities/workspace-ability.factory.ts | 2 + .../casl/interfaces/workspace-ability.type.ts | 4 +- .../migrations/20260515T120000-webhooks.ts | 93 +++++++++++++++++++ apps/server/src/database/types/db.d.ts | 33 +++++++ .../server/src/database/types/entity.types.ts | 12 +++ apps/server/src/ee | 2 +- .../queue/constants/queue.constants.ts | 4 + .../src/integrations/queue/queue.module.ts | 12 +++ 10 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/database/migrations/20260515T120000-webhooks.ts diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index cacf851f2..b219ae035 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -19,4 +19,5 @@ export const Feature = { SHARING_CONTROLS: 'sharing:controls', TEMPLATES: 'templates', VIEWER_COMMENTS: 'comment:viewer', + WEBHOOKS: 'webhooks', } as const; diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index c5fd9a200..fab70e5e5 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -20,6 +20,7 @@ export const Feature = { VIEWER_COMMENTS: 'comment:viewer', TEMPLATES: 'templates', PDF_EXPORT: 'export:pdf', + WEBHOOKS: 'webhooks', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts index 683cf4b63..7d5ca6c24 100644 --- a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts @@ -42,6 +42,7 @@ function buildWorkspaceOwnerAbility() { can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit); + can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook); return build(); } @@ -58,6 +59,7 @@ function buildWorkspaceAdminAbility() { can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment); can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API); + can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Webhook); return build(); } diff --git a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts index 896b5338f..086394f7f 100644 --- a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts +++ b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts @@ -13,6 +13,7 @@ export enum WorkspaceCaslSubject { Attachment = 'attachment', API = 'api_key', Audit = 'audit', + Webhook = 'webhook', } export type IWorkspaceAbility = @@ -22,4 +23,5 @@ export type IWorkspaceAbility = | [WorkspaceCaslAction, WorkspaceCaslSubject.Group] | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment] | [WorkspaceCaslAction, WorkspaceCaslSubject.API] - | [WorkspaceCaslAction, WorkspaceCaslSubject.Audit]; + | [WorkspaceCaslAction, WorkspaceCaslSubject.Audit] + | [WorkspaceCaslAction, WorkspaceCaslSubject.Webhook]; diff --git a/apps/server/src/database/migrations/20260515T120000-webhooks.ts b/apps/server/src/database/migrations/20260515T120000-webhooks.ts new file mode 100644 index 000000000..e021f4f71 --- /dev/null +++ b/apps/server/src/database/migrations/20260515T120000-webhooks.ts @@ -0,0 +1,93 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('webhooks') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('name', 'varchar', (col) => col.notNull()) + .addColumn('url', 'text', (col) => col.notNull()) + .addColumn('signing_secret', 'text', (col) => col.notNull()) + .addColumn('subscribed_events', 'jsonb', (col) => + col.notNull().defaultTo(sql`'[]'::jsonb`), + ) + .addColumn('is_active', 'boolean', (col) => + col.notNull().defaultTo(true), + ) + .addColumn('consecutive_failure_count', 'integer', (col) => + col.notNull().defaultTo(0), + ) + .addColumn('disabled_at', 'timestamptz') + .addColumn('creator_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()`), + ) + .execute(); + + await db.schema + .createIndex('idx_webhooks_workspace_id') + .ifNotExists() + .on('webhooks') + .columns(['workspace_id', 'id desc']) + .execute(); + + await db.schema + .createIndex('idx_webhooks_subscribed_events') + .ifNotExists() + .on('webhooks') + .using('gin') + .column('subscribed_events') + .execute(); + + await db.schema + .createTable('webhook_deliveries') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('webhook_id', 'uuid', (col) => + col.notNull().references('webhooks.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('event', 'varchar', (col) => col.notNull()) + .addColumn('payload', 'jsonb', (col) => col.notNull()) + .addColumn('status', 'varchar', (col) => + col.notNull().defaultTo('pending'), + ) + .addColumn('http_status', 'integer') + .addColumn('response_body', 'text') + .addColumn('error_message', 'text') + .addColumn('attempt_count', 'integer', (col) => + col.notNull().defaultTo(0), + ) + .addColumn('duration_ms', 'integer') + .addColumn('delivered_at', 'timestamptz') + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('idx_webhook_deliveries_webhook_id') + .ifNotExists() + .on('webhook_deliveries') + .columns(['webhook_id', 'id desc']) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('webhook_deliveries').execute(); + await db.schema.dropTable('webhooks').execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 4463aa7aa..d4a357289 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -589,6 +589,37 @@ export interface UserSessions { createdAt: Generated; } +export interface Webhooks { + id: Generated; + workspaceId: string; + name: string; + url: string; + signingSecret: string; + subscribedEvents: Generated; + isActive: Generated; + consecutiveFailureCount: Generated; + disabledAt: Timestamp | null; + creatorId: string | null; + createdAt: Generated; + updatedAt: Generated; +} + +export interface WebhookDeliveries { + id: Generated; + webhookId: string; + workspaceId: string; + event: string; + payload: Json; + status: Generated; + httpStatus: number | null; + responseBody: string | null; + errorMessage: string | null; + attemptCount: Generated; + durationMs: number | null; + deliveredAt: Timestamp | null; + createdAt: Generated; +} + export interface DB { aiChats: AiChats; aiChatMessages: AiChatMessages; @@ -625,6 +656,8 @@ export interface DB { userSessions: UserSessions; userTokens: UserTokens; watchers: Watchers; + webhooks: Webhooks; + webhookDeliveries: WebhookDeliveries; workspaceInvitations: WorkspaceInvitations; workspaces: Workspaces; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 88594281c..96a3939b6 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -37,6 +37,8 @@ import { Watchers, Audit as _Audit, Templates, + Webhooks, + WebhookDeliveries, } from './db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; @@ -238,3 +240,13 @@ export type UpdatableAudit = Updateable>; export type Template = Selectable; export type InsertableTemplate = Insertable; export type UpdatableTemplate = Updateable>; + +// Webhook +export type Webhook = Selectable; +export type InsertableWebhook = Insertable; +export type UpdatableWebhook = Updateable>; + +// Webhook delivery +export type WebhookDelivery = Selectable; +export type InsertableWebhookDelivery = Insertable; +export type UpdatableWebhookDelivery = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index b30e92f6a..0e782be55 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit b30e92f6a024b2b1106a8243e5a313122c4b0712 +Subproject commit 0e782be55dd4d7b3697eeeb2324f09b958f58ec5 diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index c783ec05e..df2146c3c 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -9,6 +9,7 @@ export enum QueueName { HISTORY_QUEUE = '{history-queue}', NOTIFICATION_QUEUE = '{notification-queue}', AUDIT_QUEUE = '{audit-queue}', + WEBHOOK_QUEUE = '{webhook-queue}', } export enum QueueJob { @@ -83,4 +84,7 @@ export enum QueueJob { PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', + + WEBHOOK_DELIVERY = 'webhook-delivery', + WEBHOOK_DELIVERY_CLEANUP = 'webhook-delivery-cleanup', } diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index a7b83c3f9..3b1ee5b95 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -92,6 +92,18 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; attempts: 3, }, }), + BullModule.registerQueue({ + name: QueueName.WEBHOOK_QUEUE, + defaultJobOptions: { + attempts: 5, + backoff: { + type: 'exponential', + delay: 10 * 1000, + }, + removeOnComplete: { count: 200 }, + removeOnFail: { count: 200 }, + }, + }), ], exports: [BullModule], providers: [GeneralQueueProcessor],