1.4.4 Service layer: createWorkItem (with key allocation) + update + assign + archive + move + link/unlink + ready-set helpers
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, callprojectRepository.allocateWorkItemNumber(projectId, tx)for the next key, deriveidentifier="${project.identifier}-${key}", computepositionvia fractional indexing (append after the current last sibling), callworkItemRepository.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 vspatch(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), callworkItemRepository.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 frombeforeIdandafterId's positions. Trigger validates the kind-parent rule + cycle.listWorkItems(projectId, filter, ctx)— paginated list with optional kind/status/assignee filters. Calls repository'sfindByProject.getWorkItemSubtree(rootId, ctx)— returns the full subtree DTOs via repository'sfindSubtree.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'sworkspaceIdfrom the from item, callworkItemLinkRepository.create. Forrelates_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 (typedWorkItemLinkNotFoundErrorif absent); forrelates_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 allis_blocked_bylinks wherefromId = workItemId. CallsworkItemLinkRepository.findByFromItem(workItemId, 'is_blocked_by')then resolves the toIds to summary DTOs viaworkItemRepository.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 ontoId = 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. Returnstrueiff every blocker (every to-item of anis_blocked_bylink withfromId = workItemId) hasstatus = '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.tsexports the 11 methods above (the 7 work-item methods +linkWorkItems+unlinkWorkItems+getBlockers+getBlocking+isReady). Every write method opens a single$transactionand threadstxto every repository call inside it.- Input types (
CreateWorkItemInput,UpdateWorkItemInput,LinkWorkItemsInput) live inlib/dto/workItems.ts+lib/dto/workItemLinks.tsalongside the output DTOs. createWorkItemallocates the next per-project key atomically with the work-item insert (one transaction) and the initial revision row insert. A concurrentcreateWorkItemagainst the same project produces non-overlapping keys.updateWorkItemwrites 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
explanationMdAND the current row'sexplanationSourceisai_draftAND the patch did NOT explicitly setexplanationSource, the service auto-transitionsexplanationSourcetouser_editedin 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 setsexplanationSource = ai_draftin 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. moveWorkItemcomputes a fractional-indexing key from thebeforeId/afterIdneighbors; the resulting key sorts between them. Edge cases handled: move to start (beforeId=null), move to end (afterId=null), only sibling.archiveWorkItemleaves children intact (NOT cascade-archived); a code comment documents the Linear-shape choice.linkWorkItemswrites the link row + a revision on the from item; forrelates_to, writes the reciprocal row in the same transaction. Same-workspace asserted at the service layer (the trigger backstops). Rejects withWorkItemLinkCycleErrorwhen the trigger fires on cycle insertion.unlinkWorkItemsdeletes the link + writes the removal revision; forrelates_to, deletes the reciprocal row too.getBlockersandgetBlockingreturnWorkItemSummaryDto[]; resolution from link IDs to work-item summaries is a single follow-up query (findByIds), not N+1.isReadyimplemented as a single SQL query (no fetch-then-check); for v1, "ready" = everyis_blocked_byblocker hasstatus = '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_towrites both directions; unlinkWorkItems removes both directions forrelates_to; getBlockers + getBlocking return correct sets; isReady returns false until all blockers are done and true after. - No
db.*or$transactioncalls insideworkItemRepositoryorworkItemLinkRepository. No repository methods called without a passed-intxon 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 mirrorlib/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 +
isReadyspec