diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 7d82e92af..2755d5ebb 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -440,6 +440,7 @@ export default function Editor({ content, onChange, onBlur, + onModEnter, readOnly = false, workspaceMembers, enableYouTubeEmbed = true, @@ -449,6 +450,7 @@ export default function Editor({ content: string | null; onChange?: (value: string) => void; onBlur?: () => void; + onModEnter?: () => void; readOnly?: boolean; workspaceMembers: WorkspaceMember[]; enableYouTubeEmbed?: boolean; @@ -569,6 +571,18 @@ export default function Editor({ attributes: { class: "outline-none focus:outline-none focus-visible:ring-0", }, + handleKeyDown: (_view, event) => { + if ( + onModEnter && + event.key === "Enter" && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + onModEnter(); + return true; + } + return false; + }, }, editable: !readOnly, injectCSS: false, diff --git a/apps/web/src/components/SettingsLayout.tsx b/apps/web/src/components/SettingsLayout.tsx index 7f6cdcc3e..cb42d77bd 100644 --- a/apps/web/src/components/SettingsLayout.tsx +++ b/apps/web/src/components/SettingsLayout.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from "react"; import { HiChevronDown, HiOutlineBanknotes, + HiOutlineBolt, HiOutlineCodeBracketSquare, HiOutlineRectangleGroup, HiOutlineShieldCheck, @@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) { label: t`API`, condition: true, }, + { + key: "webhooks", + icon: , + label: t`Webhooks`, + condition: true, + }, { key: "integrations", icon: , diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 3d00b33ce..ad7af4f56 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -79,6 +79,9 @@ export const env = createEnv({ S3_FORCE_PATH_STYLE: z.string().optional(), EMAIL_FROM: z.string().optional(), REDIS_URL: z.string().url().optional().or(z.literal("")), + // Webhook configuration + WEBHOOK_URL: z.string().url().optional(), + WEBHOOK_SECRET: z.string().optional(), }, /** diff --git a/apps/web/src/pages/settings/webhooks.tsx b/apps/web/src/pages/settings/webhooks.tsx new file mode 100644 index 000000000..2719867c9 --- /dev/null +++ b/apps/web/src/pages/settings/webhooks.tsx @@ -0,0 +1,16 @@ +import type { NextPageWithLayout } from "~/pages/_app"; +import { getDashboardLayout } from "~/components/Dashboard"; +import { SettingsLayout } from "~/components/SettingsLayout"; +import WebhookSettings from "~/views/settings/WebhookSettings"; + +const WebhookSettingsPage: NextPageWithLayout = () => { + return ( + + + + ); +}; + +WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page); + +export default WebhookSettingsPage; diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx index c1a9aaded..742bfe8bb 100644 --- a/apps/web/src/views/card/components/NewCommentForm.tsx +++ b/apps/web/src/views/card/components/NewCommentForm.tsx @@ -63,6 +63,7 @@ const NewCommentForm = ({ setValue("comment", value)} + onModEnter={handleSubmit(onSubmit)} workspaceMembers={workspaceMembers} enableYouTubeEmbed={false} placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index b91b7bdc8..2a65f60aa 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { t } from "@lingui/core/macro"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { IoChevronForwardSharp } from "react-icons/io5"; import { HiXMark } from "react-icons/hi2"; @@ -18,6 +18,10 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { PageHead } from "~/components/PageHead"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { usePermissions } from "~/hooks/usePermissions"; +import { + useKeyboardShortcut, + type KeyboardShortcut, +} from "~/providers/keyboard-shortcuts"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { useWorkspace } from "~/providers/workspace"; @@ -201,6 +205,25 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) { const workspaceMembers = board?.workspace.members; const boardId = board?.publicId; + const navigateToBoard = useCallback(() => { + if (!isOpen && boardId) { + const boardPath = isTemplate ? "templates" : "boards"; + void router.push(`/${boardPath}/${boardId}`); + } + }, [isOpen, isTemplate, boardId, router]); + + const escShortcut = useMemo( + (): KeyboardShortcut => ({ + type: "PRESS", + stroke: { key: "Escape" }, + action: navigateToBoard, + description: t`Close card`, + group: "NAVIGATION", + }), + [navigateToBoard], + ); + useKeyboardShortcut(escShortcut); + const editorWorkspaceMembers = workspaceMembers ?.filter((member) => member.email) diff --git a/apps/web/src/views/settings/WebhookSettings.tsx b/apps/web/src/views/settings/WebhookSettings.tsx new file mode 100644 index 000000000..b6e885886 --- /dev/null +++ b/apps/web/src/views/settings/WebhookSettings.tsx @@ -0,0 +1,78 @@ +import { t } from "@lingui/core/macro"; + +import Button from "~/components/Button"; +import FeedbackModal from "~/components/FeedbackModal"; +import Modal from "~/components/modal"; +import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; +import { PageHead } from "~/components/PageHead"; +import { useModal } from "~/providers/modal"; +import { useWorkspace } from "~/providers/workspace"; +import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation"; +import { NewWebhookModal } from "./components/NewWebhookModal"; +import WebhookList from "./components/WebhookList"; + +export default function WebhookSettings() { + const { modalContentType, openModal, isOpen } = useModal(); + const { workspace } = useWorkspace(); + + if (!workspace) { + return null; + } + + return ( + <> + + +
+

+ {t`Webhooks`} +

+

+ {t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`} +

+ +
+ +
+ + +
+ + {/* Webhook-specific modals */} + + + + + + + + + + + {/* Global modals */} + + + + + + + + ); +} diff --git a/apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx b/apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx new file mode 100644 index 000000000..48a67f9df --- /dev/null +++ b/apps/web/src/views/settings/components/DeleteWebhookConfirmation.tsx @@ -0,0 +1,78 @@ +import { t } from "@lingui/core/macro"; +import { HiXMark } from "react-icons/hi2"; + +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface DeleteWebhookConfirmationProps { + workspacePublicId: string; +} + +export function DeleteWebhookConfirmation({ + workspacePublicId, +}: DeleteWebhookConfirmationProps) { + const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); + + const deleteWebhookMutation = api.webhook.delete.useMutation({ + onSuccess: () => { + void utils.webhook.list.invalidate({ workspacePublicId }); + showPopup({ message: t`Webhook deleted successfully`, type: "success" }); + closeModal(); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to delete webhook`, + type: "error", + }); + }, + }); + + const handleDelete = () => { + if (!webhookPublicId) return; + deleteWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhookPublicId as string, + }); + }; + + return ( +
+
+
+

{t`Delete webhook`}

+ +
+ +

+ {t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`} +

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/views/settings/components/NewWebhookModal.tsx b/apps/web/src/views/settings/components/NewWebhookModal.tsx new file mode 100644 index 000000000..6a711e5f5 --- /dev/null +++ b/apps/web/src/views/settings/components/NewWebhookModal.tsx @@ -0,0 +1,327 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { t } from "@lingui/core/macro"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { HiXMark } from "react-icons/hi2"; +import { z } from "zod"; + +import Button from "~/components/Button"; +import Input from "~/components/Input"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +const webhookEvents = [ + "card.created", + "card.updated", + "card.moved", + "card.deleted", +] as const; + +const newWebhookSchema = z.object({ + name: z + .string() + .min(1, { message: t`Webhook name is required` }) + .max(255, { message: t`Webhook name cannot exceed 255 characters` }), + url: z + .string() + .min(1, { message: t`Webhook URL is required` }) + .url({ message: t`Please enter a valid URL` }) + .max(2048, { message: t`URL cannot exceed 2048 characters` }), + secret: z + .string() + .max(512, { message: t`Secret cannot exceed 512 characters` }) + .optional(), + events: z + .array(z.enum(webhookEvents)) + .min(1, { message: t`Select at least one event` }), + active: z.boolean(), +}); + +type WebhookFormData = z.infer; + +interface NewWebhookModalProps { + workspacePublicId: string; + isEdit?: boolean; +} + +export function NewWebhookModal({ + workspacePublicId, + isEdit = false, +}: NewWebhookModalProps) { + const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal(); + const { showPopup } = usePopup(); + const [isTestingWebhook, setIsTestingWebhook] = useState(false); + + const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null; + + const utils = api.useUtils(); + + const { + register, + handleSubmit, + control, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(newWebhookSchema), + defaultValues: { + name: "", + url: "", + secret: "", + events: ["card.created", "card.updated", "card.moved", "card.deleted"], + active: true, + }, + }); + + useEffect(() => { + if (isEdit && webhookPublicId && modalState) { + reset({ + name: modalState.name ?? "", + url: modalState.url ?? "", + secret: "", + events: modalState.events ?? ["card.created"], + active: modalState.active ?? true, + }); + } else if (!isEdit) { + reset({ + name: "", + url: "", + secret: "", + events: ["card.created", "card.updated", "card.moved", "card.deleted"], + active: true, + }); + } + }, [isEdit, webhookPublicId, modalState, reset]); + + // Clear modal state when closing + useEffect(() => { + return () => { + if (isEdit) { + clearModalState("EDIT_WEBHOOK"); + } + }; + }, [isEdit, clearModalState]); + + const createWebhookMutation = api.webhook.create.useMutation({ + onSuccess: () => { + void utils.webhook.list.invalidate({ workspacePublicId }); + showPopup({ message: t`Webhook created successfully`, type: "success" }); + closeModal(); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to create webhook`, + type: "error", + }); + }, + }); + + const updateWebhookMutation = api.webhook.update.useMutation({ + onSuccess: () => { + void utils.webhook.list.invalidate({ workspacePublicId }); + showPopup({ message: t`Webhook updated successfully`, type: "success" }); + closeModal(); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to update webhook`, + type: "error", + }); + }, + }); + + const testWebhookMutation = api.webhook.test.useMutation({ + onSuccess: (result) => { + if (result.success) { + showPopup({ message: t`Test webhook sent successfully!`, type: "success" }); + } else { + showPopup({ + message: result.error || t`Webhook test failed`, + type: "error", + }); + } + setIsTestingWebhook(false); + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to test webhook`, + type: "error", + }); + setIsTestingWebhook(false); + }, + }); + + const onSubmit = (data: WebhookFormData) => { + if (isEdit && webhookPublicId) { + updateWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhookPublicId as string, + name: data.name, + url: data.url, + secret: data.secret || undefined, + events: data.events, + active: data.active, + }); + } else { + createWebhookMutation.mutate({ + workspacePublicId, + name: data.name, + url: data.url, + secret: data.secret || undefined, + events: data.events, + }); + } + }; + + const handleTestWebhook = () => { + if (!webhookPublicId) return; + setIsTestingWebhook(true); + testWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: webhookPublicId as string, + }); + }; + + const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending; + + return ( +
+
+
+

+ {isEdit ? t`Edit webhook` : t`New webhook`} +

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +

+ {t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`} +

+
+ +
+ + ( +
+ {webhookEvents.map((event) => ( + + ))} +
+ )} + /> + {errors.events && ( +

{errors.events.message}

+ )} +
+ + {isEdit && ( +
+ +
+ )} +
+
+ +
+
+ {isEdit && webhookPublicId && ( + + )} +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/views/settings/components/WebhookList.tsx b/apps/web/src/views/settings/components/WebhookList.tsx new file mode 100644 index 000000000..48222186f --- /dev/null +++ b/apps/web/src/views/settings/components/WebhookList.tsx @@ -0,0 +1,283 @@ +import { t } from "@lingui/core/macro"; +import { HiEllipsisHorizontal } from "react-icons/hi2"; +import { twMerge } from "tailwind-merge"; + +import Dropdown from "~/components/Dropdown"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface WebhookListProps { + workspacePublicId: string; +} + +export default function WebhookList({ workspacePublicId }: WebhookListProps) { + const { openModal, setModalState } = useModal(); + const { showPopup } = usePopup(); + + const { data: webhooks, isLoading } = api.webhook.list.useQuery({ + workspacePublicId, + }); + + const testWebhookMutation = api.webhook.test.useMutation({ + onSuccess: (result) => { + if (result.success) { + showPopup({ message: t`Test webhook sent successfully!`, type: "success" }); + } else { + showPopup({ + message: result.error || t`Webhook test failed`, + type: "error", + }); + } + }, + onError: (error) => { + showPopup({ + message: error.message || t`Failed to test webhook`, + type: "error", + }); + }, + }); + + const formatEvents = (events: string[]) => { + return events + .map((e) => e.replace("card.", "")) + .join(", "); + }; + + const TableRow = ({ + publicId, + name, + url, + events, + active, + createdAt, + isLastRow, + showSkeleton, + }: { + publicId?: string; + name?: string; + url?: string; + events?: string[]; + active?: boolean; + createdAt?: Date | null; + isLastRow?: boolean; + showSkeleton?: boolean; + }) => { + const formatDate = (date?: Date | string | null) => { + if (!date) return "Never"; + const dateObj = date instanceof Date ? date : new Date(date); + return dateObj.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + return ( + + +
+
+
+

+ {name} +

+
+
+
+ + +

+ {url} +

+ + +

+ {events && formatEvents(events)} +

+ + + + {!showSkeleton && (active ? t`Active` : t`Inactive`)} + + + +

+ {formatDate(createdAt)} +

+ + + {!showSkeleton && ( +
+
+ { + setModalState("EDIT_WEBHOOK", { + publicId, + name, + url, + events, + active, + }); + openModal("EDIT_WEBHOOK", publicId, name ?? ""); + }, + }, + { + label: t`Test`, + action: () => { + if (publicId) { + testWebhookMutation.mutate({ + workspacePublicId, + webhookPublicId: publicId, + }); + } + }, + }, + { + label: t`Delete`, + action: () => + openModal("DELETE_WEBHOOK", publicId, name ?? ""), + }, + ]} + > + + +
+
+ )} + + + ); + }; + + if (!isLoading && (!webhooks || webhooks.length === 0)) { + return ( +
+

+ {t`No webhooks configured. Add a webhook to receive notifications.`} +

+
+ ); + } + + return ( +
+
+
+
+ + + + + + + + + + + + + {!isLoading && + webhooks?.map((webhook, index) => ( + + ))} + + {isLoading && ( + <> + + + + + )} + +
+ {t`Name`} + + {t`URL`} + + {t`Events`} + + {t`Status`} + + {t`Created`} + + {/* Actions column */} +
+
+
+
+
+ ); +} diff --git a/packages/api/integration-tests/test-db.ts b/packages/api/integration-tests/test-db.ts new file mode 100644 index 000000000..2c190319b --- /dev/null +++ b/packages/api/integration-tests/test-db.ts @@ -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 "../../db/src/schema"; + +export type TestDbClient = NodePgDatabase & { + $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 { + 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! }; +} diff --git a/packages/api/integration-tests/webhook.integration.test.ts b/packages/api/integration-tests/webhook.integration.test.ts new file mode 100644 index 000000000..fee842102 --- /dev/null +++ b/packages/api/integration-tests/webhook.integration.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +import * as webhookRepo from "../../db/src/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(); + }); + }); +}); diff --git a/packages/api/package.json b/packages/api/package.json index 30bdb7a17..b8caf4074 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" } diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 21493c5b7..711e3ca61 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -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, }); diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index fd3003121..31796a839 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -173,8 +173,31 @@ export const boardRouter = createTRPCRouter({ } : result.workspace; + // Generate presigned URLs for card member avatars + const listsWithAvatarUrls = await Promise.all( + result.lists.map(async (list) => ({ + ...list, + cards: await Promise.all( + list.cards.map(async (card) => ({ + ...card, + members: await Promise.all( + card.members.map(async (member) => { + if (!member.user?.image) return member; + const avatarUrl = await generateAvatarUrl(member.user.image); + return { + ...member, + user: { ...member.user, image: avatarUrl }, + }; + }), + ), + })), + ), + })), + ); + return { ...result, + lists: listsWithAvatarUrls, workspace: workspaceWithAvatarUrls, }; }), diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index 949804d15..382589562 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -13,6 +13,10 @@ import { mergeActivities } from "../utils/activities"; import { sendMentionEmails } from "../utils/notifications"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; +import { + createCardWebhookPayload, + sendWebhooksForWorkspace, +} from "../utils/webhook"; export const cardRouter = createTRPCRouter({ create: protectedProcedure @@ -165,6 +169,30 @@ export const cardRouter = createTRPCRouter({ }); } + // Fire webhooks (non-blocking) + void sendWebhooksForWorkspace( + ctx.db, + list.workspaceId, + createCardWebhookPayload( + "card.created", + { + id: String(newCard.id), + title: input.title, + description: input.description, + dueDate: input.dueDate ?? null, + listId: String(newCard.listId), + }, + { + boardId: String(list.workspaceId), + boardName: list.boardName, + listName: list.name, + user: ctx.user + ? { id: ctx.user.id, name: ctx.user.name } + : undefined, + }, + ), + ); + return newCard; }), addComment: protectedProcedure @@ -1007,6 +1035,57 @@ export const cardRouter = createTRPCRouter({ await cardActivityRepo.bulkCreate(ctx.db, activities); } + // Build changes object for webhook + const webhookChanges: Record = {}; + if (input.title && existingCard.title !== input.title) { + webhookChanges.title = { from: existingCard.title, to: input.title }; + } + if (input.description && existingCard.description !== input.description) { + webhookChanges.description = { + from: existingCard.description, + to: input.description, + }; + } + if ( + input.dueDate !== undefined && + previousDueDate?.getTime() !== input.dueDate?.getTime() + ) { + webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate }; + } + if (newListId && existingCard.listId !== newListId) { + webhookChanges.listId = { from: existingCard.listId, to: newListId }; + } + + // Fire webhooks (non-blocking) + void sendWebhooksForWorkspace( + ctx.db, + card.workspaceId, + createCardWebhookPayload( + newListId && existingCard.listId !== newListId + ? "card.moved" + : "card.updated", + { + id: String(result.id), + title: result.title, + description: result.description, + dueDate: result.dueDate, + listId: String(newListId ?? existingCard.listId), + }, + { + boardId: String(card.workspaceId), + boardName: card.boardName, + listName: card.listName, + user: ctx.user + ? { id: ctx.user.id, name: ctx.user.name } + : undefined, + changes: + Object.keys(webhookChanges).length > 0 + ? webhookChanges + : undefined, + }, + ), + ); + return result; }), delete: protectedProcedure @@ -1054,6 +1133,9 @@ export const cardRouter = createTRPCRouter({ card.createdBy, ); + // Fetch full card data before delete for webhook + const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); + const deletedAt = new Date(); await cardRepo.softDelete(ctx.db, { @@ -1068,6 +1150,32 @@ export const cardRouter = createTRPCRouter({ createdBy: userId, }); + // Fire webhooks (non-blocking) + if (fullCard) { + void sendWebhooksForWorkspace( + ctx.db, + card.workspaceId, + createCardWebhookPayload( + "card.deleted", + { + id: String(fullCard.id), + title: fullCard.title, + description: fullCard.description, + dueDate: fullCard.dueDate, + listId: String(fullCard.listId), + }, + { + boardId: String(card.workspaceId), + boardName: card.boardName, + listName: card.listName, + user: ctx.user + ? { id: ctx.user.id, name: ctx.user.name } + : undefined, + }, + ), + ); + } + return { success: true }; }), }); diff --git a/packages/api/src/routers/webhook.test.ts b/packages/api/src/routers/webhook.test.ts new file mode 100644 index 000000000..33face711 --- /dev/null +++ b/packages/api/src/routers/webhook.test.ts @@ -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/auth", () => ({ + assertUserInWorkspace: vi.fn(), +})); + +import * as webhookRepo from "@kan/db/repository/webhook.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { assertUserInWorkspace } from "../utils/auth"; + +const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType; +const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType; +const mockCreate = webhookRepo.create as ReturnType; +const mockUpdate = webhookRepo.update as ReturnType; +const mockHardDelete = webhookRepo.hardDelete as ReturnType; +const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType; +const mockAssertUserInWorkspace = assertUserInWorkspace as ReturnType; + +// 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(); + mockAssertUserInWorkspace.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 admin role via assertUserInWorkspace", 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(mockAssertUserInWorkspace).toHaveBeenCalledWith( + mockDb, + mockUser.id, + mockWorkspace.id, + "admin", + ); + }); + }); + + 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).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).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"); + }); + }); +}); diff --git a/packages/api/src/routers/webhook.ts b/packages/api/src/routers/webhook.ts new file mode 100644 index 000000000..463eb9e82 --- /dev/null +++ b/packages/api/src/routers/webhook.ts @@ -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 { assertUserInWorkspace } from "../utils/auth"; + +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 assertUserInWorkspace(ctx.db, userId, workspace.id, "admin"); + + 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: z.string().url().max(2048), + 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 assertUserInWorkspace(ctx.db, userId, workspace.id, "admin"); + + 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: z.string().url().max(2048).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 assertUserInWorkspace(ctx.db, userId, workspace.id, "admin"); + + 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 assertUserInWorkspace(ctx.db, userId, workspace.id, "admin"); + + 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 assertUserInWorkspace(ctx.db, userId, workspace.id, "admin"); + + 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", + }); + + // Import sendWebhookToUrl dynamically to avoid circular dependencies + const { sendWebhookToUrl, createCardWebhookPayload } = await import( + "../utils/webhook" + ); + + 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; + }), +}); diff --git a/packages/api/src/utils/webhook.test.ts b/packages/api/src/utils/webhook.test.ts new file mode 100644 index 000000000..d0dc7692e --- /dev/null +++ b/packages/api/src/utils/webhook.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@kan/db/repository/webhook.repo", () => ({ + getActiveByWorkspaceId: vi.fn(), +})); + +import * as webhookRepo from "@kan/db/repository/webhook.repo"; +import { + sendWebhookToUrl, + sendWebhooksForWorkspace, + createCardWebhookPayload, + type WebhookPayload, +} from "./webhook"; + +const mockGetActiveByWorkspaceId = webhookRepo.getActiveByWorkspaceId as ReturnType; + +describe("webhook utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-15T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("createCardWebhookPayload", () => { + it("creates a payload with required card fields", () => { + const payload = createCardWebhookPayload( + "card.created", + { + id: "card-123", + title: "Test Card", + listId: "list-456", + }, + { + boardId: "board-789", + }, + ); + + expect(payload.event).toBe("card.created"); + expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z"); + expect(payload.data.card).toEqual({ + id: "card-123", + title: "Test Card", + description: undefined, + dueDate: null, + listId: "list-456", + boardId: "board-789", + }); + }); + + it("includes optional card fields when provided", () => { + const dueDate = new Date("2024-02-01T10:00:00.000Z"); + const payload = createCardWebhookPayload( + "card.updated", + { + id: "card-123", + title: "Test Card", + description: "A description", + dueDate, + listId: "list-456", + }, + { + boardId: "board-789", + }, + ); + + expect(payload.data.card.description).toBe("A description"); + expect(payload.data.card.dueDate).toBe("2024-02-01T10:00:00.000Z"); + }); + + it("includes board context when provided", () => { + const payload = createCardWebhookPayload( + "card.created", + { + id: "card-123", + title: "Test Card", + listId: "list-456", + }, + { + boardId: "board-789", + boardName: "My Board", + }, + ); + + expect(payload.data.board).toEqual({ + id: "board-789", + name: "My Board", + }); + }); + + it("includes list context when provided", () => { + const payload = createCardWebhookPayload( + "card.created", + { + id: "card-123", + title: "Test Card", + listId: "list-456", + }, + { + boardId: "board-789", + listName: "To Do", + }, + ); + + expect(payload.data.list).toEqual({ + id: "list-456", + name: "To Do", + }); + }); + + it("includes user context when provided", () => { + const payload = createCardWebhookPayload( + "card.created", + { + id: "card-123", + title: "Test Card", + listId: "list-456", + }, + { + boardId: "board-789", + user: { + id: "user-111", + name: "John Doe", + }, + }, + ); + + expect(payload.data.user).toEqual({ + id: "user-111", + name: "John Doe", + }); + }); + + it("includes changes for card.updated events", () => { + const payload = createCardWebhookPayload( + "card.updated", + { + id: "card-123", + title: "Updated Title", + listId: "list-456", + }, + { + boardId: "board-789", + changes: { + title: { from: "Old Title", to: "Updated Title" }, + }, + }, + ); + + expect(payload.data.changes).toEqual({ + title: { from: "Old Title", to: "Updated Title" }, + }); + }); + }); + + describe("sendWebhookToUrl", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockPayload: WebhookPayload = { + event: "card.created", + timestamp: "2024-01-15T12:00:00.000Z", + data: { + card: { + id: "card-123", + title: "Test Card", + listId: "list-456", + boardId: "board-789", + }, + }, + }; + + it("sends POST request with correct headers", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload); + + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/webhook", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + "X-Webhook-Event": "card.created", + "X-Webhook-Timestamp": "2024-01-15T12:00:00.000Z", + }), + body: JSON.stringify(mockPayload), + }), + ); + }); + + it("includes signature header when secret is provided", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + await sendWebhookToUrl("https://example.com/webhook", "my-secret", mockPayload); + + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/webhook", + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Webhook-Signature": expect.stringMatching(/^[a-f0-9]{64}$/), + }), + }), + ); + }); + + it("returns success for 2xx responses", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const result = await sendWebhookToUrl( + "https://example.com/webhook", + undefined, + mockPayload, + ); + + expect(result).toEqual({ success: true, statusCode: 200 }); + }); + + it("returns failure for non-2xx responses", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const result = await sendWebhookToUrl( + "https://example.com/webhook", + undefined, + mockPayload, + ); + + expect(result).toEqual({ + success: false, + statusCode: 500, + error: "500 Internal Server Error", + }); + }); + + it("returns failure on network error", async () => { + (global.fetch as ReturnType).mockRejectedValueOnce( + new Error("Network error"), + ); + + const result = await sendWebhookToUrl( + "https://example.com/webhook", + undefined, + mockPayload, + ); + + expect(result).toEqual({ + success: false, + error: "Network error", + }); + }); + + it("returns timeout error when request takes too long", async () => { + (global.fetch as ReturnType).mockImplementationOnce( + () => + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }, 15000); + }), + ); + + const resultPromise = sendWebhookToUrl( + "https://example.com/webhook", + undefined, + mockPayload, + ); + + // Advance timers to trigger the abort + vi.advanceTimersByTime(15000); + + const result = await resultPromise; + expect(result).toEqual({ + success: false, + error: "Request timed out", + }); + }); + }); + + describe("sendWebhooksForWorkspace", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockDb = {} as Parameters[0]; + + const mockPayload: WebhookPayload = { + event: "card.created", + timestamp: "2024-01-15T12:00:00.000Z", + data: { + card: { + id: "card-123", + title: "Test Card", + listId: "list-456", + boardId: "board-789", + }, + }, + }; + + it("sends to all active webhooks subscribed to the event", async () => { + mockGetActiveByWorkspaceId.mockResolvedValueOnce([ + { + id: 1, + publicId: "wh-1", + url: "https://example.com/webhook1", + secret: "secret1", + events: ["card.created", "card.updated"], + active: true, + }, + { + id: 2, + publicId: "wh-2", + url: "https://example.com/webhook2", + secret: null, + events: ["card.created"], + active: true, + }, + ]); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + status: 200, + }); + + await sendWebhooksForWorkspace(mockDb, 1, mockPayload); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/webhook1", + expect.any(Object), + ); + expect(global.fetch).toHaveBeenCalledWith( + "https://example.com/webhook2", + expect.any(Object), + ); + }); + + it("does not send to webhooks not subscribed to the event", async () => { + mockGetActiveByWorkspaceId.mockResolvedValueOnce([ + { + id: 1, + publicId: "wh-1", + url: "https://example.com/webhook1", + secret: null, + events: ["card.deleted"], // Not subscribed to card.created + active: true, + }, + ]); + + await sendWebhooksForWorkspace(mockDb, 1, mockPayload); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("continues sending to other webhooks when one fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + mockGetActiveByWorkspaceId.mockResolvedValueOnce([ + { + id: 1, + publicId: "wh-1", + url: "https://example.com/webhook1", + secret: null, + events: ["card.created"], + active: true, + }, + { + id: 2, + publicId: "wh-2", + url: "https://example.com/webhook2", + secret: null, + events: ["card.created"], + active: true, + }, + ]); + + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + await sendWebhooksForWorkspace(mockDb, 1, mockPayload); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Webhook delivery failed"), + ); + + consoleSpy.mockRestore(); + }); + + it("handles empty webhook list", async () => { + mockGetActiveByWorkspaceId.mockResolvedValueOnce([]); + + await sendWebhooksForWorkspace(mockDb, 1, mockPayload); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/utils/webhook.ts b/packages/api/src/utils/webhook.ts new file mode 100644 index 000000000..8cc0a15c9 --- /dev/null +++ b/packages/api/src/utils/webhook.ts @@ -0,0 +1,175 @@ +import crypto from "crypto"; + +import type { dbClient } from "@kan/db/client"; +import * as webhookRepo from "@kan/db/repository/webhook.repo"; + +export type WebhookEventType = + | "card.created" + | "card.updated" + | "card.deleted" + | "card.moved"; + +export interface WebhookPayload { + event: WebhookEventType; + timestamp: string; + data: { + card: { + id: string; + title: string; + description?: string | null; + dueDate?: string | null; // ISO string after JSON serialization + listId: string; + boardId: string; + }; + board?: { + id: string; + name: string; + }; + list?: { + id: string; + name: string; + }; + user?: { + id: string; + name: string | null; + }; + changes?: Record; + }; +} + +function generateSignature(payload: string, secret: string): string { + return crypto.createHmac("sha256", secret).update(payload).digest("hex"); +} + +/** + * Send a webhook payload to a specific URL + * Returns result for testing purposes + */ +export async function sendWebhookToUrl( + url: string, + secret: string | undefined, + payload: WebhookPayload, +): Promise<{ success: boolean; statusCode?: number; error?: string }> { + const body = JSON.stringify(payload); + const headers: Record = { + "Content-Type": "application/json", + "X-Webhook-Event": payload.event, + "X-Webhook-Timestamp": payload.timestamp, + }; + + if (secret) { + headers["X-Webhook-Signature"] = generateSignature(body, secret); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(url, { + method: "POST", + headers, + body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + success: false, + statusCode: response.status, + error: `${response.status} ${response.statusText}`, + }; + } + + return { success: true, statusCode: response.status }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === "AbortError") { + return { success: false, error: "Request timed out" }; + } + + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Send webhook to all active webhooks for a workspace that are subscribed to the event + */ +export async function sendWebhooksForWorkspace( + db: dbClient, + workspaceId: number, + payload: WebhookPayload, +): Promise { + // Get all active webhooks for this workspace + const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId); + + // Filter to webhooks that are subscribed to this event + const subscribedWebhooks = webhooks.filter((webhook) => + webhook.events.includes(payload.event as (typeof webhook.events)[number]), + ); + + // Send to all webhooks in parallel (fire and forget) + const promises = subscribedWebhooks.map((webhook) => + sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then( + (result) => { + if (!result.success) { + console.error( + `Webhook delivery failed to ${webhook.url}: ${result.error}`, + ); + } + }, + ), + ); + + // Wait for all to complete but don't block on failures + await Promise.allSettled(promises); +} + +export function createCardWebhookPayload( + event: WebhookEventType, + card: { + id: string; + title: string; + description?: string | null; + dueDate?: Date | null; + listId: string; + }, + context: { + boardId: string; + boardName?: string; + listName?: string; + user?: { + id: string; + name: string | null; + }; + changes?: Record; + }, +): WebhookPayload { + return { + event, + timestamp: new Date().toISOString(), + data: { + card: { + id: card.id, + title: card.title, + description: card.description, + dueDate: card.dueDate?.toISOString() ?? null, + listId: card.listId, + boardId: context.boardId, + }, + board: context.boardName + ? { id: context.boardId, name: context.boardName } + : undefined, + list: context.listName + ? { id: card.listId, name: context.listName } + : undefined, + user: context.user, + changes: context.changes, + }, + }; +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 000000000..33dd7db1d --- /dev/null +++ b/packages/api/vitest.config.ts @@ -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"), + }, + }, +}); diff --git a/packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql b/packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql new file mode 100644 index 000000000..81436c30e --- /dev/null +++ b/packages/db/migrations/20260129210000_AddWorkspaceWebhooks.sql @@ -0,0 +1,28 @@ +CREATE TYPE "public"."webhook_event" AS ENUM('card.created', 'card.updated', 'card.moved', 'card.deleted');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "workspace_webhooks" ( + "id" bigserial PRIMARY KEY NOT NULL, + "publicId" varchar(12) NOT NULL, + "workspaceId" bigint NOT NULL, + "name" varchar(255) NOT NULL, + "url" varchar(2048) NOT NULL, + "secret" text, + "events" text NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "createdBy" uuid NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp, + CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId") +); +--> statement-breakpoint +ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 85dfcf6c0..a2767c1a0 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1770500457005, "tag": "20260207214056_AddNotificationsTable", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1738188000000, + "tag": "20260129210000_AddWorkspaceWebhooks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 5dd085801..ad8598dea 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -945,12 +945,13 @@ export const getWorkspaceAndCardIdByCardPublicId = async ( where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)), with: { list: { - columns: {}, + columns: { name: true }, with: { board: { columns: { workspaceId: true, visibility: true, + name: true, }, }, }, @@ -964,6 +965,8 @@ export const getWorkspaceAndCardIdByCardPublicId = async ( createdBy: result.createdBy, workspaceId: result.list.board.workspaceId, workspaceVisibility: result.list.board.visibility, + listName: result.list.name, + boardName: result.list.board.name, } : null; }; diff --git a/packages/db/src/repository/list.repo.ts b/packages/db/src/repository/list.repo.ts index 0afc3973e..5edaada7b 100644 --- a/packages/db/src/repository/list.repo.ts +++ b/packages/db/src/repository/list.repo.ts @@ -419,12 +419,13 @@ export const getWorkspaceAndListIdByListPublicId = async ( listPublicId: string, ) => { const result = await db.query.lists.findFirst({ - columns: { id: true, createdBy: true }, + columns: { id: true, name: true, createdBy: true }, where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)), with: { board: { columns: { workspaceId: true, + name: true, }, }, }, @@ -433,8 +434,10 @@ export const getWorkspaceAndListIdByListPublicId = async ( return result ? { id: result.id, + name: result.name, createdBy: result.createdBy, workspaceId: result.board.workspaceId, + boardName: result.board.name, } : null; }; diff --git a/packages/db/src/repository/webhook.repo.ts b/packages/db/src/repository/webhook.repo.ts new file mode 100644 index 000000000..7c8255f26 --- /dev/null +++ b/packages/db/src/repository/webhook.repo.ts @@ -0,0 +1,165 @@ +import { and, eq } from "drizzle-orm"; + +import type { dbClient } from "@kan/db/client"; +import { workspaceWebhooks } from "@kan/db/schema"; +import { generateUID } from "@kan/shared/utils"; + +import type { WebhookEvent } from "../schema/webhooks"; + +export const create = async ( + db: dbClient, + webhookInput: { + workspaceId: number; + name: string; + url: string; + secret?: string; + events: WebhookEvent[]; + createdBy: string; + }, +) => { + const [webhook] = await db + .insert(workspaceWebhooks) + .values({ + publicId: generateUID(), + workspaceId: webhookInput.workspaceId, + name: webhookInput.name, + url: webhookInput.url, + secret: webhookInput.secret, + events: JSON.stringify(webhookInput.events), + createdBy: webhookInput.createdBy, + }) + .returning({ + publicId: workspaceWebhooks.publicId, + name: workspaceWebhooks.name, + url: workspaceWebhooks.url, + events: workspaceWebhooks.events, + active: workspaceWebhooks.active, + createdAt: workspaceWebhooks.createdAt, + }); + + return webhook + ? { + ...webhook, + events: JSON.parse(webhook.events) as WebhookEvent[], + } + : null; +}; + +export const update = async ( + db: dbClient, + webhookPublicId: string, + webhookInput: { + name?: string; + url?: string; + secret?: string; + events?: WebhookEvent[]; + active?: boolean; + }, +) => { + const [result] = await db + .update(workspaceWebhooks) + .set({ + name: webhookInput.name, + url: webhookInput.url, + secret: webhookInput.secret, + events: webhookInput.events + ? JSON.stringify(webhookInput.events) + : undefined, + active: webhookInput.active, + updatedAt: new Date(), + }) + .where(eq(workspaceWebhooks.publicId, webhookPublicId)) + .returning({ + publicId: workspaceWebhooks.publicId, + name: workspaceWebhooks.name, + url: workspaceWebhooks.url, + events: workspaceWebhooks.events, + active: workspaceWebhooks.active, + createdAt: workspaceWebhooks.createdAt, + updatedAt: workspaceWebhooks.updatedAt, + }); + + return result + ? { + ...result, + events: JSON.parse(result.events) as WebhookEvent[], + } + : null; +}; + +export const getByPublicId = async (db: dbClient, webhookPublicId: string) => { + const result = await db.query.workspaceWebhooks.findFirst({ + columns: { + id: true, + publicId: true, + workspaceId: true, + name: true, + url: true, + secret: true, + events: true, + active: true, + createdAt: true, + updatedAt: true, + }, + where: eq(workspaceWebhooks.publicId, webhookPublicId), + }); + + return result + ? { + ...result, + events: JSON.parse(result.events) as WebhookEvent[], + } + : null; +}; + +export const getAllByWorkspaceId = async ( + db: dbClient, + workspaceId: number, +) => { + const results = await db.query.workspaceWebhooks.findMany({ + columns: { + publicId: true, + name: true, + url: true, + events: true, + active: true, + createdAt: true, + updatedAt: true, + }, + where: eq(workspaceWebhooks.workspaceId, workspaceId), + }); + + return results.map((webhook) => ({ + ...webhook, + events: JSON.parse(webhook.events) as WebhookEvent[], + })); +}; + +export const getActiveByWorkspaceId = async ( + db: dbClient, + workspaceId: number, +) => { + const results = await db.query.workspaceWebhooks.findMany({ + columns: { + publicId: true, + url: true, + secret: true, + events: true, + }, + where: and( + eq(workspaceWebhooks.workspaceId, workspaceId), + eq(workspaceWebhooks.active, true), + ), + }); + + return results.map((webhook) => ({ + ...webhook, + events: JSON.parse(webhook.events) as WebhookEvent[], + })); +}; + +export const hardDelete = (db: dbClient, webhookPublicId: string) => { + return db + .delete(workspaceWebhooks) + .where(eq(workspaceWebhooks.publicId, webhookPublicId)); +}; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b72f722a0..eaa9fc288 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -14,3 +14,4 @@ export * from "./subscriptions"; export * from "./workspaceInviteLinks"; export * from "./permissions"; export * from "./notifications"; +export * from "./webhooks"; diff --git a/packages/db/src/schema/webhooks.ts b/packages/db/src/schema/webhooks.ts new file mode 100644 index 000000000..26b05ebe3 --- /dev/null +++ b/packages/db/src/schema/webhooks.ts @@ -0,0 +1,58 @@ +import { relations } from "drizzle-orm"; +import { + bigint, + bigserial, + boolean, + pgEnum, + pgTable, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { users } from "./users"; +import { workspaces } from "./workspaces"; + +export const webhookEvents = [ + "card.created", + "card.updated", + "card.moved", + "card.deleted", +] as const; +export type WebhookEvent = (typeof webhookEvents)[number]; +export const webhookEventEnum = pgEnum("webhook_event", webhookEvents); + +export const workspaceWebhooks = pgTable("workspace_webhooks", { + id: bigserial("id", { mode: "number" }).primaryKey(), + publicId: varchar("publicId", { length: 12 }).notNull().unique(), + workspaceId: bigint("workspaceId", { mode: "number" }) + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + url: varchar("url", { length: 2048 }).notNull(), + secret: text("secret"), + events: text("events").notNull(), // JSON array of webhook events + active: boolean("active").notNull().default(true), + createdBy: uuid("createdBy") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt"), +}).enableRLS(); + +export const workspaceWebhooksRelations = relations( + workspaceWebhooks, + ({ one }) => ({ + workspace: one(workspaces, { + fields: [workspaceWebhooks.workspaceId], + references: [workspaces.id], + relationName: "workspaceWebhooksWorkspace", + }), + createdByUser: one(users, { + fields: [workspaceWebhooks.createdBy], + references: [users.id], + relationName: "workspaceWebhooksCreatedByUser", + }), + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd67efd0a..617f193c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -348,6 +348,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.2 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) packages/auth: dependencies: @@ -12650,6 +12653,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -17911,6 +17922,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1): dependencies: esbuild: 0.27.2 @@ -17926,6 +17958,21 @@ snapshots: terser: 5.44.1 yaml: 2.8.1 + vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.0 + fsevents: 2.3.3 + jiti: 2.4.2 + terser: 5.44.1 + yaml: 2.8.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 @@ -17968,6 +18015,48 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-keyname@2.2.8: {} watchpack@2.4.4: