* 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>
176 lines
4.2 KiB
TypeScript
176 lines
4.2 KiB
TypeScript
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));
|
|
};
|