Files
kan/packages/api/src/routers/card.ts
Nick Meinhold bd25fb33f7 feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration

Add the core webhook delivery logic and wire it into card mutations:

- Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout
- Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget)
- Add createCardWebhookPayload() for building webhook payloads
- Fire webhooks on card create, update, move, and delete events
- Add unit tests for webhook utility functions

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use correct boardId in webhook payloads and add rejection safety

- Fix bug where workspaceId was incorrectly passed as boardId in all
  webhook payloads — now uses board's publicId via boardPublicId
- Replace void sendWebhooksForWorkspace() with .catch() to prevent
  unhandled promise rejections if the DB query inside fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add SSRF protection to webhook delivery

Block webhook URLs targeting internal networks:
- Require HTTPS (reject HTTP)
- Block localhost, 127.0.0.1, ::1, 0.0.0.0
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
- Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Add tests for all blocked URL patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use WebhookEvent type from schema instead of duplicating

Replace the hardcoded WebhookEventType union with the canonical
WebhookEvent type from @kan/db/schema, addressing reviewer feedback
on PR #392.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): improve webhook delivery safety and validation

Cherry-pick delivery-related changes from b2cc9ac:
- Extract URL validation into reusable webhookUrlSchema zod validator
  for SSRF checks
- Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled
  promise rejections
- Document SSRF risk mitigation on sendWebhookToUrl
- Add corresponding tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:15:35 +00:00

1188 lines
33 KiB
TypeScript

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<Awaited<ReturnType<typeof cardRepo.create>>>())
.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<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
.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<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
.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<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.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<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
>,
"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<typeof cardActivityRepo.getPaginatedActivities>
>
>["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<Awaited<ReturnType<typeof cardRepo.update>>>())
.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<string, { from: unknown; to: unknown }> = {};
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 };
}),
});