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),