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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
Reference in New Issue
Block a user