Skip to content

feat(project): move a project to another workspace - #1525

Open
rdlugs wants to merge 7 commits into
usekaneo:mainfrom
rdlugs:feat/move-project-between-workspaces
Open

feat(project): move a project to another workspace#1525
rdlugs wants to merge 7 commits into
usekaneo:mainfrom
rdlugs:feat/move-project-between-workspaces

Conversation

@rdlugs

@rdlugs rdlugs commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #1328

Problem

There's no way to reassign a project to a different workspace. Teams that reorganize are stuck either recreating the project by hand or editing the database directly, which is what the issue reporter was resorting to.

What this adds

PUT /project/:id/move plus a Move to workspace control in project general settings.

Permissions. The caller needs project:update and project:delete in the source workspace — a move removes the project from there — and project:create in the target. Every built-in role that grants update also grants delete, so the pair only matters for custom roles, where update alone would otherwise let someone move a project out of a workspace they can't delete from. The target is authorized separately (membership, permission, entitlement) because the access middleware only resolves one workspace per request.

Key collisions. The project key doubles as the ticket-id prefix (KAN-12), and short-id lookup resolves it per workspace with a limit of 1. If the target workspace already uses the key, the move is refused with a 409 naming the incumbent project, rather than silently making ticket ids ambiguous. Compared case-insensitively, matching the lookup.

Data that moves with it. All in one transaction:

  • project.workspaceId is repointed; tasks, columns, and comments follow by project id.
  • asset and label rows denormalize the workspace, so they're rewritten.
  • Project notification subscriptions are deleted — they reference the project through a composite (workspace_id, project_id) key, and the cascade from the workspace update would break the rule side, since the rule itself stays behind in the source workspace.
  • Tasks assigned to people who aren't members of the target workspace are unassigned, each with its own task.unassigned event so the change lands on the task timeline. Events publish after commit, so a rollback can't leave activity rows behind for a move that never happened.

The confirm dialog spells out the unassignment and subscription clearing before the user commits, and the resulting toast reports how many tasks lost their assignee.

Testing

tests/api-integration/project-move.test.ts — 8 integration tests covering: non-member target, target without project:create, key collision (including differing case), same key left behind in the source not blocking, source without project:delete, moving into the current workspace, the full side-data rewrite, and assignee dropping with its activity row.

Full suite green: pnpm typecheck, pnpm test, pnpm test:integration (117 tests), pnpm lint, pnpm build.

Notes

  • All 17 locales updated along with i18n/schema.json.
  • Entitlement is enforced on the target workspace only, matching how update and archive behave — an expired source workspace can still move projects out.
  • Stored S3 object keys keep their original workspace/<id>/… prefix after a move. Serving and cleanup both use the stored key verbatim and authorize against the asset row's workspace, so this is cosmetic; rewriting the keys would mean copying objects.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Projects can now be moved between workspaces from project settings.
    • Destination workspaces are filtered by access and permissions, with confirmation and outcome notifications.
    • Tasks assigned to people without destination access are unassigned and reported.
    • Workspace-specific project data and notification settings are updated during the move.
  • Localization
    • Added project-move translations across supported languages.
  • Tests
    • Added coverage for permissions, conflicts, successful moves, data updates, and task reassignment.

Adds PUT /project/:id/move, which repoints a project (and everything
hanging off it) at a different workspace.

The caller needs project update + delete in the source workspace and
project create in the target, since the move both removes the project
from one workspace and adds it to another. The target is authorized
separately from the source, as the access middleware only resolves one
workspace per request.

The move runs in a single transaction and refuses when the target
workspace already uses the project's key, which would make short ids
like KAN-12 ambiguous. Tasks assigned to people who aren't members of
the target are unassigned, each with a task.unassigned event. Rows that
denormalize the workspace (assets, labels) are rewritten, and project
notification subscriptions are dropped, since their rule stays behind.

