feat(api): add webhook CRUD API router and tests (#393)

* feat(api): add webhook CRUD API router and tests

Add tRPC router for managing workspace webhooks:

- list, create, update, delete endpoints (admin role required)
- test endpoint to send a synthetic payload to a webhook URL
- URL validation, event subscription filtering
- Unit tests for all router procedures
- Integration tests with PGlite test database
- Add vitest config and test infrastructure for API package

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use assertPermission instead of assertUserInWorkspace

Replace assertUserInWorkspace with assertPermission("workspace:manage")
per project conventions. The permissions system is the preferred
authorization approach for new code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use @kan/db alias instead of relative imports in tests

Replace relative path imports (../../db/src/...) with the @kan/db
alias configured in vitest.config.ts for consistency and robustness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use webhookUrlSchema in router input validation

Cherry-pick router-related changes from b2cc9ac:
- Use extracted webhookUrlSchema zod validator in create/update
  input schemas for consistent SSRF checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): replace dynamic import with static import for webhook utility

Add packages/api/src/utils/webhook.ts with sendWebhookToUrl,
createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic
import() in the test endpoint with a static import at the top of the
file for better tree-shaking, type-checking, and readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): align sendWebhooksForWorkspace tests with merged PR #392

The merged delivery utility uses client-side event filtering
(getActiveByWorkspaceId takes 2 args, not 3). Update test assertions
to match the actual implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
This commit is contained in:
Nick Meinhold
2026-03-11 09:27:52 +11:00
committed by GitHub
parent 0b49f502a9
commit 1d5e3a936c
10 changed files with 1217 additions and 12 deletions

View File

@@ -0,0 +1,75 @@
import { PGlite } from "@electric-sql/pglite";
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import * as schema from "@kan/db/schema";
export type TestDbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
};
/**
* Creates a fresh in-memory PGlite database for testing.
* Each call returns an isolated database instance with migrations applied.
*/
export async function createTestDb(): Promise<TestDbClient> {
const client = new PGlite({
extensions: { uuid_ossp, pg_trgm },
});
const db = drizzle(client, { schema });
// Run migrations
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
return db as unknown as TestDbClient;
}
/**
* Seeds a test database with a workspace and user for testing.
* Returns the created entities for use in tests.
*/
export async function seedTestData(db: TestDbClient) {
// Create a test user
const [user] = await db
.insert(schema.users)
.values({
id: crypto.randomUUID(),
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
// Create a test workspace (publicId must be exactly 12 chars)
const [workspace] = await db
.insert(schema.workspaces)
.values({
publicId: "wstest123456",
name: "Test Workspace",
slug: "test-workspace",
ownerId: user!.id,
createdAt: new Date(),
})
.returning();
// Add user as admin member of workspace
await db.insert(schema.workspaceMembers).values({
publicId: "wm1234567890",
email: user!.email,
workspaceId: workspace!.id,
userId: user!.id,
createdBy: user!.id,
role: "admin",
status: "active",
createdAt: new Date(),
});
return { user: user!, workspace: workspace! };
}

View File

@@ -0,0 +1,242 @@
import { describe, it, expect, beforeEach } from "vitest";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
describe("webhook repository integration tests", () => {
let db: TestDbClient;
let testUser: { id: string; name: string | null };
let testWorkspace: { id: number; publicId: string };
beforeEach(async () => {
db = await createTestDb();
const seeded = await seedTestData(db);
testUser = seeded.user;
testWorkspace = seeded.workspace;
});
describe("create", () => {
it("creates a webhook with all fields", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "my-secret",
events: ["card.created", "card.updated"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("My Webhook");
expect(webhook!.url).toBe("https://example.com/webhook");
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
expect(webhook!.active).toBe(true);
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
});
it("creates a webhook without secret", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "No Secret Webhook",
url: "https://example.com/webhook",
events: ["card.deleted"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("No Secret Webhook");
});
});
describe("getByPublicId", () => {
it("retrieves a webhook by public ID", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).not.toBeNull();
expect(retrieved!.publicId).toBe(created!.publicId);
expect(retrieved!.name).toBe("Test Webhook");
});
it("returns null for non-existent public ID", async () => {
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
expect(retrieved).toBeNull();
});
});
describe("getAllByWorkspaceId", () => {
it("returns all webhooks for a workspace", async () => {
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 1",
url: "https://example.com/webhook1",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 2",
url: "https://example.com/webhook2",
events: ["card.updated"],
createdBy: testUser.id,
});
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toHaveLength(2);
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
});
it("returns empty array for workspace with no webhooks", async () => {
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toEqual([]);
});
});
describe("getActiveByWorkspaceId", () => {
it("returns only active webhooks", async () => {
const active = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Active Webhook",
url: "https://example.com/active",
events: ["card.created"],
createdBy: testUser.id,
});
const inactive = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Inactive Webhook",
url: "https://example.com/inactive",
events: ["card.created"],
createdBy: testUser.id,
});
// Deactivate one webhook
await webhookRepo.update(db, inactive!.publicId, { active: false });
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
expect(activeWebhooks).toHaveLength(1);
// getActiveByWorkspaceId returns only publicId, url, secret, events
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Original Name",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated Name",
});
expect(updated).not.toBeNull();
expect(updated!.name).toBe("Updated Name");
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
});
it("updates webhook events", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
events: ["card.created", "card.updated", "card.deleted"],
});
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
});
it("updates webhook active status", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
expect(created!.active).toBe(true);
const updated = await webhookRepo.update(db, created!.publicId, {
active: false,
});
expect(updated!.active).toBe(false);
});
it("sets updatedAt timestamp on update", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
// create() doesn't return updatedAt, verify via getByPublicId
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
expect(initial!.updatedAt).toBeNull();
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated",
});
expect(updated!.updatedAt).not.toBeNull();
expect(updated!.updatedAt).toBeInstanceOf(Date);
});
it("returns null for non-existent webhook", async () => {
const updated = await webhookRepo.update(db, "nonexistent12", {
name: "Updated",
});
expect(updated).toBeNull();
});
});
describe("hardDelete", () => {
it("deletes a webhook permanently", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "To Be Deleted",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.hardDelete(db, created!.publicId);
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).toBeNull();
});
it("does not throw for non-existent webhook", async () => {
await expect(
webhookRepo.hardDelete(db, "nonexistent12"),
).resolves.not.toThrow();
});
});
});

