Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-228Done

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 a category = 'done' workflow status — the finding-#21 terminal predicate, resolved the SAME way 4.5.2 resolves "done") in one query; and sumPointsForParent(parentId, statistic) → a recursive-CTE SUM over 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.tslib/dto/estimation.ts), typed errors (lib/estimation/errors.ts):

  • setEstimate(itemId, points | null) — validates the value (non-negative; within Decimal(6,2) range; null clears), writes storyPoints, records a 1.4.6 work_item_revision in the SAME transaction, enforces the workspaceId gate. (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; validate customScaleValues (non-empty + numeric when pointScale = custom); admin-only (the same project-admin gate the workflow/board settings use).
  • rollupForSprint(sprintId){ committed, completed, remaining } (resolving the project's statistic, calling sumPointsForSprint; 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 its SprintSummaryDto.points (4.5.2 adds only the scrum-specific columnPoints breakdown) — this is the seam that keeps the sprint-points SUM in ONE place.
  • rollupForParent(parentId) → the subtree point total (resolving the statistic, calling sumPointsForParent) for the epic/parent roll-up badge.
  • Mappers return EstimationConfigDto + SprintPointsDto + a points field 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

  • setEstimate writes/clears work_item.storyPoints in one transaction (value validated), records a 1.4.6 revision, and enforces the workspaceId gate; getEstimationConfig/updateEstimationConfig read/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 (completed scoped to category = '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 require tx; 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:coverage keeps the new files ≥90% branch/fn/line.

Context refs

  • Story 4.3.2 (the storyPoints column + project estimation config + enums) — the schema this reads/writes
  • Story 4.5.2 (SprintSummaryDto.points = SUM(storyPoints)) — the consumer of rollupForSprint; resolve "done" the SAME way (workflow category = '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/* (the category = 'done' terminal-status resolution, finding #21)
  • lib/services/workItemsService.ts + the 1.4.6 workItemRevisionsService — the audit-trail write to reuse in the same tx; lib/mappers/*, lib/dto/*, lib/<domain>/errors.ts layout
  • motir-core/CLAUDE.md (4-layer; entity-name-wins) + prodect-core-coverage-gate (empty-input guards) + finding #57 (bounded aggregates) + finding #26 (workspaceId gate)