diff --git a/apps/web/src/views/board/components/BoardDropdown.tsx b/apps/web/src/views/board/components/BoardDropdown.tsx index eea42440..783ccd44 100644 --- a/apps/web/src/views/board/components/BoardDropdown.tsx +++ b/apps/web/src/views/board/components/BoardDropdown.tsx @@ -1,6 +1,7 @@ import { useRouter } from "next/router"; import { t } from "@lingui/core/macro"; import { + HiArrowRightOnRectangle, HiEllipsisHorizontal, HiLink, HiOutlineDocumentDuplicate, @@ -119,6 +120,17 @@ export default function BoardDropdown({ }, ] : []), + ...(!isTemplate && canEditBoard + ? [ + { + label: t`Move to workspace`, + action: () => openModal("MOVE_BOARD"), + icon: ( + + ), + }, + ] + : []), { label: isFavorite ? t`Remove from favorites` diff --git a/apps/web/src/views/board/components/MoveBoardForm.tsx b/apps/web/src/views/board/components/MoveBoardForm.tsx new file mode 100644 index 00000000..4ebb5f91 --- /dev/null +++ b/apps/web/src/views/board/components/MoveBoardForm.tsx @@ -0,0 +1,113 @@ +import { useRouter } from "next/navigation"; +import { t } from "@lingui/core/macro"; +import { useState } from "react"; + +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; + +export function MoveBoardForm({ + boardPublicId, +}: { + boardPublicId: string; +}) { + const router = useRouter(); + const { closeModal } = useModal(); + const { showPopup } = usePopup(); + const { workspace, availableWorkspaces, switchWorkspace } = useWorkspace(); + const [targetWorkspacePublicId, setTargetWorkspacePublicId] = useState(""); + + const otherWorkspaces = availableWorkspaces.filter( + (ws) => ws.publicId !== workspace.publicId && ws.role !== "guest", + ); + + const moveBoard = api.board.move.useMutation({ + onSuccess: () => { + const targetWorkspace = availableWorkspaces.find( + (ws) => ws.publicId === targetWorkspacePublicId, + ); + closeModal(); + showPopup({ + header: t`Board moved`, + message: t`The board has been moved to ${targetWorkspace?.name ?? "the workspace"}.`, + icon: "success", + }); + if (targetWorkspace) { + switchWorkspace(targetWorkspace); + } else { + router.push("/boards"); + } + }, + onError: (error) => { + showPopup({ + header: t`Unable to move board`, + message: error.message, + icon: "error", + }); + }, + }); + + const handleMoveBoard = () => { + if (!targetWorkspacePublicId) return; + moveBoard.mutate({ + boardPublicId, + targetWorkspacePublicId, + }); + }; + + return ( +
+
+

+ {t`Move board to another workspace`} +

+ {otherWorkspaces.length === 0 ? ( +

+ {t`You don't have any other workspaces to move this board to.`} +

+ ) : ( + <> + + +

+ {t`Card member assignments will be cleared when moving to a different workspace.`} +

+ + )} +
+
+ + {otherWorkspaces.length > 0 && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index d8a5ca4e..0ffbf8eb 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -46,6 +46,7 @@ import { CardContextMembersModal } from "./components/CardContextMembersModal"; import { CardContextMenu } from "./components/CardContextMenu"; import { CardContextMoveListModal } from "./components/CardContextMoveListModal"; import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation"; +import { MoveBoardForm } from "./components/MoveBoardForm"; import { DeleteListConfirmation } from "./components/DeleteListConfirmation"; import Filters from "./components/Filters"; import List from "./components/List"; @@ -460,6 +461,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { /> + + + + ({ + getBoardForMove: vi.fn(), + isBoardSlugAvailable: vi.fn(), + moveToWorkspace: vi.fn(), + getIdByPublicId: vi.fn(), + getByPublicId: vi.fn(), + getWithListIdsByPublicId: vi.fn(), + getWithLatestListIndexByPublicId: vi.fn(), + getWorkspaceAndBoardIdByBoardPublicId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updatePositions: vi.fn(), + archive: vi.fn(), + deleteBoard: vi.fn(), + getAllByWorkspaceId: vi.fn(), + createFavorite: vi.fn(), + deleteFavorite: vi.fn(), + getFavorite: vi.fn(), +})); + +vi.mock("@kan/db/repository/workspace.repo", () => ({ + getByPublicId: vi.fn(), +})); + +vi.mock("@kan/db/repository/card.repo", () => ({ + getByPublicId: vi.fn(), + create: vi.fn(), + update: vi.fn(), +})); + +vi.mock("@kan/db/repository/cardActivity.repo", () => ({ + create: vi.fn(), +})); + +vi.mock("@kan/db/repository/label.repo", () => ({ + create: vi.fn(), + getById: vi.fn(), + getByPublicId: vi.fn(), +})); + +vi.mock("@kan/db/repository/list.repo", () => ({ + create: vi.fn(), + getByPublicId: vi.fn(), +})); + +vi.mock("../utils/permissions", () => ({ + assertCanEdit: vi.fn(), + assertCanDelete: vi.fn(), + assertPermission: vi.fn(), +})); + +vi.mock("@kan/shared/utils", () => ({ + generateSlug: vi.fn((name: string) => name.toLowerCase().replace(/\s+/g, "-")), + generateUID: vi.fn(() => "abc123"), + generateAvatarUrl: vi.fn(), + convertDueDateFiltersToRanges: vi.fn(), +})); + +vi.mock("@kan/shared/constants", () => ({ + colours: [], +})); + +import * as boardRepo from "@kan/db/repository/board.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { assertCanEdit, assertPermission } from "../utils/permissions"; + +const mockGetBoardForMove = boardRepo.getBoardForMove as ReturnType; +const mockIsBoardSlugAvailable = boardRepo.isBoardSlugAvailable as ReturnType; +const mockMoveToWorkspace = boardRepo.moveToWorkspace as ReturnType; +const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType; +const mockAssertCanEdit = assertCanEdit as ReturnType; +const mockAssertPermission = assertPermission as ReturnType; + +describe("board.move", () => { + const mockDb = {} as never; + const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" }; + const mockInput = { + boardPublicId: "brd-123456789", + targetWorkspacePublicId: "ws-target-789", + }; + const mockBoard = { + id: 1, + name: "My Board", + slug: "my-board", + type: "board" as const, + isArchived: false, + workspaceId: 10, + createdBy: "user-123", + }; + const mockTargetWorkspace = { id: 20, publicId: "ws-target-789" }; + + beforeEach(() => { + vi.clearAllMocks(); + mockAssertCanEdit.mockResolvedValue(undefined); + mockAssertPermission.mockResolvedValue(undefined); + }); + + it("throws UNAUTHORIZED when user is not authenticated", async () => { + const { boardRouter } = await import("./board"); + const ctx = { user: null, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("throws NOT_FOUND when board does not exist", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(null); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("throws BAD_REQUEST for template boards", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce({ ...mockBoard, type: "template" }); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("throws BAD_REQUEST for archived boards", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce({ ...mockBoard, isArchived: true }); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("checks board:edit permission on source workspace", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockAssertCanEdit.mockRejectedValueOnce( + new TRPCError({ code: "FORBIDDEN", message: "No permission" }), + ); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + + expect(mockAssertCanEdit).toHaveBeenCalledWith( + mockDb, + mockUser.id, + mockBoard.workspaceId, + "board:edit", + mockBoard.createdBy, + ); + }); + + it("throws NOT_FOUND when target workspace does not exist", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockWorkspaceGetByPublicId.mockResolvedValueOnce(null); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("throws NOT_FOUND when target workspace is soft-deleted", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockWorkspaceGetByPublicId.mockResolvedValueOnce({ + ...mockTargetWorkspace, + deletedAt: new Date(), + }); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("throws BAD_REQUEST when target is the same workspace", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockWorkspaceGetByPublicId.mockResolvedValueOnce({ + id: mockBoard.workspaceId, + publicId: "ws-target-789", + }); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + }); + + it("checks board:create permission on target workspace", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace); + mockAssertPermission.mockRejectedValueOnce( + new TRPCError({ code: "FORBIDDEN", message: "No permission" }), + ); + + const ctx = { user: mockUser, db: mockDb } as never; + + await expect( + boardRouter.createCaller(ctx).move(mockInput), + ).rejects.toThrow(TRPCError); + + expect(mockAssertPermission).toHaveBeenCalledWith( + mockDb, + mockUser.id, + mockTargetWorkspace.id, + "board:create", + ); + }); + + it("appends UID suffix when slug conflicts in target workspace", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace); + mockIsBoardSlugAvailable.mockResolvedValueOnce(false); + mockMoveToWorkspace.mockResolvedValueOnce(undefined); + + const ctx = { user: mockUser, db: mockDb } as never; + + await boardRouter.createCaller(ctx).move(mockInput); + + expect(mockMoveToWorkspace).toHaveBeenCalledWith( + mockDb, + mockBoard.id, + mockTargetWorkspace.id, + "my-board-abc123", + ); + }); + + it("moves board successfully with available slug", async () => { + const { boardRouter } = await import("./board"); + mockGetBoardForMove.mockResolvedValueOnce(mockBoard); + mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockTargetWorkspace); + mockIsBoardSlugAvailable.mockResolvedValueOnce(true); + mockMoveToWorkspace.mockResolvedValueOnce(undefined); + + const ctx = { user: mockUser, db: mockDb } as never; + + const result = await boardRouter.createCaller(ctx).move(mockInput); + + expect(result).toEqual({ success: true }); + expect(mockMoveToWorkspace).toHaveBeenCalledWith( + mockDb, + mockBoard.id, + mockTargetWorkspace.id, + "my-board", + ); + }); +}); diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 42bf817a..0f1ac5cf 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -644,6 +644,119 @@ export const boardRouter = createTRPCRouter({ } } + return { success: true }; + }), + move: protectedProcedure + .meta({ + openapi: { + method: "POST", + path: "/boards/{boardPublicId}/move", + summary: "Move board to another workspace", + description: + "Moves a board and all its contents to a different workspace", + tags: ["Boards"], + protect: true, + }, + }) + .input( + z.object({ + boardPublicId: z.string().min(12), + targetWorkspacePublicId: 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", + }); + + // Get source board + const board = await boardRepo.getBoardForMove( + ctx.db, + input.boardPublicId, + ); + + if (!board) + throw new TRPCError({ + message: `Board with public ID ${input.boardPublicId} not found`, + code: "NOT_FOUND", + }); + + if (board.type === "template") + throw new TRPCError({ + message: `Templates cannot be moved between workspaces`, + code: "BAD_REQUEST", + }); + + if (board.isArchived) + throw new TRPCError({ + message: `Archived boards cannot be moved. Unarchive the board first.`, + code: "BAD_REQUEST", + }); + + // Check permission to edit board in source workspace + await assertCanEdit( + ctx.db, + userId, + board.workspaceId, + "board:edit", + board.createdBy ?? null, + ); + + // Get target workspace. workspaceRepo.getByPublicId does not yet + // filter soft-deleted workspaces (legacy: same is true for several + // peer callers); guard at this call site so we never move a board + // into a tombstoned workspace. A wider fix to make the repo treat + // deleted-as-not-found is a separate concern. + const targetWorkspace = await workspaceRepo.getByPublicId( + ctx.db, + input.targetWorkspacePublicId, + ); + + if (!targetWorkspace || targetWorkspace.deletedAt) + throw new TRPCError({ + message: `Target workspace not found`, + code: "NOT_FOUND", + }); + + if (targetWorkspace.id === board.workspaceId) + throw new TRPCError({ + message: `Board is already in this workspace`, + code: "BAD_REQUEST", + }); + + // Check permission to create boards in target workspace + await assertPermission( + ctx.db, + userId, + targetWorkspace.id, + "board:create", + ); + + let slug = board.slug ?? generateSlug(board.name); + + const isSlugAvailable = await boardRepo.isBoardSlugAvailable( + ctx.db, + slug, + targetWorkspace.id, + ); + + if (!isSlugAvailable) { + slug = `${slug}-${generateUID()}`; + } + + // Move the board + await boardRepo.moveToWorkspace( + ctx.db, + board.id, + targetWorkspace.id, + slug, + ); + return { success: true }; }), checkSlugAvailability: publicProcedure diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index aa37a110..5b266c58 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -724,6 +724,30 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async ( return result; }; +/** + * Fetches the board fields needed by the move mutation: + * identity, naming, type guards, and workspace ownership. + * Soft-deleted boards are excluded — moving a tombstoned board has + * no defensible semantics. + */ +export const getBoardForMove = async ( + db: dbClient, + boardPublicId: string, +) => { + return db.query.boards.findFirst({ + columns: { + id: true, + name: true, + slug: true, + type: true, + isArchived: true, + workspaceId: true, + createdBy: true, + }, + where: and(eq(boards.publicId, boardPublicId), isNull(boards.deletedAt)), + }); +}; + export const isBoardSlugAvailable = async ( db: dbClient, boardSlug: string, @@ -969,6 +993,61 @@ export const createFromSnapshot = async ( }); }; +export const moveToWorkspace = async ( + db: dbClient, + boardId: number, + targetWorkspaceId: number, + newSlug?: string, +) => { + return db.transaction(async (tx) => { + // Update the board's workspace (and slug if provided) + const [updatedBoard] = await tx + .update(boards) + .set({ + workspaceId: targetWorkspaceId, + ...(newSlug && { slug: newSlug }), + updatedAt: new Date(), + }) + .where(eq(boards.id, boardId)) + .returning({ + publicId: boards.publicId, + name: boards.name, + }); + + if (!updatedBoard) throw new Error("Failed to move board"); + + // Get every card ID ever belonging to this board, including + // soft-deleted cards under soft-deleted lists. Member assignments + // point at workspace-scoped members that no longer exist after + // the move; if we leave assignments on soft-deleted cards, a later + // restore would resurrect rogue references to the old workspace. + const boardLists = await tx + .select({ id: lists.id }) + .from(lists) + .where(eq(lists.boardId, boardId)); + + if (boardLists.length > 0) { + const listIds = boardLists.map((l) => l.id); + + const boardCards = await tx + .select({ id: cards.id }) + .from(cards) + .where(inArray(cards.listId, listIds)); + + if (boardCards.length > 0) { + const cardIds = boardCards.map((c) => c.id); + + // Clear all card member assignments (they reference workspace-scoped members) + await tx + .delete(cardToWorkspaceMembers) + .where(inArray(cardToWorkspaceMembers.cardId, cardIds)); + } + } + + return updatedBoard; + }); +}; + export const addUserFavorite = async ( db: dbClient, userId: string, diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index 6d5892fc..f11df74c 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => { name: true, plan: true, slug: true, + deletedAt: true, createdBy: true, }, where: eq(workspaces.publicId, workspacePublicId),