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:
359
packages/api/src/routers/webhook.ts
Normal file
359
packages/api/src/routers/webhook.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user