diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index e7a608e6..81cc54fa 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -138,7 +138,7 @@ export default function BoardPage() { }, }); - const updateCardMutation = api.card.reorder.useMutation({ + const updateCardMutation = api.card.update.useMutation({ onMutate: async (args) => { await utils.board.byId.cancel(); @@ -148,21 +148,29 @@ export default function BoardPage() { if (!oldBoard) return oldBoard; const updatedLists = Array.from(oldBoard.lists); - const sourceList = updatedLists.find( - (list) => list.publicId === args.currentListPublicId, + + const sourceList = updatedLists.find((list) => + list.cards.some((card) => card.publicId === args.cardPublicId), ); const destinationList = updatedLists.find( - (list) => list.publicId === args.newListPublicId, + (list) => list.publicId === args.listPublicId, ); - const removedCard = sourceList?.cards.splice(args.currentIndex, 1)[0]; + + const cardToMove = sourceList?.cards.find( + (card) => card.publicId === args.cardPublicId, + ); + + if (!cardToMove) return oldBoard; + + const removedCard = sourceList?.cards.splice(cardToMove.index, 1)[0]; if ( sourceList && destinationList && removedCard && - args.newIndex !== undefined + args.index !== undefined ) { - destinationList.cards.splice(args.newIndex, 0, removedCard); + destinationList.cards.splice(args.index, 0, removedCard); return { ...oldBoard, @@ -217,10 +225,9 @@ export default function BoardPage() { if (type === "CARD") { updateCardMutation.mutate({ cardPublicId: draggableId, - currentListPublicId: source.droppableId, - newListPublicId: destination.droppableId, - currentIndex: source.index, - newIndex: destination.index, + + listPublicId: destination.droppableId, + index: destination.index, }); } }; diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index e4e5908a..26064840 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -575,8 +575,10 @@ export const cardRouter = createTRPCRouter({ .input( z.object({ cardPublicId: z.string().min(12), - title: z.string().min(1), - description: z.string(), + title: z.string().min(1).optional(), + description: z.string().optional(), + index: z.number().optional(), + listPublicId: z.string().min(12).optional(), }), ) .output(z.custom>>()) @@ -594,6 +596,23 @@ export const cardRouter = createTRPCRouter({ 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`, @@ -601,11 +620,33 @@ export const cardRouter = createTRPCRouter({ }); } - const result = await cardRepo.update( - ctx.db, - { title: input.title, description: input.description }, - { cardPublicId: input.cardPublicId }, - ); + let result: + | { + id: number; + title: string; + description: string | null; + publicId: string; + } + | undefined; + + if (input.title || input.description) { + result = await cardRepo.update( + ctx.db, + { + ...(input.title && { title: input.title }), + ...(input.description && { description: input.description }), + }, + { 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({ @@ -705,111 +746,4 @@ export const cardRouter = createTRPCRouter({ 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), - currentListPublicId: z.string().min(12).optional(), - newListPublicId: z.string().min(12), - currentIndex: z.number().optional(), - 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.supabaseClient, { - 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 }; - }), }); diff --git a/packages/api/src/routers/list.ts b/packages/api/src/routers/list.ts index f31bb3ab..4615153a 100644 --- a/packages/api/src/routers/list.ts +++ b/packages/api/src/routers/list.ts @@ -167,8 +167,6 @@ export const listRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { let result: { name: string; publicId: string } | undefined; - console.log({ input }); - if (input.name) { result = await listRepo.update( ctx.db, diff --git a/packages/api/src/types/router.types.ts b/packages/api/src/types/router.types.ts index 4ee16103..e033a585 100644 --- a/packages/api/src/types/router.types.ts +++ b/packages/api/src/types/router.types.ts @@ -2,7 +2,6 @@ import type { RouterInputs, RouterOutputs } from "../index"; export type GetBoardByIdOutput = RouterOutputs["board"]["byId"]; export type GetCardByIdOutput = RouterOutputs["card"]["byId"]; -export type ReorderCardInput = RouterInputs["card"]["reorder"]; export type UpdateBoardInput = RouterInputs["board"]["update"]; export type NewLabelInput = RouterInputs["label"]["create"]; export type NewListInput = RouterInputs["list"]["create"]; diff --git a/packages/db/seed.sql b/packages/db/seed.sql index a4076368..5ff5c7c6 100644 --- a/packages/db/seed.sql +++ b/packages/db/seed.sql @@ -1,54 +1,3 @@ - - -CREATE OR REPLACE FUNCTION reorder_cards(card_id BIGINT, current_list_id BIGINT, new_list_id BIGINT, current_index INT, new_index INT) -RETURNS BOOLEAN -LANGUAGE PLPGSQL -AS $$ - DECLARE - card_index INT; - BEGIN - SELECT index INTO card_index FROM card WHERE "listId" = current_list_id AND id = card_id AND "deletedAt" IS NULL; - - IF current_list_id = new_list_id THEN - UPDATE card - SET index = - CASE - WHEN index = current_index THEN new_index - WHEN current_index < new_index AND index > current_index AND index <= new_index THEN index - 1 - WHEN current_index > new_index AND index >= new_index AND index < current_index THEN index + 1 - ELSE index - END - WHERE "listId" = current_list_id AND "deletedAt" IS NULL; - ELSE - UPDATE card - SET index = index + 1 - WHERE "listId" = new_list_id AND index >= new_index AND "deletedAt" IS NULL; - - UPDATE card - SET index = index - 1 - WHERE "listId" = current_list_id AND index >= current_index AND "deletedAt" IS NULL; - - UPDATE card - SET "listId" = new_list_id, index = new_index - WHERE id = card_id AND "deletedAt" IS NULL; - END IF; - - -- Check for duplicate indices in both affected lists - IF EXISTS ( - SELECT index, COUNT(*) - FROM card - WHERE "listId" IN (current_list_id, new_list_id) - AND "deletedAt" IS NULL - GROUP BY "listId", index - HAVING COUNT(*) > 1 - ) THEN - RAISE EXCEPTION 'Duplicate indices found after reordering in list % or %', current_list_id, new_list_id; - END IF; - - RETURN TRUE; - END; -$$; - CREATE OR REPLACE FUNCTION shift_list_index(board_id BIGINT, list_index INT) RETURNS VOID LANGUAGE SQL diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 92fb9125..646d83d5 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -1,5 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; -import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import type { Database } from "@kan/db/types/database.types"; @@ -108,8 +108,8 @@ export const bulkCreateCardWorkspaceMemberRelationships = async ( export const update = async ( db: dbClient, cardInput: { - title: string; - description: string; + title?: string; + description?: string; }, args: { cardPublicId: string; @@ -428,26 +428,143 @@ export const getWithListAndMembersByPublicId = async ( return formattedResult; }; -// Move to update - should take two arguments cardId and index export const reorder = async ( - db: SupabaseClient, + db: dbClient, args: { - currentListId: number; - newListId: number; - currentIndex: number; - newIndex: number; + newListId: number | undefined; + newIndex: number | undefined; cardId: number; }, ) => { - const { error } = await db.rpc("reorder_cards", { - current_list_id: args.currentListId, - new_list_id: args.newListId, - current_index: args.currentIndex, - new_index: args.newIndex, - card_id: args.cardId, - }); + return db.transaction(async (tx) => { + const card = await tx.query.cards.findFirst({ + columns: { + id: true, + index: true, + }, + where: and(eq(cards.id, args.cardId), isNull(cards.deletedAt)), + with: { + list: { + columns: { + id: true, + index: true, + }, + }, + }, + }); - return { success: !error }; + if (!card?.list) + throw new Error(`Card not found for public ID ${args.cardId}`); + + const currentList = card.list; + const currentIndex = card.index; + let newList: + | { id: number; index: number; cards: { id: number; index: number }[] } + | undefined; + + if (args.newListId) { + newList = await tx.query.lists.findFirst({ + columns: { + id: true, + index: true, + }, + with: { + cards: { + columns: { + id: true, + index: true, + }, + orderBy: desc(cards.index), + limit: 1, + }, + }, + where: and(eq(lists.id, args.newListId), isNull(lists.deletedAt)), + }); + + if (!newList) + throw new Error(`List not found for public ID ${args.newListId}`); + } + + let newIndex = args.newIndex; + + if (newIndex === undefined) { + const lastCardIndex = newList?.cards.length + ? newList.cards[0]?.index + : undefined; + + newIndex = lastCardIndex !== undefined ? lastCardIndex + 1 : 0; + } + + if (currentList.id === newList?.id) { + await tx.execute(sql` + UPDATE card + SET index = + CASE + WHEN index = ${currentIndex} THEN ${newIndex} + WHEN ${currentIndex} < ${newIndex} AND index > ${currentIndex} AND index <= ${newIndex} THEN index - 1 + WHEN ${currentIndex} > ${newIndex} AND index >= ${newIndex} AND index < ${currentIndex} THEN index + 1 + ELSE index + END + WHERE "listId" = ${currentList.id} AND "deletedAt" IS NULL; + `); + } else { + await tx.execute(sql` + UPDATE card + SET index = index + 1 + WHERE "listId" = ${newList?.id} AND index >= ${newIndex} AND "deletedAt" IS NULL; + `); + + await tx.execute(sql` + UPDATE card + SET index = index - 1 + WHERE "listId" = ${currentList.id} AND index >= ${currentIndex} AND "deletedAt" IS NULL; + `); + + await tx.execute(sql` + UPDATE card + SET "listId" = ${newList?.id}, index = ${newIndex} + WHERE id = ${card.id} AND "deletedAt" IS NULL; + `); + } + + const countExpr = sql`COUNT(*)`.mapWith(Number); + + const duplicateIndices = await db + .select({ + index: cards.index, + count: countExpr, + }) + .from(cards) + .where( + and( + inArray( + cards.listId, + [currentList.id, newList?.id].filter((id) => id !== undefined), + ), + isNull(cards.deletedAt), + ), + ) + .groupBy(cards.listId, cards.index) + .having(gt(countExpr, 1)); + + if (duplicateIndices.length > 0) { + throw new Error( + `Duplicate indices found after reordering card ${card.id}`, + ); + } + + const updatedCard = await tx.query.cards.findFirst({ + columns: { + id: true, + publicId: true, + title: true, + description: true, + }, + where: eq(cards.id, card.id), + }); + + return updatedCard; + }); }; // Again should be handled in update transaction