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-47

1.4.4 Service layer: createWorkItem (with key allocation) + update + assign + archive + move + link/unlink + ready-set helpers

Done
Description

Estimate: 32m · Depends on: 1.4.2, 1.4.3

Add lib/services/workItemsService.ts — the layer that owns transactions, calls the repository, validates business rules, and returns DTOs. This is the surface Epic 2's route handlers will call. Per motir-core/CLAUDE.md: services own $transaction; repositories never call db.* without a passed-in tx; routes are HTTP-only and call services with mapped inputs.

Method set:

  • createWorkItem(input: CreateWorkItemInput, ctx: ServiceContext): Promise<WorkItemDto> — within a transaction: assert project membership (the reporter must belong to the project's workspace), assert parent (if any) belongs to the same project, assert assignee (if any) is a workspace member, call projectRepository.allocateWorkItemNumber(projectId, tx) for the next key, derive identifier = "${project.identifier}-${key}", compute position via fractional indexing (append after the current last sibling), call workItemRepository.create (the trigger validates kind/depth), write the initial revision row (changeKind='created'). Returns DTO.
  • updateWorkItem(id, patch: UpdateWorkItemInput, ctx): Promise<WorkItemDto> — within a transaction: load the current row, compute the diff vs patch (omit unchanged fields), validate parent move (if parentId in patch) against the allowed-children rule at the service layer too (cheap pre-flight before the trigger), call workItemRepository.update, write a revision row with the diff (changeKind='updated'). Returns DTO. Rejects no-op patches early without writing.
  • assignWorkItem(id, assigneeId, ctx) — specialized service for the common case; same shape as updateWorkItem but with explicit assignee-membership validation. (Worth a method because reassignment is the highest-volume mutation in a working PM tool — Linear's metrics show 40%+ of all writes are reassignments.)
  • archiveWorkItem(id, ctx) — soft-delete via repository, write a revision (changeKind='archived'). Archiving an epic does NOT cascade-archive children — that's a Linear-shape choice: orphaned children become top-level until manually re-parented. Document this in the service.
  • moveWorkItem(id, newParentId, beforeId, afterId, ctx) — re-parent + reorder atomically. Computes the new fractional-indexing key from beforeId and afterId's positions. Trigger validates the kind-parent rule + cycle.
  • listWorkItems(projectId, filter, ctx) — paginated list with optional kind/status/assignee filters. Calls repository's findByProject.
  • getWorkItemSubtree(rootId, ctx) — returns the full subtree DTOs via repository's findSubtree.
  • linkWorkItems(fromId, toId, kind, ctx) — within a transaction: load both items, assert same-workspace at the service layer (the trigger backstops this), derive the link row's workspaceId from the from item, call workItemLinkRepository.create. For relates_to, write the reciprocal row in the same transaction so both endpoints see the symmetric link. Writes a revision row on the from item (changeKind='updated', diff { links: { added: [{toId, kind}] } }) — so the activity feed surfaces dependency changes, not just field changes.
  • unlinkWorkItems(linkId, ctx) — load the link (typed WorkItemLinkNotFoundError if absent); for relates_to, also delete the reciprocal row; write a revision row with diff { links: { removed: [{toId, kind}] } }.
  • getBlockers(workItemId, ctx): Promise<WorkItemSummaryDto[]> — "what does A depend on?" Returns the to-items of all is_blocked_by links where fromId = workItemId. Calls workItemLinkRepository.findByFromItem(workItemId, 'is_blocked_by') then resolves the toIds to summary DTOs via workItemRepository.findByIds (add this method to the repo in 1.4.3 if not already there).
  • getBlocking(workItemId, ctx): Promise<WorkItemSummaryDto[]> — reverse: "what depends on A?" Selects on toId = workItemId AND kind = is_blocked_by, resolves fromIds. This is the query the AI ready-set engine runs over many items to figure out what unblocks when an item ships.
  • isReady(workItemId, ctx): Promise<boolean> — the ready-set predicate Principle #14 specifies. Returns true iff every blocker (every to-item of an is_blocked_by link with fromId = workItemId) has status = 'done' (or, conservatively for v1, any "terminal" status; v1 hardcodes done, Epic 2's workflow Story generalizes to the per-project terminal-status set). Implemented as a single SQL query — a LEFT JOIN over the link table that returns rows where any blocker is not-done; if no rows returned, item is ready. Document: this is the building block Epic 7's ready-set engine batches across the whole tree.

ServiceContext: matches the existing workspacesService / projectsService contract — { userId: string, workspaceId: string }. The middleware that sets the app.workspace_id GUC has already run; service methods never re-set it.

Revision rows live in the same transaction. If the work-item write commits but the revision write fails, the audit trail is broken — both must be in the same $transaction. Easy to get wrong; tests in 1.4.7 verify atomicity by injecting a revision-repo failure mid-flight.

Acceptance criteria

  • lib/services/workItemsService.ts exports the 11 methods above (the 7 work-item methods + linkWorkItems + unlinkWorkItems + getBlockers + getBlocking + isReady). Every write method opens a single $transaction and threads tx to every repository call inside it.
  • Input types (CreateWorkItemInput, UpdateWorkItemInput, LinkWorkItemsInput) live in lib/dto/workItems.ts + lib/dto/workItemLinks.ts alongside the output DTOs.
  • createWorkItem allocates the next per-project key atomically with the work-item insert (one transaction) and the initial revision row insert. A concurrent createWorkItem against the same project produces non-overlapping keys.
  • updateWorkItem writes a revision row only when at least one field actually changes; no-op patches return the current row without writing.
  • Explanation-source state machine: when a patch contains explanationMd AND the current row's explanationSource is ai_draft AND the patch did NOT explicitly set explanationSource, the service auto-transitions explanationSource to user_edited in the same patch. (The user's edit IS the signal that they've taken ownership.) When the AI-drafting service (Epic 7) writes a fresh draft, it explicitly sets explanationSource = ai_draft in its patch, overriding any prior state. The revision diff includes the source transition as one of its fields, so the activity feed shows "User edited the AI draft" as a first-class event.
  • moveWorkItem computes a fractional-indexing key from the beforeId/afterId neighbors; the resulting key sorts between them. Edge cases handled: move to start (beforeId=null), move to end (afterId=null), only sibling.
  • archiveWorkItem leaves children intact (NOT cascade-archived); a code comment documents the Linear-shape choice.
  • linkWorkItems writes the link row + a revision on the from item; for relates_to, writes the reciprocal row in the same transaction. Same-workspace asserted at the service layer (the trigger backstops). Rejects with WorkItemLinkCycleError when the trigger fires on cycle insertion.
  • unlinkWorkItems deletes the link + writes the removal revision; for relates_to, deletes the reciprocal row too.
  • getBlockers and getBlocking return WorkItemSummaryDto[]; resolution from link IDs to work-item summaries is a single follow-up query (findByIds), not N+1.
  • isReady implemented as a single SQL query (no fetch-then-check); for v1, "ready" = every is_blocked_by blocker has status = 'done'. Document the v1 hardcode and the Epic-2 generalization point inline.
  • Service-layer Vitest tests: createWorkItem assigns sequential keys; updateWorkItem writes a revision with the right diff; concurrent creates against the same project don't collide; archive doesn't cascade; moveWorkItem reorders within parent; moveWorkItem to a new parent updates parentId atomically; linkWorkItems writes link + revision atomically; linkWorkItems with relates_to writes both directions; unlinkWorkItems removes both directions for relates_to; getBlockers + getBlocking return correct sets; isReady returns false until all blockers are done and true after.
  • No db.* or $transaction calls inside workItemRepository or workItemLinkRepository. No repository methods called without a passed-in tx on writes.
  • All quality gates green; existing suite stays green.

Context refs

  • motir-core/CLAUDE.md — 4-layer rule (auto-loaded)
  • lib/services/projectsService.ts + lib/services/workspacesService.ts — the exact transactional pattern to mirror
  • lib/repositories/workItemRepository.ts (from 1.4.2) + lib/repositories/projectRepository.ts + lib/repositories/workItemLinkRepository.ts (from 1.4.3)
  • lib/dto/workItems.ts + lib/dto/workItemLinks.ts + lib/workItems/errors.ts + lib/workItems/linkErrors.ts + lib/workItems/positioning.ts
  • This Story page — service-method contract + ServiceContext shape + isReady spec