From 33d2f23955828637e29547ccca269f6f979d8c97 Mon Sep 17 00:00:00 2001 From: Nick Meinhold Date: Tue, 17 Feb 2026 20:23:00 +0700 Subject: [PATCH] feat(db): add webhook schema, migration, and repository (#391) * feat(db): add webhook schema, migration, and repository Add the database foundation for workspace webhooks: - Add workspace_webhooks table with migration (webhook_event enum, URL, secret, event subscriptions, active flag) - Add webhook repository with CRUD operations - Add webhooks schema definition with relations - Extend card and list repos to return board/list names for webhook payload context Co-Authored-By: Claude Opus 4.6 * fix(db): remove unused webhook_event enum and fix migration timestamp - Remove dead webhook_event pgEnum from schema (events column uses text) - Remove CREATE TYPE statement from migration SQL - Fix migration journal timestamp to be chronologically after the notifications migration (idx 25) Co-Authored-By: Claude Opus 4.6 * feat(db): return boardPublicId from card and list repo queries Add board publicId to getWorkspaceAndCardIdByCardPublicId and getWorkspaceAndListIdByListPublicId return values, needed for correct boardId in webhook payloads. Co-Authored-By: Claude Opus 4.6 * fix(db): add workspaceId index and document secret exposure - Add index on workspaceId for efficient webhook lookups per workspace - Add JSDoc comment on getActiveByWorkspaceId explaining that it returns secrets for server-side HMAC signing only and must never be exposed via client-facing endpoints Co-Authored-By: Claude Opus 4.6 * refactor(db): extract parseEvents helper in webhook repo DRY up 6 repeated JSON.parse-and-cast calls into a single helper function, addressing reviewer feedback on PR #391. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../20260129210000_AddWorkspaceWebhooks.sql | 28 +++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/repository/card.repo.ts | 7 +- packages/db/src/repository/list.repo.ts | 7 +- packages/db/src/repository/webhook.repo.ts | 175 ++++++++++++++++++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/webhooks.ts | 59 ++++++ 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql create mode 100644 packages/db/src/repository/webhook.repo.ts create mode 100644 packages/db/src/schema/webhooks.ts diff --git a/packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql b/packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql new file mode 100644 index 00000000..db8e7820 --- /dev/null +++ b/packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS "workspace_webhooks" ( + "id" bigserial PRIMARY KEY NOT NULL, + "publicId" varchar(12) NOT NULL, + "workspaceId" bigint NOT NULL, + "name" varchar(255) NOT NULL, + "url" varchar(2048) NOT NULL, + "secret" text, + "events" text NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "createdBy" uuid NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp, + CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId") +); +--> statement-breakpoint +ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 7f49b7fc..4983e4e3 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1770521594167, "tag": "20260208033314_AddAttachmentToActivity", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1771192000000, + "tag": "20260129210000_AddWorkspaceWebhooks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 5dd08580..431d00ec 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -945,12 +945,14 @@ export const getWorkspaceAndCardIdByCardPublicId = async ( where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)), with: { list: { - columns: {}, + columns: { name: true }, with: { board: { columns: { + publicId: true, workspaceId: true, visibility: true, + name: true, }, }, }, @@ -964,6 +966,9 @@ export const getWorkspaceAndCardIdByCardPublicId = async ( createdBy: result.createdBy, workspaceId: result.list.board.workspaceId, workspaceVisibility: result.list.board.visibility, + listName: result.list.name, + boardPublicId: result.list.board.publicId, + boardName: result.list.board.name, } : null; }; diff --git a/packages/db/src/repository/list.repo.ts b/packages/db/src/repository/list.repo.ts index 0afc3973..1b5249c9 100644 --- a/packages/db/src/repository/list.repo.ts +++ b/packages/db/src/repository/list.repo.ts @@ -419,12 +419,14 @@ export const getWorkspaceAndListIdByListPublicId = async ( listPublicId: string, ) => { const result = await db.query.lists.findFirst({ - columns: { id: true, createdBy: true }, + columns: { id: true, name: true, createdBy: true }, where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)), with: { board: { columns: { + publicId: true, workspaceId: true, + name: true, }, }, }, @@ -433,8 +435,11 @@ export const getWorkspaceAndListIdByListPublicId = async ( return result ? { id: result.id, + name: result.name, createdBy: result.createdBy, workspaceId: result.board.workspaceId, + boardPublicId: result.board.publicId, + boardName: result.board.name, } : null; }; diff --git a/packages/db/src/repository/webhook.repo.ts b/packages/db/src/repository/webhook.repo.ts new file mode 100644 index 00000000..2720b966 --- /dev/null +++ b/packages/db/src/repository/webhook.repo.ts @@ -0,0 +1,175 @@ +import { and, eq } from "drizzle-orm"; + +import type { dbClient } from "@kan/db/client"; +import { workspaceWebhooks } from "@kan/db/schema"; +import { generateUID } from "@kan/shared/utils"; + +import type { WebhookEvent } from "../schema/webhooks"; + +/** Parse JSON-encoded events column into typed array */ +function parseEvents(raw: string): WebhookEvent[] { + return JSON.parse(raw) as WebhookEvent[]; +} + +export const create = async ( + db: dbClient, + webhookInput: { + workspaceId: number; + name: string; + url: string; + secret?: string; + events: WebhookEvent[]; + createdBy: string; + }, +) => { + const [webhook] = await db + .insert(workspaceWebhooks) + .values({ + publicId: generateUID(), + workspaceId: webhookInput.workspaceId, + name: webhookInput.name, + url: webhookInput.url, + secret: webhookInput.secret, + events: JSON.stringify(webhookInput.events), + createdBy: webhookInput.createdBy, + }) + .returning({ + publicId: workspaceWebhooks.publicId, + name: workspaceWebhooks.name, + url: workspaceWebhooks.url, + events: workspaceWebhooks.events, + active: workspaceWebhooks.active, + createdAt: workspaceWebhooks.createdAt, + }); + + return webhook + ? { + ...webhook, + events: parseEvents(webhook.events), + } + : null; +}; + +export const update = async ( + db: dbClient, + webhookPublicId: string, + webhookInput: { + name?: string; + url?: string; + secret?: string; + events?: WebhookEvent[]; + active?: boolean; + }, +) => { + const [result] = await db + .update(workspaceWebhooks) + .set({ + name: webhookInput.name, + url: webhookInput.url, + secret: webhookInput.secret, + events: webhookInput.events + ? JSON.stringify(webhookInput.events) + : undefined, + active: webhookInput.active, + updatedAt: new Date(), + }) + .where(eq(workspaceWebhooks.publicId, webhookPublicId)) + .returning({ + publicId: workspaceWebhooks.publicId, + name: workspaceWebhooks.name, + url: workspaceWebhooks.url, + events: workspaceWebhooks.events, + active: workspaceWebhooks.active, + createdAt: workspaceWebhooks.createdAt, + updatedAt: workspaceWebhooks.updatedAt, + }); + + return result + ? { + ...result, + events: parseEvents(result.events), + } + : null; +}; + +export const getByPublicId = async (db: dbClient, webhookPublicId: string) => { + const result = await db.query.workspaceWebhooks.findFirst({ + columns: { + id: true, + publicId: true, + workspaceId: true, + name: true, + url: true, + secret: true, + events: true, + active: true, + createdAt: true, + updatedAt: true, + }, + where: eq(workspaceWebhooks.publicId, webhookPublicId), + }); + + return result + ? { + ...result, + events: parseEvents(result.events), + } + : null; +}; + +export const getAllByWorkspaceId = async ( + db: dbClient, + workspaceId: number, +) => { + const results = await db.query.workspaceWebhooks.findMany({ + columns: { + publicId: true, + name: true, + url: true, + events: true, + active: true, + createdAt: true, + updatedAt: true, + }, + where: eq(workspaceWebhooks.workspaceId, workspaceId), + }); + + return results.map((webhook) => ({ + ...webhook, + events: parseEvents(webhook.events), + })); +}; + +/** + * Returns active webhooks with secrets for server-side delivery (HMAC signing). + * DO NOT expose this via tRPC or any client-facing endpoint. + * Use getAllByWorkspaceId (which omits secrets) for the admin list endpoint. + */ +export const getActiveByWorkspaceId = async ( + db: dbClient, + workspaceId: number, +) => { + const results = await db.query.workspaceWebhooks.findMany({ + columns: { + publicId: true, + url: true, + secret: true, + events: true, + }, + where: and( + eq(workspaceWebhooks.workspaceId, workspaceId), + eq(workspaceWebhooks.active, true), + ), + }); + + return results.map((webhook) => ({ + ...webhook, + events: parseEvents(webhook.events), + })); +}; + +export const hardDelete = (db: dbClient, webhookPublicId: string) => { + return db + .delete(workspaceWebhooks) + .where(eq(workspaceWebhooks.publicId, webhookPublicId)); +}; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b72f722a..eaa9fc28 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -14,3 +14,4 @@ export * from "./subscriptions"; export * from "./workspaceInviteLinks"; export * from "./permissions"; export * from "./notifications"; +export * from "./webhooks"; diff --git a/packages/db/src/schema/webhooks.ts b/packages/db/src/schema/webhooks.ts new file mode 100644 index 00000000..6c353fe5 --- /dev/null +++ b/packages/db/src/schema/webhooks.ts @@ -0,0 +1,59 @@ +import { relations } from "drizzle-orm"; +import { + bigint, + bigserial, + boolean, + index, + pgTable, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { users } from "./users"; +import { workspaces } from "./workspaces"; + +export const webhookEvents = [ + "card.created", + "card.updated", + "card.moved", + "card.deleted", +] as const; +export type WebhookEvent = (typeof webhookEvents)[number]; + +export const workspaceWebhooks = pgTable("workspace_webhooks", { + id: bigserial("id", { mode: "number" }).primaryKey(), + publicId: varchar("publicId", { length: 12 }).notNull().unique(), + workspaceId: bigint("workspaceId", { mode: "number" }) + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + url: varchar("url", { length: 2048 }).notNull(), + secret: text("secret"), + events: text("events").notNull(), // JSON array of webhook events + active: boolean("active").notNull().default(true), + createdBy: uuid("createdBy") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt"), +}, (table) => [ + index("workspace_webhooks_workspace_idx").on(table.workspaceId), +]).enableRLS(); + +export const workspaceWebhooksRelations = relations( + workspaceWebhooks, + ({ one }) => ({ + workspace: one(workspaces, { + fields: [workspaceWebhooks.workspaceId], + references: [workspaces.id], + relationName: "workspaceWebhooksWorkspace", + }), + createdByUser: one(users, { + fields: [workspaceWebhooks.createdBy], + references: [users.id], + relationName: "workspaceWebhooksCreatedByUser", + }), + }), +);