feat(budget-sources): PATCH /api/budget-sources/:sourceId/budget-lines/move (#1246)#1258
Conversation
… sources
- Add PATCH /api/budget-sources/:sourceId/budget-lines/move endpoint
- Accepts { workItemBudgetIds, householdItemBudgetIds, targetSourceId }
- Returns { movedWorkItemLines, movedHouseholdItemLines }
- New error codes: SAME_SOURCE (400), EMPTY_SELECTION (400), STALE_OWNERSHIP (409)
- Atomic transaction: validates ownership before any UPDATE; rollback on failure
- Document endpoint in API-Contract wiki page
Fixes #1246
Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude backend-developer (Haiku) <noreply@anthropic.com>
…-lines/move Covers moveBudgetSourceBudgetLines() service and PATCH route handler: - 16 service-layer tests: validation order (same-source > target-exists > empty-selection > stale-ownership), atomic rollback on mixed-valid/invalid IDs, updatedAt bump - 14 route integration tests: 401, 400 (schema + SAME_SOURCE + EMPTY_SELECTION), 404 (both sources), 409 STALE_OWNERSHIP with DB unchanged verification, 200 for WIB-only, HIB-only, and mixed moves Fixes #1246 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
|
Thank you for your submission! We require all contributors to sign our Contributor License Agreement before we can accept your contribution. I have read the CLA Document and I hereby sign the CLA Frank Steiler seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. |
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer]
No security findings. This implementation is clean.
Checklist:
- Auth:
if (!request.user) throw new UnauthorizedError()— matches existing pattern - Injection: all SQL via Drizzle
inArray(col, ids)/eq(col, id)— no raw ID interpolation, no string concatenation - IDOR: both
sourceIdandtargetSourceIdare unrestricted — consistent with the established single-tenant model (1–5 homeowners per instance) - Input validation: JSON schema enforces array-of-string body, all three fields required,
targetSourceId: minLength:1;additionalProperties: falsestrips unknown fields (Fastify removes rather than rejects per established codebase behavior, confirmed by test) - Transaction integrity: ownership check + UPDATE both run inside
db.transaction(tx => {...})—StaleOwnershipErrorthrown inside the callback triggers automatic rollback; tests 9, 13, and 14 explicitly verify no DB mutation on rollback - Return value:
{ movedWorkItemLines, movedHouseholdItemLines }— counts only, no row data, no sensitive fields leaked - Error messages: all three new errors use generic messages with no internal detail (source IDs, counts, or row contents)
- Bulk DoS: two independent
inArrayqueries (one per table) — O(N) single-pass, no N+1 loop. NomaxItemscap on arrays, consistent with all prior bulk endpoints in this codebase and acceptable at the 1–5 user scale. - New error codes (
SAME_SOURCE,EMPTY_SELECTION,STALE_OWNERSHIP) added to sharederrors.tsand matched 1:1 to newAppErrorsubclasses - Dependency changes: none
steilerDev
left a comment
There was a problem hiding this comment.
[product-owner] APPROVED — all 7 ACs from #1246 verified. (Posted as comment: GitHub blocks --approve on self-authored PRs.)
AC Coverage
| AC | Code | Tests | Status |
|---|---|---|---|
1. Body shape + 200 {movedWorkItemLines, movedHouseholdItemLines} + DB updated |
MoveBudgetLinesRequest/Response + tx.update |
svc #1-4, route #8-10 (explicit getWibSourceId/getHibSourceId DB checks) |
PASS |
| 2. 400 SAME_SOURCE when target === URL source | SameSourceError (code SAME_SOURCE, 400) |
svc #6/#6b, route #4 | PASS |
| 3. 400 EMPTY_SELECTION both arrays empty | EmptySelectionError |
svc #5, route #3 | PASS |
| 4. 404 target AND source missing | NotFoundError on both lookups |
svc #7/#8, route #5/#6 | PASS |
| 5. 401 unauthenticated | UnauthorizedError() guard in handler |
route #1 | PASS |
| 6. 409 STALE_OWNERSHIP — missing OR wrong source — atomic rollback | foundWib.length !== ids.length + row.budgetSourceId !== sourceId, both WIB and HIB paths |
svc #9-14, route #7 (all verify DB unchanged post-throw) | PASS |
| 7. Single transaction | Entire validation + update wrapped in db.transaction((tx) => {...}) |
svc #13 (partial valid + invalid rolls back), svc #14 (WIB valid + HIB wrong source → WIB NOT moved) | PASS |
Strengths
inArray()for efficient batch ownership validation (no N+1)- Explicit
updatedAtbump verified (svc #15) - Mixed-array atomic rollback tested across WIB/HIB boundary (svc #14)
- Route tests verify DB state post-409 confirming rollback at route layer too
additionalProperties: falseon body schema (Fastify strip behavior verified)- Member role verified, not just admin
Observations (non-blocking)
- Validation order: source-exists → SAME_SOURCE → target-exists → EMPTY_SELECTION → ownership. AC does not prescribe order; all error codes return correctly. Service test 6b explicitly locks SAME_SOURCE-before-target-lookup ordering.
- New error codes
SAME_SOURCE/EMPTY_SELECTION/STALE_OWNERSHIPadded to sharedErrorCodeunion — consistent with project convention.
30 test cases (16 service + 14 route) provide thorough coverage. Ready for merge once CI passes.
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect] Architectural review — VERDICT: APPROVE (posted as comment because GitHub blocks self-approval on this PR).
Verified the implementation against the wiki API contract, ADR-001/003/005/007 constraints, and issue #1246 acceptance criteria.
Verified
- Validation order: sourceId exists →
SAME_SOURCE→ target exists →EMPTY_SELECTION→STALE_OWNERSHIP(AC-compliant). Test6bexplicitly pins the order by passing a nonexistent WIB withtargetSourceId === sourceIdand assertingSAME_SOURCEwins over any target lookup. Test13pins atomic rollback with 2 valid + 1 invalid WIB. Test14pins the cross-array rollback (valid WIB + wrong-source HIB → WIB stays put). - Transaction correctness:
db.transaction((tx) => { ... })wraps both SELECT validations and both UPDATE statements. Throwing anyAppErrorinside the callback triggers rollback — confirmed by tests 9, 10, 13, 14. inArraysafety: Guarded by explicitif (workItemBudgetIds.length > 0)/if (householdItemBudgetIds.length > 0)checks. Never invoked with an empty array.- Error handler wiring:
errorHandlerPluginroutes allAppErrorsubclasses viaerror.code+error.statusCode. The three new subclasses (SameSourceError400,EmptySelectionError400,StaleOwnershipError409) match the wiki table atAPI-Contract.md:4119-4127. - Wiki accuracy:
API-Contract.mddocuments the validation order (lines 4110-4117), error-response table (lines 4119-4127), and atomic-rollback + two-empty-arrays semantics (lines 4129-4133). Submodule ref bumped frome472e6ato78aea7d. - Shared types:
ErrorCodeunion extended withSAME_SOURCE | EMPTY_SELECTION | STALE_OWNERSHIP.MoveBudgetLinesRequest/MoveBudgetLinesResponseexported viashared/src/index.ts. Naming (PascalCase type / camelCase members / snake_case DB columns) follows conventions. - Test realism: Real SQLite + full migration stack via
buildApp(); route tests useapp.inject()per ADR-005. Both suites use isolated temp directories per test. - ADR compliance: ADR-001 (Fastify
request.user/reply.status), ADR-003 (Drizzle ORM,better-sqlite3,db.transaction()), ADR-005 (Jest +app.inject()), ADR-007 (@cornerstone/sharedtypes consumed through workspace alias). Endpoint is kebab-case under/api/. Error shape matches wiki contract. - Scale/perf: Two bulk SELECTs + two bulk UPDATEs. Linear in input size; no N+1.
Minor observations (non-blocking, auto-fix bot territory)
- Low — Prettier drift: a handful of lines exceed
printWidth: 100— notablyserver/src/routes/budgetSources.ts:222, and several function signatures inserver/src/services/budgetSourceService.ts(lines 543, 576, 673, 725, 726) that were collapsed onto a single line.npx prettier --checkreports both files as needing reformatting. The auto-fix bot (.github/workflows/auto-fix.yml) handles this onbetapush per CLAUDE.md "Local Validation Policy". Not a blocker, but an easy follow-up if the implementing agent wants to normalise before merge. - Informational — The service does not re-validate
Array.isArray(workItemBudgetIds)because the Fastify JSON schema guaranteestype: 'array'at the route layer. Acceptable given route-level validation precedes service entry.
Verdict
No critical or high findings. This PR is architecturally sound and contract-compliant. Approve.
Summary
PATCH /api/budget-sources/:sourceId/budget-lines/moveendpoint to atomically move budget lines between sourcesworkItemBudgetIdsand/orhouseholdItemBudgetIdsplus atargetSourceId; returns arrays of moved linesSAME_SOURCE(400),EMPTY_SELECTION(400),STALE_OWNERSHIP(409); fully atomic transaction with ownership validation before any UPDATEFixes #1246
Test plan
updatedAtbumpCo-Authored-By: Claude dev-team-lead (Sonnet 4.6) noreply@anthropic.com
Co-Authored-By: Claude backend-developer (Haiku) noreply@anthropic.com
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) noreply@anthropic.com