Closes usekaneo#1328

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a project move workflow between workspaces. The API validates access and updates related records transactionally. The web settings page provides workspace selection, confirmation, feedback, and navigation. Localization and integration tests cover the workflow.

Changes

Project move workflow

Layer / File(s) Summary
Move route authorization
apps/api/src/project/index.ts, apps/api/src/utils/require-workspace-permission.ts
Adds source and target workspace validation, permission checks, entitlement checks, and workspace override support.
Transactional project relocation
apps/api/src/project/controllers/move-project.ts, tests/api-integration/project-move.test.ts
Moves project-related records transactionally, removes notification links, unassigns ineligible task assignees, publishes events, and returns the unassigned-task count.
Project settings move flow
apps/web/src/fetchers/project/move-project.ts, apps/web/src/hooks/mutations/project/use-move-project.ts, apps/web/src/hooks/queries/workspace/use-workspaces-with-permission.ts, apps/web/src/routes/.../general.tsx
Adds the API fetcher, mutation hook, permitted workspace filtering, confirmation UI, query invalidation, feedback, and navigation.
Move workflow localization
i18n/schema.json, i18n/*
Adds required project-move translation keys and localized labels, confirmations, success messages, warnings, and errors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ProjectSettings
  participant MoveFetcher
  participant ProjectRoute
  participant moveProject
  participant Database
  User->>ProjectSettings: Select target workspace and confirm
  ProjectSettings->>MoveFetcher: Submit project and workspace IDs
  MoveFetcher->>ProjectRoute: PUT /:id/move
  ProjectRoute->>moveProject: Validate authorization and execute move
  moveProject->>Database: Update project-related records transactionally
  Database-->>moveProject: Commit move
  moveProject-->>ProjectRoute: Return project and unassignedTaskCount
  ProjectRoute-->>MoveFetcher: Return response
  MoveFetcher-->>ProjectSettings: Return move result
  ProjectSettings-->>User: Show status and navigate to target workspace
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: moving a project to another workspace.
Linked Issues check ✅ Passed The PR implements project relocation through project settings and an API, directly addressing issue #1328.
Out of Scope Changes check ✅ Passed The API, UI, localization, permission support, and integration tests all support the project relocation feature.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Move a project between workspaces (API + UI + transactional data rewrite)

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add a project “Move to workspace” API and settings UI flow.
• Enforce source+target workspace permissions/entitlement and prevent project-key collisions.
• Move in one transaction, rewriting workspace-scoped rows and unassigning invalid assignees.
Diagram

graph TD
UI["Web: Project settings"] --> API["API: PUT /project/:id/move"] --> AUTH["AuthZ: source perms + target access"] --> TX["DB transaction: move"] --> DB[("DB: project + scoped rows")]
TX --> EVT["Publish task.unassigned"] --> ACT[("DB: activity rows")]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Model as copy-then-delete (clone project into target)
  • ➕ Keeps both workspaces’ history intact (original remains)
  • ➕ Avoids cross-workspace updates to existing rows
  • ➖ Breaks stable task identities/URLs unless complex redirect/mapping is added
  • ➖ More expensive (duplication) and harder to make atomic
  • ➖ Still needs key-collision and permission semantics
2. Auto-rename project key on collision
  • ➕ Improves UX by avoiding a hard stop on move
  • ➖ Changes ticket prefix semantics (KAN-12) and can surprise users
  • ➖ Requires cascading updates/handling for any key-derived references
  • ➖ Makes behavior less predictable; explicit 409 is safer
3. Allow updating project.workspaceId via existing update endpoint
  • ➕ Fewer endpoints; conceptually “just an update”
  • ➖ Harder to correctly authorize two workspaces with current middleware model
  • ➖ More likely to accidentally permit privilege escalation or partial updates
  • ➖ Less explicit API semantics for a destructive cross-workspace operation

Recommendation: Keep the PR’s dedicated move endpoint with explicit dual-workspace authorization and a single transaction. Refusing key collisions (409) preserves short-id/ticket-prefix integrity, and post-commit per-task unassignment events ensure timelines remain auditable without risking orphaned activity on rollback.

Files changed (25) +1080 / -20

Enhancement (6) +429 / -2
move-project.tsAdd transactional controller to move a project across workspaces +166/-0

Add transactional controller to move a project across workspaces

• Implements a single-transaction move that repoints project.workspaceId, rewrites workspace-denormalized assets/labels, clears project notification subscriptions, and unassigns tasks whose assignees aren’t members of the target workspace. Rejects same-workspace moves (400), missing source project (404), and case-insensitive key collisions in the target (409), then publishes per-task unassignment events after commit.

apps/api/src/project/controllers/move-project.ts

index.tsExpose PUT /project/:id/move with dual-workspace authorization +78/-1

Expose PUT /project/:id/move with dual-workspace authorization

• Registers the move endpoint with request/response schema, and wires workspaceAccess.fromProject() for the source workspace. Enforces update+delete in the source workspace, separately validates target membership/access and project:create permission in the target, and applies target entitlement checks before invoking the move controller.

apps/api/src/project/index.ts

require-workspace-permission.tsAllow permission checks against a non-request workspace +5/-1

Allow permission checks against a non-request workspace

• Extends hasWorkspacePermission() with an optional workspaceId override so a single request can safely validate permissions in multiple workspaces (used by project move). Existing behavior remains unchanged when no override is provided.

apps/api/src/utils/require-workspace-permission.ts

move-project.tsAdd web fetcher for project move endpoint +25/-0

Add web fetcher for project move endpoint

• Introduces a typed fetcher calling client.project[":id"].move.$put with {workspaceId} JSON and route param {id}. Surfaces non-2xx responses as thrown errors using the response body text.

apps/web/src/fetchers/project/move-project.ts

use-move-project.tsAdd React Query mutation hook for moving projects +10/-0

Add React Query mutation hook for moving projects

• Wraps the moveProject fetcher in a useMutation hook for UI consumption, exposing isPending/mutateAsync semantics.

apps/web/src/hooks/mutations/project/use-move-project.ts

general.tsxAdd “Move to workspace” UI with confirm dialog and toasts +145/-0

Add “Move to workspace” UI with confirm dialog and toasts

• Adds a workspace selector and Move action to project general settings (only when editable and other workspaces exist), plus a confirmation dialog. Executes the move mutation, invalidates project/task caches, navigates to the project URL under the new workspace, and shows success/error toasts including unassigned task counts.

apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx

Tests (1) +423 / -0
project-move.test.tsAdd integration coverage for project move behavior +423/-0

Add integration coverage for project move behavior

• Adds end-to-end API integration tests covering target membership and permissions, source delete permission requirement, key collision detection (case-insensitive), same-workspace rejection, side-data rewrites (labels/assets/subscriptions), and assignee dropping with corresponding activity rows.

tests/api-integration/project-move.test.ts

Other (18) +228 / -18
de-DE.jsonAdd German strings for project move flow +11/-1

Add German strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/de-DE.json

el-GR.jsonAdd Greek strings for project move flow +11/-1

Add Greek strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/el-GR.json

en-US.jsonAdd English strings for project move flow +11/-1

Add English strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/en-US.json

es-ES.jsonAdd Spanish strings for project move flow +11/-1

Add Spanish strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/es-ES.json

fr-FR.jsonAdd French strings for project move flow +11/-1

Add French strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/fr-FR.json

hi-IN.jsonAdd Hindi strings for project move flow +11/-1

Add Hindi strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/hi-IN.json

id-ID.jsonAdd Indonesian strings for project move flow +11/-1

Add Indonesian strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/id-ID.json

it-IT.jsonAdd Italian strings for project move flow +11/-1

Add Italian strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/it-IT.json

ko-KR.jsonAdd Korean strings for project move flow +11/-1

Add Korean strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/ko-KR.json

mk-MK.jsonAdd Macedonian strings for project move flow +11/-1

Add Macedonian strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/mk-MK.json

nl-NL.jsonAdd Dutch strings for project move flow +11/-1

Add Dutch strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/nl-NL.json

pt-BR.jsonAdd Brazilian Portuguese strings for project move flow +11/-1

Add Brazilian Portuguese strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/pt-BR.json

ru-RU.jsonAdd Russian strings for project move flow +11/-1

Add Russian strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/ru-RU.json

schema.jsonExtend i18n schema for project move strings +41/-1

Extend i18n schema for project move strings

• Adds schema entries and required keys under settings.projectGeneral for move control labels, modal strings, and move result toasts so all locales remain complete.

i18n/schema.json

tr-TR.jsonAdd Turkish strings for project move flow +11/-1

Add Turkish strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/tr-TR.json

uk-UA.jsonAdd Ukrainian strings for project move flow +11/-1

Add Ukrainian strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/uk-UA.json

vi-VN.jsonAdd Vietnamese strings for project move flow +11/-1

Add Vietnamese strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/vi-VN.json

zh-CN.jsonAdd Simplified Chinese strings for project move flow +11/-1

Add Simplified Chinese strings for project move flow

• Adds localized labels, modal copy, and toast messages for moving a project, and adjusts the import/export description punctuation for schema consistency.

i18n/zh-CN.json

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Move TOCTOU workspace race ✓ Resolved 🐞 Bug ⛨ Security
Description
moveProject() checks the project belongs to sourceWorkspaceId, but the UPDATE that performs the move
only filters by project id and does not lock the row, so a concurrent workspace change can
invalidate the source-workspace/permission basis while the move still succeeds. This can also lead
to inconsistent side-effects (task unassignments / notification subscription deletion) being applied
against a project state different from what was validated.
Code

apps/api/src/project/controllers/move-project.ts[R128-131]

+      .update(projectTable)
+      .set({ workspaceId: targetWorkspaceId })
+      .where(eq(projectTable.id, id))
+      .returning();
Evidence
The controller performs a source-workspace check early in the transaction, but the later UPDATE that
moves the project is not constrained to that workspace and does not lock the row, leaving a
concurrency window where the project’s workspace can change after validation. Authorization for the
endpoint is based on the source workspace resolved by middleware, so a concurrent move can
invalidate the permission basis while the UPDATE still runs. The repo already uses `SELECT ... FOR
UPDATE` on projects inside transactions in other code paths, indicating the intended pattern for
preventing these races.

apps/api/src/project/controllers/move-project.ts[26-35]
apps/api/src/project/controllers/move-project.ts[127-131]
apps/api/src/project/index.ts[264-305]
apps/api/src/gitea-integration/controllers/import-gitea-issues.ts[237-243]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`moveProject()` validates that the project is in the source workspace, but the subsequent UPDATE that moves it uses only `WHERE project.id = ?` and does not lock the row. In a concurrent scenario, the project can be moved (and side-effects applied) after the source-workspace assumption is no longer true.
## Issue Context
- The request is authorized against the *source* workspace (`workspaceAccess.fromProject()` + `requireWorkspacePermission({ project: ["update", "delete"] })`), and the controller separately validates the project is currently in that source workspace.
- Without a row lock or an optimistic predicate on the UPDATE, another transaction can change `project.workspaceId` between the check and the move.
## Fix Focus Areas
- apps/api/src/project/controllers/move-project.ts[26-147]
### Concrete fix sketch
1. Lock the project row when reading it inside the transaction (e.g. `...for("update")`) to prevent concurrent moves/updates while this transaction is deciding.
2. Make the move UPDATE conditional on the source workspace as well:
- `WHERE id = ? AND workspace_id = ?`
- then verify exactly one row was returned/updated; otherwise throw a 409/404.
3. Consider also scoping side-effect statements (e.g. notification subscription deletion) to `workspaceId = sourceWorkspaceId` as a safety net, so the transaction can’t delete/alter rows for a different workspace if the project state was changed before the lock was acquired.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/api/src/project/controllers/move-project.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
apps/api/src/project/index.ts (1)

242-261: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider documenting the non-200 responses.

The route can return 400, 402, 403, 404, and 409. Clients must handle 409 specifically, because a project-key collision is a recoverable, user-correctable state. Only the 200 response is documented now. Adding the error responses makes the generated OpenAPI contract actionable for API consumers.

This matches an existing gap in the other routes in this file, so it is optional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/project/index.ts` around lines 242 - 261, Extend the moveProject
describeRoute response schema to document the route’s 400, 402, 403, 404, and
409 outcomes alongside the existing 200 response, including the 409 project-key
collision response so clients can handle it explicitly. Reuse the established
error response schemas or conventions already used by nearby routes in this
file.
apps/api/src/project/controllers/move-project.ts (1)

50-66: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚖️ Poor tradeoff

The key-conflict check is advisory only; concurrent writes can still create a duplicate key.

This check reads the target workspace, then the move proceeds. No unique constraint on (workspace_id, lower(slug)) backs it. Two concurrent requests that move or create projects with the same key into the same target workspace can both pass this check and both commit. The result is the ambiguous short-id state the comment above describes.

The window is narrow and requires concurrent authorized writes, so this does not block the feature. The durable fix is a partial unique index on lower(slug) per workspace, plus handling the resulting constraint violation as the same 409. That change needs a backfill, because existing workspaces may already hold duplicate keys.

Consider tracking this separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/project/controllers/move-project.ts` around lines 50 - 66, Track
the advisory key-conflict check in the move flow separately and add durable
enforcement with a backfilled partial unique index on workspace and lowercased
slug. Update the move operation’s constraint-error handling to translate
violations of this index into the existing HTTP 409 conflict response, while
preserving the current pre-check and message for detected conflicts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/project/controllers/move-project.ts`:
- Around line 139-144: Replace the taskIds-based predicate in the label update
within moveProject with a subquery selecting taskTable.id for the current
project, and remove the now-unused taskIds declaration. At the sibling update
site in apps/api/src/project/controllers/move-project.ts lines 114-124, retain
the row list for event titles but chunk its ids into batches below the
PostgreSQL parameter limit before issuing updates.

In `@apps/web/src/hooks/mutations/project/use-move-project.ts`:
- Around line 4-8: Update useMoveProject to add the standard mutation onSuccess
and onError handlers alongside mutationFn: invalidate the related project and
workspace queries after a successful move, and display Sonner success or error
feedback through the established toast pattern. Reuse existing query keys and
notification conventions from nearby mutation hooks.

In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Around line 616-620: Update the move UI condition in the project settings
component to require both canEdit and canDelete before rendering. Also filter
moveTargets to only include workspaces where project:create permission is
available, ensuring the move dialog cannot offer unauthorized targets.

---

Nitpick comments:
In `@apps/api/src/project/controllers/move-project.ts`:
- Around line 50-66: Track the advisory key-conflict check in the move flow
separately and add durable enforcement with a backfilled partial unique index on
workspace and lowercased slug. Update the move operation’s constraint-error
handling to translate violations of this index into the existing HTTP 409
conflict response, while preserving the current pre-check and message for
detected conflicts.

In `@apps/api/src/project/index.ts`:
- Around line 242-261: Extend the moveProject describeRoute response schema to
document the route’s 400, 402, 403, 404, and 409 outcomes alongside the existing
200 response, including the 409 project-key collision response so clients can
handle it explicitly. Reuse the established error response schemas or
conventions already used by nearby routes in this file.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e313e61b-b79d-4bc8-855e-0ec1b33f4335

📥 Commits

Reviewing files that changed from the base of the PR and between fa2198e and 0ce75d5.

📒 Files selected for processing (25)
  • apps/api/src/project/controllers/move-project.ts
  • apps/api/src/project/index.ts
  • apps/api/src/utils/require-workspace-permission.ts
  • apps/web/src/fetchers/project/move-project.ts
  • apps/web/src/hooks/mutations/project/use-move-project.ts
  • apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx
  • i18n/de-DE.json
  • i18n/el-GR.json
  • i18n/en-US.json
  • i18n/es-ES.json
  • i18n/fr-FR.json
  • i18n/hi-IN.json
  • i18n/id-ID.json
  • i18n/it-IT.json
  • i18n/ko-KR.json
  • i18n/mk-MK.json
  • i18n/nl-NL.json
  • i18n/pt-BR.json
  • i18n/ru-RU.json
  • i18n/schema.json
  • i18n/tr-TR.json
  • i18n/uk-UA.json
  • i18n/vi-VN.json
  • i18n/zh-CN.json
  • tests/api-integration/project-move.test.ts

Comment thread apps/api/src/project/controllers/move-project.ts Outdated
Comment on lines +4 to +8
function useMoveProject() {
return useMutation({
mutationFn: moveProject,
});
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add mutation side effects to useMoveProject.

This hook only sets mutationFn. Add the established success and error handlers to invalidate related project and workspace queries and show Sonner feedback. Otherwise, consumers can keep stale cache data or omit user feedback after a move.

As per coding guidelines, mutation hooks must invalidate related queries and use toast notifications in mutation success and error handlers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/hooks/mutations/project/use-move-project.ts` around lines 4 - 8,
Update useMoveProject to add the standard mutation onSuccess and onError
handlers alongside mutationFn: invalidate the related project and workspace
queries after a successful move, and display Sonner success or error feedback
through the established toast pattern. Reuse existing query keys and
notification conventions from nearby mutation hooks.

Source: Coding guidelines

… move

Addresses review feedback on usekaneo#1525:

- Lock the project row and predicate the move UPDATE on the source
  workspace, so a concurrent move can't invalidate the authorization
  basis after it was checked. Scope the notification-subscription
  delete to the source workspace as well.
- Replace the materialized task-id lists with set-based predicates: a
  subquery for the label rewrite, and the target's member set for the
  unassignment. Both previously scaled with task count and could exceed
  Postgres' 65535 bind-parameter cap on a large project.
- Offer only workspaces the caller can create projects in as move
  targets, matching what the endpoint requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx (1)

628-628: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Check project:update in the project source workspace.

useWorkspacePermission() checks the active workspace, but the source project can belong to a different workspace. In that case, this gate can hide the move action from an authorized user or show it to a user that the API rejects.

Query source permissions with project.workspaceId before rendering this action.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx
at line 628, Update the move-action gate near the canEdit condition to use
permissions queried for the source project's workspaceId, rather than the active
workspace from useWorkspacePermission(). Check project:update for
project.workspaceId before rendering the action, preserving the existing
moveTargets.length requirement.
🧹 Nitpick comments (2)
apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts (2)

15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant return type.

TypeScript infers Promise<string[]> from the filtered results.

As per coding guidelines, "Do not add explicit type annotations that can be inferred by TypeScript."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts` at
line 15, Remove the explicit Promise<string[]> return type from the queryFn
callback in the workspace creation query, allowing TypeScript to infer the
return type from the filtered results.

Source: Coding guidelines


40-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a named export for useProjectCreateWorkspaces.

Update the import at apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx Line 53 to use the named export.

As per coding guidelines, "Use named imports when possible instead of default imports."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts` at
line 40, Change useProjectCreateWorkspaces to a named export, then update its
import in the general project settings module to use the named-import syntax.
Remove the default-export usage while preserving the hook’s existing behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Line 628: Update the move-action gate near the canEdit condition to use
permissions queried for the source project's workspaceId, rather than the active
workspace from useWorkspacePermission(). Check project:update for
project.workspaceId before rendering the action, preserving the existing
moveTargets.length requirement.

---

Nitpick comments:
In `@apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts`:
- Line 15: Remove the explicit Promise<string[]> return type from the queryFn
callback in the workspace creation query, allowing TypeScript to infer the
return type from the filtered results.
- Line 40: Change useProjectCreateWorkspaces to a named export, then update its
import in the general project settings module to use the named-import syntax.
Remove the default-export usage while preserving the hook’s existing behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ad6c502e-df7a-4d20-bc53-059917f89c6b

📥 Commits

Reviewing files that changed from the base of the PR and between 0ce75d5 and 7c40f98.

📒 Files selected for processing (4)
  • apps/api/src/project/controllers/move-project.ts
  • apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts
  • apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx
  • tests/api-integration/project-move.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/src/project/controllers/move-project.ts

The move action was gated on `canEdit`, which `useWorkspacePermission`
answers for the *active* workspace. This route has no `workspaceId` param,
so the active workspace can differ from the one the project actually lives
in (deep link, bookmark, back after a workspace switch) — the same reason
the move candidates already filter against `project.workspaceId`. The gate
could therefore hide the action from an authorized user, or show it to one
the API rejects.

Generalize `useProjectCreateWorkspaces` into `useWorkspacesWithPermission`,
which takes the permission set as an argument, and use it twice: `project:create`
over the candidate targets as before, and `project:update` over the project's
own workspace for the gate. Export it by name and drop the inferable return
type annotation.

Notes on the rest of the review:

- The API findings (move TOCTOU race, unbounded `inArray` parameter lists)
  were already fixed in 7c40f98: the controller locks the source workspace
  with SELECT ... FOR UPDATE, predicates the unassign on assignee ids, and
  uses a subquery for the label update.
- Moving invalidation and toasts into `useMoveProject` was skipped on
  purpose. Those side effects already live in `handleMoveProject` next to
  the post-move `navigate()`, which is inherently route-specific; splitting
  them across two files would read worse.
- `canEdit` and `canDelete` have the same active-vs-source workspace flaw,
  but that predates this PR and is left for a separate fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx (3)

356-363: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persist the latest project edits before moving the project.

If the user changes slug and confirms before the 800 ms autosave runs, this request uses the old persisted slug. The API collision check in apps/api/src/project/controllers/move-project.ts then evaluates the old value. The unmount cleanup can save the new slug only after navigation.

Serialize the move after the latest valid autosave, or combine the edit and move in one server transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx
around lines 356 - 363, Update handleMoveProject to persist the latest valid
project edits before calling moveProject, ensuring pending slug changes are
saved rather than relying on unmount cleanup; serialize the autosave completion
ahead of the move request while preserving the existing move flow.

356-363: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate Move and Delete on the route-owned project.

moveProject and deleteProject use the global store value (project.id), while this route’s projectId comes from URL params. The store is updated only when the query for that param returns, so a route transition can still carry the previous project. Only allow the mutation if project?.id === projectId, or clear and gate the store until the current project loads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx
around lines 356 - 363, Update handleMoveProject and the corresponding delete
handler to verify that the store project belongs to the current route before
mutating: require project?.id === projectId alongside the existing guards, and
continue using the validated project ID for moveProject and deleteProject.
Ensure route transitions cannot operate on a stale store project while the
current project query is loading.

739-764: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render custom dialog buttons without nesting primitives.

AlertDialogClose renders Base UI’s Close component, so wrapping child <Button> elements creates nested interactive elements. Move each closing action’s props onto a single button that replaces the primitive instead (render={<Button ...>}), and attach onClick and disabled to the rendered button.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx
around lines 739 - 764, Update the AlertDialogClose elements in the move dialog
around isMoveModalOpen to render a single Button via the primitive’s render prop
instead of nesting Button children. Preserve the cancel button’s outline styling
and the confirm button’s isMoving onClick/disabled behavior by applying those
props to the rendered buttons.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Around line 175-181: Update the permission checks around canMoveFromSource and
the move action to query PROJECT_DELETE for sourceWorkspaceIds, then require
both source PROJECT_UPDATE and PROJECT_DELETE permissions when determining move
availability. Do not use canDeleteProjects() for this gate, since it evaluates
the active workspace.

---

Outside diff comments:
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Around line 356-363: Update handleMoveProject to persist the latest valid
project edits before calling moveProject, ensuring pending slug changes are
saved rather than relying on unmount cleanup; serialize the autosave completion
ahead of the move request while preserving the existing move flow.
- Around line 356-363: Update handleMoveProject and the corresponding delete
handler to verify that the store project belongs to the current route before
mutating: require project?.id === projectId alongside the existing guards, and
continue using the validated project ID for moveProject and deleteProject.
Ensure route transitions cannot operate on a stale store project while the
current project query is loading.
- Around line 739-764: Update the AlertDialogClose elements in the move dialog
around isMoveModalOpen to render a single Button via the primitive’s render prop
instead of nesting Button children. Preserve the cancel button’s outline styling
and the confirm button’s isMoving onClick/disabled behavior by applying those
props to the rendered buttons.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d93de07b-d904-4f1e-92d9-3fe794a2014b

📥 Commits

Reviewing files that changed from the base of the PR and between 7c40f98 and 383d362.

📒 Files selected for processing (2)
  • apps/web/src/hooks/queries/workspace/use-workspaces-with-permission.ts
  • apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx

rojensonlugo and others added 3 commits August 9, 2026 00:34
The move endpoint authorizes against the project's own workspace with
`project: ["update", "delete"]`, since a move takes the project out of
that workspace. The UI gate only queried `project:update` there. A custom
role granting update without delete would therefore see the Move action
and get a 403 on submit.

Query `project:delete` over the same source workspace and require both in
`canMoveFromSource`, so the section's existing condition needs no change.

`canDelete` stays as-is: it answers for the active workspace and correctly
gates the delete section, so it can't stand in for the source-workspace
check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ween-workspaces

# Conflicts:
#	apps/api/src/project/index.ts
@randoneering

Copy link
Copy Markdown
Contributor

@rdlugs thank you for your contribution! Please take a look at the coderabbit suggestions when you have a chance! Thank you!

@rdlugs

rdlugs commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! I take a look at the coderabbit suggestions and address them accordingly, appreciate the feedback

The move landed the project on whatever position it held in the source
workspace, which is a slot the target almost always already gave to
another project. Two projects then shared an ordering position and the
board's order became arbitrary. The move now appends at max(position) + 1
in the target, the same place createProject puts a new one, and takes the
advisory lock createProject and reorderProjects already share on the
target workspace, so the key-collision check and the position read stop
being read-then-write races. Only the target is locked, so two moves in
opposite directions cannot deadlock; the source is left with a gap
exactly as a delete leaves one.

Unassigning no longer emits one task.unassigned event per task. That set
is not a client-supplied batch but every assigned task in the project,
and publishEvent is a synchronous emit into detached subscribers, so a
project-sized loop fired that many activity inserts, webhook deliveries
and board broadcasts in a single tick. History is written instead as
chunked bulk inserts, staying under the 65535 bind-parameter cap, and
clients get a single project-scoped task.bulk_unassigned refresh. Plugin
and webhook consumers no longer see per-task unassign events for a move.

The permission probe for candidate targets stopped swallowing failures.
Reporting a failed check as "no permission" is indistinguishable from a
real denial, so a transient error silently hid the action; the hook now
surfaces the error and the move section explains it rather than showing
an empty workspace list, which answers a different question.

Moving on a debounced edit checked the stale project key against the
target and then persisted the new one after navigation, so the flush is
now awaited first and reports whether the write actually landed. Move and
delete are also gated on the project store matching the route, which it
does not during a transition, and cache invalidation for the move moved
into useMoveProject where the other project mutations keep theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

feat: relocate project from one workspace to another?

3 participants