feat(budget-sources): GET /api/budget-sources/:sourceId/budget-lines (#1245)#1251
Conversation
|
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. |
95d2913 to
bd4ecba
Compare
…get-sources/:sourceId/budget-lines Covers getBudgetSourceBudgetLines() service function and the new route handler: - 25 service-layer unit tests: source-not-found, empty source, WI/HI lines with all invoice statuses (claimed/paid/pending/quotation), area population, sorting by area name/parentName/createdAt, cross-source isolation, budgetCategory/vendor/ createdBy population, and household item invoice aggregation - 13 route integration tests: 401 without auth, 404 for unknown sourceId, 200 with empty/WI/HI/mixed lines, hasClaimedInvoice true/false via claimed/paid invoices, member and admin access, full BaseBudgetLine field presence validation Fixes #1245 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
- Add GET /api/budget-sources/:sourceId/budget-lines endpoint - Returns workItemLines + householdItemLines extending BaseBudgetLine with parentId, parentName, area, and hasClaimedInvoice - Sorted by area (nulls last) -> parent name -> createdAt - New shared types: BudgetSourceBudgetLine, BudgetSourceBudgetLinesResponse - Document endpoint in API-Contract wiki page Fixes #1245 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude backend-developer (Haiku) <noreply@anthropic.com>
bd4ecba to
a7ea84c
Compare
steilerDev
left a comment
There was a problem hiding this comment.
[product-owner] APPROVE — all 7 acceptance criteria from #1245 are met. (Submitting as comment because the PR author is the bot account; treat this as the product-owner approval for the /develop review gate.)
AC Verification
| # | AC | Status | Evidence |
|---|---|---|---|
| 1 | 200 with { workItemLines, householdItemLines }, each extends BaseBudgetLine with parentId, parentName, area, hasClaimedInvoice |
PASS | shared/src/types/budgetSource.ts — BudgetSourceBudgetLine extends BaseBudgetLine with the four additional fields; BudgetSourceBudgetLinesResponse defines the two arrays. Route test response includes all BaseBudgetLine fields verifies all 19 inherited fields + 4 extension fields. |
| 2 | Empty source → both arrays empty | PASS | Route test returns 200 with empty arrays when source has no budget lines; service test returns empty arrays when source has no budget lines. |
| 3 | Nonexistent sourceId → 404 | PASS | Service throws NotFoundError('Budget source not found'); route test returns 404 for nonexistent sourceId verifies error.code === 'NOT_FOUND'. |
| 4 | Unauthenticated → 401 | PASS | Route handler throws UnauthorizedError when !request.user; test returns 401 without authentication verifies error.code === 'UNAUTHORIZED'. |
| 5 | hasClaimedInvoice: true iff linked invoice status === 'claimed' |
PASS | SQL uses CASE WHEN COUNT(CASE WHEN i.status = 'claimed' THEN 1 END) > 0. Covered by positive cases (claimed → true) and negative cases (paid, pending, quotation, no invoice → false) in both service and route tests, for both WI and HI lines. |
| 6 | Sort: area name (nulls last) → parent name → createdAt | PASS | compareBudgetSourceLines applies the three-tier sort in exact order. Service tests verify each tier: sorting: work item lines sorted by area name (nulls last), two lines in same area sorted by parentName, two lines on same work item sorted by createdAt. |
| 7 | Reuse join helpers from budgetSourceService.ts rather than duplicating |
PASS | buildWorkItemBudgetLine / buildHouseholdItemBudgetLine import toAreaSummary, toBudgetCategory, toBudgetSourceSummary, toVendorSummary, toUserSummary from ./shared/converters.js. The previously local toUserSummary helper in budgetSourceService.ts was removed in favor of the shared version — no duplication. |
Additional verified quality
- Response shape matches the wiki API-Contract spec
- Member and admin roles both authorized (consistent with other
/budget-sourcesendpoints) actualCost/actualCostPaid/invoiceCountcorrectly derived frominvoice_budget_linesjoined withinvoicesinvoiceLinkpopulated from first linked invoice, null when none- 24 service unit tests + 12 route integration tests = 36 tests covering shape, auth, sorting, invoice aggregation, entity resolution, cross-source isolation
Observations (non-blocking, future refinement)
- N+1 query pattern in
buildWorkItemBudgetLine/buildHouseholdItemBudgetLine: each line issues ~8 sequential point-lookup queries (work/household item, area, category, source, vendor, user, invoice aggregates, invoice link). For sources with 50+ lines this could add up. Consider consolidating into a single LEFT JOIN query in a future refinement — not a blocker for this story. - JS-layer sort instead of SQL
ORDER BY: acceptable given the JS-layer joins, but flag if pagination is later added.
Verdict: APPROVE. Orchestrator may merge once CI gates are green.
steilerDev
left a comment
There was a problem hiding this comment.
[security-engineer] PR #1251 — Security Review: GET /api/budget-sources/:sourceId/budget-lines
Authentication
The new route follows the identical if (!request.user) { throw new UnauthorizedError(); } guard used by every other handler in budgetSources.ts. Auth enforcement is correct and consistent.
Test case #1 in budgetSources.budgetLines.test.ts explicitly asserts a 401 with UNAUTHORIZED on unauthenticated requests — verified by the test suite.
Injection
All SQL in the new code paths is parameterized:
getBudgetSourceBudgetLines: Drizzleeq(budgetSources.id, sourceId)— safe.buildWorkItemBudgetLine/buildHouseholdItemBudgetLine: All entity lookups useeq()with ORM table references — safe.getWorkItemLineInvoiceData,getHouseholdItemLineInvoiceData,getWorkItemLineInvoiceLink,getHouseholdItemLineInvoiceLink: All usesql`...WHERE ibl.work_item_budget_id = ${lineId}`tagged templates — Drizzle parameterizes these — safe.
No raw string interpolation of user-controlled values anywhere in the new code.
IDOR
sourceId from the URL is used in a WHERE clause without a per-user ownership check. This is consistent with every other budget-source route in the file (list, get-by-id, patch, delete — none filter by user). The application is single-tenant (1–5 homeowners sharing all data by design), so there is no IDOR risk here. No finding.
Information Disclosure
The BudgetSourceBudgetLine shape is built via explicit field mapping in buildWorkItemBudgetLine / buildHouseholdItemBudgetLine. Sensitive fields examined:
createdBy→toUserSummary(createdByUser)— strips everything exceptid,displayName,email. No password hash or OIDC subject leaks.budgetSource→toBudgetSourceSummary(source)— summary shape, no sensitive fields.hasClaimedInvoice— boolean derived from invoice status, not sensitive.
No sensitive data exposure.
Query Volume (Informational)
getBudgetSourceBudgetLines fetches all lines in two bulk SELECTs, then iterates each line synchronously executing up to 8 individual lookups (work item/household item, area, category, source, vendor, user, invoice aggregates, invoice link). For a source with N budget lines this is O(N × 8) round-trips to SQLite.
At the documented scale of 1–5 homeowners this is unlikely to be a practical problem. However, if sources accumulate hundreds of lines (realistic for a full home build), response times could grow noticeably. This mirrors an already-accepted pattern from earlier EPICs. No blocking concern for this PR — noting for future optimization consideration.
Severity: Informational — no exploit path, scale-constrained deployment.
Response Schema (Informational)
budgetLinesParamsSchema defines params but no response schema. This is consistent with other routes in the file (e.g., the list route). No security impact — explicit field mapping in the converter functions is the defense-in-depth layer against accidental field leakage.
Severity: Informational — consistent with existing patterns.
Checklist
- No SQL/command/XSS injection vectors in new code
- Authentication enforced on new endpoint (matches file-wide pattern)
- No sensitive data exposed in logs, errors, or client responses
- User input (
sourceId) used only via parameterized queries - No new dependencies
- No hardcoded credentials or secrets
- IDOR not applicable (single-tenant, shared-data model)
Verdict: No blocking findings. Approve when the token constraint allows.
|
[product-architect] Review posted as a comment because the orchestrator account authored this PR and GitHub blocks self-approvals. Treat this as an Reviewed the new What passes
Medium findings (non-blocking, flag for refinement)M1. Duplicated helpers already available in
|
Summary
GET /api/budget-sources/:sourceId/budget-linesendpoint that returns all budget lines (work item lines + household item lines) linked to a given budget sourceBudgetSourceBudgetLineandBudgetSourceBudgetLinesResponseexposeparentId,parentName,area, andhasClaimedInvoicealongside the existingBaseBudgetLinefieldscreatedAtFixes #1245
Test plan
budgetSourceServicenew functions (budgetSourceService.test.ts)GET /api/budget-sources/:sourceId/budget-linesroute (budgetSources.test.ts)Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) noreply@anthropic.com