import { TRPCError } from "@trpc/server"; import { z } from "zod"; import * as cardRepo from "@kan/db/repository/card.repo"; import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; import * as cardCommentRepo from "@kan/db/repository/cardComment.repo"; import * as labelRepo from "@kan/db/repository/label.repo"; import * as listRepo from "@kan/db/repository/list.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 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 .meta({ openapi: { summary: "Create a card", method: "POST", path: "/cards", description: "Creates a new card for a given list", tags: ["Cards"], protect: true, }, }) .input( z.object({ title: z.string().min(1).max(2000), description: z.string().max(10000), listPublicId: z.string().min(12), labelPublicIds: z.array(z.string().min(12)), memberPublicIds: z.array(z.string().min(12)), position: z.enum(["start", "end"]), dueDate: z.date().nullable().optional(), }), ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const list = await listRepo.getWorkspaceAndListIdByListPublicId( ctx.db, input.listPublicId, ); if (!list) throw new TRPCError({ message: `List with public ID ${input.listPublicId} not found`, code: "NOT_FOUND", }); await assertPermission(ctx.db, userId, list.workspaceId, "card:create"); const newCard = await cardRepo.create(ctx.db, { title: input.title, description: input.description, createdBy: userId, listId: list.id, position: input.position, dueDate: input.dueDate ?? null, }); const newCardId = newCard.id; if (!newCardId) throw new TRPCError({ message: `Failed to create card`, code: "INTERNAL_SERVER_ERROR", }); if (newCardId && input.labelPublicIds.length) { const labels = await labelRepo.getAllByPublicIds( ctx.db, input.labelPublicIds, ); if (!labels.length) throw new TRPCError({ message: `Labels with public IDs (${input.labelPublicIds.join(", ")}) not found`, code: "NOT_FOUND", }); const labelsInsert = labels.map((label) => ({ cardId: newCardId, labelId: label.id, })); const cardLabels = await cardRepo.bulkCreateCardLabelRelationships( ctx.db, labelsInsert, ); if (!cardLabels.length) throw new TRPCError({ message: `Failed to create card label relationships`, code: "INTERNAL_SERVER_ERROR", }); const cardActivitesInsert = cardLabels.map((cardLabel) => ({ type: "card.updated.label.added" as const, cardId: cardLabel.cardId, labelId: cardLabel.labelId, createdBy: userId, })); await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); } if (newCardId && input.memberPublicIds.length) { const members = await workspaceRepo.getAllMembersByPublicIds( ctx.db, input.memberPublicIds, ); if (!members.length) throw new TRPCError({ message: `Members with public IDs (${input.memberPublicIds.join(", ")}) not found`, code: "NOT_FOUND", }); const membersInsert = members.map((member) => ({ cardId: newCardId, workspaceMemberId: member.id, })); const cardMembers = await cardRepo.bulkCreateCardWorkspaceMemberRelationships( ctx.db, membersInsert, ); if (!cardMembers.length) throw new TRPCError({ message: `Failed to create card member relationships`, code: "INTERNAL_SERVER_ERROR", }); const cardActivitesInsert = cardMembers.map((cardMember) => ({ type: "card.updated.member.added" as const, cardId: cardMember.cardId, workspaceMemberId: cardMember.workspaceMemberId, createdBy: userId, })); await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); } if (input.description) { sendMentionEmails({ db: ctx.db, cardPublicId: newCard.publicId, commentHtml: input.description, commenterUserId: userId, }).catch((error) => { console.error("Failed to send mention emails:", error); }); } // Fire webhooks (non-blocking) 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: list.boardPublicId, boardName: list.boardName, listName: list.name, user: ctx.user ? { id: ctx.user.id, name: ctx.user.name } : undefined, }, ), ).catch((error) => { console.error("Webhook delivery failed:", error); }); return newCard; }), addComment: protectedProcedure .meta({ openapi: { summary: "Add a comment to a card", method: "POST", path: "/cards/{cardPublicId}/comments", description: "Adds a comment to a card", tags: ["Cards"], protect: true, }, }) .input( z.object({ cardPublicId: z.string().min(12), comment: z.string().min(1), }), ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); await assertPermission(ctx.db, userId, card.workspaceId, "comment:create"); const newComment = await cardCommentRepo.create(ctx.db, { comment: input.comment, createdBy: userId, cardId: card.id, }); if (!newComment?.id) throw new TRPCError({ message: `Failed to create comment`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.comment.added" as const, cardId: card.id, commentId: newComment.id, toComment: newComment.comment, createdBy: userId, }); sendMentionEmails({ db: ctx.db, cardPublicId: input.cardPublicId, commentHtml: input.comment, commenterUserId: userId, commentId: newComment.id, }).catch((error) => { console.error("Failed to send mention emails:", error); }); return newComment; }), updateComment: protectedProcedure .meta({ openapi: { summary: "Update a comment", method: "PUT", path: "/cards/{cardPublicId}/comments/{commentPublicId}", description: "Updates a comment", tags: ["Cards"], protect: true, }, }) .input( z.object({ cardPublicId: z.string().min(12), commentPublicId: z.string().min(12), comment: z.string().min(1), }), ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); const existingComment = await cardCommentRepo.getByPublicId( ctx.db, input.commentPublicId, ); if (!existingComment) throw new TRPCError({ message: `Comment with public ID ${input.commentPublicId} not found`, code: "NOT_FOUND", }); await assertCanEdit( ctx.db, userId, card.workspaceId, "comment:edit", existingComment.createdBy, ); const updatedComment = await cardCommentRepo.update(ctx.db, { id: existingComment.id, comment: input.comment, }); if (!updatedComment?.id) throw new TRPCError({ message: `Failed to update comment`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.comment.updated" as const, cardId: card.id, commentId: updatedComment.id, fromComment: existingComment.comment, toComment: updatedComment.comment, createdBy: userId, }); sendMentionEmails({ db: ctx.db, cardPublicId: input.cardPublicId, commentHtml: input.comment, commenterUserId: userId, commentId: updatedComment.id, }).catch((error) => { console.error("Failed to send mention emails:", error); }); return updatedComment; }), deleteComment: protectedProcedure .meta({ openapi: { summary: "Delete a comment", method: "DELETE", path: "/cards/{cardPublicId}/comments/{commentPublicId}", description: "Deletes a comment", tags: ["Cards"], }, }) .input( z.object({ cardPublicId: z.string().min(12), commentPublicId: z.string().min(12), }), ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); const existingComment = await cardCommentRepo.getByPublicId( ctx.db, input.commentPublicId, ); if (!existingComment) throw new TRPCError({ message: `Comment with public ID ${input.commentPublicId} not found`, code: "NOT_FOUND", }); await assertCanDelete( ctx.db, userId, card.workspaceId, "comment:delete", existingComment.createdBy, ); const deletedComment = await cardCommentRepo.softDelete(ctx.db, { commentId: existingComment.id, deletedAt: new Date(), deletedBy: userId, }); if (!deletedComment) throw new TRPCError({ message: `Failed to delete comment`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.comment.deleted" as const, cardId: card.id, commentId: existingComment.id, createdBy: userId, }); return deletedComment; }), addOrRemoveLabel: protectedProcedure .meta({ openapi: { summary: "Add or remove a label from a card", method: "PUT", path: "/cards/{cardPublicId}/labels/{labelPublicId}", description: "Adds or removes a label from a card", tags: ["Cards"], protect: true, }, }) .input( z.object({ cardPublicId: z.string().min(12), labelPublicId: z.string().min(12), }), ) .output(z.object({ newLabel: z.boolean() })) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); await assertPermission(ctx.db, userId, card.workspaceId, "card:edit"); const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId); if (!label) throw new TRPCError({ message: `Label with public ID ${input.labelPublicId} not found`, code: "NOT_FOUND", }); const cardLabelIds = { cardId: card.id, labelId: label.id }; const existingLabel = await cardRepo.getCardLabelRelationship( ctx.db, cardLabelIds, ); if (existingLabel) { const deletedCardLabelRelationship = await cardRepo.hardDeleteCardLabelRelationship(ctx.db, cardLabelIds); if (!deletedCardLabelRelationship) throw new TRPCError({ message: `Failed to remove label from card`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.label.removed" as const, cardId: card.id, labelId: label.id, createdBy: userId, }); return { newLabel: false }; } const newCardLabelRelationship = await cardRepo.createCardLabelRelationship(ctx.db, cardLabelIds); if (!newCardLabelRelationship) throw new TRPCError({ message: `Failed to add label to card`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.label.added" as const, cardId: card.id, labelId: label.id, createdBy: userId, }); return { newLabel: true }; }), addOrRemoveMember: protectedProcedure .meta({ openapi: { summary: "Add or remove a member from a card", method: "PUT", path: "/cards/{cardPublicId}/members/{workspaceMemberPublicId}", description: "Adds or removes a member from a card", tags: ["Cards"], }, }) .input( z.object({ cardPublicId: z.string().min(12), workspaceMemberPublicId: z.string().min(12), }), ) .output(z.object({ newMember: z.boolean() })) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); await assertPermission(ctx.db, userId, card.workspaceId, "card:edit"); const member = await workspaceRepo.getMemberByPublicId( ctx.db, input.workspaceMemberPublicId, ); if (!member) throw new TRPCError({ message: `Member with public ID ${input.workspaceMemberPublicId} not found`, code: "NOT_FOUND", }); const cardMemberIds = { cardId: card.id, memberId: member.id }; const existingMember = await cardRepo.getCardMemberRelationship( ctx.db, cardMemberIds, ); if (existingMember) { const deletedCardMemberRelationship = await cardRepo.hardDeleteCardMemberRelationship( ctx.db, cardMemberIds, ); if (!deletedCardMemberRelationship.success) throw new TRPCError({ message: `Failed to remove member from card`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.member.removed" as const, cardId: card.id, workspaceMemberId: member.id, createdBy: userId, }); return { newMember: false }; } const newCardMemberRelationship = await cardRepo.createCardMemberRelationship(ctx.db, cardMemberIds); if (!newCardMemberRelationship.success) throw new TRPCError({ message: `Failed to add member to card`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.updated.member.added" as const, cardId: card.id, workspaceMemberId: member.id, createdBy: userId, }); return { newMember: true }; }), byId: publicProcedure .meta({ openapi: { summary: "Get a card by public ID", method: "GET", path: "/cards/{cardPublicId}", description: "Retrieves a card by its public ID", tags: ["Cards"], }, }) .input(z.object({ cardPublicId: z.string().min(12) })) .output( z.custom< Omit< NonNullable< Awaited> >, "attachments" > & { attachments: { publicId: string; contentType: string; s3Key: string; originalFilename: string | null; size?: number | null; url: string | null; }[]; } >(), ) .query(async ({ ctx, input }) => { const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); if (card.workspaceVisibility === "private") { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); await assertPermission(ctx.db, userId, card.workspaceId, "card:view"); } const result = await cardRepo.getWithListAndMembersByPublicId( ctx.db, input.cardPublicId, ); if (!result) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); // Generate URLs for all attachments const attachmentsWithUrls = await Promise.all( result.attachments.map(async (attachment) => { const url = await generateAttachmentUrl(attachment.s3Key); return { publicId: attachment.publicId, contentType: attachment.contentType, s3Key: attachment.s3Key, originalFilename: attachment.originalFilename, size: attachment.size, url, }; }), ); // Generate presigned URLs for workspace member avatars const workspaceWithAvatarUrls = result.list.board.workspace ? { ...result.list.board.workspace, members: await Promise.all( result.list.board.workspace.members.map(async (member) => { if (!member.user?.image) { return member; } const avatarUrl = await generateAvatarUrl(member.user.image); return { ...member, user: { ...member.user, image: avatarUrl, }, }; }), ), } : result.list.board.workspace; return { ...result, attachments: attachmentsWithUrls, list: { ...result.list, board: { ...result.list.board, workspace: workspaceWithAvatarUrls, }, }, }; }), getActivities: publicProcedure .meta({ openapi: { summary: "Get paginated card activities", method: "GET", path: "/cards/{cardPublicId}/activities", description: "Retrieves paginated activities for a card with merged frequent changes", tags: ["Cards"], }, }) .input( z.object({ cardPublicId: z.string().min(12), limit: z.number().min(1).max(100).optional().default(10), cursor: z.string().datetime().optional(), // ISO datetime string }), ) .output( z.object({ activities: z.array( z.custom< NonNullable< Awaited< ReturnType > >["activities"][number] >(), ), hasMore: z.boolean(), nextCursor: z.string().datetime().nullable(), }), ) .query(async ({ ctx, input }) => { const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); if (card.workspaceVisibility === "private") { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); await assertPermission(ctx.db, userId, card.workspaceId, "card:view"); } const cursor = input.cursor ? new Date(input.cursor) : undefined; const result = await cardActivityRepo.getPaginatedActivities( ctx.db, card.id, { limit: input.limit, cursor, }, ); // Generate presigned URLs for user avatars in activities const activitiesWithAvatarUrls = await Promise.all( result.activities.map(async (activity) => { const updatedActivity = { ...activity }; // Generate presigned URL for activity user avatar if (activity.user?.image) { const userAvatarUrl = await generateAvatarUrl(activity.user.image); updatedActivity.user = { ...activity.user, image: userAvatarUrl, }; } // Generate presigned URL for member user avatar (if exists) if (activity.member?.user?.image) { const memberAvatarUrl = await generateAvatarUrl( activity.member.user.image, ); updatedActivity.member = { ...activity.member, user: { ...activity.member.user, image: memberAvatarUrl, }, }; } return updatedActivity; }), ); const mergedActivities = mergeActivities(activitiesWithAvatarUrls); return { activities: mergedActivities, hasMore: result.hasMore, nextCursor: result.nextCursor?.toISOString() ?? null, }; }), update: protectedProcedure .meta({ openapi: { summary: "Update a card", method: "PUT", path: "/cards/{cardPublicId}", description: "Updates a card by its public ID", tags: ["Cards"], protect: true, }, }) .input( z.object({ cardPublicId: z.string().min(12), title: z.string().min(1).max(2000).optional(), description: z.string().optional(), index: z.number().optional(), listPublicId: z.string().min(12).optional(), dueDate: z.date().nullable().optional(), }), ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); await assertCanEdit( ctx.db, userId, card.workspaceId, "card:edit", card.createdBy, ); const existingCard = await cardRepo.getByPublicId( ctx.db, input.cardPublicId, ); let newListId: number | undefined; if (input.listPublicId) { const newList = await listRepo.getByPublicId( ctx.db, input.listPublicId, ); if (!newList) throw new TRPCError({ message: `List with public ID ${input.listPublicId} not found`, code: "NOT_FOUND", }); newListId = newList.id; } if (!existingCard) { throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); } let result: | { id: number; title: string; description: string | null; publicId: string; dueDate: Date | null; } | undefined; const previousDueDate = existingCard.dueDate; if (input.title || input.description || input.dueDate !== undefined) { result = await cardRepo.update( ctx.db, { ...(input.title && { title: input.title }), ...(input.description && { description: input.description }), ...(input.dueDate !== undefined && { dueDate: input.dueDate }), }, { cardPublicId: input.cardPublicId }, ); } if (input.index !== undefined) { result = await cardRepo.reorder(ctx.db, { cardId: existingCard.id, newIndex: input.index, newListId: newListId, }); } if (!result) throw new TRPCError({ message: `Failed to update card`, code: "INTERNAL_SERVER_ERROR", }); const activities = []; if (input.title && existingCard.title !== input.title) { activities.push({ type: "card.updated.title" as const, cardId: result.id, createdBy: userId, fromTitle: existingCard.title, toTitle: input.title, }); } if (input.description && existingCard.description !== input.description) { activities.push({ type: "card.updated.description" as const, cardId: result.id, createdBy: userId, fromDescription: existingCard.description ?? undefined, toDescription: input.description, }); sendMentionEmails({ db: ctx.db, cardPublicId: input.cardPublicId, commentHtml: input.description, commenterUserId: userId, }).catch((error) => { console.error("Failed to send mention emails:", error); }); } if ( input.dueDate !== undefined && previousDueDate?.getTime() !== input.dueDate?.getTime() ) { let activityType: | "card.updated.dueDate.added" | "card.updated.dueDate.updated" | "card.updated.dueDate.removed"; if (!previousDueDate) { activityType = "card.updated.dueDate.added"; } else if (!input.dueDate) { activityType = "card.updated.dueDate.removed"; } else { activityType = "card.updated.dueDate.updated"; } activities.push({ type: activityType, cardId: result.id, createdBy: userId, fromDueDate: previousDueDate ?? undefined, toDueDate: input.dueDate ?? undefined, }); } if (newListId && existingCard.listId !== newListId) { activities.push({ type: "card.updated.list" as const, cardId: result.id, createdBy: userId, fromListId: existingCard.listId, toListId: newListId, }); } if (activities.length > 0) { 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) 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: card.boardPublicId, 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, }, ), ).catch((error) => { console.error("Webhook delivery failed:", error); }); return result; }), delete: protectedProcedure .meta({ openapi: { summary: "Delete a card", method: "DELETE", path: "/cards/{cardPublicId}", description: "Deletes a card by its public ID", tags: ["Cards"], protect: true, }, }) .input( z.object({ cardPublicId: 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 card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); await assertCanDelete( ctx.db, userId, card.workspaceId, "card:delete", 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, { cardId: card.id, deletedAt, deletedBy: userId, }); await cardActivityRepo.create(ctx.db, { type: "card.archived", cardId: card.id, createdBy: userId, }); // Fire webhooks (non-blocking) if (fullCard) { 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: card.boardPublicId, boardName: card.boardName, listName: card.listName, user: ctx.user ? { id: ctx.user.id, name: ctx.user.name } : undefined, }, ), ).catch((error) => { console.error("Webhook delivery failed:", error); }); } return { success: true }; }), });