2.2.3 `workflowsService` read API + repository
Estimate: 16m · Depends on: 2.2.1
Add lib/services/workflowsService.ts + lib/repositories/workflowsRepository.ts shipping the read surface every later consumer needs. The service is the only doorway — repositories are single-Prisma-op leaves per CLAUDE.md, services own DTO shaping + tenant-context.
Read methods on the service (all gated by an explicit workspaceId per finding #26):
getWorkflow(projectId, workspaceId): returns{ statuses: WorkflowStatus[], transitions: WorkflowTransition[], policyMode: 'restricted' | 'open' }, statuses ordered byposition.listStatusesByProject(projectId, workspaceId): convenience for board columns + pickers.getStatusByKey(projectId, key, workspaceId): returnsWorkflowStatus | null— the lookupwork_item.statusresolves through.getTerminalStatusKeys(projectId, workspaceId): Set<string>: returns every status key whose category isdone. This is the surface that resolves finding #21 —workItemsService.isReadyandworkItemLinkRepository.countOpenBlockersswap their'done'literal for this set in 2.2.6.canTransition(projectId, fromKey, toKey, workspaceId): Promise<boolean>: returns true ifpolicyMode === 'open'OR aworkflow_transitionrow exists for (from, to); also true iffromKey === toKey(no-op moves are always legal). Reads the curated transition set via the repository.
Per finding #26: every public service method takes workspaceId explicitly and the repository methods filter WHERE workspaceId = $ws rather than trusting RLS — the dev/CI superuser bypasses RLS, so the explicit filter is the actual gate, and the (forced) RLS from 2.2.1 is the defense-in-depth backstop. Mirrors Story 1.4.8's pattern.
Repository methods are pure single-op reads (findStatuses, findTransitions, findStatusByKey, findProjectPolicyMode); no joins beyond what Prisma's relation includes deliver.
Acceptance criteria
- Service exports the 5 read methods above, all with explicit
workspaceIdparameters and DTO-shaped returns (no raw Prisma types leaked to callers). - Repository methods filter by
workspaceIdexplicitly (defense in depth on top of RLS); a cross-workspacefindStatuses(otherProjectId, wrongWorkspaceId)returns[]. getTerminalStatusKeysreturns the set ofcategory === 'done'keys — verified against the default seed (returnsnew Set(['done', 'cancelled'])since 2.2.2 ships both as terminal); after a test-side insert adding a'wont_fix'status with categorydone, returnsnew Set(['done', 'cancelled', 'wont_fix']).canTransitioncovers all four matrix cells: open mode → true for any (from, to); restricted mode + transition row exists → true; restricted mode + no row → false;fromKey === toKey→ true regardless of mode.- Vitest under real Postgres covering the matrix above + the cross-workspace filter assertion.
- No route, no UI — pure service + repo layer.
Context refs
- Story 1.4's
workItemsService+workItemRepository— the 4-layer + explicit-workspaceIdshape this mirrors - Story 1.6.5's
jobsDashboardService.listByWorkspace— the explicit-workspaceId-filter precedent - Finding #26 — explicit application-layer tenant gate; finding #21 — terminal-status set surface
motir-core/CLAUDE.md— DTO mapping owned by service; repos are single-op leaves