Skip to content

feat: move boards between workspaces#458

Open
nickmeinhold wants to merge 14 commits into
kanbn:mainfrom
10xdeca:feat/move-board-between-workspaces
Open

feat: move boards between workspaces#458
nickmeinhold wants to merge 14 commits into
kanbn:mainfrom
10xdeca:feat/move-board-between-workspaces

Conversation

@nickmeinhold
Copy link
Copy Markdown
Contributor

Summary

Adds the ability to move a board (and all its contents) from one workspace to another. Closes #344.

  • New board.move API mutation with dual permission checks (source + target workspace)
  • "Move to workspace" menu item in the board dropdown
  • Workspace picker modal with warning about card member clearing
  • Auto-resolves slug conflicts in the target workspace by appending a UID suffix

Design decisions

Per @hjball's comment about handling comments, activity, and assignees:

Entity References Workspace-scoped? Behaviour on move
Card assignees workspaceMembers.id Yes Cleared — these reference workspace-scoped member IDs that may not exist in the target workspace. User sees a warning before confirming.
Comments users.id No Preserved — comments reference the global user UUID, not workspace membership. They'll display correctly even if the author isn't a member of the target workspace.
Activity log users.id No Preserved — same as comments, activity entries reference global user IDs. Full history is retained.

Permission model

  • Requires board:edit permission (or creator) in the source workspace
  • Requires board:create permission in the target workspace
  • Templates and archived boards cannot be moved

What moves

Everything: lists, cards, labels, checklists, checklist items, comments, activity, attachments. All of these reference the board (directly or transitively), not the workspace — so updating board.workspaceId is sufficient.

What gets cleared

Only _card_workspace_members entries (card assignees), because they join through workspace-scoped member IDs. Users can reassign cards after the move.

Files changed

  • packages/db/src/repository/board.repo.tsmoveToWorkspace() transactional function
  • packages/api/src/routers/board.tsboard.move mutation
  • apps/web/src/views/board/components/BoardDropdown.tsx — menu item
  • apps/web/src/views/board/components/MoveBoardForm.tsx — new component
  • apps/web/src/views/board/index.tsx — modal registration
  • apps/web/src/locales/ — extracted + compiled translations

Test plan

  • Move a board between workspaces — verify board appears in target, disappears from source
  • Verify card member assignments are cleared after move
  • Verify comments and activity history are preserved
  • Test slug conflict: create boards with same name in both workspaces, move one
  • Verify templates cannot be moved (menu item hidden)
  • Verify archived boards show error message
  • Verify user without board:create in target workspace cannot move

🤖 Generated with Claude Code

@nickmeinhold
Copy link
Copy Markdown
Contributor Author

Automated Code Review — Cage Match Edition

Two AI reviewers independently analyzed this PR:


MaxwellMergeSlam's Review 🤼

Verdict: APPROVE

Summary: A clean, well-architected feature that moves boards between workspaces with proper permission gates and data integrity — this PR gets it done without breaking a sweat.

Tyler Durden: "It's only after we've lost everything that we're free to do anything." And it's only after we've properly cleared workspace-scoped card assignments that we're free to move a board anywhere.

Findings:

  • board.repo.ts:moveToWorkspace — The transactional approach is correct. Updates workspaceId, then walks the boards → lists → cards → _card_workspace_members chain to clear orphaned assignments. Two SELECT queries inside the transaction (lists, then cards) before the DELETE is clean and readable.

  • board.ts:move mutation — Dual permission model (assertCanEdit on source, assertPermission("board:create") on target) is the right call. Template and archive guards are present. Slug conflict auto-resolution via generateUID() suffix matches the existing pattern used in board.create.

  • board.ts:~line 680 — There's a secondary query to fetch boardDetails.slug using ctx.db.query.boards.findFirst() inline rather than through the repo layer. This works but breaks the pattern slightly — every other board query in this file goes through boardRepo. Minor consistency nit, not a blocker.

  • MoveBoardForm.tsx — Clean component. Uses switchWorkspace() on success to redirect the user to the target workspace's boards page, which is good UX. The "no other workspaces" empty state is handled. Warning text about member clearing is present.

  • MoveBoardForm.tsx:27availableWorkspaces is filtered by publicId to exclude the current workspace. But there's no filtering for permission — a user might see workspaces where they're a guest without board:create permission. The server will reject it (permission check is in the mutation), so this isn't a security issue — but it's a minor UX annoyance (user selects workspace, gets error). Could filter by role !== "guest" client-side as a heuristic.

  • BoardDropdown.tsx — Menu item placement is logical: after archive, before favorites. Gated on !isTemplate && canEditBoard. The HiArrowRightOnRectangle icon is a reasonable choice.

