3.1.4 Board projection service — columns + grouped, per-column-paginated cards + unmapped statuses
Estimate: 24m · Depends on: 3.1.3, 3.1.2, 2.5.12
The read heart of the story: boardsService turns a project’s board + workflow + issues into the column-of-cards projection the 3.2 UI renders — bounded, never load-all (finding #57).
getBoard(projectId, opts, ctx) → BoardProjectionDto. Reads the default board (3.1.3), its columns (ordered by position), and the board_column_status mapping; reads the project's workflow statuses (workflowsService.listStatusesByProject). For each column, it loads the first page of cards whose work_item.status ∈ that column’s mapped status keys, ordered by work_item.position asc, via the Story-2.5 read path (a grouped/per-status workItemRepository query — reuse findProjectIssuesFlat + count, the same AND-across-facet filter shape, NOT a new full scan). Returns, per column: the column meta (id, name, position, wipLimit), the page of card DTOs, the per-column total count, and a cursor for "load more". Plus a top-level unmappedStatuses: WorkflowStatusDto[] — every project status with no board_column_status row (Jira’s behavior; surfaced, never silently dropped).
Per-column pagination + lazy load (finding #57). Default page size (e.g. 50) per column; the projection NEVER returns every card. loadColumnCards(boardId, columnId, cursor, ctx) → PagedColumnCardsDto returns subsequent pages so 3.2 can virtualize within a tall column. Done/terminal columns (status.category = 'done') are additionally bounded to a recent window (most-recent N by completion/position) with the full count surfaced — mirroring Jira’s "hide done older than ~14 days"; document the window as the durable shape, not a magic cap.
BoardCardDto — the card projection of work_item: (id, key, identifier, kind, title, status, priority, assigneeId, assignee summary, position, parentId, dueDate, estimateMinutes, isReady/blocked signal). The blocked/ready signal reuses Story 1.4 / finding #21 readiness (category = 'done' terminal generalization) so a board card can show a blocked indicator. Mapper in lib/mappers/boardMappers.ts; DTOs in lib/dto/boards.ts.
Out of scope: swimlane grouping + WIP enforcement (Story 3.3 — the projection returns wipLimit but does not enforce it); the move/reorder writes (3.1.5); any UI (3.2).
Acceptance criteria
boardsService.getBoardreturns ordered columns, each with its mapped statuses, a bounded first page of cards (ordered byposition), a per-column total count, and a cursor — plus a top-levelunmappedStatuseslist.- A project status with no column mapping appears in
unmappedStatusesand in NO column (not dropped, not auto-columned). - The projection reuses the Story-2.5 issue-read path (no new full-table scan); it never returns all cards for a column —
loadColumnCardsreturns subsequent pages by cursor. - Done-category columns are bounded to a documented recent window with the full count still reported.
BoardCardDtocarries the readiness/blocked signal via the finding-#21 terminal predicate; cards are DTOs (no raw Prisma rows cross the boundary), mapped inlib/mappers/boardMappers.ts.- Vitest (real Postgres) covers: grouping into the right columns, per-column count + pagination + cursor, unmapped-status surfacing, terminal-column windowing, and workspace scoping.
Context refs
lib/services/workItemsService.ts+lib/repositories/workItemRepository.ts—findProjectIssuesFlat/countProjectIssues/ theRepoIssueFiltershape (Story 2.5.12) the projection reuseslib/services/workflowsService.ts—listStatusesByProject/getTerminalStatusKeys(the done-category set for terminal-column windowing)lib/dto/workItems.ts— the card-field precedent forBoardCardDto- Story 1.4 / finding #21 — the readiness predicate the blocked signal reuses
- Finding #57 — the bounded-projection (no load-all) scale rule; finding #26 — the workspace gate
motir-core/CLAUDE.md— service owns DTO mapping; mappers inlib/mappers/*