MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-77

2.2.3 `workflowsService` read API + repository

Done
Description

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 by position.
  • listStatusesByProject(projectId, workspaceId): convenience for board columns + pickers.
  • getStatusByKey(projectId, key, workspaceId): returns WorkflowStatus | null — the lookup work_item.status resolves through.
  • getTerminalStatusKeys(projectId, workspaceId): Set<string>: returns every status key whose category is done. This is the surface that resolves finding #21 — workItemsService.isReady and workItemLinkRepository.countOpenBlockers swap their 'done' literal for this set in 2.2.6.
  • canTransition(projectId, fromKey, toKey, workspaceId): Promise<boolean>: returns true if policyMode === 'open' OR a workflow_transition row exists for (from, to); also true if fromKey === 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 workspaceId parameters and DTO-shaped returns (no raw Prisma types leaked to callers).
  • Repository methods filter by workspaceId explicitly (defense in depth on top of RLS); a cross-workspace findStatuses(otherProjectId, wrongWorkspaceId) returns [].
  • getTerminalStatusKeys returns the set of category === 'done' keys — verified against the default seed (returns new Set(['done', 'cancelled']) since 2.2.2 ships both as terminal); after a test-side insert adding a 'wont_fix' status with category done, returns new Set(['done', 'cancelled', 'wont_fix']).
  • canTransition covers 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-workspaceId shape 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