View File

@@ -40,6 +40,8 @@
"dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
@@ -60,7 +62,8 @@
"@kan/tsconfig": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^3.0.0"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -11,6 +11,7 @@ import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member";
import { permissionRouter } from "./routers/permission";
import { userRouter } from "./routers/user";
import { webhookRouter } from "./routers/webhook";
import { workspaceRouter } from "./routers/workspace";
import { createTRPCRouter } from "./trpc";
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
import: importRouter,
permission: permissionRouter,
user: userRouter,
webhook: webhookRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});

View File

@@ -0,0 +1,423 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("@kan/db/repository/webhook.repo", () => ({
getAllByWorkspaceId: vi.fn(),
getByPublicId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
hardDelete: vi.fn(),
}));
vi.mock("@kan/db/repository/workspace.repo", () => ({
getByPublicId: vi.fn(),
}));
vi.mock("../utils/permissions", () => ({
assertPermission: vi.fn(),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { assertPermission } from "../utils/permissions";
const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType<typeof vi.fn>;
const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockCreate = webhookRepo.create as ReturnType<typeof vi.fn>;
const mockUpdate = webhookRepo.update as ReturnType<typeof vi.fn>;
const mockHardDelete = webhookRepo.hardDelete as ReturnType<typeof vi.fn>;
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
// We need to import the router after mocks are set up
// Testing approach: call the internal handler logic through a test wrapper
describe("webhook router", () => {
const mockDb = {} as never;
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
const mockWorkspace = { id: 1, publicId: "ws-123456789" };
const mockWebhook = {
id: 1,
publicId: "wh-123456789",
workspaceId: 1,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "secret123",
events: ["card.created", "card.updated"] as const,
active: true,
createdAt: new Date("2024-01-15"),
updatedAt: null,
};
beforeEach(() => {
vi.clearAllMocks();
mockAssertPermission.mockResolvedValue(undefined);
});
describe("authorization", () => {
it("throws UNAUTHORIZED when user is not authenticated", async () => {
// Import fresh to get mocked version
const { webhookRouter } = await import("./webhook");
const ctx = {
user: null,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when workspace does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }),
).rejects.toThrow(TRPCError);
});
it("checks workspace:manage permission via assertPermission", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" });
expect(mockAssertPermission).toHaveBeenCalledWith(
mockDb,
mockUser.id,
mockWorkspace.id,
"workspace:manage",
);
});
});
describe("list", () => {
it("returns all webhooks for workspace", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).list({
workspacePublicId: "ws-123456789",
});
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe("My Webhook");
expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id);
});
it("returns empty array when no webhooks exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).list({
workspacePublicId: "ws-123456789",
});
expect(result).toEqual([]);
});
});
describe("create", () => {
it("creates a webhook with valid input", async () => {
const { webhookRouter } = await import("./webhook");
const newWebhook = {
publicId: "wh-new123456",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"] as const,
active: true,
createdAt: new Date(),
};
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(newWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"],
});
expect(result.name).toBe("New Webhook");
expect(mockCreate).toHaveBeenCalledWith(mockDb, {
workspaceId: mockWorkspace.id,
name: "New Webhook",
url: "https://example.com/new",
secret: undefined,
events: ["card.created"],
createdBy: mockUser.id,
});
});
it("creates a webhook with secret", async () => {
const { webhookRouter } = await import("./webhook");
const newWebhook = {
publicId: "wh-new123456",
name: "Secure Webhook",
url: "https://example.com/secure",
events: ["card.created"] as const,
active: true,
createdAt: new Date(),
};
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(newWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "Secure Webhook",
url: "https://example.com/secure",
secret: "my-secret-key",
events: ["card.created"],
});
expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({
secret: "my-secret-key",
}));
});
it("throws INTERNAL_SERVER_ERROR when create fails", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockCreate.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).create({
workspacePublicId: "ws-123456789",
name: "New Webhook",
url: "https://example.com/new",
events: ["card.created"],
}),
).rejects.toThrow(TRPCError);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const { webhookRouter } = await import("./webhook");
const updatedWebhook = { ...mockWebhook, name: "Updated Name" };
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
mockUpdate.mockResolvedValueOnce(updatedWebhook);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
name: "Updated Name",
});
expect(result.name).toBe("Updated Name");
expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", {
name: "Updated Name",
url: undefined,
secret: undefined,
events: undefined,
active: undefined,
});
});
it("throws NOT_FOUND when webhook does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-nonexistent",
name: "Updated Name",
}),
).rejects.toThrow(TRPCError);
});
it("throws NOT_FOUND when webhook belongs to different workspace", async () => {
const { webhookRouter } = await import("./webhook");
const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 };
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).update({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
name: "Updated Name",
}),
).rejects.toThrow(TRPCError);
});
});
describe("delete", () => {
it("deletes webhook successfully", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
mockHardDelete.mockResolvedValueOnce(undefined);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).delete({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result).toEqual({ success: true });
expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789");
});
it("throws NOT_FOUND when webhook does not exist", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(null);
const ctx = {
user: mockUser,
db: mockDb,
} as never;
await expect(
webhookRouter.createCaller(ctx).delete({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-nonexistent",
}),
).rejects.toThrow(TRPCError);
});
});
describe("test", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it("sends test payload to webhook URL", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
});
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).test({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
expect(global.fetch).toHaveBeenCalledWith(
mockWebhook.url,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
"X-Webhook-Event": "card.created",
}),
}),
);
});
it("returns error when test fails", async () => {
const { webhookRouter } = await import("./webhook");
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const ctx = {
user: mockUser,
db: mockDb,
} as never;
const result = await webhookRouter.createCaller(ctx).test({
workspacePublicId: "ws-123456789",
webhookPublicId: "wh-123456789",
});
expect(result.success).toBe(false);
expect(result.statusCode).toBe(500);
expect(result.error).toContain("500");
});
});
});

