From 76b65acc0f43f62a19232160df5fe756ddd5b2fc Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 26 May 2025 21:30:09 +0100 Subject: [PATCH] feat: add workspace member assertion to card router --- packages/api/src/routers/card.ts | 120 ++++++++++++++++++------ packages/db/src/repository/card.repo.ts | 81 ++++++++++++++-- 2 files changed, 161 insertions(+), 40 deletions(-) diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index 57929f35..7cfdc2fa 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -9,6 +9,7 @@ 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 { assertUserInWorkspace } from "../utils/auth"; export const cardRouter = createTRPCRouter({ create: protectedProcedure @@ -42,31 +43,31 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const list = await listRepo.getWithCardsByPublicId( + const list = await listRepo.getWorkspaceAndListIdByListPublicId( ctx.db, input.listPublicId, ); - if (!list?.id) + if (!list) throw new TRPCError({ message: `List with public ID ${input.listPublicId} not found`, code: "NOT_FOUND", }); - const lastCard = list.cards.length && list.cards[0]; + await assertUserInWorkspace(ctx.db, userId, list.workspaceId); - let index = 0; - - if (list.cards.length && input.position === "end" && lastCard) { - index = lastCard.index + 1; - } + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); const newCard = await cardRepo.create(ctx.db, { title: input.title, description: input.description, createdBy: userId, listId: list.id, - index, + position: input.position, }); const newCardId = newCard.id; @@ -183,7 +184,10 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + ctx.db, + input.cardPublicId, + ); if (!card) throw new TRPCError({ @@ -191,6 +195,8 @@ export const cardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + const newComment = await cardCommentRepo.create(ctx.db, { comment: input.comment, createdBy: userId, @@ -241,10 +247,9 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); - const existingComment = await cardCommentRepo.getByPublicId( + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, - input.commentPublicId, + input.cardPublicId, ); if (!card) @@ -253,6 +258,13 @@ export const cardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + + const existingComment = await cardCommentRepo.getByPublicId( + ctx.db, + input.commentPublicId, + ); + if (!existingComment) throw new TRPCError({ message: `Comment with public ID ${input.commentPublicId} not found`, @@ -313,10 +325,9 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); - const existingComment = await cardCommentRepo.getByPublicId( + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, - input.commentPublicId, + input.cardPublicId, ); if (!card) @@ -325,6 +336,13 @@ export const cardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + + const existingComment = await cardCommentRepo.getByPublicId( + ctx.db, + input.commentPublicId, + ); + if (!existingComment) throw new TRPCError({ message: `Comment with public ID ${input.commentPublicId} not found`, @@ -379,8 +397,10 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); - const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId); + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + ctx.db, + input.cardPublicId, + ); if (!card) throw new TRPCError({ @@ -388,6 +408,10 @@ export const cardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + + const label = await labelRepo.getByPublicId(ctx.db, input.labelPublicId); + if (!label) throw new TRPCError({ message: `Label with public ID ${input.labelPublicId} not found`, @@ -465,10 +489,9 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); - const member = await workspaceRepo.getMemberByPublicId( + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, - input.workspaceMemberPublicId, + input.cardPublicId, ); if (!card) @@ -477,6 +500,13 @@ export const cardRouter = createTRPCRouter({ code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + + const member = await workspaceRepo.getMemberByPublicId( + ctx.db, + input.workspaceMemberPublicId, + ); + if (!member) throw new TRPCError({ message: `Member with public ID ${input.workspaceMemberPublicId} not found`, @@ -548,6 +578,27 @@ export const cardRouter = createTRPCRouter({ >(), ) .query(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 assertUserInWorkspace(ctx.db, userId, card.workspaceId); + const result = await cardRepo.getWithListAndMembersByPublicId( ctx.db, input.cardPublicId, @@ -591,6 +642,19 @@ export const cardRouter = createTRPCRouter({ 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 assertUserInWorkspace(ctx.db, userId, card.workspaceId); + const existingCard = await cardRepo.getByPublicId( ctx.db, input.cardPublicId, @@ -708,31 +772,27 @@ export const cardRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const card = await cardRepo.getCardWithListByPublicId( + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( ctx.db, input.cardPublicId, ); - if (!card?.list.id) + if (!card) throw new TRPCError({ message: `Card with public ID ${input.cardPublicId} not found`, code: "NOT_FOUND", }); + await assertUserInWorkspace(ctx.db, userId, card.workspaceId); + const deletedAt = new Date(); - const deletedCard = await cardRepo.softDelete(ctx.db, { + 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 cardActivityRepo.create(ctx.db, { type: "card.archived", cardId: card.id, diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 9e534f20..d57dc02e 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -18,10 +18,24 @@ export const create = async ( description: string; createdBy: string; listId: number; - index: number; + position: "start" | "end"; }, ) => { return db.transaction(async (tx) => { + let index = 0; + + if (cardInput.position === "end") { + const lastCard = await tx.query.cards.findFirst({ + columns: { + index: true, + }, + where: and(eq(cards.listId, cardInput.listId), isNull(cards.deletedAt)), + orderBy: desc(cards.index), + }); + + if (lastCard) index = lastCard.index + 1; + } + const getExistingCardAtIndex = async () => tx.query.cards.findFirst({ columns: { @@ -29,7 +43,7 @@ export const create = async ( }, where: and( eq(cards.listId, cardInput.listId), - eq(cards.index, cardInput.index), + eq(cards.index, index), isNull(cards.deletedAt), ), }); @@ -40,12 +54,8 @@ export const create = async ( await tx.execute(sql` UPDATE card SET index = index + 1 - WHERE "listId" = ${cardInput.listId} AND index >= ${cardInput.index} AND "deletedAt" IS NULL; + WHERE "listId" = ${cardInput.listId} AND index >= ${index} AND "deletedAt" IS NULL; `); - - const refetchedExistingCardAtIndex = await getExistingCardAtIndex(); - - if (refetchedExistingCardAtIndex?.id) return tx.rollback(); } const result = await tx @@ -56,11 +66,11 @@ export const create = async ( description: cardInput.description, createdBy: cardInput.createdBy, listId: cardInput.listId, - index: cardInput.index, + index: index, }) - .returning({ id: cards.id }); + .returning({ id: cards.id, listId: cards.listId }); - if (!result[0]) return tx.rollback(); + if (!result[0]) throw new Error("Unable to create card"); await tx.insert(cardActivities).values({ publicId: generateUID(), @@ -69,6 +79,24 @@ export const create = async ( createdBy: cardInput.createdBy, }); + const countExpr = sql`COUNT(*)`.mapWith(Number); + + const duplicateIndices = await tx + .select({ + index: cards.index, + count: countExpr, + }) + .from(cards) + .where(and(eq(cards.listId, result[0].listId), isNull(cards.deletedAt))) + .groupBy(cards.listId, cards.index) + .having(gt(countExpr, 1)); + + if (duplicateIndices.length > 0) { + throw new Error( + `Duplicate indices found after creating card ${result[0].id}`, + ); + } + return result[0]; }); }; @@ -405,6 +433,7 @@ export const getWithListAndMembersByPublicId = async ( comment: true, createdBy: true, updatedAt: true, + deletedAt: true, }, // https://github.com/drizzle-team/drizzle-orm/issues/2903 // where: isNull(comments.deletedAt), @@ -421,6 +450,9 @@ export const getWithListAndMembersByPublicId = async ( ...card, labels: card.labels.map((label) => label.label), members: card.members.map((member) => member.member), + activities: card.activities.filter( + (activity) => !activity.comment?.deletedAt, + ), }; return formattedResult; @@ -679,3 +711,32 @@ export const hardDeleteAllCardLabelRelationships = async ( return result; }; + +export const getWorkspaceAndCardIdByCardPublicId = async ( + db: dbClient, + cardPublicId: string, +) => { + const result = await db.query.cards.findFirst({ + columns: { id: true }, + where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)), + with: { + list: { + columns: {}, + with: { + board: { + columns: { + workspaceId: true, + }, + }, + }, + }, + }, + }); + + return result + ? { + id: result.id, + workspaceId: result.list.board.workspaceId, + } + : null; +};