4.3.3 `estimationService` — story-point write + project estimation config CRUD + bounded sprint/epic roll-up reads (reusable `rollupForSprint`/`rollupForParent`); repo single-ops + DTOs/errors
Estimate: 30m · Depends on: 4.3.2
The business-logic + data-access layer for estimation: the per-issue estimate write, the project estimation config CRUD, and the BOUNDED roll-up aggregates the UI binds to — including the reusable rollupForSprint Story 4.5.2 consumes. Per the 4-layer rule (CLAUDE.md): repo methods are single Prisma ops (writes require tx; aggregates use $queryRaw); the service owns transactions + DTO mapping + typed errors + the finding-#26 workspaceId gate.
Repository methods:
workItemRepository.setStoryPoints(itemId, points | null, tx)— the single-row estimate write (the entity owns it; no new repo).projectRepository.findEstimationConfig(projectId)+updateEstimationConfig(projectId, { estimationStatistic?, pointScale?, customScaleValues? }, tx)— the config read/write.- Roll-up aggregates (
$queryRaw, BOUNDED — finding #57):sumPointsForSprint(sprintId, statistic)→ a grouped aggregate returning committed (all the sprint's issues) + completed (scoped to issues whose status maps to acategory = 'done'workflow status — the finding-#21 terminal predicate, resolved the SAME way 4.5.2 resolves "done") in one query; andsumPointsForParent(parentId, statistic)→ a recursive-CTESUMover the parent's SUBTREE (descendants at any depth) in one query. Both parameterise over the statistic (SUM(storyPoints)|SUM(estimateMinutes)|COUNT(*)). NEVER a load-all + sum.
estimationService (lib/services/estimationService.ts) — one method = one transaction, DTO mapping (lib/mappers/estimationMappers.ts → lib/dto/estimation.ts), typed errors (lib/estimation/errors.ts):
setEstimate(itemId, points | null)— validates the value (non-negative; withinDecimal(6,2)range; null clears), writesstoryPoints, records a 1.4.6work_item_revisionin the SAME transaction, enforces theworkspaceIdgate. (Editing the TIME estimate stays on its existing 2.3.6 path — this method owns story points.)getEstimationConfig(projectId)/updateEstimationConfig(projectId, patch)— read/admin-update the project config; validatecustomScaleValues(non-empty + numeric whenpointScale = custom); admin-only (the same project-admin gate the workflow/board settings use).rollupForSprint(sprintId)→{ committed, completed, remaining }(resolving the project's statistic, callingsumPointsForSprint;remaining = committed − completed, never negative; an unestimated sprint returns{0,0,0}— the DTO stays total, the UI owns "—"). Exported as the reusable bounded aggregate Story 4.5.2 consumes for itsSprintSummaryDto.points(4.5.2 adds only the scrum-specificcolumnPointsbreakdown) — this is the seam that keeps the sprint-points SUM in ONE place.rollupForParent(parentId)→ the subtree point total (resolving the statistic, callingsumPointsForParent) for the epic/parent roll-up badge.- Mappers return
EstimationConfigDto+SprintPointsDto+ apointsfield on the work-item summary DTO — never raw Prisma models.
Typed errors (lib/estimation/errors.ts): InvalidEstimateError, InvalidScaleConfigError, EstimationConfigForbiddenError (non-admin) — distinct codes the route layer maps to 422/403.
Routes (HTTP-only, one service call + error→status mapping each): PATCH /api/work-items/[id]/estimate (set/clear story points), GET/PATCH /api/projects/[id]/estimation-config. The roll-up reads ride the existing board/backlog/detail read endpoints (the UI subtasks 4.3.4/4.3.5 wire them in) rather than new dedicated routes where an existing read already returns the issue/sprint.
Empty-input guards (prodect-core-coverage-gate): a roll-up over a sprint/parent with no issues, and an empty customScaleValues, short-circuit with a direct unit test so the branch-coverage gate stays green.
Acceptance criteria
setEstimatewrites/clearswork_item.storyPointsin one transaction (value validated), records a 1.4.6 revision, and enforces theworkspaceIdgate;getEstimationConfig/updateEstimationConfigread/admin-update the project config (custom-scale validation; admin-only via the project-admin gate).rollupForSprint(sprintId)returns{ committed, completed, remaining }from a BOUNDED grouped aggregate (completedscoped tocategory = 'done'statuses, the same predicate 4.5.2 uses; remaining floored at 0; unestimated →{0,0,0}); it is EXPORTED + reused by Story 4.5.2 (documented).rollupForParent(parentId)returns the recursive-subtree total from one bounded CTE aggregate. Neither loads all rows.- Both roll-ups parameterise over the configured statistic (
SUM(storyPoints)|SUM(estimateMinutes)|COUNT(*)); repo methods are single Prisma ops (writes requiretx; aggregates$queryRaw); the service owns transactions/DTOs/typed errors; routes are HTTP-only. - Empty-sprint/empty-subtree/empty-custom-scale guards are directly unit-tested;
pnpm test:coveragekeeps the new files ≥90% branch/fn/line.
Context refs
- Story 4.3.2 (the
storyPointscolumn +projectestimation config + enums) — the schema this reads/writes - Story 4.5.2 (
SprintSummaryDto.points=SUM(storyPoints)) — the consumer ofrollupForSprint; resolve "done" the SAME way (workflowcategory = 'done'), so the figure matches the scrum header lib/repositories/workItemRepository.ts/projectRepository.ts/boardRepository.ts— the single-op + required-tx+$queryRaw-aggregate patterns;lib/workflows/*(thecategory = 'done'terminal-status resolution, finding #21)lib/services/workItemsService.ts+ the 1.4.6workItemRevisionsService— the audit-trail write to reuse in the same tx;lib/mappers/*,lib/dto/*,lib/<domain>/errors.tslayoutmotir-core/CLAUDE.md(4-layer; entity-name-wins) +prodect-core-coverage-gate(empty-input guards) + finding #57 (bounded aggregates) + finding #26 (workspaceIdgate)