The Good:

  • The PR description is chef's kiss — the entity reference table showing what's workspace-scoped vs global is exactly what a reviewer needs. Henry shouldn't have to dig through schema files to understand the design.
  • Transaction in the repo layer keeps the mutation atomic.
  • Follows every existing pattern: permission helpers, repo functions, modal system, Lingui translations, conventional commit message.
  • Net new code is ~190 lines (excluding locale files). Tight.

The Concerns:

  • Minor UX: Guest users might see workspaces they can't move to in the dropdown. Server rejects it safely, but an error popup for a predictable case isn't ideal.
  • Minor consistency: The inline ctx.db.query.boards.findFirst() in the mutation should ideally go through the repo layer.
  • No tests: There are no new unit/integration tests for board.move or moveToWorkspace. The existing helpers.test.ts pattern shows tests are valued in this codebase. Not blocking for a first pass, but would strengthen confidence.

Neo: "I know kung fu." This PR knows its schema. APPROVE.


KelvinBitBrawler's Review

Verdict: REQUEST_CHANGES

Summary: This PR introduces a functional board movement feature, complete with proper permission checks and transactional integrity, but suffers from thermal inefficiency and a frosty oversight in slug generation.

Findings:

  • packages/api/src/routers/board.ts: L658, L667, L707 - Multiple, redundant database queries are executed to retrieve board information (workspaceId, type, isArchived, slug). This is a gross thermal inefficiency. You're hitting the database repeatedly when a single, well-structured query could pull all necessary data. HAL 9000: "I am putting myself to the fullest possible use, which is all I think that any conscious entity can ever hope to do." Your code isn't.
  • packages/api/src/routers/board.ts: L712 - The fallback for slug generation generateSlug(input.boardPublicId) is utterly devoid of human sense. Slugs are meant to be readable identifiers, not a raw public ID. If the original board slug is missing, you should be deriving it from the board's name, not some cryptic identifier. This will result in an unreadable slug, and that's just poor form, a literal cold shoulder to user experience.

The Good:

  • packages/db/src/repository/board.repo.ts: L963-L1017 - The moveToWorkspace function is wrapped in a db.transaction, ensuring atomicity. This cold, hard transactional integrity is precisely what's needed for data consistency. Good.
  • packages/api/src/routers/board.ts: L680, L700 - Permission checks for both the source (board:edit) and target (board:create) workspaces are explicitly handled and correctly enforced. Zero tolerance for unauthorized access.
  • packages/api/src/routers/board.ts: L714-L716 - Automated slug conflict resolution by appending a UID suffix is a practical and robust solution to prevent data collisions. No icy stalemates there.
  • apps/web/src/views/board/components/MoveBoardForm.tsx: L25, L31 - Clear user feedback with success and error popups, and the explicit warning about clearing card member assignments, shows a consideration for the end-user. It's not a complete deep freeze, but it's a good step.

The Concerns:

  • The redundant database calls in packages/api/src/routers/board.ts must be refactored into a single, comprehensive query. This isn't just about performance; it's about not writing unnecessary boilerplate that obscures intent. Deckard: "Gifts for the boss, huh?" You're bringing too many separate gifts to the same table.
  • The generateSlug(input.boardPublicId) fallback needs to be reconsidered. If boardDetails?.slug is null, the system should retrieve the board's name to generate a meaningful slug, rather than using the publicId. This is a basic expectation for user-friendly URLs.

Consensus & Resolution

Maxwell: APPROVE | Kelvin: REQUEST_CHANGES

Kelvin caught two valid issues that Maxwell missed:

  1. Redundant DB queries — 3 separate queries for the same board consolidated into 1
  2. Slug fallbackgenerateSlug(input.boardPublicId) changed to generateSlug(board.name) for readable URLs

Both fixes addressed in commit 2c2a349.

