MOTIR-216Done
4.1.4 Issue↔sprint association + backlog rank ordering + bounded backlog/sprint read API (the data layer Story 4.2 binds to)
Estimate: 28m · Depends on: 4.1.3
The association + ranking + read API that powers the Story-4.2 backlog UI — the part of the "data model" that moves issues between the backlog and sprints and orders them, built to real-product SCALE (finding #57).
Association writes (in sprintsService, or a focused backlogService if cleaner — same 4-layer rules):
assignToSprint(itemId, sprintId, { rank? })— setwork_item.sprint_id; same-project guard: the sprint and the issue MUST share a project (throwCrossProjectSprintAssignmentErrorotherwise). Optionally place it at a rank within the sprint.moveToBacklog(itemId)— nullsprint_id; the issue re-appears in the backlog in its existingbacklog_rankorder.rankIssue(itemId, { beforeId? , afterId? })— compute the newbacklog_rankviapositioning.tskeyBetween(prevRank, nextRank)and write the SINGLE row (no N-row renumber). Works for both a backlog issue and an issue within a sprint (one global rank field). Guards the degenerate "no neighbours" (append/prepend) cases.- Create-time rank: new issues get a
backlog_rankappended (keyForAppend) when created — wire this into the issue-create path (workItemsService.create) so the backfill (4.1.1) stays total going forward. Keep the touch on the create path minimal. - Every association/rank write records a
work_item_revisionrow (reuse the 1.4.6 audit service) in the SAME transaction.
Bounded reads (the finding-#57 shape):
getBacklog(projectId, { cursor?, limit })→{ items: WorkItemSummaryDto[], nextCursor, totalCount }—sprint_id IS NULL,backlog_rankorder, cursor-paginated (NEVER load-all), with the aggregate count for the "N issues" header.getSprintIssues(sprintId)→ the sprint's ranked issues (+ count) for the planning view; bounded the same way (a sprint is smaller, but the read is still paged-capable, not unbounded). Routes:POST /api/work-items/[id]/sprint(assign/clear),POST /api/work-items/[id]/rank,GET /api/projects/[id]/backlog— HTTP-only, one service call each. Mapping vialib/mappers/*.
Acceptance criteria
assignToSprint(same-project guarded),moveToBacklog, andrankIssue(single-rowkeyBetweenwrite, append/prepend edge cases handled) exist, own their transaction, record a 1.4.6 revision, and enforce theworkspaceIdgate.- New issues receive a
backlog_rankat creation (the create path appends one); cross-project sprint assignment is rejected with the typed error. getBacklogis cursor-paginated in rank order with a total count and never selects every row;getSprintIssuesreturns the sprint's ranked issues + count; both return DTOs.pnpm db:seed:large→ the backlog read returns one bounded page + the count and rank writes stay O(1) (finding #57); routes are HTTP-only one-service-call handlers.pnpm test:coveragekeeps the new files ≥90% branch/fn/line.
Context refs
- Story 4.1.2 (
work_itemsprint/rank repo methods) + 4.1.3 (sprintsService+ DTOs/errors) — the layers this composes lib/workItems/positioning.ts(keyBetween/keyForAppend/keyForPrepend) — the single-row rank computationlib/services/workItemsService.ts+ the 1.4.6workItemRevisionsService— the create path to append the rank into + the audit-trail write to reuse- Story 4.2 (backlog UI) — the consumer of
getBacklog/assignToSprint/rankIssue; finding #57 (bounded reads); finding #26 (workspaceIdgate);motir-core/CLAUDE.md