Skip to content

refactor(expenditures): declarative route table [5/8] - #345

Merged
nourshoreibah merged 28 commits into
mainfrom
refactor/lambda-expenditures
Aug 23, 2026
Merged

refactor(expenditures): declarative route table [5/8]#345
nourshoreibah merged 28 commits into
mainfrom
refactor/lambda-expenditures

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Stack 5/8. Base is #344, not main. Pure reorganization of the expenditures lambda; no behaviour change.

handler.ts drops from 505 lines to 4. The seven routes, in the order the if chain tested them:

Method Pattern Controller
GET /expenditures getExpenditures
POST /expenditures createExpenditure
GET /expenditures/upload-url getUploadUrl
GET /expenditures/:id/receipt getReceipt
GET /expenditures/:id getExpenditureById
DELETE /expenditures/:id deleteExpenditure
PATCH /expenditures/:id/status patchExpenditureStatus

This lambda is the reason the refactor exists. upload-url had to precede the :id GET purely by if-ordering — nothing enforced it, and reordering two blocks would have quietly routed GET /expenditures/upload-url into "load expenditure with id upload-url". That ordering is now explicit in one readable table, with a test pinning it. The endsWith('receipt') / endsWith('status') suffix sniffing and the split('/').filter(Boolean) index juggling are gone in favour of :param patterns.

Layout

At 505 lines this one earns a service layer:

  • controllers/expenditures.ts — validation and response shaping.
  • services/expenditures.ts — Kysely queries, S3 presigning, receiptKeyFromUrl.
  • validation-utils.ts stays as-is; it was already the validation layer.
  • Local json() and requireAuth() deleted in favour of the shared ones.

Admin gate confirmed

PATCH /expenditures/:id/status still runs requireAuth(authContext, 'ADMIN') before it touches the id or the body. One nuance: it now reaches the real checkAuthorization in @branch/lambda-auth via the shared requireAuth, rather than the handler-local wrapper that forwarded to the same function. The ADMIN branch was compared against the unit test's mock — identical logic — so this is one fewer layer of indirection, not a behaviour change.

Tests

  • npx jest test/expenditures.unit.test.ts69/69 (68 original with no assertions changed, + 1 new precedence test for upload-url vs :id).
  • tsc --noEmit clean.
  • test/expenditures.e2e.test.ts was not run, and is not claimed to pass. Postgres was reachable, but db/testkit.ts's resetData() does TRUNCATE ... RESTART IDENTITY CASCADE over the whole branch schema in every beforeEach, and five sibling conversions were running against that same database at the time. Running it would have truncated rows out from under their in-flight suites. Worth a run in CI, which gets its own Postgres per lambda.

tsconfig.json's include gains controllers/**/*.ts and services/**/*.ts.

Dead code dropped

Two if (!id) return json(400, { message: 'id is required' }) checks in the GET and DELETE /:id handlers were already unreachable — the routing regex guaranteed a non-empty segment. matchPattern's :id capture makes the same guarantee, so they are gone. No observable behaviour change.

Noticed, not fixed

expenditures/openapi.yaml documents /expenditures, /expenditures/{id} and /expenditures/{id}/status, but has no entry for GET /expenditures/upload-url or GET /expenditures/{id}/receipt, both of which the handler implements. Pre-existing spec gap.

Request bodies deliberately keep raw JSON.parse rather than the shared parseBody: parseBody returns null on malformed JSON instead of throwing, which would turn today's 500 into something else.

Stack-wide note: unmatched paths now 404 instead of 401

Previously the top-level auth check ran before route matching, so an unauthenticated request to a nonexistent path got 401. dispatch returns 404 centrally without running auth, so an unauthenticated caller can now distinguish "route exists" (401) from "route doesn't" (404). Accepted deliberately — the route inventory is already in the repo's openapi.yaml files, and auth-before-routing is exactly what forces each lambda to hand-roll public-route exemptions. Flagged in every stack PR so it is a decision rather than an accident.

🤖 Generated with Claude Code

nourshoreibah and others added 5 commits August 22, 2026 13:48
No lambda is converted yet -- this only lands the package the conversions
build on, so it is a pure addition.

The six handlers each route with a chain of `if (normalizedPath === ...)`
statements that test two or three path spellings per route, because API
Gateway's {proxy+} forwards the full path (/projects/7) while the shared
dev-server strips the first segment (/7). Params come out of hand-rolled
`split('/')[2]` and regex tests, correctness depends on `if` ordering that
nothing enforces, and `json()` is defined six times over with `requireAuth`
three times.

