4.4.3 Backend — `completeSprint` flow + carry-over (transition + move unfinished to backlog / planned sprint, one tx)
Estimate: 28m · Depends on: 4.4.2, 4.1.4
The complete half of the lifecycle service — closes an active sprint and carries its unfinished work somewhere, composing Story 4.1's transition guard + association writes + the Epic-3 done-category set.
sprintsService.completeSprint(sprintId, { carryOverTo }) where carryOverTo is 'backlog' (default) or { sprintId: <planned sprint id> } (one method = one prisma.$transaction):
- Load the sprint; assert it is
activeand composeassertSprintTransition(active, complete). - Determine the unfinished set: the sprint's issues whose workflow status is NOT in a
category = 'done'terminal status — resolve the project's done-category status keys via the shippedworkflowsService.getTerminalStatusKeys(projectId)(Epic 3) and select the sprint issues whose status key is outside that set. The DONE issues STAY on the completed sprint (the historical record); only the unfinished ones move. - Carry-over move (bounded batch, one tx — the 4.2.2 bulk shape): for
'backlog',moveToBacklogeach unfinished issue (4.1.4 — they re-appear inbacklog_rankorder); for{ sprintId }, validate the target is a planned sprint in the same project (throwInvalidCarryOverTargetError/ reuse the 4.1.4 same-project guard otherwise) andassignToSprinteach unfinished issue into it. The whole carry-over is ONE transaction (partial failure rolls back — never a half-moved set), recording a 1.4.6 revision per move. - Close: set
completedAt = now, flipstatetocomplete(freeing the project's one-active slot so the next sprint can start). Return the completedSprintDto+ the report payload (or let the UI fetchgetSprintReport, 4.4.4). - A sprint with no unfinished issues completes with an empty carry-over (no-op move). Enforce the finding-#26
workspaceIdgate.
Typed errors: add SprintNotCompletableError (not active), InvalidCarryOverTargetError (target not a same-project planned sprint) to lib/sprints/errors.ts. Route: POST /api/sprints/[id]/complete — HTTP-only, one service call + error mapping (422 state, 409/422 target).
Acceptance criteria
completeSprintcomposesassertSprintTransition(active→complete), computes the unfinished set viagetTerminalStatusKeys(non-done-category issues), moves them in ONE transaction — to the backlog (moveToBacklog, rank order restored) or into a same-project PLANNED sprint (assignToSprint, same-project guarded) — leaves done issues on the sprint, setscompletedAt, flips state tocomplete, and records a revision per move.- A carry-over target that is cross-project or not planned throws
InvalidCarryOverTargetError; completing a non-active sprint throwsSprintNotCompletableError; a sprint with no incomplete issues completes with a no-op carry-over; the freed one-active slot lets a new sprint start. - The carry-over is a bounded batch (one tx, rollback on partial failure — not N round-trips); new typed errors live in
lib/sprints/errors.ts;POST /api/sprints/[id]/completeis HTTP-only; the finding-#26workspaceIdgate covers it. pnpm test:coveragekeeps the changed service file ≥90% branch/fn/line (the coverage gate).
Context refs
- Story 4.4.2 (
startSprint+ the extendedsprintsService/errors) — the layer this completes; the pureassertSprintTransitionto compose lib/repositories/workItemRepository.tssetSprint/lib/servicesmoveToBacklog+assignToSprint(Story 4.1.4) — the association MOVES the carry-over drives; the same-project guard to reuse; the 4.2.2 bulk-tx shape to mirror for the bounded batchlib/services/workflowsService.tsgetTerminalStatusKeys(Epic 3) — thecategory = 'done'set that defines "unfinished"- the 1.4.6
workItemRevisionsService— the audit-trail write to reuse per move;motir-core/CLAUDE.md(4-layer); finding #26 + #57 (bounded batch);motir-core-coverage-gate