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 } from "../trpc"; 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), 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"]), }), ) .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.getWithCardsByPublicId( ctx.db, input.listPublicId, ); if (!list?.id) throw new TRPCError({ message: `List with public ID ${input.listPublicId} not found`, code: "NOT_FOUND", }); const lastCard = list.cards.length && list.cards[0]; let index = 0; if (list.cards.length) { if (input.position === "end" && lastCard) index = lastCard.index + 1; if (input.position === "start") { await cardRepo.pushIndex(ctx.db, { listId: list.id, cardIndex: 0, }); } } const newCard = await cardRepo.create(ctx.db, { title: input.title, description: input.description, createdBy: userId, listId: list.id, index, }); const newCardId = newCard?.id; if (!newCardId) throw new TRPCError({ message: `Failed to create card`, code: "INTERNAL_SERVER_ERROR", }); await cardActivityRepo.create(ctx.db, { type: "card.created", cardId: newCard.id, createdBy: userId, }); 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); } 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.getByPublicId(ctx.db, input.cardPublicId); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); 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, }); 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.getByPublicId(ctx.db, input.cardPublicId); const existingComment = await cardCommentRepo.getByPublicId( ctx.db, input.commentPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); if (!existingComment) throw new TRPCError({ message: `Comment with public ID ${input.commentPublicId} not found`, code: "NOT_FOUND", }); if (existingComment.createdBy !== userId) throw new TRPCError({ message: `You do not have permission to update this comment`, code: "FORBIDDEN", }); 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, }); return updatedComment; }), 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.getByPublicId(ctx.db, input.cardPublicId); const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); 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.getByPublicId(ctx.db, input.cardPublicId); const member = await workspaceRepo.getMemberByPublicId( ctx.db, input.workspaceMemberPublicId, ); if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); 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: protectedProcedure .meta({ openapi: { summary: "Get a card by public ID", method: "GET", path: "/cards/{cardPublicId}", description: "Retrieves a card by its public ID", tags: ["Cards"], protect: true, }, }) .input(z.object({ cardPublicId: z.string().min(12) })) .output( z.custom< Awaited> >(), ) .query(async ({ ctx, input }) => { 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", }); return result; }), 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), description: z.string(), }), ) .output(z.custom>>()) .mutation(async ({ ctx, input }) => { const userId = ctx.user?.id; if (!userId) throw new TRPCError({ message: `User not authenticated`, code: "UNAUTHORIZED", }); const existingCard = await cardRepo.getByPublicId( ctx.db, input.cardPublicId, ); if (!existingCard) { throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); } const result = await cardRepo.update( ctx.db, { title: input.title, description: input.description }, { cardPublicId: input.cardPublicId }, ); if (!result) throw new TRPCError({ message: `Failed to update card`, code: "INTERNAL_SERVER_ERROR", }); const activities = []; if (existingCard.title !== input.title) { activities.push({ type: "card.updated.title" as const, cardId: result.id, createdBy: userId, fromTitle: existingCard.title, toTitle: input.title, }); } if (existingCard.description !== input.description) { activities.push({ type: "card.updated.description" as const, cardId: result.id, createdBy: userId, fromDescription: existingCard.description ?? undefined, toDescription: input.description, }); } if (activities.length > 0) { await cardActivityRepo.bulkCreate(ctx.db, activities); } 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.getCardWithListByPublicId( ctx.db, input.cardPublicId, ); if (!card?.list?.id) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); const deletedAt = new Date().toISOString(); const deletedCard = await cardRepo.softDelete(ctx.db, { cardId: card.id, deletedAt, deletedBy: userId, }); if (!deletedCard) throw new TRPCError({ message: `Failed to delete card`, code: "INTERNAL_SERVER_ERROR", }); await cardRepo.shiftIndex(ctx.db, { listId: card.list.id, cardIndex: card.index, }); await cardActivityRepo.create(ctx.db, { type: "card.archived", cardId: card.id, createdBy: userId, }); return { success: true }; }), reorder: protectedProcedure .meta({ openapi: { summary: "Reorder a card", method: "PUT", path: "/cards/{cardPublicId}/reorder", description: "Reorders the position of a card in a given list", tags: ["Cards"], protect: true, }, }) .input( z.object({ cardPublicId: z.string().min(12), newListPublicId: z.string().min(12), newIndex: z.number().optional(), }), ) .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.getCardWithListByPublicId( ctx.db, input.cardPublicId, ); if (!card?.list) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); const currentList = card.list; const currentIndex = card.index; let newIndex = input.newIndex; const newList = await listRepo.getWithCardsByPublicId( ctx.db, input.newListPublicId, ); if (!newList) throw new TRPCError({ message: `List with public ID ${input.newListPublicId} not found`, code: "NOT_FOUND", }); if (newIndex === undefined) { const lastCardIndex = newList.cards.length ? newList.cards[0]?.index : undefined; newIndex = lastCardIndex !== undefined ? lastCardIndex + 1 : 0; } const { success } = await cardRepo.reorder(ctx.db, { currentListId: currentList.id, newListId: newList.id, currentIndex, newIndex, cardId: card.id, }); if (!success) throw new TRPCError({ message: `Failed to reorder card`, code: "INTERNAL_SERVER_ERROR", }); const activities = []; if (currentIndex !== newIndex) { activities.push({ type: "card.updated.index" as const, cardId: card.id, createdBy: userId, fromIndex: currentIndex, toIndex: newIndex, }); } if (currentList.id !== newList.id) { activities.push({ type: "card.updated.list" as const, cardId: card.id, createdBy: userId, fromListId: currentList.id, toListId: newList.id, }); } if (activities.length > 0) { await cardActivityRepo.bulkCreate(ctx.db, activities); } return { success }; }), });