nickmeinhold and others added 5 commits April 2, 2026 15:05
Implements the "Move to workspace" feature (kanbn#344) allowing users to
relocate a board and all its contents (lists, cards, labels, checklists,
comments, activity) to a different workspace.

Key design decisions:
- Card member assignments are cleared on move (they reference
  workspace-scoped members that may not exist in the target workspace)
- Comments and activity history are preserved (they reference global
  user IDs, not workspace members)
- Slug conflicts in the target workspace are auto-resolved by
  appending a UID suffix
- Permission model: requires board:edit in source workspace and
  board:create in target workspace
- Templates and archived boards cannot be moved

Co-Authored-By: Claude <noreply@anthropic.com>
Address review feedback:
- Consolidate 3 separate board queries into a single findFirst()
  that fetches all needed fields (id, name, slug, type, isArchived,
  workspaceId, createdBy)
- Fix slug fallback to use board.name instead of publicId for
  human-readable URLs

Co-Authored-By: Claude <noreply@anthropic.com>
Guests typically lack board:create permission in the target workspace,
so showing them as destinations leads to a confusing server rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moves the inline board query from the move mutation into the repo
layer, consistent with how every other board mutation fetches data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 test cases covering auth, validation, permissions, slug conflict
resolution, and the happy path. Follows webhook.test.ts patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nickmeinhold nickmeinhold force-pushed the feat/move-board-between-workspaces branch from 2c2a349 to 66251df Compare April 2, 2026 04:08
GitHub will now auto-collapse compiled translation files (messages.json,
messages.ts, messages.po) in PR diffs and exclude them from language
stats. This makes PRs that touch i18n strings much easier to review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nickmeinhold
Copy link
Copy Markdown
Contributor Author

Hey @hjball — heads up, the diff stats on this PR look scary (50k+ lines) but that's almost entirely auto-generated locale files (18 of 24 changed files).

I've added a .gitattributes file that marks those as linguist-generated, so GitHub should now auto-collapse them in the diff view. The actual feature code is just 6 files:

  • packages/db/src/repository/board.repo.ts — repo function for the move
  • packages/api/src/routers/board.tsboard.move mutation
  • packages/api/src/routers/board-move.test.ts — tests
  • apps/web/src/views/board/components/BoardDropdown.tsx — menu item
  • apps/web/src/views/board/components/MoveBoardForm.tsx — workspace picker modal
  • apps/web/src/views/board/index.tsx — modal registration

@hjball
Copy link
Copy Markdown
Contributor

hjball commented Apr 18, 2026

This is great @nickmeinhold - please could you remove the locale changes from this PR (it's all handled automatically on merge now)

Copy link
Copy Markdown
Contributor

@hjball hjball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be good to go after the locale changes have been removed

Reverts locale file diffs and .gitattributes to match main, per review
feedback. The locale changes were unrelated translation updates that
inflated the PR diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@nickmeinhold nickmeinhold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Locale file changes removed — the PR diff is now just the feature code (20 files, ~1.7k lines). Ready for another look when you get a chance!

Accept main's locale files and auto-resolve board/index.tsx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fetches the board fields needed by the move mutation:
* identity, naming, type guards, and workspace ownership.
*/
export const getBoardForMove = async (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the existing getBoardbyPublicId func instead? That should already filter out deletedAt records

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — getByPublicId is the wrong shape here (it pulls the full board with cards/labels/joins for the board-detail view). I kept getBoardForMove because it has the right column projection for this flow, and added the isNull(deletedAt) filter inline. Same effect: tombstoned boards now return null.

);

// Get target workspace
const targetWorkspace = await workspaceRepo.getByPublicId(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably check this doesn't return deleted records too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — guarded at the call site. workspaceRepo.getByPublicId doesn't filter deletedAt (legacy: same is true for ~14 other callers across workspace.ts, member.ts, webhook.ts, etc.). Extended its column projection to include deletedAt and added || targetWorkspace.deletedAt to the guard. The wider fix to make the repo treat deleted-as-not-found across all callers is a separate concern — happy to follow up in another PR if you'd prefer that pattern globally.

const boardCards = await tx
.select({ id: cards.id })
.from(cards)
.where(and(inArray(cards.listId, listIds), isNull(cards.deletedAt)));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want to include all cards (even those already deleted) so we don't have rouge members assignments when restoring cards in future

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that's the rogue-restore case. Removed the isNull(deletedAt) filters on both the lists query and the cards query in this loop, so member assignments are now cleared for every card under every list ever associated with this board — soft-deleted or not. Added a regression test for the soft-deleted target workspace path while in the area (board-move.test.ts now has 11 tests, all passing).

Copy link
Copy Markdown
Contributor

@hjball hjball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great @nickmeinhold - just a few small comments to address otherwise this one lgtm

nickmeinhold and others added 2 commits May 6, 2026 10:25
Per @hjball's review: locale compilation/translations are handled
automatically on merge to main, so this PR shouldn't carry them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous removal commit used local main, which had drifted from
upstream. Re-syncing to upstream/main so the PR carries no locale diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nickmeinhold
Copy link
Copy Markdown
Contributor Author

Thanks @hjball — locale changes removed in 4f11ed4 (re-aligned to upstream/main since the earlier removal had used a stale local main). PR's Files changed should now be free of locale/i18n diffs. Ready for another look.

@hjball
Copy link
Copy Markdown
Contributor

hjball commented May 11, 2026

Hey @nickmeinhold, couple of bits needed before we can merge this:

  • Looks like the locale changes have snuck back in
  • There's a couple of comments that need addressing

nickmeinhold and others added 3 commits May 14, 2026 09:49
Three changes addressing @hjball's review comments, all about the
schema treating deletedAt as optional metadata while the move-board
flow needs it as a load-bearing invariant.

1. getBoardForMove now filters isNull(deletedAt). Moving a tombstoned
   board has no defensible semantics. Replaces the implicit
   "the board exists in the table" check with an explicit
   "the board is not soft-deleted" check.

2. Move-flow's clearing of cardToWorkspaceMembers now spans every
   card under every list ever associated with this board, including
   soft-deleted ones. If we leave member assignments on a deleted
   card and that card is later restored, the assignments would
   resurrect rogue references to workspace members from the OLD
   workspace. Removed the isNull filters on both lists and cards in
   that loop.

3. Move-flow now refuses to move into a soft-deleted target
   workspace. workspaceRepo.getByPublicId did not previously project
   deletedAt; extended its column selection so the call-site guard
   in board.move can check it. (A wider fix to make the repo treat
   deleted-as-not-found across all 14+ callers is left for a
   separate PR — narrow scope here.)

Plus one regression test: throws NOT_FOUND when target workspace is
soft-deleted. All 11 board-move tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ween-workspaces

# Conflicts:
#	apps/web/i18n.lock
#	apps/web/src/locales/de/messages.json
#	apps/web/src/locales/de/messages.ts
#	apps/web/src/locales/en/messages.ts
#	apps/web/src/locales/es/messages.json
#	apps/web/src/locales/es/messages.ts
#	apps/web/src/locales/fr/messages.json
#	apps/web/src/locales/fr/messages.ts
#	apps/web/src/locales/it/messages.json
#	apps/web/src/locales/it/messages.ts
#	apps/web/src/locales/nl/messages.json
#	apps/web/src/locales/nl/messages.ts
#	apps/web/src/locales/pl/messages.json
#	apps/web/src/locales/pl/messages.ts
#	apps/web/src/locales/pt-BR/messages.json
#	apps/web/src/locales/pt-BR/messages.ts
#	apps/web/src/locales/ru/messages.json
#	apps/web/src/locales/ru/messages.ts
…ween-workspaces

# Conflicts:
#	apps/web/src/locales/de/messages.json
#	apps/web/src/locales/es/messages.json
#	apps/web/src/locales/fr/messages.json
#	apps/web/src/locales/it/messages.json
#	apps/web/src/locales/nl/messages.json
#	apps/web/src/locales/pl/messages.json
#	apps/web/src/locales/pt-BR/messages.json
#	apps/web/src/locales/ru/messages.json
@nickmeinhold
Copy link
Copy Markdown
Contributor Author

@hjball — all three comments addressed in 272011d, plus a regression test for the soft-deleted target workspace case (board-move.test.ts now has 11 tests, all passing). Also merged latest upstream/main to clear conflicts. Ready for another look when you have a moment 🙏

…ween-workspaces

# Conflicts:
#	apps/web/i18n.lock
#	apps/web/src/locales/de/messages.json
#	apps/web/src/locales/de/messages.ts
#	apps/web/src/locales/en/messages.ts
#	apps/web/src/locales/es/messages.json
#	apps/web/src/locales/es/messages.ts
#	apps/web/src/locales/fr/messages.json
#	apps/web/src/locales/fr/messages.ts
#	apps/web/src/locales/it/messages.json
#	apps/web/src/locales/it/messages.ts
#	apps/web/src/locales/nl/messages.json
#	apps/web/src/locales/nl/messages.ts
#	apps/web/src/locales/pl/messages.json
#	apps/web/src/locales/pl/messages.ts
#	apps/web/src/locales/pt-BR/messages.json
#	apps/web/src/locales/pt-BR/messages.ts
#	apps/web/src/locales/ru/messages.json
#	apps/web/src/locales/ru/messages.ts
#	packages/db/src/repository/workspace.repo.ts
@nickmeinhold
Copy link
Copy Markdown
Contributor Author

Conflicts resolved and CI is green again. The PR went stale purely from upstream drift (main moved ~32 commits ahead with the partner/Stripe/subscriptions work), not from anything outstanding on the review.

Merged latest main:

  • All locale/i18n conflicts resolved by taking main's version, so the locale files are dropped from the diff entirely (verified: git diff main -- locales/ is empty). Files changed is back to just the 7 feature files.
  • One real code conflict in workspace.repo.ts: main added createdBy to the getByPublicId select while this branch added deletedAt (for the soft-delete guard). Kept both.

11/11 board-move tests passing, @kan/db and @kan/api typecheck clean, build check green. Ready for another look when you get a chance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Move boards between workspaces

2 participants