Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-161Done

3.1.5 Board mutation service — cross-column move = workflow transition; in-column = rank

Estimate: 22m · Depends on: 3.1.3, 2.2.4

The write side that makes "moving a card = a workflow transition, not a board-local write" real. boardsService.moveCard(boardId, workItemId, target, ctx) where target = { toColumnId, beforeId?, afterId? }, in one transaction:

1. Resolve the target column’s status. Read the column’s board_column_status mapping. If the card’s current status is already in the target column’s mapped set (a within-column drop, OR a drop into a multi-status column that already contains the card’s status) → no transition, only a rank change. Otherwise pick the target status: the column’s mapped status; for a multi-status column, the one ordered first by status.position (Jira’s rule). A drop onto an unmapped target (a column with no statuses, or an unmapped status) is rejected with a typed error.

2. Cross-column move → transition. Delegate the status change to the validated path issuesService.updateStatus (Story 2.2.4), which runs workflowsService.canTransition under the project's policy mode. An illegal transition raises — caught + re-raised as a typed IllegalBoardMoveError (carrying from/to status + the reason) so the 3.1.6 route maps it to HTTP 409 and the 3.2 UI snaps the card back. The issue’s status is the single source of truth; the board stores nothing about placement.

3. Rank within the column. Recompute work_item.position between the beforeId / afterId neighbors via the existing lib/workItems/positioning.ts fractional-index helper (finding #18) — the same mechanism the tree ordering uses. A pure within-column reorder (step 1 found no status change) does ONLY this, attempting no transition. (Board rank is the global work_item.position; the backlog-rank semantics deepen in Epic 4 — this story does not fork a board-local rank.)

Returns the updated BoardCardDto + the applied status + the resolved column, so the UI can reconcile its optimistic update. One service method = one transaction; typed errors (IllegalBoardMoveError, UnmappedColumnTargetError, not-found) live in lib/boards/errors.ts.

Out of scope: the drag-drop UI + optimistic update + snapback animation (Story 3.2 — this is the server contract it calls); WIP-limit rejection on over-limit drops (Story 3.3).

Acceptance criteria

  • boardsService.moveCard resolves the target column’s status, delegates cross-column moves to the validated issuesService.updateStatus path (2.2.4), and updates work_item.position for rank — all in one transaction.
  • A within-column move (or a drop into a column that already maps the card’s status) changes ONLY position; no transition is attempted.
  • An illegal cross-column transition under restricted policy raises a typed IllegalBoardMoveError and leaves the issue status + position unchanged (the snapback contract).
  • A drop onto an unmapped target raises a typed UnmappedColumnTargetError.
  • Multi-status target columns resolve to the first status by status.position (Jira rule); the resolution is unit-tested.
  • Vitest (real Postgres) covers: legal cross-column move (status + rank change), illegal move rejection (no mutation), pure in-column reorder (rank only), unmapped-target rejection, and workspace scoping.

Context refs

  • Story 2.2.4 — issuesService.updateStatus + workflowsService.canTransition (the validated transition path the move delegates to; do NOT re-implement transition validation)
  • lib/workItems/positioning.ts — the fractional-index rank helper (finding #18) reused for in-column reorder
  • lib/services/workflowsService.ts — policy mode + canTransition
  • motir-core/CLAUDE.md — one-service-method-one-transaction; typed errors in lib/<domain>/errors.ts