4.2.2 Backend — atomic bulk sprint-assign / move-to-backlog + create-into-sprint (composes the 4.1.4 single-issue primitives; bounded batch, one transaction)
Estimate: 22m · Depends on: 4.1.3, 4.1.4
The ONLY net-new backend in this Story: a thin composition layer over Story 4.1.4's single-issue primitives, so the backlog's multi-select + inline-create affordances are ATOMIC and bounded rather than N client round-trips. Strictly composes existing service methods + the 4-layer rules (CLAUDE.md); adds no new schema (the sprint + backlog_rank model is Story 4.1).
Why a backend subtask at all (vs. looping 4.1.4 in the client). Jira moves a multi-selection to a sprint in one operation; doing it as N sequential assignToSprint calls from the browser is slow at real-team scale and NON-ATOMIC — a mid-batch failure leaves half the selection moved. A bounded server transaction is the durable shape (completeness/scale axis), so the bulk move is one prisma.$transaction.
Methods (extend sprintsService / backlogService — wherever 4.1.4's association writes live; same module, one tx per method, DTO mapping, typed errors, the finding-#26 workspaceId gate on every read/write):
bulkAssignToSprint(itemIds: string[], sprintId: string)— assign every issue in ONE transaction, reusing 4.1.4assignToSprint's same-project guard per item (reject the WHOLE batch withCrossProjectSprintAssignmentErrorif any member is cross-project — atomic, no partial move); records a 1.4.6 revision per moved item in the same tx. Bounded: cap the batch (e.g. ≤ a sane max) and reject oversize with a typed error — never an unbounded write.bulkMoveToBacklog(itemIds: string[])— nullsprint_idfor every issue in one tx (they reappear inbacklog_rankorder); same bounded + revision rules.createBacklogIssue(projectId, { sprintId?, title, type, ... })— create an issue, append itsbacklog_rank(4.1.4 create-time-rank path), and — whensprintIdis given — assign it (same-project guard) ALL in one transaction; records the create + assignment revision. ReusesworkItemsService.create; keep the new surface thin.- Empty-input guards (
prodect-core-coverage-gate): an emptyitemIdsshort-circuits (no-op, not an error) with a direct unit test so the branch-coverage gate stays green.
Routes (HTTP-only, one service call + error→status mapping each): POST /api/sprints/[id]/issues:bulk (bulk assign) / POST /api/projects/[id]/backlog:bulk-move (bulk to backlog) / POST /api/projects/[id]/backlog/issues (create into backlog or sprint) — or the closest existing route shape; no business logic in the route.
Acceptance criteria
bulkAssignToSprint+bulkMoveToBacklogmove every issue in the batch in ONE transaction (a forced mid-batch failure rolls the whole batch back — no partial move); the same-project guard rejects the whole batch if any member is cross-project; the batch is bounded (oversize → typed error); each moved item records a 1.4.6 revision.createBacklogIssuecreates an issue, appends abacklog_rank, and optionally assigns it to a sprint (same-project guarded) in one transaction, recording the revision; it reusesworkItemsService.createrather than re-implementing creation.- Methods own their transaction, return DTOs (mapped in
lib/mappers/*), throw typed errors the routes map to status codes, and enforce the finding-#26workspaceIdgate; emptyitemIdsis a guarded no-op with a direct test. - Routes are HTTP-only one-service-call handlers; no new migration (the model is Story 4.1).
pnpm test:coveragekeeps the new/changed service + route files ≥90% branch/fn/line (the CI coverage gate).
Context refs
- Story 4.1.4 (
assignToSprint/moveToBacklog/rankIssue+ the create-time-rank path) + 4.1.3 (sprintsService+ DTOs/errors) — the single-issue primitives this composes; do NOT re-implement them lib/services/workItemsService.ts— thecreatepathcreateBacklogIssuereuses; the 1.4.6workItemRevisionsServiceaudit write to reuse in the same txlib/services/boardsService.ts— the bulk/transaction + DTO-mapping shape to mirror;lib/mappers/*,lib/dto/*,lib/<domain>/errors.tslayoutmotir-core/CLAUDE.md(4-layer: one-tx-per-method, repo single-ops, required-txwrites, HTTP-only routes) +prodect-core-coverage-gate(empty-input guard tests) + finding #26 (workspaceIdgate)