11.3.5 The sprint WRITE pair — `POST /api/v1/projects/{projectKey}/sprints` and `PATCH /api/v1/sprints/{sprintId}`, gated on `sprints:write` AND the sprint-admin role
Creating and editing a sprint over the API — the first sprints:write operations, and the pair that turns 11.3 from a read surface into a cadence a script can actually run.
Thin adapters over sprintsService.createSprint and updateSprint, returning the sprint schema 11.3.4 pins.
Path correction
The story originally wrote this pair as "POST / PATCH /api/v1/projects/{projectKey}/sprints". PATCH on a collection path is not the edit operation — the edit addresses one sprint, so it is PATCH /api/v1/sprints/{sprintId}, matching §7's rule that an identifier in a path names the resource, and matching how 11.2 placed PATCH /api/v1/work-items/{key} beside the collection POST. Create stays on the collection, where the project scopes it.
⚠️ Two gates, not one — and the second is the one a client will be surprised by
sprints:write is the SCOPE. Every sprint write additionally calls assertSprintAdmin(ctx.userId, projectId, workspaceId) and raises NotSprintAdminError (403). A token carrying sprints:write whose OWNER is an ordinary project member is refused — a scope NARROWS the owner's role and never widens it (ADR §3). That is shipped behaviour this card exposes rather than changes, and it must be asserted, because "my token has the scope and I still get 403" is the single most confusing thing this endpoint can do to an integrator. Make the error's code and message carry that distinction rather than reading identically to a missing scope.
What to build
POST /api/v1/projects/{projectKey}/sprints— create. Every field ofCreateSprintInputis optional (namedefaults to"Sprint <n>"from the project's max sequence;goal,startDate,endDateare nullable planned-sprint metadata). The request body is validated by azodbody schema; dates arrive as ISO-8601 strings and the SERVICE parses and validates them — do not re-implement window validation at the route. Returns 201 with the created sprint and aLocationheader naming the new resource, the pattern 11.2.6 set.PATCH /api/v1/sprints/{sprintId}— edit name / goal / the planned window. Preserve the shipped tri-state exactly: an absent key leaves the field unchanged, an explicitnullclears it, a value sets it.exactOptionalPropertyTypesmakes{ x: undefined }and{}different types, so the body must be picked key-by-key rather than spread — the samepickdiscipline the shipped work-item route uses, and for the same reason: spreading blurs precisely the distinction the schema drew.
Acceptance criteria
- Both endpoints exist and declare
scope: 'sprints:write'; aread-only token is refused 403 on each. - A token with
sprints:writewhose owner is NOT a sprint admin is refused 403, with an errorcodedistinguishable from the insufficient-scope refusal — asserted on both endpoints. - Create returns 201, the sprint schema's output, and a
Locationheader; an omittednamecomes back as the service's"Sprint <n>"default rather than empty. - A created-but-never-started sprint reads back with
committedPoints: null/committedIssueCount: nullandstate: 'planned'. - PATCH's tri-state is asserted per field: absent leaves the value, explicit
nullclearsgoal/ a date, a value sets it — three cases, not one. - An invalid window (
endDatebeforestartDate) surfaces the service's typed error as a 422 with{ code, error }, not a 500; an invalid name surfaces its own typed error; both are raised by the service, not re-checked at the route. - A sprint in another workspace is a 404 on PATCH, and an unknown
projectKeya 404 on POST — never a 403. - No route calls Prisma or opens a transaction; each write is one service call after key resolution.
- Unit tests ship with the routes; every new file holds the ≥90% per-file coverage floor.
- ONE PR against
motir-core.
Context refs
lib/services/sprintsService.ts—createSprint(sequence derivation,maxSequenceForProject),updateSprint,assertSprintAdmin/NotSprintAdminError,validateName,assertWindow,parseNullableDate.lib/dto/sprints.ts—CreateSprintInput,UpdateSprintInput, and the recorded meaning ofundefinedvs explicitnull.app/api/v1/projects/[projectKey]/work-items/route.ts— the shipped create pattern:parseV1Body, 201 +Location, and thepickhelper that preserves the absent/null distinction.lib/api/v1/errors.ts— the domain-error → status map a new typed error must have a deliberate row in.docs/decisions/public-api-conventions.md§3 (scope narrows the role, never widens it), §4 (the status table), §7 (naming).- Blocker: 11.3.4 (the sprint schema + the resource path). Pattern precedent: 11.2.6. Parent story: 11.3.