View File

@@ -0,0 +1,359 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { webhookEvents } from "@kan/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
import {
webhookUrlSchema,
sendWebhookToUrl,
createCardWebhookPayload,
} from "../utils/webhook";
const webhookEventSchema = z.enum(webhookEvents);
export const webhookRouter = createTRPCRouter({
list: protectedProcedure
.meta({
openapi: {
summary: "Get all webhooks for a workspace",
method: "GET",
path: "/workspaces/{workspacePublicId}/webhooks",
description: "Retrieves all webhooks configured for a workspace",
tags: ["Webhooks"],
protect: true,
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(
z.array(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable(),
}),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id);
}),
create: protectedProcedure
.meta({
openapi: {
summary: "Create a webhook",
method: "POST",
path: "/workspaces/{workspacePublicId}/webhooks",
description: "Creates a new webhook for a workspace",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
name: z.string().min(1).max(255),
url: webhookUrlSchema,
secret: z.string().max(512).optional(),
events: z.array(webhookEventSchema).min(1),
}),
)
.output(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const result = await webhookRepo.create(ctx.db, {
workspaceId: workspace.id,
name: input.name,
url: input.url,
secret: input.secret,
events: input.events,
createdBy: userId,
});
if (!result)
throw new TRPCError({
message: "Unable to create webhook",
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
update: protectedProcedure
.meta({
openapi: {
summary: "Update a webhook",
method: "PUT",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
description: "Updates a webhook by its public ID",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
name: z.string().min(1).max(255).optional(),
url: webhookUrlSchema.optional(),
secret: z.string().max(512).optional(),
events: z.array(webhookEventSchema).min(1).optional(),
active: z.boolean().optional(),
}),
)
.output(
z.object({
publicId: z.string(),
name: z.string(),
url: z.string(),
events: z.array(webhookEventSchema),
active: z.boolean(),
createdAt: z.date(),
updatedAt: z.date().nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
const result = await webhookRepo.update(ctx.db, input.webhookPublicId, {
name: input.name,
url: input.url,
secret: input.secret,
events: input.events,
active: input.active,
});
if (!result)
throw new TRPCError({
message: "Unable to update webhook",
code: "INTERNAL_SERVER_ERROR",
});
return result;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a webhook",
method: "DELETE",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
description: "Deletes a webhook by its public ID",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
await webhookRepo.hardDelete(ctx.db, input.webhookPublicId);
return { success: true };
}),
test: protectedProcedure
.meta({
openapi: {
summary: "Test a webhook",
method: "POST",
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test",
description: "Sends a test payload to a webhook",
tags: ["Webhooks"],
protect: true,
},
})
.input(
z.object({
workspacePublicId: z.string().min(12),
webhookPublicId: z.string().min(12),
}),
)
.output(
z.object({
success: z.boolean(),
statusCode: z.number().optional(),
error: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicId(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: "Workspace not found",
code: "NOT_FOUND",
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
const webhook = await webhookRepo.getByPublicId(
ctx.db,
input.webhookPublicId,
);
if (!webhook || webhook.workspaceId !== workspace.id)
throw new TRPCError({
message: "Webhook not found",
code: "NOT_FOUND",
});
const testPayload = createCardWebhookPayload("card.created", {
id: "test-card-id",
title: "Test Card",
description: "This is a test webhook payload",
dueDate: null,
listId: "test-list-id",
}, {
boardId: "test-board-id",
boardName: "Test Board",
listName: "Test List",
user: {
id: userId,
name: ctx.user?.name ?? "Test User",
},
});
const result = await sendWebhookToUrl(
webhook.url,
webhook.secret ?? undefined,
testPayload,
);
return result;
}),
});

View File

@@ -404,11 +404,10 @@ describe("webhook utilities", () => {
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
// Event filtering now happens at DB level
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith(
@@ -421,8 +420,8 @@ describe("webhook utilities", () => {
);
});
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
// DB-level event filter returns empty array when no webhooks match
it("does not send when no webhooks match the event (client-side filtering)", async () => {
// Returns webhooks that don't match the event — client-side filter excludes them
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
@@ -430,7 +429,6 @@ describe("webhook utilities", () => {
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).not.toHaveBeenCalled();
});

View File

@@ -185,12 +185,13 @@ export async function sendWebhooksForWorkspace(
payload: WebhookPayload,
): Promise<void> {
try {
// Get active webhooks for this workspace
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
// Filter webhooks that are subscribed to this specific event
const webhooksForEvent = webhooks.filter((webhook) =>
webhook.events.includes(payload.event),
// Get active webhooks for this workspace and filter by event client-side
const allWebhooks = await webhookRepo.getActiveByWorkspaceId(
db,
workspaceId,
);
const webhooks = allWebhooks.filter((w) =>
w.events.includes(payload.event),
);
// Send to all subscribed webhooks in parallel (fire and forget)

View File

@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
export default defineConfig({
test: {
include: ["src/**/*.test.ts", "integration-tests/**/*.test.ts"],
},
resolve: {
alias: {
"@kan/db": resolve(__dirname, "../db/src"),
},
},
});