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,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;
}),
});