4.1.3 `sprintsService` — sprint CRUD + `assertSprintTransition` state-machine guard + DTOs/errors
Estimate: 30m · Depends on: 4.1.2
The business-logic layer for the sprint ENTITY + the state-machine RULES (Story 4.4 composes the rules into its start/complete flows — they are NOT implemented here).
sprintsService (lib/services/sprintsService.ts) — one method = one transaction, owns DTO mapping (lib/mappers/sprintMappers.ts → lib/dto/sprints.ts), throws typed errors (lib/sprints/errors.ts) the route layer maps to status codes, and enforces the finding-#26 application-layer workspaceId gate on every read/write:
createSprint(projectId, { name?, goal?, startDate?, endDate? })— creates a planned sprint; default-names it"Sprint <maxSequence+1>"whennameis omitted; validates the date window (endDate≥startDatewhen both given). Does NOT start it.updateSprint(id, patch)— rename / edit goal / adjust the planned window. Date/name validation; rejects editing acompletesprint.deleteSprint(id)— deletes aplanned(orcomplete) sprint; its issues fall back to the backlog via theonDelete: SetNullFK (theirbacklog_rankalready exists, so they re-appear in rank order). Rejects deleting theactivesprint (that goes through 4.4's complete flow).assertSprintTransition(from: SprintState, to: SprintState)— the PURE state-machine guard: allowsplanned→activeandactive→complete; throwsInvalidSprintTransitionErrorfor skips (planned→complete), reopens (complete→active,active→planned), and self-transitions. Exported as a pure function so Story 4.4's start/complete flows + the one-active guard call it without re-deriving the rules. (4.1 ships + tests the guard; 4.4 owns the orchestration that consumes it — scope-lock, carry-over, report.)- Mappers return a
SprintDto(id, name, goal, state, startDate, endDate, completedAt, sequence, issueCount) — never a raw Prisma model.
Typed errors (lib/sprints/errors.ts): SprintNotFoundError, InvalidSprintTransitionError, SprintWindowInvalidError, CannotModifyCompletedSprintError, CannotDeleteActiveSprintError — distinct codes so the (future) route layer maps them to 404/409/422.
Routes are minimal here (CRUD endpoints POST/PATCH/DELETE /api/sprints) — HTTP-only, one service call each, error→status mapping; the rich sprint-planning surface is Story 4.2.
Acceptance criteria
sprintsServiceexposescreateSprint(planned, default-named, window-validated),updateSprint,deleteSprint(issues fall to backlog; active rejected), and the pure exportedassertSprintTransition; each write is one transaction; reads/writes enforce the finding-#26workspaceIdgate; methods returnSprintDtos.assertSprintTransitionallowsplanned→active+active→completeand throwsInvalidSprintTransitionErrorfor every skip/reopen/self transition; it is a pure function (no I/O) Story 4.4 can import.- Typed errors live in
lib/sprints/errors.ts; the CRUD routes are HTTP-only (one service call + error mapping each). pnpm test:coveragekeeps the new service file ≥90% branch/fn/line (the coverage gate); start/complete ORCHESTRATION is explicitly absent (deferred to Story 4.4).
Context refs
lib/services/boardsService.ts(3.1/3.7) — the service shape to mirror (one-tx-per-method, DTO mapping,workspaceIdgate);lib/mappers/*,lib/dto/*,lib/<domain>/errors.tslayout- Story 4.4 (sprint lifecycle) — the consumer of
assertSprintTransition+ the one-active guard; Story 4.2 (backlog UI) — the consumer of the CRUD + DTOs motir-core/CLAUDE.md(service layer: transactions, DTOs, typed errors) +motir-core-coverage-gate