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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 $$;
|
||||||
@@ -190,6 +190,13 @@
|
|||||||
"when": 1770521594167,
|
"when": 1770521594167,
|
||||||
"tag": "20260208033314_AddAttachmentToActivity",
|
"tag": "20260208033314_AddAttachmentToActivity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771192000000,
|
||||||
|
"tag": "20260129210000_AddWorkspaceWebhooks",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -945,12 +945,14 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
||||||
with: {
|
with: {
|
||||||
list: {
|
list: {
|
||||||
columns: {},
|
columns: { name: true },
|
||||||
with: {
|
with: {
|
||||||
board: {
|
board: {
|
||||||
columns: {
|
columns: {
|
||||||
|
publicId: true,
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
visibility: true,
|
visibility: true,
|
||||||
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -964,6 +966,9 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
|||||||
createdBy: result.createdBy,
|
createdBy: result.createdBy,
|
||||||
workspaceId: result.list.board.workspaceId,
|
workspaceId: result.list.board.workspaceId,
|
||||||
workspaceVisibility: result.list.board.visibility,
|
workspaceVisibility: result.list.board.visibility,
|
||||||
|
listName: result.list.name,
|
||||||
|
boardPublicId: result.list.board.publicId,
|
||||||
|
boardName: result.list.board.name,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -419,12 +419,14 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
|||||||
listPublicId: string,
|
listPublicId: string,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query.lists.findFirst({
|
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)),
|
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
||||||
with: {
|
with: {
|
||||||
board: {
|
board: {
|
||||||
columns: {
|
columns: {
|
||||||
|
publicId: true,
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -433,8 +435,11 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
|||||||
return result
|
return result
|
||||||
? {
|
? {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
createdBy: result.createdBy,
|
createdBy: result.createdBy,
|
||||||
workspaceId: result.board.workspaceId,
|
workspaceId: result.board.workspaceId,
|
||||||
|
boardPublicId: result.board.publicId,
|
||||||
|
boardName: result.board.name,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
175
packages/db/src/repository/webhook.repo.ts
Normal file
175
packages/db/src/repository/webhook.repo.ts
Normal file
@@ -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));
|
||||||
|
};
|
||||||
@@ -14,3 +14,4 @@ export * from "./subscriptions";
|
|||||||
export * from "./workspaceInviteLinks";
|
export * from "./workspaceInviteLinks";
|
||||||
export * from "./permissions";
|
export * from "./permissions";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
|
export * from "./webhooks";
|
||||||
|
|||||||
59
packages/db/src/schema/webhooks.ts
Normal file
59
packages/db/src/schema/webhooks.ts
Normal file
@@ -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",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user