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:
423
packages/api/src/routers/webhook.test.ts
Normal file
423
packages/api/src/routers/webhook.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
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