3.1 Board data model + column-from-workflow projection
The data-model + projection floor under every board surface in Epic 3. A project owns one or more boards; each board owns an ordered set of columns; each column maps to a SET of the project's workflow statuses (Story 2.2). The board is a pure read projection over issues — a card's column is derived from its work_item.status, and the board stores no per-card placement state of its own. The load-bearing principle (carried from the stub): moving a card = a workflow transition, never a board-local write. A cross-column drop resolves to issuesService.updateStatus (validated by workflowsService.canTransition under the project's policy mode — Story 2.2.4); an in-column drop is a pure rank change on work_item.position. This story builds the backend for both; Story 3.2 builds the drag-drop UI that calls it (illegal drops snap back on the typed error this story raises).
Durable shape — why a real board entity, not "column == status". The mirror product (Jira; decision-ladder rung 1) does NOT hardwire one column per status: a column maps to many statuses (the canonical example — merge In Progress + In Review into one "In Progress" column), and a status mapped to no column is unmapped (hidden from the board until an admin maps it). So the schema is a board + board_column + a board_column_status mapping (many statuses → one column; a status maps to ≤1 column per board). The "column == one status, 1:1" form is the shortcut the no-shortcuts rule forbids ("simpler X now, migrate later"). The default board generated for a new project IS one-column-per-status (the column-from-workflow projection) — but that is a seeded default over the durable mapping, not a hardcoded shape. v1 auto-creates exactly ONE Kanban board per project; the board.projectId FK is non-unique, so multiple boards per project is a non-breaking addition planned as Story 3.7 (board CRUD + switcher). The Scrum (sprint-scoped) board is Story 4.5 (moved to Epic 4 per mistake #32 — see data/story-4.5.ts).
Scale shape — the projection is bounded, never "load every row" (finding #57). A real team's project has thousands of issues; a board that reads them all to render is prototype- thinking. The projection paginates per column: each column returns its first N cards (ordered by work_item.position), a per-column total count, and a cursor for lazy "load more" (Story 3.2 virtualizes within a column). Done/terminal columns are additionally bounded to a recent window (Jira hides done issues older than ~14 days) with the full count surfaced. The projection reuses Story 2.5's issue-read path + filter shape (workItemRepository flat reads, the AND-across-facet / OR-within-facet filter), not a new full-table scan.
Prerequisites & contracts. Story 2.2 ships workflow_status (the columns derive from these, in status.position order) + project.workflow_policy_mode + the transition validation the move path delegates to (2.2.4). Story 1.4 ships work_item with the status string + the position fractional index (finding #18) the within-column reorder writes. Story 2.5 ships the paginated issue-read precedent (2.5.12). All work follows motir-core/CLAUDE.md's 4-layer architecture (Route → Service → Repository → Prisma); every new route carries an explicit workspaceId application-layer gate (finding #26 — RLS is the backstop, not the sole gate, because the dev/CI superuser bypasses RLS).
Verification
- Pull the Story branch,
pnpm install && pnpm prisma generate && pnpm prisma migrate devagainst a fresh local DB. pnpm test— vitest covers the schema RLS, the default-board seed (one column per status, in workflow order), the repository reads, the projection (grouping + per-column pagination + count + unmapped statuses), and the move/reorder service (cross-column move = transition, illegal-move rejection, in-column rank change).pnpm test:e2e --grep board-projection— the closing API-level suite drives the real stack through the board endpoints (no UI yet — that arrives in 3.2).- Manual API check: create a workspace + project (the default board auto-seeds).
GET /api/projects/[key]/board→ six columns (To Do / Blocked / In Progress / In Review / Done / Cancelled, in workflow order), each with its cards grouped by status, per-column counts, and an emptyunmappedStatuses. Create ~10 issues across statuses → they appear in the right columns. - Unmapped-status check (finding #57 / mirror-product): add a custom status via the workflow editor (Story 2.2.5).
GET …/board→ the new status appears inunmappedStatuses(NOT a new column, and NOT silently dropped) — proving the projection surfaces, rather than hides, statuses with no column mapping. - Scale check (finding #57):
pnpm db:seed:large(Story 2.5.16), open a column with hundreds of cards → the projection returns a bounded first page + a total count + a cursor;GET …/board/columns/[id]/cards?cursor=…returns the next page. The board never loads every row. - Move = transition check:
POST …/board/movea card from To Do → In Progress → it returns the moved card with the new status, and the issue'sstatusis updated via the workflow path. Attempt an illegal cross-column move underrestrictedpolicy (e.g. To Do → Done if no such transition) → a typedIllegalBoardMoveError(HTTP 409), issue status unchanged — the snapback contract 3.2 relies on. - In-column reorder check: move a card within the same column (no status change) → only
work_item.positionchanges, no transition is attempted, column membership is unchanged. - RLS proof: open a psql session as
prodect_app,SET app.workspace_id = '<workspace-A>', queryboard/board_column/board_column_status— see only workspace A's rows.