Files
kan/packages/api/src/routers/webhook.ts
Midas 607672718c Add cardPublicId to card webhook payloads (#490)
* Add cardPublicId to card webhook payloads

* fix: include card publicId in webhook payloads

Add data.card.publicId to webhook payloads while keeping data.card.id for compatibility, and update card/webhook call sites and tests so webhook consumers can reliably use public IDs.
2026-05-11 13:22:21 +01:00

361 lines
9.2 KiB
TypeScript

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",
publicId: "test-card-public-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;
}),
});