@branch/lambda-http replaces that with a declarative route table:

- dispatch({ prefix, routes }) canonicalizes the path to the prefixed shape so
  one table serves both callers, matches `:param` segments, and centralizes
  OPTIONS preflight, /<prefix>/health, 404 and 500.
- json() with CORS headers, parseBody(), requireAuth() and a createAuthGuard()
  factory that binds a service's db-scoped authenticateRequest.
- 28 unit tests, including route precedence and both path shapes.

The dispatch/match/response/types modules are recovered from the closed PR
#257; the auth and body helpers are new. No infrastructure change is needed --
{proxy+} and ANY already landed on main via PR #279.

CI: both workflows build lambda-http after lambda-auth (it consumes that
package's dist), a shared-http job runs its tests and is added to the
lambda-tests gate, and lambda-deploy triggers on shared/lambda-http/**.

Note: lambda-deploy still does not trigger on shared/lambda-auth/**, a
pre-existing gap left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure reorganization, no behaviour change:
- handler.ts is now a one-liner: dispatch(event, { prefix: 'users', routes }).
- routes.ts holds the ordered Route[] table, bracketed by the ROUTES-START/
  ROUTES-END markers (moved here from handler.ts) in the same order as the
  original if-chain: GET /users, GET /users/:userId, PATCH /users/:userId,
  DELETE /users/:userId, POST /users.
- controllers/users.ts holds one RouteHandler per route, calling Kysely
  directly (no services/ layer — this lambda is thin). Auth now goes through
  createAuthGuard(authenticateRequest) from @branch/lambda-http instead of a
  handler-local requireAuth/checkAuthorization pairing; @branch/lambda-auth's
  checkAuthorization (which the shared requireAuth calls) is behaviourally
  identical to the removed local copy for every level this lambda uses.
- Local json()/requireAuth() helpers deleted in favor of the @branch/lambda-http
  exports. dev-server.ts, db.ts, auth.ts, validation-utils.ts, swagger-utils.ts
  untouched.
- Added @branch/lambda-http as a dependency, regenerated package-lock.json.
- tsconfig.json now includes controllers/**/*.ts.
- Added a route-precedence unit test (literal /users/me vs /users/:userId).

Existing suites pass unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the if-chain in handler.ts with a Route[] table dispatched via
@branch/lambda-http's dispatch(). handler.ts is now a one-liner; route
logic moved into controllers/donors.ts (GET/POST /donors, DELETE
/donors/:id) and controllers/donations.ts (GET/POST /donors/donations,
DELETE /donors/donations/:id), in the same order as the original if
chain. No services/ layer added, per this lambda's existing shape.

Local json() removed in favor of the shared one; auth stays manual
(authenticateRequest + custom 401/403 messages) since this lambda's
authorization messages don't match @branch/lambda-http's generic
requireAuth reasons. ROUTES-START/END markers moved into routes.ts,
now bracketing the route table entries.

Added @branch/lambda-http as a dependency and regenerated
package-lock.json. Added one test asserting GET /donors/donations
reaches the donations controller rather than a donor-id route.

No behavior change: same status codes, messages, and validation
order. Verified via tsc --noEmit and jest (--runInBand to avoid
DB contention with sibling lambda test runs): 51 passed, 1
pre-existing failure (health test requires a live dev-server on
:3000, fails identically on main).
Replaces the if-chain in handler.ts with a Route[] table (routes.ts) and
one RouteHandler per route (controllers/reports.ts). handler.ts is now
a thin `dispatch(event, { prefix: 'reports', routes })`.

- Local json() and the local async requireAuth() are gone; dispatch
  provides json/OPTIONS/health/404/500 centrally, and
  createAuthGuard(authenticateRequest) replaces the local requireAuth,
  preserving its exact 401 "Authentication required" message.
- Route order preserved from the original if-chain: POST /generate and
  GET /upload-url stay ahead of the /:id pattern they'd otherwise be
  swallowed by (both are 2-segment paths, same as /reports/:id).
- REPORT_ID_ROUTE/REPORT_DOWNLOAD_ROUTE's \d+ constraint is now an
  explicit numeric check in getReport/deleteReport/downloadReport, so a
  non-numeric :id still falls through to the same 404 instead of being
  looked up as a report id.
- report-service.ts is untouched; controllers still parse/validate/
  respond and delegate to it. S3 presigning stays in the controllers,
  matching where it lived in handler.ts.
- Added two route-precedence unit tests (GET /reports/upload-url and
  POST /reports/generate each reach their own controller, not
  /reports/:id or the generic POST /reports controller).
- ROUTES-START/END markers moved into routes.ts around the route array.

No behaviour change: same status codes, messages, validation order,
and S3/TTL values as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ambda-http

Replaces the if-chain in handler.ts with dispatch() + a routes.ts table,
matching the shared @branch/lambda-http package adopted repo-wide.

- handler.ts: now just `dispatch(event, { prefix: 'expenditures', routes })`.
- routes.ts: ordered Route[] table, ROUTES-START/END markers preserved
  around the entries. Route order matches the original if-chain, notably
  keeping /expenditures/upload-url before /expenditures/:id.
- controllers/expenditures.ts: one RouteHandler per route — validates
  input, calls the service layer, shapes the response. Same status codes,
  messages, and validation order as before.
- services/expenditures.ts: Kysely queries, S3 presigning, and
  receiptKeyFromUrl, unchanged in behavior, just relocated.
- Local json()/requireAuth() dropped in favor of the @branch/lambda-http
  exports (requireAuth's ADMIN gate on PATCH /expenditures/:id/status is
  now backed by the real @branch/lambda-auth checkAuthorization instead
  of the handler-local wrapper; identical logic).
- package.json: added @branch/lambda-http as a dependency;
  package-lock.json regenerated via `npm install --legacy-peer-deps`.
- test/expenditures.unit.test.ts: added a route-precedence regression
  test for GET /expenditures/upload-url vs /expenditures/:id.

No behavior change: same status codes, response shapes, S3 TTLs, and
content-type restriction as the previous if-chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Aug 22, 2026
nourshoreibah and others added 23 commits August 22, 2026 14:33
# Conflicts:
#	.github/workflows/lambda-deploy.yml
#	.github/workflows/lambda-tests.yml
# Conflicts:
#	apps/backend/lambdas/donors/handler.ts
# Conflicts:
#	apps/backend/lambdas/reports/handler.ts
# Conflicts:
#	apps/backend/lambdas/expenditures/handler.ts
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Preview deploys were failing at esbuild with "Could not resolve
@branch/lambda-http" while lambda-tests and lambda-deploy were green.

Three workflows each encoded their own copy of "build the shared packages a
lambda depends on before packaging it", and adding @branch/lambda-http updated
only two of them. preview-env.yml still built lambda-auth alone, so the lambda's
npm ci installed a file: dependency whose dist had never been built and the
bundle could not resolve the import.

Replaces all of it with .github/actions/build-shared-packages, used by
lambda-tests (test + shared-http), lambda-deploy (build) and preview-env
(deploy). Build order lives in one place now: lambda-http declares lambda-auth
as file:../lambda-auth and compiles against its dist, so it goes second.

The next shared package added is the actual test of this: one edit instead of
four, with no fourth copy left to forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nversion

The lambda-readme workflow regenerates every README and pushes the result, and
it ran the old CLI against a converted lambda: extractRoutesFromHandler parses
if-conditions out of handler.ts, which is now four lines, so it found no routes
and auto-committed a README with all five of users' endpoints deleted.

- extractRoutesFromHandler now prefers a sibling routes.ts and falls back to the
  if-chain parse, so it reads converted and unconverted lambdas alike. That
  matters inside this stack, where only some lambdas have been converted at any
  given commit.
- collectRoutes lists health once, under the service prefix for a converted
  lambda and bare otherwise, instead of hardcoding /health and duplicating a
  spec entry that spells it the other way.
- users/openapi.yaml is normalized to match: paths carry the /users prefix and
  servers is the bare host. It previously contradicted itself -- servers ended
  in /users AND the /users path was prefixed, so Swagger built /users/users,
  while /{userId} had no prefix at all.

The fuller CLI rebuild lands in 8/8; this is the subset needed for the README
workflow to stop rewriting these files as each lambda converts.

expenditures/README.md picks up a POST /expenditures row: pre-existing drift on
main that the workflow would have auto-committed anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

README regenerated so the lambda-readme workflow has nothing to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

README regenerated so the lambda-readme workflow has nothing to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

README regenerated so the lambda-readme workflow has nothing to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 23, 2026 00:38
Base automatically changed from refactor/lambda-reports to main August 23, 2026 00:40
@nourshoreibah
nourshoreibah merged commit cb88689 into main Aug 23, 2026
20 checks passed
@nourshoreibah
nourshoreibah deleted the refactor/lambda-expenditures branch August 23, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant