From 2285cf09a658961d913ee858f81c053687876acb Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Tue, 30 Jun 2026 00:09:41 -0400 Subject: [PATCH 1/6] fix: route lambda sub-paths via API Gateway proxy + shared dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST API exposed only single-segment resources (/auth, /projects, ...) with per-method integrations and no {proxy+}, so any sub-path (/auth/login, /projects/123/members) matched no resource and API Gateway returned 403 — the lambda never ran. Even bare paths 404'd inside the lambda because handlers were written for the dev-server's prefix-stripped shape while API Gateway forwards the full path. Separately, shared file: deps were never bundled into the deploy zip (latent MODULE_NOT_FOUND). Changes: - infrastructure/aws/api_gateway.tf: greedy {proxy+} + ANY per lambda (forwards full path, routes OPTIONS preflight, fixes missing PUT); deployment redeploy trigger. Removes the per-method map. - shared/lambda-http (@branch/lambda-http): dispatch({prefix,routes}) route-table router with :param matching + path canonicalization (one table works behind API Gateway's full path and the dev-server's stripped path); centralizes json()/CORS, OPTIONS, /health, 404, 500. - All 6 handlers converted to route tables with full prefixed patterns; business logic preserved. - esbuild bundling: each lambda's package script bundles handler + shared deps into one dist/handler.js (@aws-sdk external); CI builds lambda-http. Fixes the shared-dep packaging gap. - lambda-cli.js emits route-table entries; openapi specs normalized to full prefixed paths (+ fixed pre-existing donors dup key / reports YAML colon). - next.config.ts: dev rewrites no longer strip the service prefix. - AGENTS.md (root, backend, lambdas, infra, frontend) updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/lambda-deploy.yml | 4 +- .github/workflows/lambda-tests.yml | 2 + .gitignore | 1 + AGENTS.md | 4 +- apps/backend/AGENTS.md | 16 +- apps/backend/lambdas/AGENTS.md | 81 ++- apps/backend/lambdas/auth/handler.ts | 366 +++++------ apps/backend/lambdas/auth/openapi.yaml | 18 +- apps/backend/lambdas/auth/package-lock.json | 499 +++++++++++++++ apps/backend/lambdas/auth/package.json | 8 +- apps/backend/lambdas/donors/handler.ts | 270 ++++---- apps/backend/lambdas/donors/openapi.yaml | 6 +- apps/backend/lambdas/donors/package-lock.json | 500 +++++++++++++++ apps/backend/lambdas/donors/package.json | 8 +- apps/backend/lambdas/expenditures/handler.ts | 310 +++++----- .../backend/lambdas/expenditures/openapi.yaml | 4 +- .../lambdas/expenditures/package-lock.json | 532 +++++++++++++++- .../backend/lambdas/expenditures/package.json | 8 +- apps/backend/lambdas/projects/handler.ts | 579 ++++++++---------- apps/backend/lambdas/projects/openapi.yaml | 6 +- .../lambdas/projects/package-lock.json | 500 +++++++++++++++ apps/backend/lambdas/projects/package.json | 8 +- apps/backend/lambdas/reports/handler.ts | 262 ++++---- apps/backend/lambdas/reports/openapi.yaml | 7 +- .../backend/lambdas/reports/package-lock.json | 500 +++++++++++++++ apps/backend/lambdas/reports/package.json | 8 +- apps/backend/lambdas/tools/lambda-cli.js | 223 ++----- apps/backend/lambdas/users/handler.ts | 451 ++++++-------- apps/backend/lambdas/users/openapi.yaml | 6 +- apps/backend/lambdas/users/package-lock.json | 500 +++++++++++++++ apps/backend/lambdas/users/package.json | 8 +- apps/frontend/AGENTS.md | 2 +- apps/frontend/next.config.ts | 39 +- infrastructure/AGENTS.md | 2 +- infrastructure/aws/api_gateway.tf | 104 ++-- shared/lambda-http/package-lock.json | 55 ++ shared/lambda-http/package.json | 15 + shared/lambda-http/src/dispatch.ts | 55 ++ shared/lambda-http/src/index.ts | 4 + shared/lambda-http/src/match.ts | 26 + shared/lambda-http/src/response.ts | 15 + shared/lambda-http/src/types.ts | 29 + shared/lambda-http/tsconfig.json | 17 + 43 files changed, 4507 insertions(+), 1551 deletions(-) create mode 100644 shared/lambda-http/package-lock.json create mode 100644 shared/lambda-http/package.json create mode 100644 shared/lambda-http/src/dispatch.ts create mode 100644 shared/lambda-http/src/index.ts create mode 100644 shared/lambda-http/src/match.ts create mode 100644 shared/lambda-http/src/response.ts create mode 100644 shared/lambda-http/src/types.ts create mode 100644 shared/lambda-http/tsconfig.json diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index 25e299ba..6f51e30f 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -68,10 +68,12 @@ jobs: - name: Build shared lambda-auth package run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth + - name: Build shared lambda-http package + run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps - + - name: Package lambda working-directory: ${{ matrix.lambda }} run: npm run package diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index e54870d6..18b0a433 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -51,6 +51,8 @@ jobs: run: psql postgres://branch_dev:password@localhost:5432/branch_db -f apps/backend/db/db_setup.sql - name: Build shared lambda-auth package run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth + - name: Build shared lambda-http package + run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.gitignore b/.gitignore index 7dcf7d01..5c090aed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist tmp /out-tsc +lambda.zip # dependencies node_modules diff --git a/AGENTS.md b/AGENTS.md index 52d1b1c2..f2f82a3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi | `apps/backend/lambdas/` | The lambda services + `lambda-cli.js` tooling | `apps/backend/lambdas/AGENTS.md` | | `shared/types/` | `@branch/types` — types-only pkg (DB rows + auth DTOs) | see backend doc | | `shared/lambda-auth/` | `@branch/lambda-auth` — runtime Cognito auth/authz pkg | see backend doc | +| `shared/lambda-http/` | `@branch/lambda-http` — runtime HTTP router/dispatcher for lambdas | see backend doc | | `infrastructure/` | Terraform: `aws/`, `github/`, `test/` | `infrastructure/AGENTS.md` | | `.github/workflows/` | CI/CD + PR review bot | `.github/AGENTS.md` | @@ -28,10 +29,11 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi ## Shared packages (critical) -Two `file:`-linked packages dedupe code across lambdas: +Three `file:`-linked packages dedupe code across lambdas: - **`@branch/types`** (`shared/types/`) — types only, no runtime. Exports DB row types (`DB`, `BranchUsers`, ...) + auth DTOs (`AuthContext`, `AuthenticatedUser`, `AccessLevel`, `AuthorizationCheck`). `db-types.d.ts` is **generated** from `apps/backend/db/db_setup.sql` by the `regenerate-db-types` workflow — never hand-edit it. - **`@branch/lambda-auth`** (`shared/lambda-auth/`) — runtime auth: `authenticateRequest(db, event)`, `extractToken(event)`, `checkAuthorization(ctx, level, resourceUserId?)`. Lambdas wrap it in their local `auth.ts`. +- **`@branch/lambda-http`** (`shared/lambda-http/`) — runtime HTTP router: `dispatch(event, { prefix, routes })`, `json(status, body)`, `matchPattern`. Each lambda is a `routes` table of `{ method, pattern, handler }` with full prefixed `:param` patterns; `dispatch` canonicalizes the path (so the same table works behind API Gateway's full path and the dev-server's stripped path) and centralizes OPTIONS/CORS, `/health`, 404 and 500. Runtime deps are `file:`-linked but **bundled into each lambda's zip by esbuild** at `npm run package` — they are not on `node_modules` at runtime. ## Root commands diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 3b73fc4b..88ff9f1d 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -32,13 +32,14 @@ make logs-service SERVICE=users ``` Defaults work without `.env` (DB: branch_dev/password@postgres:5432/branch_db). See `apps/backend/README.md` for the full Make target table. -**Shared dev-server (single service iteration)** — from a lambda dir (`npm run dev`). All lambdas register on **port 3000**; first one started owns the server, others register via `POST /_register`. Routes dispatch by first path segment: +**Shared dev-server (single service iteration)** — from a lambda dir (`npm run dev`). All lambdas register on **port 3000**; first one started owns the server, others register via `POST /_register`. It routes by first path segment to a service, then **strips that prefix** before invoking the handler: ``` -http://localhost:3000/auth/register -http://localhost:3000/donors # GET / +http://localhost:3000/auth/register # handler receives /register +http://localhost:3000/donors # handler receives / http://localhost:3000//swagger # Swagger UI from openapi.yaml http://localhost:3000//health ``` +The `@branch/lambda-http` dispatcher **re-canonicalizes** the stripped path back to the full prefixed form (`/register` → `/auth/register`), so the same route table matches here and behind API Gateway (which forwards the full path). ## Database @@ -49,9 +50,12 @@ http://localhost:3000//health ## Shared packages -Both linked via `file:` deps in each lambda's `package.json`: +All linked via `file:` deps in each lambda's `package.json`: - `@branch/types` (`../../../../shared/types`) — devDependency, types only. -- `@branch/lambda-auth` (`../../../../shared/lambda-auth`) — dependency, runtime auth. Build it (`npm run build` in `shared/lambda-auth`) when its source changes; lambdas consume `dist/`. +- `@branch/lambda-auth` (`../../../../shared/lambda-auth`) — dependency, runtime auth. Build it (`npm run build` in `shared/lambda-auth`) when its source changes. +- `@branch/lambda-http` (`../../../../shared/lambda-http`) — dependency, runtime HTTP router (`dispatch`/`json`/`matchPattern`). Build it when its source changes. + +Runtime shared deps are **bundled into each lambda's zip by esbuild** (see Deploy), so they don't need to be present in `node_modules` at runtime — but they must be built (`dist/`) before bundling. ## Deploy @@ -60,7 +64,7 @@ Automatic on push to `main` touching `apps/backend/lambdas/**` or `shared/types/ 2. Build per-lambda: `npm ci --legacy-peer-deps` + `npm run package` → `lambda.zip`. 3. `aws lambda update-function-code --function-name branch-` (region `us-east-2`). -`npm run package` = `tsc` then zip `dist/` excluding maps, `dev-server.*`, `swagger-utils.*`. Function names **must** match `branch-`. Infra (function definitions, IAM, API Gateway routes) is in `infrastructure/aws/lambda.tf` + `api_gateway.tf`; the deploy workflow only swaps code (TF `lifecycle` ignores `s3_key`). +The workflow builds `shared/lambda-auth` and `shared/lambda-http` first. `npm run package` = **esbuild bundle** of `handler.ts` (deps + shared packages inlined, `@aws-sdk/*` external) → single `dist/handler.js`, zipped to `lambda.zip`. Function names **must** match `branch-`. Infra (function definitions, IAM, API Gateway routes) is in `infrastructure/aws/lambda.tf` + `api_gateway.tf`; the deploy workflow only swaps code (TF `lifecycle` ignores `s3_key`). API Gateway forwards the **full path** to each lambda via a greedy `{proxy+}` per service (see `infrastructure/AGENTS.md`). ## Env vars (lambdas) diff --git a/apps/backend/lambdas/AGENTS.md b/apps/backend/lambdas/AGENTS.md index 62f35751..df208d80 100644 --- a/apps/backend/lambdas/AGENTS.md +++ b/apps/backend/lambdas/AGENTS.md @@ -6,7 +6,7 @@ Each `/` here is one Lambda. They share a near-identical shape. **Use t ``` / - handler.ts # entry: export const handler = async (event) => ... + handler.ts # entry: route table dispatched via @branch/lambda-http dev-server.ts # local shared-server registration (port 3000) db.ts # Kysely + pg.Pool auth.ts # thin wrapper over @branch/lambda-auth + domain authz helpers @@ -20,35 +20,40 @@ Each `/` here is one Lambda. They share a near-identical shape. **Use t ## Handler pattern +Each handler is a **route table** dispatched by `@branch/lambda-http`. Business +logic lives in per-route functions; `dispatch` owns path/method parsing, OPTIONS +preflight, `//health`, 404 and 500. + ```ts -export const handler = async (event: any): Promise => { - try { - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - if (method === 'OPTIONS') return json(200, {}); // CORS preflight - if (normalizedPath.endsWith('/health') && method === 'GET') // health (no auth) - return json(200, { ok: true }); - - const authContext = await authenticateRequest(event); // every service except `auth` - if (!authContext.isAuthenticated) return json(401, { message: 'Unauthorized' }); - - // >>> ROUTES-START (do not remove this marker) - if (normalizedPath === '/donors' && method === 'GET') { /* ... */ } - // <<< ROUTES-END - - return json(404, { message: 'Not Found' }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; + +async function listDonors({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); // every service except `auth` + if (!authContext.isAuthenticated) return json(401, { message: 'Unauthorized' }); + // ... + return json(200, { data: [] }); +} + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'donors', + routes: [ + { method: 'GET', pattern: '/donors', handler: listDonors }, + { method: 'GET', pattern: '/donors/:id', handler: getDonor }, // ctx.params.id + ], + }); ``` -- **NEVER remove or modify the `ROUTES-START` / `ROUTES-END` markers** — the CLI injects routes between them. -- Handles both API Gateway and Lambda Function URL event shapes (`rawPath`/`path`, `requestContext.http.method`/`httpMethod`). -- Responses go through a local `json(status, body)` helper that sets CORS headers (`Access-Control-Allow-Origin: *`, allowed headers `Content-Type,Authorization`). +- **Patterns are full prefixed paths** (`/donors`, `/projects/:id/members`), with + `:param` segments surfaced as `ctx.params`. Routes are tried in order — list + more specific patterns first (e.g. `/projects/dashboard` before `/projects/:id`). +- `dispatch` **canonicalizes** the path: API Gateway delivers the full path, the + shared dev-server strips the service prefix — both resolve to the same pattern. +- CORS is centralized: `json()` sets the headers and OPTIONS returns 200. Auth is + still per-route (call `authenticateRequest` inside each handler that needs it). +- The old per-handler `if`-chain + `ROUTES-START/END` markers + local `json()` are + gone. Scaffolds created by `lambda-cli` wrap the routes array in the markers so + `add-route` can inject entries; hand-converted handlers omit them. ## Auth & authorization @@ -92,11 +97,18 @@ Convention: optional `page` + `limit` query params → `offset = (page-1)*limit` ``` dev ts-node --transpile-only dev-server.ts -build tsc -package npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*' +build tsc # typecheck only +package esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs \ + --outfile=dist/handler.js --external:@aws-sdk/* && zip the single dist/handler.js test jest (or start-server-and-test wrapping jest) ``` +`package` **bundles** the handler + all `file:`-linked shared packages +(`@branch/lambda-http`, `@branch/lambda-auth`) and node deps into one +`dist/handler.js` via esbuild (`@aws-sdk/*` left external — provided by the +node20 runtime). The zip ships only that file. The CI `lambda-deploy` / +`lambda-tests` workflows build `shared/lambda-http` before installing each lambda. + --- # Lambda CLI @@ -106,14 +118,17 @@ When adding new API endpoints or scaffolding new Lambda handlers, use the CLI at ## Commands ### `init-handler ` -Creates a new Lambda handler with boilerplate (handler.ts, dev-server.ts, openapi.yaml, swagger-utils.ts, package.json, tsconfig.json, README.md, test/). Wires in `@branch/types` and `@branch/lambda-auth` automatically. +Creates a new Lambda handler with boilerplate (handler.ts, dev-server.ts, openapi.yaml, swagger-utils.ts, package.json, tsconfig.json, README.md, test/). Wires in `@branch/types`, `@branch/lambda-auth`, and `@branch/lambda-http` automatically. The scaffolded `handler.ts` is a `dispatch(...)` route table with an empty `routes` array between the `ROUTES-START/END` markers. ```bash node tools/lambda-cli.js init-handler orders ``` ### `add-route [options]` -Adds a route stub to both `handler.ts` (between the ROUTES-START/ROUTES-END markers) and `openapi.yaml`. +Injects a route-table entry (a `{ method, pattern, handler }` object with a stub +handler) into `handler.ts` between the ROUTES-START/ROUTES-END markers, and the +path into `openapi.yaml`. **Pass the full prefixed path** — `{id}` is converted to +the dispatcher's `:id` pattern. Options: - `--body field:type,field:type` — request body fields @@ -122,8 +137,8 @@ Options: - `--status ` — response status code (default: 200) ```bash -node tools/lambda-cli.js add-route auth POST /reset-password --body email:string,code:string,newPassword:string -node tools/lambda-cli.js add-route users GET /users/{id} +node tools/lambda-cli.js add-route auth POST /auth/reset-password --body email:string,code:string,newPassword:string +node tools/lambda-cli.js add-route users GET /users/{userId} node tools/lambda-cli.js add-route users GET /users --query page:number,limit:number node tools/lambda-cli.js add-route users POST /users --body name:string --headers authorization:string --status 201 ``` diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 63e1eaa8..6384bc61 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -1,15 +1,13 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; import { CognitoIdentityProviderClient, SignUpCommand, SignUpCommandInput, AdminDeleteUserCommand, - InitiateAuthCommand, - InitiateAuthCommandInput, ConfirmSignUpCommand, ConfirmSignUpCommandInput, ResendConfirmationCodeCommand, - GlobalSignOutCommand, GlobalSignOutCommandInput, ForgotPasswordCommand, @@ -28,216 +26,175 @@ const cognitoClient = new CognitoIdentityProviderClient({ const USER_POOL_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; -export const handler = async (event: any): Promise => { +// POST /auth/verify-email +async function handleVerifyEmail({ event }: RouteCtx): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email, code } = body; + if (!email || !code) { + return json(400, { message: 'email and code are required' }); + } + const params: ConfirmSignUpCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + ConfirmationCode: code as string, + }; try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); + await cognitoClient.send(new ConfirmSignUpCommand(params)); + } catch (error: any) { + console.error('Email verification error:', error); + if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { + return json(200, { message: `Email already verified for ${email}` }); } - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); + if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { + return json(400, { message: 'Invalid or expired verification code' }); } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /register - if (normalizedPath === '/register' && method === 'POST') { - return await handleRegister(event); + if (error.name === 'UserNotFoundException') { + return json(400, { message: 'Invalid code or email' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); } + return json(500, { message: 'Failed to verify email' }); + } + return json(200, { message: `Email verified successfully for ${email}` }); +} - - // POST /login - if (normalizedPath === '/login' && method === 'POST') { - return await handleLogin(event); +// POST /auth/resend-code +async function handleResendCode({ event }: RouteCtx): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email } = body; + if (!email) { + return json(400, { message: 'email is required' }); + } + try { + await cognitoClient.send(new ResendConfirmationCodeCommand({ + ClientId: USER_POOL_CLIENT_ID, + Username: email as string, + })); + return json(200, { message: `Verification code resent to ${email}` }); + } catch (error: any) { + if (error.name === 'UserNotFoundException') { + return json(404, { message: 'User not found' }); } - - // POST /verify-email - if (normalizedPath === '/verify-email' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code } = body; - if (!email || !code) { - return json(400, { message: 'email and code are required' }); - } - const params: ConfirmSignUpCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - ConfirmationCode: code as string, - }; - try { - await cognitoClient.send(new ConfirmSignUpCommand(params)); - } catch (error: any) { - console.error('Email verification error:', error); - if (error.name === 'NotAuthorizedException' && error.message?.includes('CONFIRMED')) { - return json(200, { message: `Email already verified for ${email}` }); - } - if (error.name === 'CodeMismatchException' || error.name === 'ExpiredCodeException') { - return json(400, { message: 'Invalid or expired verification code' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid code or email' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return json(500, { message: 'Failed to verify email' }); - } - return json(200, { message: `Email verified successfully for ${email}` }); + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'User is already confirmed' }); } - - // POST /resend-code - if (normalizedPath === '/resend-code' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - try { - await cognitoClient.send(new ResendConfirmationCodeCommand({ - ClientId: USER_POOL_CLIENT_ID, - Username: email as string, - })); - return json(200, { message: `Verification code resent to ${email}` }); - } catch (error: any) { - if (error.name === 'UserNotFoundException') { - return json(404, { message: 'User not found' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'User is already confirmed' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - console.error('Resend code error:', error); - return json(500, { message: 'Failed to resend verification code' }); - } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); } - - // POST /logout - if (normalizedPath === '/logout' && method === 'POST') { - const authHeader = event.headers?.authorization || event.headers?.Authorization; - if (!authHeader) { - return json(401, { message: 'Authorization header is required' }); - } + console.error('Resend code error:', error); + return json(500, { message: 'Failed to resend verification code' }); + } +} - // Extract token (remove "Bearer " prefix if present) - const accessToken = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : authHeader; +// POST /auth/logout +async function handleLogout({ event }: RouteCtx): Promise { + const authHeader = event.headers?.authorization || event.headers?.Authorization; + if (!authHeader) { + return json(401, { message: 'Authorization header is required' }); + } - if (!accessToken) { - return json(401, { message: 'Access token is required' }); - } + // Extract token (remove "Bearer " prefix if present) + const accessToken = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : authHeader; - const params: GlobalSignOutCommandInput = { - AccessToken: accessToken, - }; + if (!accessToken) { + return json(401, { message: 'Access token is required' }); + } - try { - await cognitoClient.send(new GlobalSignOutCommand(params)); - return json(200, { message: 'Logged out successfully' }); - } catch (error: any) { - console.error('Logout error:', error); + const params: GlobalSignOutCommandInput = { + AccessToken: accessToken, + }; - if (error.name === 'NotAuthorizedException') { - return json(401, { message: 'Invalid or expired token' }); - } + try { + await cognitoClient.send(new GlobalSignOutCommand(params)); + return json(200, { message: 'Logged out successfully' }); + } catch (error: any) { + console.error('Logout error:', error); - return json(500, { message: 'Failed to logout' }); - } + if (error.name === 'NotAuthorizedException') { + return json(401, { message: 'Invalid or expired token' }); } - - // POST /forgot-password - if (normalizedPath === '/forgot-password' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email } = body; - if (!email) { - return json(400, { message: 'email is required' }); - } - const params: ForgotPasswordCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: (email as string).toLowerCase(), - }; + return json(500, { message: 'Failed to logout' }); + } +} - try { - const response = await cognitoClient.send(new ForgotPasswordCommand(params)); - return json(200, { - message: 'Password reset code sent', - deliveryMedium: response.CodeDeliveryDetails?.DeliveryMedium, - destination: response.CodeDeliveryDetails?.Destination, - }); - } catch (error: any) { - console.error('Forgot password error:', error); - if (error.name === 'UserNotFoundException') { - // Don't reveal whether the user exists - return json(200, { message: 'If an account with that email exists, a reset code has been sent' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many requests, please try again later' }); - } - if (error.name === 'InvalidParameterException') { - return json(400, { message: 'Cannot reset password for unverified email. Please verify your email first.' }); - } - return json(500, { message: 'Failed to initiate password reset' }); - } - } - - // POST /reset-password - if (normalizedPath === '/reset-password' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - const { email, code, newPassword } = body; - if (!email || !code || !newPassword) { - return json(400, { message: 'email, code, and newPassword are required' }); - } +// POST /auth/forgot-password +async function handleForgotPassword({ event }: RouteCtx): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email } = body; + if (!email) { + return json(400, { message: 'email is required' }); + } - const params: ConfirmForgotPasswordCommandInput = { - ClientId: USER_POOL_CLIENT_ID, - Username: (email as string).toLowerCase(), - ConfirmationCode: code as string, - Password: newPassword as string, - }; + const params: ForgotPasswordCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: (email as string).toLowerCase(), + }; - try { - await cognitoClient.send(new ConfirmForgotPasswordCommand(params)); - return json(200, { message: 'Password reset successfully' }); - } catch (error: any) { - console.error('Reset password error:', error); - if (error.name === 'CodeMismatchException') { - return json(400, { message: 'Invalid verification code' }); - } - if (error.name === 'ExpiredCodeException') { - return json(400, { message: 'Verification code has expired, please request a new one' }); - } - if (error.name === 'InvalidPasswordException') { - return json(400, { message: 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)' }); - } - if (error.name === 'UserNotFoundException') { - return json(400, { message: 'Invalid email or code' }); - } - if (error.name === 'LimitExceededException') { - return json(429, { message: 'Too many attempts, please try again later' }); - } - return json(500, { message: 'Failed to reset password' }); - } + try { + const response = await cognitoClient.send(new ForgotPasswordCommand(params)); + return json(200, { + message: 'Password reset code sent', + deliveryMedium: response.CodeDeliveryDetails?.DeliveryMedium, + destination: response.CodeDeliveryDetails?.Destination, + }); + } catch (error: any) { + console.error('Forgot password error:', error); + if (error.name === 'UserNotFoundException') { + // Don't reveal whether the user exists + return json(200, { message: 'If an account with that email exists, a reset code has been sent' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many requests, please try again later' }); + } + if (error.name === 'InvalidParameterException') { + return json(400, { message: 'Cannot reset password for unverified email. Please verify your email first.' }); } - // <<< ROUTES-END + return json(500, { message: 'Failed to initiate password reset' }); + } +} - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); +// POST /auth/reset-password +async function handleResetPassword({ event }: RouteCtx): Promise { + const body = event.body ? JSON.parse(event.body) as Record : {}; + const { email, code, newPassword } = body; + if (!email || !code || !newPassword) { + return json(400, { message: 'email, code, and newPassword are required' }); } -}; + + const params: ConfirmForgotPasswordCommandInput = { + ClientId: USER_POOL_CLIENT_ID, + Username: (email as string).toLowerCase(), + ConfirmationCode: code as string, + Password: newPassword as string, + }; + + try { + await cognitoClient.send(new ConfirmForgotPasswordCommand(params)); + return json(200, { message: 'Password reset successfully' }); + } catch (error: any) { + console.error('Reset password error:', error); + if (error.name === 'CodeMismatchException') { + return json(400, { message: 'Invalid verification code' }); + } + if (error.name === 'ExpiredCodeException') { + return json(400, { message: 'Verification code has expired, please request a new one' }); + } + if (error.name === 'InvalidPasswordException') { + return json(400, { message: 'Password does not meet requirements (min 8 chars, uppercase, lowercase, number)' }); + } + if (error.name === 'UserNotFoundException') { + return json(400, { message: 'Invalid email or code' }); + } + if (error.name === 'LimitExceededException') { + return json(429, { message: 'Too many attempts, please try again later' }); + } + return json(500, { message: 'Failed to reset password' }); + } +} async function handleLogin(event: any): Promise { let body: Record; @@ -429,15 +386,16 @@ async function handleRegister(event: any): Promise { } } -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'auth', + routes: [ + { method: 'POST', pattern: '/auth/register', handler: ({ event }) => handleRegister(event) }, + { method: 'POST', pattern: '/auth/login', handler: ({ event }) => handleLogin(event) }, + { method: 'POST', pattern: '/auth/verify-email', handler: handleVerifyEmail }, + { method: 'POST', pattern: '/auth/resend-code', handler: handleResendCode }, + { method: 'POST', pattern: '/auth/logout', handler: handleLogout }, + { method: 'POST', pattern: '/auth/forgot-password', handler: handleForgotPassword }, + { method: 'POST', pattern: '/auth/reset-password', handler: handleResetPassword }, + ], + }); diff --git a/apps/backend/lambdas/auth/openapi.yaml b/apps/backend/lambdas/auth/openapi.yaml index 32b5a0d5..1ee02976 100644 --- a/apps/backend/lambdas/auth/openapi.yaml +++ b/apps/backend/lambdas/auth/openapi.yaml @@ -3,9 +3,9 @@ info: title: auth (Local) version: 1.0.0 servers: - - url: /auth + - url: / paths: - /health: + /auth/health: get: summary: Health check responses: @@ -19,7 +19,7 @@ paths: ok: type: boolean - /register: + /auth/register: post: summary: Register a new user description: Creates a new user account in Cognito and the database @@ -108,7 +108,7 @@ paths: type: string example: Failed to create user account - /login: + /auth/login: post: summary: Log in a user description: Authenticates a user with email and password via Cognito SRP @@ -165,7 +165,7 @@ paths: type: string example: Email not verified - /verify-email: + /auth/verify-email: post: summary: POST /verify-email requestBody: @@ -183,7 +183,7 @@ paths: '200': description: OK - /resend-code: + /auth/resend-code: post: summary: POST /resend-code requestBody: @@ -199,14 +199,14 @@ paths: '200': description: OK - /logout: + /auth/logout: post: summary: POST /logout responses: '200': description: OK - /forgot-password: + /auth/forgot-password: post: summary: Request a password reset code description: Sends a verification code to the user's email for password reset @@ -260,7 +260,7 @@ paths: type: string example: Too many requests, please try again later - /reset-password: + /auth/reset-password: post: summary: Reset password with verification code description: Confirms the password reset using the code sent to the user's email diff --git a/apps/backend/lambdas/auth/package-lock.json b/apps/backend/lambdas/auth/package-lock.json index fd2fff88..14c2e806 100644 --- a/apps/backend/lambdas/auth/package-lock.json +++ b/apps/backend/lambdas/auth/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "amazon-cognito-identity-js": "^6.3.16", "dotenv": "^17.2.3", "jest": "^30.2.0", @@ -22,6 +23,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.16.0", + "esbuild": "^0.25.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", @@ -29,6 +31,15 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1169,6 +1180,10 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -1228,6 +1243,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -3620,6 +4077,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index ff26cd65..791987ce 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -7,7 +7,7 @@ "dev": "ts-node --transpile-only dev-server.ts", "build": "tsc", "test": "start-server-and-test dev http-get://localhost:3000/auth/health jest", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -20,7 +20,8 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "esbuild": "^0.25.0" }, "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", @@ -28,6 +29,7 @@ "dotenv": "^17.2.3", "jest": "^30.2.0", "kysely": "^0.28.10", - "pg": "^8.17.2" + "pg": "^8.17.2", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index cf2dc3d5..257fdad5 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -1,161 +1,141 @@ import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; import db from './db'; import { authenticateRequest } from './auth'; -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } +async function requireAuth(event: any): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + return null; +} - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } +// GET /donors +async function listDonors({ event }: RouteCtx): Promise { + const unauth = await requireAuth(event); + if (unauth) return unauth; - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); } + } - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // GET /donors - if (rawPath === '/' && method === 'GET') { - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.donors') - .select(db.fn.count('donor_id').as('count')) - .executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const donors = await db - .selectFrom('branch.donors') - .selectAll() - .orderBy('donor_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - - return json(200, { - data: donors, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const donors = await db.selectFrom('branch.donors').selectAll().execute(); - return json(200, { data: donors }); + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.donors') + .select(db.fn.count('donor_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donors = await db + .selectFrom('branch.donors') + .selectAll() + .orderBy('donor_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donors, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const donors = await db.selectFrom('branch.donors').selectAll().execute(); + return json(200, { data: donors }); +} + +// GET /donors/donations +async function listDonations({ event }: RouteCtx): Promise { + const unauth = await requireAuth(event); + if (unauth) return unauth; - // GET /donations - if ((normalizedPath === '/donations') && method === 'GET') { - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.project_donations') - .select(db.fn.count('donation_id').as('count')) - .executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const donations = await db - .selectFrom('branch.project_donations') - .selectAll() - .orderBy('donation_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - - return json(200, { - data: donations, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const donations = await db - .selectFrom('branch.project_donations') - .selectAll() - .execute(); - return json(200, { data: donations }); + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); } + } - // POST /donors - if (normalizedPath === '/donors' && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - // TODO: Add your business logic here - return json(201, { ok: true, route: 'POST /donors', body }); + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); } - // <<< ROUTES-END + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.project_donations') + .select(db.fn.count('donation_id').as('count')) + .executeTakeFirst(); - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .orderBy('donation_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donations, + pagination: { page, limit, totalItems, totalPages }, + }); } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .execute(); + return json(200, { data: donations }); } + +// POST /donors +async function createDonor({ event }: RouteCtx): Promise { + const unauth = await requireAuth(event); + if (unauth) return unauth; + + const body = event.body ? JSON.parse(event.body) as Record : {}; + // TODO: Add your business logic here + return json(201, { ok: true, route: 'POST /donors', body }); +} + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'donors', + routes: [ + { method: 'GET', pattern: '/donors', handler: listDonors }, + { method: 'GET', pattern: '/donors/donations', handler: listDonations }, + { method: 'POST', pattern: '/donors', handler: createDonor }, + ], + }); diff --git a/apps/backend/lambdas/donors/openapi.yaml b/apps/backend/lambdas/donors/openapi.yaml index 53c52d18..b702bdca 100644 --- a/apps/backend/lambdas/donors/openapi.yaml +++ b/apps/backend/lambdas/donors/openapi.yaml @@ -3,7 +3,7 @@ info: title: donors (Local) version: 1.0.0 servers: - - url: http://localhost:3000/ + - url: / paths: /donors/health: get: @@ -25,8 +25,6 @@ paths: responses: '200': description: OK - - /donors: post: summary: POST /donors requestBody: @@ -46,7 +44,7 @@ paths: '201': description: Success - /donations: + /donors/donations: post: summary: POST /donations requestBody: diff --git a/apps/backend/lambdas/donors/package-lock.json b/apps/backend/lambdas/donors/package-lock.json index ca8a7894..878ab1c7 100644 --- a/apps/backend/lambdas/donors/package-lock.json +++ b/apps/backend/lambdas/donors/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", @@ -20,6 +21,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.16.0", + "esbuild": "^0.25.0", "js-yaml": "^4.1.0", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", @@ -27,6 +29,7 @@ } }, "../../../../shared/lambda-auth": { + "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { "aws-jwt-verify": "^5.1.1" @@ -36,6 +39,15 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -506,6 +518,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -565,6 +581,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2091,6 +2549,48 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index f8eb5a23..a6a16988 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -7,7 +7,7 @@ "dev": "ts-node --transpile-only dev-server.ts", "build": "tsc", "test": "jest", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -18,13 +18,15 @@ "js-yaml": "^4.1.0", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "esbuild": "^0.25.0" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", - "pg": "^8.17.2" + "pg": "^8.17.2", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index 53c2a859..155e16db 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -1,189 +1,155 @@ import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; import db from './db'; import { ExpenditureValidationUtils } from './validation-utils'; import { authenticateRequest } from './auth'; -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); +// GET /expenditures +async function listExpenditures({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); } + } - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); } + } - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // GET /expenditures - if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); - } - - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - const projectIdStr = queryParams.projectId as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - if (projectIdStr !== undefined) { - if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); - - return json(200, { - data: expenditures, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); - - return json(200, { data: expenditures }); + if (projectIdStr !== undefined) { + if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = projectId !== null + ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() + : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const expenditures = projectId !== null + ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute() + : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); + + return json(200, { + data: expenditures, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const expenditures = projectId !== null + ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() + : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); + + return json(200, { data: expenditures }); +} + +// POST /expenditures +async function createExpenditure({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + + const body = event.body ? JSON.parse(event.body) as Record : {}; + + // Validate input + const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body); + if (validationResult instanceof Error) { + return json(400, { message: validationResult.message }); + } + + const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; - // POST /expenditures - if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { - // Authenticate the request - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // Validate input - const validationResult = ExpenditureValidationUtils.validateExpenditureInput(body); - if (validationResult instanceof Error) { - return json(400, { message: validationResult.message }); - } - - const { projectID, amount, category, description, status, receiptUrl, spentOn } = validationResult; - - // Authorize: must be global admin, or PI/Accountant/Admin on this project - if (!user.isAdmin) { - const membership = await db - .selectFrom('branch.project_memberships') - .where('project_id', '=', projectID) - .where('user_id', '=', user.userId!) - .select('role') - .executeTakeFirst(); - - if (!membership || !['PI', 'Accountant', 'Admin'].includes(membership.role)) { - return json(403, { message: 'Unable to create expenditure for this project' }); - } - } - - // Check if project exists - const project = await db - .selectFrom('branch.projects') - .where('project_id', '=', projectID) - .selectAll() - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - // Insert expenditure with authenticated user as entered_by - try { - await db - .insertInto('branch.expenditures') - .values({ - project_id: projectID, - entered_by: user.userId!, - amount, - category: category ?? null, - description: description ?? null, - status, - receipt_url: receiptUrl ?? null, - spent_on: spentOn ? new Date(spentOn) : new Date(), - }) - .executeTakeFirst(); - } catch (err) { - console.error('Database insert error:', err); - return json(500, { message: 'Failed to create expenditure' }); - } - - return json(201, { - ok: true, - route: 'POST /expenditures', - body: { - projectID, - enteredBy: user.userId!, - amount, - category: category ?? null, - description: description ?? null, - status, - receiptUrl: receiptUrl ?? null, - spentOn: spentOn ?? new Date().toISOString().split('T')[0], - }, - }); + // Authorize: must be global admin, or PI/Accountant/Admin on this project + if (!user.isAdmin) { + const membership = await db + .selectFrom('branch.project_memberships') + .where('project_id', '=', projectID) + .where('user_id', '=', user.userId!) + .select('role') + .executeTakeFirst(); + + if (!membership || !['PI', 'Accountant', 'Admin'].includes(membership.role)) { + return json(403, { message: 'Unable to create expenditure for this project' }); } - // <<< ROUTES-END + } + + // Check if project exists + const project = await db + .selectFrom('branch.projects') + .where('project_id', '=', projectID) + .selectAll() + .executeTakeFirst(); + + if (!project) { + return json(404, { message: 'Project not found' }); + } - return json(404, { message: 'Not Found', path: normalizedPath, method }); + // Insert expenditure with authenticated user as entered_by + try { + await db + .insertInto('branch.expenditures') + .values({ + project_id: projectID, + entered_by: user.userId!, + amount, + category: category ?? null, + description: description ?? null, + status, + receipt_url: receiptUrl ?? null, + spent_on: spentOn ? new Date(spentOn) : new Date(), + }) + .executeTakeFirst(); } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); + console.error('Database insert error:', err); + return json(500, { message: 'Failed to create expenditure' }); } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' + + return json(201, { + ok: true, + route: 'POST /expenditures', + body: { + projectID, + enteredBy: user.userId!, + amount, + category: category ?? null, + description: description ?? null, + status, + receiptUrl: receiptUrl ?? null, + spentOn: spentOn ?? new Date().toISOString().split('T')[0], }, - body: JSON.stringify(body) - }; + }); } + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'expenditures', + routes: [ + { method: 'GET', pattern: '/expenditures', handler: listExpenditures }, + { method: 'POST', pattern: '/expenditures', handler: createExpenditure }, + ], + }); diff --git a/apps/backend/lambdas/expenditures/openapi.yaml b/apps/backend/lambdas/expenditures/openapi.yaml index a42f5b9b..addb117e 100644 --- a/apps/backend/lambdas/expenditures/openapi.yaml +++ b/apps/backend/lambdas/expenditures/openapi.yaml @@ -3,9 +3,9 @@ info: title: expenditures (Local) version: 1.0.0 servers: - - url: http://localhost:3000/expenditures + - url: / paths: - /health: + /expenditures/health: get: summary: Health check responses: diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index e8c150ea..39e2840f 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "jest": "^30.2.0", @@ -22,6 +23,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.6", + "esbuild": "^0.25.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", @@ -30,6 +32,7 @@ } }, "../../../../shared/lambda-auth": { + "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { "aws-jwt-verify": "^5.1.1" @@ -39,6 +42,15 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -509,6 +521,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -517,7 +533,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -530,7 +546,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -568,6 +584,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -1124,28 +1582,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1543,7 +2001,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1556,7 +2014,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -2247,7 +2705,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -2344,7 +2802,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -2456,6 +2914,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3973,7 +4473,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -5152,7 +5652,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -5196,7 +5696,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tslib": { @@ -5231,7 +5731,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5361,7 +5861,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -5688,7 +6188,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 92b96a0c..acc38c8e 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -8,7 +8,7 @@ "build": "tsc", "test": "jest --forceExit", "test:e2e": "start-server-and-test dev http-get://localhost:3000/expenditures/health 'jest --testMatch=\"**/*.e2e.test.ts\" --forceExit'", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -21,7 +21,8 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "esbuild": "^0.25.0" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", @@ -29,6 +30,7 @@ "aws-lambda": "^1.0.7", "jest": "^30.2.0", "kysely": "^0.28.8", - "pg": "^8.16.3" + "pg": "^8.16.3", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index f82ef6ae..d441d72f 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -1,329 +1,292 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; import db from './db'; import { ProjectValidationUtils } from './validation-utils'; import { authenticateRequest } from './auth'; -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight - if (method === 'OPTIONS') { - return json(200, {}); - } +// GET /projects/dashboard +async function getDashboard({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + if (!user.isAdmin) { + return json(403, { message: 'Admin access required' }); + } - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); + try { + const [ + totalSpentRow, + totalProjectsRow, + topCategoryRow, + projectRows, + spentByProject, + staffByProject, + monthRows, + ] = await Promise.all([ + db.selectFrom('branch.expenditures') + .select(db.fn.sum('amount').as('total')) + .executeTakeFirst(), + db.selectFrom('branch.projects') + .select(db.fn.count('project_id').as('count')) + .executeTakeFirst(), + db.selectFrom('branch.expenditures') + .select(['category', db.fn.sum('amount').as('total')]) + .where('category', 'is not', null) + .groupBy('category') + .orderBy(db.fn.sum('amount'), 'desc') + .limit(1) + .executeTakeFirst(), + db.selectFrom('branch.projects') + .selectAll() + .orderBy('project_id', 'asc') + .execute(), + db.selectFrom('branch.expenditures') + .select(['project_id', db.fn.sum('amount').as('total')]) + .groupBy('project_id') + .execute(), + db.selectFrom('branch.project_memberships') + .select(['project_id', db.fn.count('user_id').as('count')]) + .groupBy('project_id') + .execute(), + db.selectFrom('branch.expenditures') + .select(['spent_on', 'category', 'amount']) + .where('category', 'is not', null) + .execute(), + ]); + + const totalSpent = Number(totalSpentRow?.total ?? 0); + const totalProjects = Number(totalProjectsRow?.count ?? 0); + const averageSpendPerProject = totalProjects > 0 ? totalSpent / totalProjects : 0; + + const spentMap = new Map(spentByProject.map((r) => [r.project_id, Number(r.total)])); + const staffMap = new Map(staffByProject.map((r) => [r.project_id, Number(r.count)])); + + const projects = projectRows.map((p) => { + const budget = p.total_budget !== null ? Number(p.total_budget) : null; + const spent = spentMap.get(p.project_id) ?? 0; + const spentPercentage = budget && budget > 0 ? (spent / budget) * 100 : 0; + return { + project_id: p.project_id, + name: p.name, + total_budget: budget, + currency: p.currency, + spent, + staff_count: staffMap.get(p.project_id) ?? 0, + spent_percentage: Number(spentPercentage.toFixed(2)), + }; + }); + + const monthMap = new Map(); + for (const row of monthRows) { + const d = new Date(row.spent_on as any); + const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; + const category = row.category as string; + const key = `${month}|${category}`; + const prev = monthMap.get(key); + if (prev) prev.amount += Number(row.amount); + else monthMap.set(key, { month, category, amount: Number(row.amount) }); } + const expensesByMonth = [...monthMap.values()].sort((a, b) => a.month.localeCompare(b.month)); + + return json(200, { + summary: { + topExpenseCategory: topCategoryRow + ? { category: topCategoryRow.category, amount: Number(topCategoryRow.total ?? 0) } + : null, + totalSpent, + totalProjects, + averageSpendPerProject: Number(averageSpendPerProject.toFixed(2)), + }, + projects, + expensesByMonth, + }); + } catch (err) { + console.error('Dashboard query failed:', err); + return json(500, { message: 'Failed to load dashboard' }); + } +} - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - // GET /dashboard - if ((normalizedPath === '/dashboard' || normalizedPath.endsWith('/dashboard')) && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - const { user } = authContext; - if (!user.isAdmin) { - return json(403, { message: 'Admin access required' }); - } - - try { - const [ - totalSpentRow, - totalProjectsRow, - topCategoryRow, - projectRows, - spentByProject, - staffByProject, - monthRows, - ] = await Promise.all([ - db.selectFrom('branch.expenditures') - .select(db.fn.sum('amount').as('total')) - .executeTakeFirst(), - db.selectFrom('branch.projects') - .select(db.fn.count('project_id').as('count')) - .executeTakeFirst(), - db.selectFrom('branch.expenditures') - .select(['category', db.fn.sum('amount').as('total')]) - .where('category', 'is not', null) - .groupBy('category') - .orderBy(db.fn.sum('amount'), 'desc') - .limit(1) - .executeTakeFirst(), - db.selectFrom('branch.projects') - .selectAll() - .orderBy('project_id', 'asc') - .execute(), - db.selectFrom('branch.expenditures') - .select(['project_id', db.fn.sum('amount').as('total')]) - .groupBy('project_id') - .execute(), - db.selectFrom('branch.project_memberships') - .select(['project_id', db.fn.count('user_id').as('count')]) - .groupBy('project_id') - .execute(), - db.selectFrom('branch.expenditures') - .select(['spent_on', 'category', 'amount']) - .where('category', 'is not', null) - .execute(), - ]); - - const totalSpent = Number(totalSpentRow?.total ?? 0); - const totalProjects = Number(totalProjectsRow?.count ?? 0); - const averageSpendPerProject = totalProjects > 0 ? totalSpent / totalProjects : 0; - - const spentMap = new Map(spentByProject.map((r) => [r.project_id, Number(r.total)])); - const staffMap = new Map(staffByProject.map((r) => [r.project_id, Number(r.count)])); - - const projects = projectRows.map((p) => { - const budget = p.total_budget !== null ? Number(p.total_budget) : null; - const spent = spentMap.get(p.project_id) ?? 0; - const spentPercentage = budget && budget > 0 ? (spent / budget) * 100 : 0; - return { - project_id: p.project_id, - name: p.name, - total_budget: budget, - currency: p.currency, - spent, - staff_count: staffMap.get(p.project_id) ?? 0, - spent_percentage: Number(spentPercentage.toFixed(2)), - }; - }); - - const monthMap = new Map(); - for (const row of monthRows) { - const d = new Date(row.spent_on as any); - const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; - const category = row.category as string; - const key = `${month}|${category}`; - const prev = monthMap.get(key); - if (prev) prev.amount += Number(row.amount); - else monthMap.set(key, { month, category, amount: Number(row.amount) }); - } - const expensesByMonth = [...monthMap.values()].sort((a, b) => a.month.localeCompare(b.month)); - - return json(200, { - summary: { - topExpenseCategory: topCategoryRow - ? { category: topCategoryRow.category, amount: Number(topCategoryRow.total ?? 0) } - : null, - totalSpent, - totalProjects, - averageSpendPerProject: Number(averageSpendPerProject.toFixed(2)), - }, - projects, - expensesByMonth, - }); - } catch (err) { - console.error('Dashboard query failed:', err); - return json(500, { message: 'Failed to load dashboard' }); - } - } - // GET /projects/{id}/members - if (normalizedPath.endsWith('/members') && method === 'GET') { - const parts = normalizedPath.split('/').filter(Boolean); - // handles both /projects/1/members and /1/members - const id = parts.length === 3 ? parts[1] : parts[0]; - if (!id) return json(400, { message: 'id is required' }); - const users = await db - .selectFrom('branch.project_memberships as pm') - .innerJoin('branch.users as u', 'u.user_id', 'pm.user_id') - .select([ - 'u.user_id', - 'u.name', - 'u.email', - 'pm.role' - ]) - .where('pm.project_id', '=', id) - .execute(); - return json(200, { - ok: true, route: 'GET /projects/{id}/members', pathParams: { id }, body: { - users - } - }); - } - // GET /projects - if (rawPath === '/' && method === 'GET') { - const projects = await db.selectFrom("branch.projects").selectAll().execute(); - return json(200, projects); - } +// GET /projects/{id}/members +async function getMembers({ params }: RouteCtx): Promise { + const id = params.id; + if (!id) return json(400, { message: 'id is required' }); + const users = await db + .selectFrom('branch.project_memberships as pm') + .innerJoin('branch.users as u', 'u.user_id', 'pm.user_id') + .select([ + 'u.user_id', + 'u.name', + 'u.email', + 'pm.role', + ]) + .where('pm.project_id', '=', Number(id)) + .execute(); + return json(200, { + ok: true, route: 'GET /projects/{id}/members', pathParams: { id }, body: { + users, + }, + }); +} - // GET /projects/{id}/donors - const parts = normalizedPath.split('/'); - if (parts.length === 3 && parts[2] === 'donors' && method === 'GET') { - const id = parts[1]; +// GET /projects +async function listProjects(): Promise { + const projects = await db.selectFrom('branch.projects').selectAll().execute(); + return json(200, projects); +} +// GET /projects/{id}/donors +async function getDonors({ event, params }: RouteCtx): Promise { + const id = params.id; + if (!id) return json(400, { message: 'id is required' }); + if (isNaN(Number(id))) { + return json(400, { message: 'Project id must be a valid number' }); + } + const queryString = event.rawQueryString || event.queryStringParameters; - if (!id) return json(400, { message: 'id is required' }); - if (isNaN(Number(id))) { - return json(400, { message: 'Project id must be a valid number' }); - } - const queryString = event.rawQueryString || event.queryStringParameters; + if (queryString && (typeof queryString === 'string' ? queryString.length > 0 : Object.keys(queryString).length > 0)) { + return json(400, { message: 'Bad Request: Query parameters are not allowed' }); + } - if (queryString && (typeof queryString === 'string' ? queryString.length > 0 : Object.keys(queryString).length > 0)) { - return json(400, { message: 'Bad Request: Query parameters are not allowed' }); - } + const project = await db + .selectFrom('branch.projects as p') + .where('p.project_id', '=', Number(id)) + .selectAll() + .executeTakeFirst(); - const project = await db - .selectFrom("branch.projects as p") - .where("p.project_id", "=", Number(id)) - .selectAll() - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - const donors = await db.selectFrom("branch.projects as p").where("p.project_id", "=", Number(id)).innerJoin( - "branch.project_donations as bpd", - "bpd.project_id", - "p.project_id" - ).innerJoin( - "branch.donors as bd", - "bd.donor_id", - "bpd.donor_id" - ).selectAll().execute(); - return json(200, { donors }); - } + if (!project) { + return json(404, { message: 'Project not found' }); + } - // GET /projects/{id} - if (rawPath.startsWith('/') && rawPath.split('/').length === 2 && method === 'GET') { - const id = rawPath.split('/')[1]; - if (!id) return json(400, { message: 'id is required' }); - const project = await db.selectFrom("branch.projects").where("project_id", "=", Number(id)).selectAll().executeTakeFirst(); - if (!project) return json(404, { message: `Project not found for id: ${id}` }); - return json(200, project); - } + const donors = await db.selectFrom('branch.projects as p').where('p.project_id', '=', Number(id)).innerJoin( + 'branch.project_donations as bpd', + 'bpd.project_id', + 'p.project_id', + ).innerJoin( + 'branch.donors as bd', + 'bd.donor_id', + 'bpd.donor_id', + ).selectAll().execute(); + return json(200, { donors }); +} +// GET /projects/{id}/expenditures +async function getExpenditures({ params }: RouteCtx): Promise { + const id = params.id; + if (!id) return json(400, { message: 'id is required' }); - // PUT /projects/{id} - if (rawPath.startsWith('/') && rawPath.split('/').length === 2 && method === 'PUT') { - const id = rawPath.split('/')[1]; - if (!id) return json(400, { message: 'id is required' }); - const body = event.body ? JSON.parse(event.body) as Record : {}; - const updatedProject = await db - .updateTable("branch.projects") - .set(body) - .where("project_id", "=", Number(id)) - .returning(["project_id", "name", "description", "total_budget"]) // control returned fields - .executeTakeFirst(); - if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` }); - return json(200, updatedProject); - } - // <<< ROUTES-END - // POST /projects - if ((normalizedPath === '' || normalizedPath === '/' || normalizedPath === '/projects') && method === 'POST') { - let body: Record; - try { - body = event.body ? JSON.parse(event.body) as Record : {}; - } catch (e) { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const nameResult = ProjectValidationUtils.validateName(body.name); - if (!nameResult.isValid) { - return json(400, { message: nameResult.error }); - } - - const values: any = { name: nameResult.value }; - - const parsedBudget = ProjectValidationUtils.parseNumericToFixed(body.total_budget); - if (parsedBudget === 'INVALID') return json(400, { message: "'total_budget' must be a number" }); - if (parsedBudget !== null) values.total_budget = parsedBudget; - - const startDateResult = ProjectValidationUtils.validateDate(body.start_date, 'start_date'); - if (!startDateResult.isValid) return json(400, { message: startDateResult.error }); - if (startDateResult.value !== null) values.start_date = startDateResult.value; - - const endDateResult = ProjectValidationUtils.validateDate(body.end_date, 'end_date'); - if (!endDateResult.isValid) return json(400, { message: endDateResult.error }); - if (endDateResult.value !== null) values.end_date = endDateResult.value; - - const currencyResult = ProjectValidationUtils.validateCurrency(body.currency); - if (!currencyResult.isValid) return json(400, { message: currencyResult.error }); - if (currencyResult.value !== null) values.currency = currencyResult.value; - - const descriptionResult = ProjectValidationUtils.validateDescription(body.description); - if (!descriptionResult.isValid) return json(400, { message: descriptionResult.error }); - values.description = descriptionResult.value; - - try { - const inserted = await db - .insertInto('branch.projects') - .values(values) - .returning(['project_id', 'name', 'description', 'total_budget', 'currency', 'start_date', 'end_date', 'created_at']) - .executeTakeFirst(); - - return json(201, inserted); - } catch (e) { - console.error('DB insert failed', e); - return json(500, { message: 'Failed to create project' }); - } + try { + const project = await db + .selectFrom('branch.projects') + .where('project_id', '=', parseInt(id)) + .selectAll() + .executeTakeFirst(); + + if (!project) { + return json(404, { message: 'Project not found' }); } - // GET /projects/{id}/expenditures - if (normalizedPath.endsWith('/expenditures') && method === 'GET') { - const pathParts = normalizedPath.split('/').filter(Boolean); - - let id: string | undefined; - if (pathParts.length === 3 && pathParts[0] === 'projects') { - id = pathParts[1]; - } else if (pathParts.length === 2) { - id = pathParts[0]; - } - if (!id) return json(400, { message: 'id is required' }); - - try { - - const project = await db - .selectFrom('branch.projects') - .where('project_id', '=', parseInt(id)) - .selectAll() - .executeTakeFirst(); - - if (!project) { - return json(404, { message: 'Project not found' }); - } - - - const expenditures = await db - .selectFrom('branch.expenditures') - .where('project_id', '=', parseInt(id)) - .selectAll() - .orderBy('spent_on', 'desc') - .execute(); - - return json(200, expenditures); - } catch (err) { - console.error('Database error:', err); - return json(500, { message: 'Failed to fetch expenditures', error: err instanceof Error ? err.message : 'Unknown error' }); - } - } + const expenditures = await db + .selectFrom('branch.expenditures') + .where('project_id', '=', parseInt(id)) + .selectAll() + .orderBy('spent_on', 'desc') + .execute(); - return json(404, { message: 'Not Found', path: normalizedPath, method }); + return json(200, expenditures); } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); + console.error('Database error:', err); + return json(500, { message: 'Failed to fetch expenditures', error: err instanceof Error ? err.message : 'Unknown error' }); } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; } + +// GET /projects/{id} +async function getProject({ params }: RouteCtx): Promise { + const id = params.id; + if (!id) return json(400, { message: 'id is required' }); + const project = await db.selectFrom('branch.projects').where('project_id', '=', Number(id)).selectAll().executeTakeFirst(); + if (!project) return json(404, { message: `Project not found for id: ${id}` }); + return json(200, project); +} + +// PUT /projects/{id} +async function updateProject({ event, params }: RouteCtx): Promise { + const id = params.id; + if (!id) return json(400, { message: 'id is required' }); + const body = event.body ? JSON.parse(event.body) as Record : {}; + const updatedProject = await db + .updateTable('branch.projects') + .set(body) + .where('project_id', '=', Number(id)) + .returning(['project_id', 'name', 'description', 'total_budget']) // control returned fields + .executeTakeFirst(); + if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` }); + return json(200, updatedProject); +} + +// POST /projects +async function createProject({ event }: RouteCtx): Promise { + let body: Record; + try { + body = event.body ? JSON.parse(event.body) as Record : {}; + } catch (e) { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const nameResult = ProjectValidationUtils.validateName(body.name); + if (!nameResult.isValid) { + return json(400, { message: nameResult.error }); + } + + const values: any = { name: nameResult.value }; + + const parsedBudget = ProjectValidationUtils.parseNumericToFixed(body.total_budget); + if (parsedBudget === 'INVALID') return json(400, { message: "'total_budget' must be a number" }); + if (parsedBudget !== null) values.total_budget = parsedBudget; + + const startDateResult = ProjectValidationUtils.validateDate(body.start_date, 'start_date'); + if (!startDateResult.isValid) return json(400, { message: startDateResult.error }); + if (startDateResult.value !== null) values.start_date = startDateResult.value; + + const endDateResult = ProjectValidationUtils.validateDate(body.end_date, 'end_date'); + if (!endDateResult.isValid) return json(400, { message: endDateResult.error }); + if (endDateResult.value !== null) values.end_date = endDateResult.value; + + const currencyResult = ProjectValidationUtils.validateCurrency(body.currency); + if (!currencyResult.isValid) return json(400, { message: currencyResult.error }); + if (currencyResult.value !== null) values.currency = currencyResult.value; + + const descriptionResult = ProjectValidationUtils.validateDescription(body.description); + if (!descriptionResult.isValid) return json(400, { message: descriptionResult.error }); + values.description = descriptionResult.value; + + try { + const inserted = await db + .insertInto('branch.projects') + .values(values) + .returning(['project_id', 'name', 'description', 'total_budget', 'currency', 'start_date', 'end_date', 'created_at']) + .executeTakeFirst(); + + return json(201, inserted); + } catch (e) { + console.error('DB insert failed', e); + return json(500, { message: 'Failed to create project' }); + } +} + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'projects', + routes: [ + // dashboard must precede `/projects/:id` (both 2-segment GETs) + { method: 'GET', pattern: '/projects/dashboard', handler: getDashboard }, + { method: 'GET', pattern: '/projects/:id/members', handler: getMembers }, + { method: 'GET', pattern: '/projects/:id/donors', handler: getDonors }, + { method: 'GET', pattern: '/projects/:id/expenditures', handler: getExpenditures }, + { method: 'GET', pattern: '/projects', handler: listProjects }, + { method: 'POST', pattern: '/projects', handler: createProject }, + { method: 'GET', pattern: '/projects/:id', handler: getProject }, + { method: 'PUT', pattern: '/projects/:id', handler: updateProject }, + ], + }); diff --git a/apps/backend/lambdas/projects/openapi.yaml b/apps/backend/lambdas/projects/openapi.yaml index 773e2064..53478f90 100644 --- a/apps/backend/lambdas/projects/openapi.yaml +++ b/apps/backend/lambdas/projects/openapi.yaml @@ -3,9 +3,9 @@ info: title: Projects (Local) version: 1.0.0 servers: - - url: http://localhost:3000 + - url: / paths: - /health: + /projects/health: get: summary: Health check responses: @@ -97,7 +97,7 @@ paths: items: type: object - /dashboard: + /projects/dashboard: get: summary: GET /dashboard responses: diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index 62cea4b5..ead05bda 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", @@ -20,6 +21,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.6", + "esbuild": "^0.25.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", @@ -28,6 +30,7 @@ } }, "../../../../shared/lambda-auth": { + "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { "aws-jwt-verify": "^5.1.1" @@ -37,6 +40,15 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -517,6 +529,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -565,6 +581,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -2347,6 +2805,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 9c7f2b16..b975faaf 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -7,7 +7,7 @@ "dev": "ts-node --transpile-only dev-server.ts", "build": "tsc", "test": "start-server-and-test dev http-get://localhost:3000/projects/health jest", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -19,13 +19,15 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "esbuild": "^0.25.0" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", - "pg": "^8.16.3" + "pg": "^8.16.3", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 065907ce..eabb34db 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; import db from './db'; import { authenticateRequest } from './auth'; import { @@ -13,156 +14,131 @@ import { const FILE_TYPES = ['pdf', 'docx'] as const; type FileType = typeof FILE_TYPES[number]; -export const handler = async (event: any): Promise => { +// POST /reports +async function createReport({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + const body = event.body ? JSON.parse(event.body) as Record : {}; + + const projectId = body.project_id; + if (projectId === undefined || projectId === null) { + return json(400, { message: 'project_id is required' }); + } + if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { + return json(400, { message: 'project_id must be a positive integer' }); + } + + const fileType = (body.file_type ?? 'pdf') as FileType; + if (!FILE_TYPES.includes(fileType)) { + return json(400, { message: `file_type must be one of: ${FILE_TYPES.join(', ')}` }); + } + + const reportData = await fetchReportData(projectId); + if (!reportData) { + return json(404, { message: 'Project not found' }); + } + + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); + if (!hasAccess) { + return json(403, { message: 'You do not have access to generate reports for this project' }); + } + + let fileBuffer: Buffer; try { - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); + fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); + } catch (err) { + console.error('Report generation error:', err); + return json(500, { message: 'Failed to generate report' }); + } + + let objectUrl: string; + try { + objectUrl = await uploadToS3(fileBuffer, projectId, fileType); + } catch (err) { + console.error('S3 upload error:', err); + return json(500, { message: 'Failed to upload report' }); + } - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); + const record = await saveReportRecord(projectId, objectUrl); + + return json(201, { + ok: true, + report_id: record.report_id, + object_url: record.object_url, + report_type: record.report_type, + }); +} + +// GET /reports +async function listReports({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated) { + return json(401, { message: 'Authentication required' }); + } + + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + const projectIdStr = queryParams.projectId as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); } + } - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - const body = event.body ? JSON.parse(event.body) as Record : {}; - - const projectId = body.project_id; - if (projectId === undefined || projectId === null) { - return json(400, { message: 'project_id is required' }); - } - if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { - return json(400, { message: 'project_id must be a positive integer' }); - } - - const fileType = (body.file_type ?? 'pdf') as FileType; - if (!FILE_TYPES.includes(fileType)) { - return json(400, { message: `file_type must be one of: ${FILE_TYPES.join(', ')}` }); - } - - const reportData = await fetchReportData(projectId); - if (!reportData) { - return json(404, { message: 'Project not found' }); - } - - const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); - if (!hasAccess) { - return json(403, { message: 'You do not have access to generate reports for this project' }); - } - - let fileBuffer: Buffer; - try { - fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); - } catch (err) { - console.error('Report generation error:', err); - return json(500, { message: 'Failed to generate report' }); - } - - let objectUrl: string; - try { - objectUrl = await uploadToS3(fileBuffer, projectId, fileType); - } catch (err) { - console.error('S3 upload error:', err); - return json(500, { message: 'Failed to upload report' }); - } - - const record = await saveReportRecord(projectId, objectUrl); - - return json(201, { - ok: true, - report_id: record.report_id, - object_url: record.object_url, - report_type: record.report_type, - }); + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); } + } - // GET /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated) { - return json(401, { message: 'Authentication required' }); - } - - const queryParams = event.queryStringParameters || {}; - const pageStr = queryParams.page as string | undefined; - const limitStr = queryParams.limit as string | undefined; - const projectIdStr = queryParams.projectId as string | undefined; - - if (pageStr !== undefined) { - if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { - return json(400, { message: 'page must be a positive integer' }); - } - } - - if (limitStr !== undefined) { - if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { - return json(400, { message: 'limit must be a positive integer' }); - } - } - - if (projectIdStr !== undefined) { - if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - } - - const page = pageStr ? parseInt(pageStr, 10) : null; - const limit = limitStr ? parseInt(limitStr, 10) : null; - const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); - - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); - - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); - - return json(200, { - data: reports, - pagination: { page, limit, totalItems, totalPages }, - }); - } - - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); - - return json(200, { data: reports }); + if (projectIdStr !== undefined) { + if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); } - // <<< ROUTES-END + } - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() + : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const reports = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() + : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + + return json(200, { + data: reports, + pagination: { page, limit, totalItems, totalPages }, + }); } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; + + const reports = projectId !== null + ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() + : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); + + return json(200, { data: reports }); } + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'reports', + routes: [ + { method: 'POST', pattern: '/reports', handler: createReport }, + { method: 'GET', pattern: '/reports', handler: listReports }, + ], + }); diff --git a/apps/backend/lambdas/reports/openapi.yaml b/apps/backend/lambdas/reports/openapi.yaml index 92d0c20b..b04023a3 100644 --- a/apps/backend/lambdas/reports/openapi.yaml +++ b/apps/backend/lambdas/reports/openapi.yaml @@ -3,12 +3,11 @@ info: title: reports (Local) version: 1.0.0 servers: - - url: http://localhost:3005/reports - - url: http://localhost:3000/reports + - url: / security: - BearerAuth: [] paths: - /health: + /reports/health: get: summary: Health check security: [] @@ -110,7 +109,7 @@ paths: type: string enum: [pdf, docx] default: pdf - description: Output format: "pdf" (default) or "docx" + description: 'Output format: "pdf" (default) or "docx"' example: docx responses: '201': diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index 786b98ba..bbebde24 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "docx": "^9.5.0", @@ -27,6 +28,7 @@ "@types/node": "^20.11.30", "@types/pdfmake": "^0.3.1", "@types/pg": "^8.16.0", + "esbuild": "^0.25.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.6", @@ -35,6 +37,7 @@ } }, "../../../../shared/lambda-auth": { + "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { "aws-jwt-verify": "^5.1.1" @@ -44,6 +47,15 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -1365,6 +1377,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -1424,6 +1440,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -4162,6 +4620,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 8eb75406..7833cc80 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -8,7 +8,7 @@ "build": "tsc", "test": "jest --forceExit", "test:e2e": "start-server-and-test dev http-get://localhost:3000/reports/health 'jest --testMatch=\"**/*.e2e.test.ts\" --forceExit'", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -22,7 +22,8 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "esbuild": "^0.25.0" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", @@ -34,6 +35,7 @@ "kysely": "^0.28.11", "docx": "^9.5.0", "pdfmake": "^0.3.4", - "pg": "^8.18.0" + "pg": "^8.18.0", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/apps/backend/lambdas/tools/lambda-cli.js b/apps/backend/lambdas/tools/lambda-cli.js index 51c2013e..1a67b5dc 100644 --- a/apps/backend/lambdas/tools/lambda-cli.js +++ b/apps/backend/lambdas/tools/lambda-cli.js @@ -45,12 +45,13 @@ function templatePackageJson() { "dev": "ts-node --transpile-only dev-server.ts", "build": "tsc", "test": "jest", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", "@types/aws-lambda": "^8.10.131", "@types/node": "^20.11.30", + "esbuild": "^0.25.0", "ts-node": "^10.9.2", "typescript": "^5.4.5", "js-yaml": "^4.1.0", @@ -58,6 +59,7 @@ function templatePackageJson() { }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "dotenv": "^16.4.7", "jest":"^30.2.0" } @@ -521,46 +523,26 @@ server.listen(port, () => { `; } -function templateHandlerTsClean() { - return `import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; - -export const handler = async (event: any): Promise => { - try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const rawPath = event.rawPath || event.path || '/'; - const normalizedPath = rawPath.replace(/\\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); - } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); - } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} +function templateHandlerTsClean(name) { + const prefix = (name || 'service').replace(/^\/+|\/+$/g, ''); + return `import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; + +// Define route handlers above and register them in the routes table below. +// Patterns are full prefixed paths with :params, e.g. '/${prefix}/:id'. +// The dispatcher handles OPTIONS preflight, /${prefix}/health, 404 and 500, +// and canonicalizes the path so the same table works under API Gateway and the +// shared dev-server (which strips the service prefix). + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: '${prefix}', + routes: [ + // >>> ROUTES-START (do not remove this marker) + // CLI-generated routes will be inserted here + // <<< ROUTES-END + ], + }); `; } @@ -573,7 +555,11 @@ function addRouteToHandler(handlerPath, method, apiPath, options = {}) { const methodUpper = method.toUpperCase(); - // Extract path parameters + // Normalize the path and convert OpenAPI `{param}` to dispatcher `:param`. + const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; + const pattern = normalizedApiPath.replace(/\{([^}]+)\}/g, ':$1'); + + // Path params come from the dispatcher's ctx.params. const pathParams = []; const pathParamRegex = /\{([^}]+)\}/g; let match; @@ -581,98 +567,45 @@ function addRouteToHandler(handlerPath, method, apiPath, options = {}) { pathParams.push(match[1]); } - // Generate path parameter extraction using URL parsing - let pathParamExtraction = ''; - if (pathParams.length > 0) { - // Normalize apiPath to always have leading slash for consistent splitting - const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; - const pathParts = normalizedApiPath.split('/'); - const extractions = []; - - pathParts.forEach((part, index) => { - if (part.startsWith('{') && part.endsWith('}')) { - const paramName = part.slice(1, -1); - // normalizedPath always starts with '/', so split('/') gives ['', ...parts] - // index already accounts for the leading empty string - extractions.push( - `const ${paramName} = normalizedPath.split('/')[${index - }];\n if (!${paramName}) return json(400, { message: '${paramName} is required' });`, - ); - } - }); - - pathParamExtraction = extractions.join('\n '); + const stubLines = []; + for (const p of pathParams) { + stubLines.push(`const ${p} = params.${p};`); + stubLines.push(`if (!${p}) return json(400, { message: '${p} is required' });`); } - // Generate body parsing for methods that typically have bodies const needsBody = ['POST', 'PUT', 'PATCH'].includes(methodUpper) || options.body; - const bodyParse = needsBody - ? `const body = event.body ? JSON.parse(event.body) as Record : {};` - : ''; - - // Generate query parameter extraction if specified - const queryParse = options.query - ? `const query = event.queryStringParameters || {};` - : ''; - - // Generate header extraction if specified - const headerParse = options.headers - ? `const headers = event.headers || {};` - : ''; + if (needsBody) { + stubLines.push(`const body = event.body ? JSON.parse(event.body) as Record : {};`); + } + if (options.query) stubLines.push(`const query = event.queryStringParameters || {};`); + if (options.headers) stubLines.push(`const headers = event.headers || {};`); - // Generate response const statusCode = options.status || 200; const responseProps = ['ok: true', `route: '${methodUpper} ${apiPath}'`]; - if (pathParams.length > 0) { - const pathParamObj = pathParams.map((param) => `${param}`).join(', '); - responseProps.push( - `pathParams: { ${pathParams.map((p) => `${p}`).join(', ')} }`, - ); + responseProps.push(`pathParams: { ${pathParams.join(', ')} }`); } if (options.query) responseProps.push('query'); if (options.headers) responseProps.push('headers'); if (needsBody) responseProps.push('body'); - // Use simple string matching instead of regex for reliability - let matchCondition; - if (pathParams.length > 0) { - // For paths with parameters, use startsWith and split logic - // Normalize apiPath to always have leading slash for consistent splitting - const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; - const pathParts = normalizedApiPath.split('/'); - const staticParts = pathParts.filter( - (part) => !part.startsWith('{'), - ).length; - const pathPrefix = normalizedApiPath.split('{')[0]; // Get part before first parameter - - // normalizedPath always starts with '/', so split('/') includes leading empty string - matchCondition = `normalizedPath.startsWith('${pathPrefix}') && normalizedPath.split('/').length === ${pathParts.length - }`; - } else { - // For static paths, normalize to ensure leading slash - const normalizedApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; - matchCondition = `normalizedPath === '${normalizedApiPath}'`; - } + stubLines.push('// TODO: Add your business logic here'); + stubLines.push(`return json(${statusCode}, { ${responseProps.join(', ')} });`); - const codeLines = [ - pathParamExtraction, - bodyParse, - queryParse, - headerParse, - '// TODO: Add your business logic here', - `return json(${statusCode}, { ${responseProps.join(', ')} });`, - ].filter(Boolean); - - const snippet = ` - // ${methodUpper} ${apiPath} - if (${matchCondition} && method === '${methodUpper}') { - ${codeLines.join('\n ')} - } -`; + const body = stubLines.map((l) => ` ${l}`).join('\n'); + + const snippet = `// ${methodUpper} ${pattern} + { + method: '${methodUpper}', + pattern: '${pattern}', + handler: async ({ event, params }) => { +${body} + }, + }, + `; - const updated = source.replace(marker, `${snippet} ${marker} `); + const updated = source.replace(marker, `${snippet}${marker}`); fs.writeFileSync(handlerPath, updated, 'utf8'); } @@ -785,48 +718,22 @@ function normalizePathForComparison(path) { return path.replace(/\{[^}]+\}/g, '{param}'); } -// Extract routes from handler.ts +// Extract routes from handler.ts route table: { method: 'X', pattern: '/...' }. +// Works whether or not the ROUTES-START/END markers are present. function extractRoutesFromHandler(handlerPath) { const source = fs.readFileSync(handlerPath, 'utf8'); const routes = []; - - // Find the routes section between ROUTES-START and ROUTES-END - const startMarker = '// >>> ROUTES-START'; - const endMarker = '// <<< ROUTES-END'; - const startIndex = source.indexOf(startMarker); - const endIndex = source.indexOf(endMarker); - - if (startIndex === -1 || endIndex === -1) { - return routes; - } - - const routesSection = source.substring(startIndex, endIndex); - - // Extract route comments - pattern: // METHOD /path - // The comment typically contains the actual API path - const routeCommentRegex = /\/\/\s*([A-Z]+)\s+([\/\w\{\}\-]+)/g; - - let commentMatch; - while ((commentMatch = routeCommentRegex.exec(routesSection)) !== null) { - const method = commentMatch[1]; - let path = commentMatch[2].trim(); - - // Find the if condition that follows this comment to get the actual method - const afterComment = routesSection.substring(commentMatch.index + commentMatch[0].length); - const ifMatch = afterComment.match(/if\s*\([^)]*method\s*===\s*['"]([A-Z]+)['"]/); - const actualMethod = ifMatch ? ifMatch[1] : method; - - // Ensure path starts with / - if (!path.startsWith('/')) { - path = '/' + path; - } - - // Only add if we have a valid path - if (path && path.startsWith('/')) { - routes.push({ method: actualMethod, path }); - } + + const routeRegex = /method:\s*['"]([A-Za-z]+)['"]\s*,\s*pattern:\s*['"]([^'"]+)['"]/g; + let m; + while ((m = routeRegex.exec(source)) !== null) { + const method = m[1].toUpperCase(); + // Convert dispatcher :param to {param} to match openapi-derived routes. + let path = m[2].replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + if (!path.startsWith('/')) path = '/' + path; + routes.push({ method, path }); } - + return routes; } @@ -990,7 +897,7 @@ function cmdInitHandler(nameArg) { writeFileIfAbsent(openapiPath, templateOpenApiYaml(nameArg)); writeFileIfAbsent(swaggerUtilsPath, templateSwaggerUtils()); writeFileIfAbsent(devServerPath, templateDevServer(nameArg)); - writeFileIfAbsent(handlerPath, templateHandlerTsClean()); + writeFileIfAbsent(handlerPath, templateHandlerTsClean(nameArg)); writeFileIfAbsent(authPath, templateAuthTs()); writeFileIfAbsent(testPath, templateJestSetup(nameArg)); writeFileIfAbsent(readmePath, templateReadme(nameArg)); diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 911619b7..58a8c143 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,5 +1,6 @@ -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import db from './db' +import { APIGatewayProxyResult } from 'aws-lambda'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; +import db from './db'; import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; import { UserValidationUtils } from './validation-utils'; @@ -12,253 +13,207 @@ function requireAuth(authContext: AuthContext, level: Parameters { + const authContext: AuthContext = await authenticateRequest(event); + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + + const queryParams = event.queryStringParameters || {}; + const page = queryParams.page ? parseInt(queryParams.page, 10) : null; + const limit = queryParams.limit ? parseInt(queryParams.limit, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.users') + .select(db.fn.count('user_id').as('count')) + .executeTakeFirst(); + + const totalUsers = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalUsers / limit); + + const users = await db + .selectFrom('branch.users') + .selectAll() + .orderBy('user_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + return json(200, { + users, + pagination: { page, limit, totalUsers, totalPages }, + }); + } + + const users = await db.selectFrom('branch.users').selectAll().execute(); + return json(200, { users }); +} + +// GET /users/{userId} +async function getUser({ event, params }: RouteCtx): Promise { + const authContext: AuthContext = await authenticateRequest(event); + const userId = params.userId; + const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); + if (authError) return authError; + + if (!userId) return json(400, { message: 'userId is required' }); + + const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).selectAll().executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + return json(200, { + ok: true, + route: 'GET /users/{userId}', + pathParams: { userId }, + body: { + userId: user.user_id, + email: user.email, + name: user.name, + isAdmin: user.is_admin, + profile_image: user.profile_image, + }, + }); +} + +// PATCH /users/{userId} +async function patchUser({ event, params }: RouteCtx): Promise { + const authContext: AuthContext = await authenticateRequest(event); + const userId = params.userId; + const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); + if (authError) return authError; + + if (!userId) return json(400, { message: 'userId is required' }); + const body = event.body ? JSON.parse(event.body) as Record : {}; + + // make sure user exists + const user = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).selectAll().executeTakeFirst(); + if (!user) return json(404, { message: 'User not found' }); + + const updates: { email?: string; name?: string; is_admin?: boolean; profile_image?: string } = {}; + + const emailResult = UserValidationUtils.validateEmail(body.email); + if (!emailResult.isValid) return json(400, { message: emailResult.error }); + if (emailResult.value != null) updates.email = emailResult.value; + + const nameResult = UserValidationUtils.validateName(body.name); + if (!nameResult.isValid) return json(400, { message: nameResult.error }); + if (nameResult.value != null) updates.name = nameResult.value; + + const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); + if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); + if (isAdminResult.value != null) updates.is_admin = isAdminResult.value; + + const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); + if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); + if (profileImageResult.value != null) updates.profile_image = profileImageResult.value; + + if (Object.keys(updates).length === 0) { + return json(400, { message: 'No valid fields provided to update' }); + } + + // update + await db.updateTable('branch.users') + .set(updates) + .where('user_id', '=', Number(userId)) + .execute(); + + // get updated user + const updatedUser = await db.selectFrom('branch.users').where('user_id', '=', Number(userId)).selectAll().executeTakeFirst(); + + return json(200, { ok: true, route: 'PATCH /users/{userId}', pathParams: { userId }, body: { email: updatedUser!.email, name: updatedUser!.name, isAdmin: updatedUser!.is_admin, profileImage: updatedUser!.profile_image } }); +} + +// DELETE /users/{userId} +async function deleteUser({ event, params }: RouteCtx): Promise { + const authContext: AuthContext = await authenticateRequest(event); + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + + const userId = params.userId; + if (!userId) return json(400, { message: 'userId is required' }); -export const handler = async (event: any): Promise => { + const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); + + if (!deleted[0] || deleted[0].numDeletedRows === 0n) { + return json(404, { message: 'User not found' }); + } + + return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId } }); +} + +// POST /users +async function createUser({ event }: RouteCtx): Promise { + const authContext: AuthContext = await authenticateRequest(event); + const authError = requireAuth(authContext, 'ADMIN'); + if (authError) return authError; + + const body = event.body + ? (JSON.parse(event.body) as Record) + : {}; + + // email, name, and isAdmin are required on create + if (!body.email || !body.name || body.isAdmin === undefined || body.isAdmin === null) { + return json(400, { message: 'email, name, and isAdmin are required' }); + } + + // validate the type/format of each field + const emailResult = UserValidationUtils.validateEmail(body.email); + if (!emailResult.isValid) return json(400, { message: emailResult.error }); + + const nameResult = UserValidationUtils.validateName(body.name); + if (!nameResult.isValid) return json(400, { message: nameResult.error }); + + const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); + if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); + + const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); + if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); + + const email = emailResult.value as string; + const name = nameResult.value as string; + const isAdmin = isAdminResult.value as boolean; + const profile_image = profileImageResult.value ?? undefined; + + // Check if user with this email already exists + const existingUser = await db + .selectFrom('branch.users') + .where('email', '=', email) + .selectAll() + .executeTakeFirst(); + + if (existingUser) { + return json(409, { message: 'User with this email already exists' }); + } + + // insert new user (user_id auto-increments) try { - // Support both API Gateway and Lambda Function URL events - // API Gateway: event.path, event.httpMethod - // Function URL: event.rawPath, event.requestContext.http.method - const rawPath = event.rawPath || event.path || '/'; - let normalizedPath = rawPath.replace(/\/$/, ''); - if (normalizedPath.length === 0) { - normalizedPath = '/'; - } - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - console.log('DEBUG - rawPath:', rawPath, 'normalizedPath:', normalizedPath, 'method:', method); - - // Health check - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } - - const authContext: AuthContext = await authenticateRequest(event); - - - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - - // GET /users - if ((normalizedPath === '/users' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - // TODO: Add your business logic here - const queryParams = event.queryStringParameters || {}; - const page = queryParams.page ? parseInt(queryParams.page, 10) : null; - const limit = queryParams.limit ? parseInt(queryParams.limit, 10) : null; - - if (page && limit) { - const offset = (page - 1) * limit; - - const totalCount = await db - .selectFrom('branch.users') - .select(db.fn.count('user_id').as('count')) - .executeTakeFirst(); - - const totalUsers = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalUsers / limit); - - const users = await db - .selectFrom('branch.users') - .selectAll() - .orderBy('user_id', 'asc') - .limit(limit) - .offset(offset) - .execute(); - return json(200, { - users, - pagination: { - page, - limit, - totalUsers, - totalPages - } - }); - } - - const users = await db - .selectFrom('branch.users') - .selectAll() - .execute(); - - console.log(users); - return json(200, { users }); - } - - // GET /{userId} - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'GET') { - const userId = normalizedPath.split('/')[1]; - const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); - if (authError) return authError; - - if (!userId) return json(400, { message: 'userId is required' }); - - const user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - return json(200, { - ok: true, - route: 'GET /users/{userId}', - pathParams: { userId }, - body: { - userId: user.user_id, - email: user.email, - name: user.name, - isAdmin: user.is_admin, - profile_image: user.profile_image, - } - }); - } - - // PATCH /{userId} (dev server strips /users prefix) - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'PATCH') { - const userId = normalizedPath.split('/')[1]; - const authError = requireAuth(authContext, 'ADMIN_OR_SELF', userId); - if (authError) return authError; - - if (!userId) return json(400, { message: 'userId is required' }); - const body = event.body ? JSON.parse(event.body) as Record : {}; - - // make sure user exists - let user = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - if (!user) return json(404, { message: 'User not found' }); - - const updates: { email?: string; name?: string; is_admin?: boolean; profile_image?: string } = {}; - - const emailResult = UserValidationUtils.validateEmail(body.email); - if (!emailResult.isValid) return json(400, { message: emailResult.error }); - if (emailResult.value != null) updates.email = emailResult.value; - - const nameResult = UserValidationUtils.validateName(body.name); - if (!nameResult.isValid) return json(400, { message: nameResult.error }); - if (nameResult.value != null) updates.name = nameResult.value; - - const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); - if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - if (isAdminResult.value != null) updates.is_admin = isAdminResult.value; - - const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); - if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); - if (profileImageResult.value != null) updates.profile_image = profileImageResult.value; - - if (Object.keys(updates).length === 0) { - return json(400, { message: 'No valid fields provided to update' }); - } - - // update - await db.updateTable('branch.users') - .set(updates) - .where('user_id', '=', Number(userId)) - .execute(); - - // get updated user - let updatedUser = await db.selectFrom("branch.users").where("user_id", "=", Number(userId)).selectAll().executeTakeFirst(); - - return json(200, { ok: true, route: 'PATCH /users/{userId}', pathParams: { userId }, body: { email: updatedUser!.email, name: updatedUser!.name, isAdmin: updatedUser!.is_admin, profileImage: updatedUser!.profile_image } }); - } - - // DELETE /users/{userId} - if (normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 && method === 'DELETE') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const userId = normalizedPath.split('/')[1]; - if (!userId) return json(400, { message: 'userId is required' }); - - const deleted = await db.deleteFrom('branch.users').where('user_id', '=', Number(userId)).execute(); - - if (!deleted[0] || deleted[0].numDeletedRows === 0n) { - return json(404, { message: 'User not found' }); - } - - return json(200, { ok: true, route: 'DELETE /users/{userId}', pathParams: { userId } }); - } - - // POST /users - if ((normalizedPath === '/' || normalizedPath === '/users') && method === 'POST') { - const authError = requireAuth(authContext, 'ADMIN'); - if (authError) return authError; - - const body = event.body - ? (JSON.parse(event.body) as Record) - : {}; - - // email, name, and isAdmin are required on create - if (!body.email || !body.name || body.isAdmin === undefined || body.isAdmin === null) { - return json(400, { message: 'email, name, and isAdmin are required' }); - } - - // validate the type/format of each field - const emailResult = UserValidationUtils.validateEmail(body.email); - if (!emailResult.isValid) return json(400, { message: emailResult.error }); - - const nameResult = UserValidationUtils.validateName(body.name); - if (!nameResult.isValid) return json(400, { message: nameResult.error }); - - const isAdminResult = UserValidationUtils.validateIsAdmin(body.isAdmin); - if (!isAdminResult.isValid) return json(400, { message: isAdminResult.error }); - - const profileImageResult = UserValidationUtils.validateProfileImage(body.profileImage); - if (!profileImageResult.isValid) return json(400, { message: profileImageResult.error }); - - const email = emailResult.value as string; - const name = nameResult.value as string; - const isAdmin = isAdminResult.value as boolean; - const profile_image = profileImageResult.value ?? undefined; - - // Check if user with this email already exists - const existingUser = await db - .selectFrom('branch.users') - .where('email', '=', email) - .selectAll() - .executeTakeFirst(); - - if (existingUser) { - return json(409, { message: 'User with this email already exists' }); - } - - // insert new user (user_id auto-increments) - try { - await db - .insertInto('branch.users') - .values({ email, name, is_admin: isAdmin, profile_image }) - .execute(); - } catch (err) { - console.error('Database insert error:', err); - return json(500, { message: 'Failed to create user' }); - } - - return json(201, { - ok: true, - route: 'POST /users', - pathParams: {}, - body: { - email, - name, - isAdmin, - }, - }); - } - // <<< ROUTES-END - - return json(404, { message: 'Not Found', path: normalizedPath, method }); + await db + .insertInto('branch.users') + .values({ email, name, is_admin: isAdmin, profile_image }) + .execute(); } catch (err) { - console.error('Lambda error:', err); - return json(500, { message: 'Internal Server Error' }); + console.error('Database insert error:', err); + return json(500, { message: 'Failed to create user' }); } -}; - -function json(statusCode: number, body: unknown): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS' - }, - body: JSON.stringify(body) - }; -} \ No newline at end of file + + return json(201, { + ok: true, + route: 'POST /users', + pathParams: {}, + body: { email, name, isAdmin }, + }); +} + +export const handler = (event: any): Promise => + dispatch(event, { + prefix: 'users', + routes: [ + { method: 'GET', pattern: '/users', handler: listUsers }, + { method: 'POST', pattern: '/users', handler: createUser }, + { method: 'GET', pattern: '/users/:userId', handler: getUser }, + { method: 'PATCH', pattern: '/users/:userId', handler: patchUser }, + { method: 'DELETE', pattern: '/users/:userId', handler: deleteUser }, + ], + }); diff --git a/apps/backend/lambdas/users/openapi.yaml b/apps/backend/lambdas/users/openapi.yaml index fa4d4413..ebf7d059 100644 --- a/apps/backend/lambdas/users/openapi.yaml +++ b/apps/backend/lambdas/users/openapi.yaml @@ -3,9 +3,9 @@ info: title: users (Local) version: 1.0.0 servers: - - url: http://localhost:3000/users + - url: / paths: - /health: + /users/health: get: summary: Health check responses: @@ -58,7 +58,7 @@ paths: description: Bad Request '500': description: Internal Server Error - /{userId}: + /users/{userId}: get: summary: GET /users/{userId} parameters: diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index d65e3a9a..7250d641 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", + "@branch/lambda-http": "file:../../../../shared/lambda-http", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", @@ -20,6 +21,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.5", + "esbuild": "^0.25.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", @@ -28,6 +30,7 @@ } }, "../../../../shared/lambda-auth": { + "name": "@branch/lambda-auth", "version": "1.0.0", "dependencies": { "aws-jwt-verify": "^5.1.1" @@ -37,6 +40,15 @@ "typescript": "^5.4.5" } }, + "../../../../shared/lambda-http": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, "../../../../shared/types": { "name": "@branch/types", "version": "1.0.0", @@ -507,6 +519,10 @@ "resolved": "../../../../shared/lambda-auth", "link": true }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true @@ -566,6 +582,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -2297,6 +2755,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 768d2d81..d899567c 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -7,7 +7,7 @@ "dev": "ts-node --transpile-only dev-server.ts", "build": "tsc", "test": "start-server-and-test dev http-get://localhost:3000/users/health jest", - "package": "npm run build && cd dist && zip -r ../lambda.zip . -x '*.map' 'dev-server.*' 'swagger-utils.*'" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && cd dist && zip -q -r ../lambda.zip handler.js" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -19,13 +19,15 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "esbuild": "^0.25.0" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "jest": "^30.2.0", "kysely": "^0.28.8", - "pg": "^8.16.3" + "pg": "^8.16.3", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index 2377c0f5..b7eed458 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -38,7 +38,7 @@ test/ # jest + RTL mirror of src/ (custom render in test/u No React Query / SWR / Redux. Pattern: `useState` + `useEffect` + `apiFetch`, local component state. -`src/lib/api.ts` — `apiFetch(path, { token?, ... })`. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Injects `Authorization: Bearer `. Throws on non-2xx. `next.config.ts` rewrites mirror this routing for the dev server. New backend calls go through `apiFetch` — don't hand-roll `fetch`. +`src/lib/api.ts` — `apiFetch(path, { token?, ... })`. In production set `NEXT_PUBLIC_API_BASE_URL` (the API Gateway stage URL) — all calls go there with their **full prefixed path** (`/auth/login`, `/projects/:id/members`). With it unset (local dev), it routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001). Injects `Authorization: Bearer `. Throws on non-2xx. `next.config.ts` has dev-only pass-through rewrites per service (full prefixed paths, no stripping) as a fallback for same-origin requests. New backend calls go through `apiFetch` — don't hand-roll `fetch`. ## Auth diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 2879aa1c..4a5fe030 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -1,36 +1,19 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + // Dev-only convenience proxy. Each backend service owns its full prefixed + // path (e.g. /projects/:id/members) — no prefix stripping — matching how + // API Gateway forwards paths in production. Note: lib/api.ts's apiFetch calls + // the per-service ports directly, so these rewrites are a fallback for + // same-origin relative requests only. async rewrites() { return [ - { - source: '/auth/:path*', - destination: 'http://localhost:3006/auth/:path*', - }, - { - source: '/expenditures/:path*', - destination: 'http://localhost:3004/expenditures/:path*', - }, - { - source: '/expenditures', - destination: 'http://localhost:3004/expenditures', - }, - { - source: '/projects/:id/members', - destination: 'http://localhost:3002/:id/members', - }, - { - source: '/projects/:id/expenditures', - destination: 'http://localhost:3002/:id/expenditures', - }, - { - source: '/projects/:id/donors', - destination: 'http://localhost:3002/:id/donors', - }, - { - source: '/projects', - destination: 'http://localhost:3002/projects', - }, + { source: '/auth/:path*', destination: 'http://localhost:3006/auth/:path*' }, + { source: '/users/:path*', destination: 'http://localhost:3001/users/:path*' }, + { source: '/projects/:path*', destination: 'http://localhost:3002/projects/:path*' }, + { source: '/donors/:path*', destination: 'http://localhost:3003/donors/:path*' }, + { source: '/expenditures/:path*', destination: 'http://localhost:3004/expenditures/:path*' }, + { source: '/reports/:path*', destination: 'http://localhost:3005/reports/:path*' }, ]; }, }; diff --git a/infrastructure/AGENTS.md b/infrastructure/AGENTS.md index 26584829..16a6bc0a 100644 --- a/infrastructure/AGENTS.md +++ b/infrastructure/AGENTS.md @@ -11,7 +11,7 @@ Application infra. Providers: AWS 6.14.1, Infisical. - `main.tf` — RDS PostgreSQL 17.6 (db.t3.micro), `branch_rds` db; creds from Infisical `/aws/rds`. - `lambda.tf` — 6 Lambda functions (auth/donors/expenditures/projects/reports/users, Node 20.x, 256MB, 30s), IAM role (CloudWatch Logs), deployment S3 bucket. **`lifecycle` ignores `s3_key`** — code is deployed by CI (`lambda-deploy`), not Terraform. Env: `NODE_ENV`, `DB_*`. - `cognito.tf` — user pool (email sign-in, auto-verify, 8-char password policy, advanced security, deletion protection) + public client (1h access/ID tokens, 30d refresh, no secret). **Manual step:** copy output pool/client IDs into Infisical `/aws/cognito/`. -- `api_gateway.tf` — REST API, one resource per lambda, method routing, `AWS_PROXY` integration, `prod` stage. +- `api_gateway.tf` — REST API, one resource per lambda plus a greedy `//{proxy+}`, each with an `ANY` method + `AWS_PROXY` integration, `prod` stage. The full path is forwarded to the lambda (which self-routes via `@branch/lambda-http`); `ANY` also routes OPTIONS preflight to the lambda's CORS handler. The deployment has a `triggers` hash so route changes redeploy the stage. - `s3.tf` — public-read reports bucket + versioned/encrypted lambda-deployments bucket. - `amplify.tf` — frontend (Next.js SSR), monorepo root `apps/frontend`, auto-deploys `main` (GitHub token from Infisical). - `secrets.tf`, `variables.tf` — Infisical data sources. diff --git a/infrastructure/aws/api_gateway.tf b/infrastructure/aws/api_gateway.tf index 1ebde4c1..8044ee33 100644 --- a/infrastructure/aws/api_gateway.tf +++ b/infrastructure/aws/api_gateway.tf @@ -8,20 +8,7 @@ resource "aws_api_gateway_rest_api" "branch_api" { } } -# Define supported HTTP methods per Lambda function based on handlers -# NOTE: Must be kept in sync with actual Lambda handlers in apps/backend/lambdas/*/openapi.yaml -locals { - lambda_methods = { - auth = ["GET", "POST"] - donors = ["GET"] - expenditures = ["GET", "POST"] - projects = ["GET", "POST"] - reports = ["GET"] - users = ["GET", "POST", "DELETE", "PATCH"] - } -} - -# Create a resource for each Lambda function +# One resource per lambda at the API root: /auth, /donors, /projects, ... resource "aws_api_gateway_resource" "lambda_resources" { for_each = local.lambda_functions @@ -30,43 +17,61 @@ resource "aws_api_gateway_resource" "lambda_resources" { path_part = each.key } -# Create methods for each resource based on supported methods -resource "aws_api_gateway_method" "lambda_methods" { - for_each = merge([ - for lambda, methods in local.lambda_methods : { - for method in methods : - "${lambda}-${method}" => { - lambda = lambda - method = method - } - } - ]...) +# Greedy child per lambda: //{proxy+} captures all sub-paths. +# Each lambda owns its own routing (see @branch/lambda-http dispatcher); API +# Gateway just forwards the full path to the matching service. +resource "aws_api_gateway_resource" "lambda_proxy" { + for_each = local.lambda_functions + + rest_api_id = aws_api_gateway_rest_api.branch_api.id + parent_id = aws_api_gateway_resource.lambda_resources[each.key].id + path_part = "{proxy+}" +} + +# ANY on the bare resource (/) — {proxy+} requires >=1 trailing segment, +# so the prefix itself needs its own method. ANY also routes OPTIONS preflight +# to the lambda's CORS handler and removes the need to enumerate methods. +resource "aws_api_gateway_method" "lambda_root_any" { + for_each = local.lambda_functions rest_api_id = aws_api_gateway_rest_api.branch_api.id - resource_id = aws_api_gateway_resource.lambda_resources[each.value.lambda].id - http_method = each.value.method + resource_id = aws_api_gateway_resource.lambda_resources[each.key].id + http_method = "ANY" authorization = "NONE" } -# Create Lambda integrations -resource "aws_api_gateway_integration" "lambda_integrations" { - for_each = merge([ - for lambda, methods in local.lambda_methods : { - for method in methods : - "${lambda}-${method}" => { - lambda = lambda - method = method - } - } - ]...) +resource "aws_api_gateway_integration" "lambda_root_any" { + for_each = local.lambda_functions rest_api_id = aws_api_gateway_rest_api.branch_api.id - resource_id = aws_api_gateway_resource.lambda_resources[each.value.lambda].id - http_method = aws_api_gateway_method.lambda_methods[each.key].http_method + resource_id = aws_api_gateway_resource.lambda_resources[each.key].id + http_method = aws_api_gateway_method.lambda_root_any[each.key].http_method integration_http_method = "POST" type = "AWS_PROXY" - uri = aws_lambda_function.functions[each.value.lambda].invoke_arn + uri = aws_lambda_function.functions[each.key].invoke_arn +} + +# ANY on the greedy proxy (//{proxy+}). +resource "aws_api_gateway_method" "lambda_proxy_any" { + for_each = local.lambda_functions + + rest_api_id = aws_api_gateway_rest_api.branch_api.id + resource_id = aws_api_gateway_resource.lambda_proxy[each.key].id + http_method = "ANY" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "lambda_proxy_any" { + for_each = local.lambda_functions + + rest_api_id = aws_api_gateway_rest_api.branch_api.id + resource_id = aws_api_gateway_resource.lambda_proxy[each.key].id + http_method = aws_api_gateway_method.lambda_proxy_any[each.key].http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.functions[each.key].invoke_arn } # Allow API Gateway to invoke Lambda functions @@ -85,11 +90,24 @@ resource "aws_lambda_permission" "api_gateway_permissions" { # Create deployment resource "aws_api_gateway_deployment" "branch_deployment" { depends_on = [ - aws_api_gateway_integration.lambda_integrations + aws_api_gateway_integration.lambda_root_any, + aws_api_gateway_integration.lambda_proxy_any, ] rest_api_id = aws_api_gateway_rest_api.branch_api.id + # Redeploy the stage whenever the routing surface changes. + triggers = { + redeploy = sha1(jsonencode([ + [for k, r in aws_api_gateway_resource.lambda_resources : r.id], + [for k, r in aws_api_gateway_resource.lambda_proxy : r.id], + [for k, m in aws_api_gateway_method.lambda_root_any : m.id], + [for k, m in aws_api_gateway_method.lambda_proxy_any : m.id], + [for k, i in aws_api_gateway_integration.lambda_root_any : i.uri], + [for k, i in aws_api_gateway_integration.lambda_proxy_any : i.uri], + ])) + } + lifecycle { create_before_destroy = true } @@ -106,4 +124,4 @@ resource "aws_api_gateway_stage" "branch_stage" { output "api_gateway_url" { description = "The URL of the API Gateway" value = aws_api_gateway_stage.branch_stage.invoke_url -} \ No newline at end of file +} diff --git a/shared/lambda-http/package-lock.json b/shared/lambda-http/package-lock.json new file mode 100644 index 00000000..9335e8c8 --- /dev/null +++ b/shared/lambda-http/package-lock.json @@ -0,0 +1,55 @@ +{ + "name": "@branch/lambda-http", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@branch/lambda-http", + "version": "1.0.0", + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.162", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.162.tgz", + "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/shared/lambda-http/package.json b/shared/lambda-http/package.json new file mode 100644 index 00000000..1ecb40fd --- /dev/null +++ b/shared/lambda-http/package.json @@ -0,0 +1,15 @@ +{ + "name": "@branch/lambda-http", + "version": "1.0.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.131", + "@types/node": "^20.11.30", + "typescript": "^5.4.5" + } +} diff --git a/shared/lambda-http/src/dispatch.ts b/shared/lambda-http/src/dispatch.ts new file mode 100644 index 00000000..8170b631 --- /dev/null +++ b/shared/lambda-http/src/dispatch.ts @@ -0,0 +1,55 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; +import { json } from './response'; +import { matchPattern } from './match'; +import type { DispatchOptions } from './types'; + +/** + * Route a Lambda event to the first matching route and return its response. + * + * Canonicalizes the incoming path to the full prefixed shape so a single route + * table works under both: + * - API Gateway `{proxy+}` — delivers the full path, e.g. `/auth/login`. + * - the shared dev-server — routes by first segment then strips it, e.g. `/login` + * (and `/` for a bare service root). + * + * Handles OPTIONS preflight, `//health`, 404, and 500 centrally. + */ +export async function dispatch( + event: any, + { prefix, routes }: DispatchOptions, +): Promise { + try { + const rawPath: string = event.rawPath || event.path || '/'; + let path = rawPath.replace(/\/+$/, '') || '/'; + const method = ( + event.requestContext?.http?.method || + event.httpMethod || + 'GET' + ).toUpperCase(); + + // Canonicalize to `/...` when the prefix was stripped (dev-server). + const base = `/${prefix}`; + if (path !== base && !path.startsWith(`${base}/`)) { + path = path === '/' ? base : base + path; + } + + if (method === 'OPTIONS') return json(200, {}); + + if (path === `${base}/health` && method === 'GET') { + return json(200, { ok: true, timestamp: new Date().toISOString() }); + } + + for (const route of routes) { + if (route.method.toUpperCase() !== method) continue; + const params = matchPattern(route.pattern, path); + if (params) { + return await route.handler({ event, params, method, path }); + } + } + + return json(404, { message: 'Not Found', path, method }); + } catch (err) { + console.error('Lambda error:', err); + return json(500, { message: 'Internal Server Error' }); + } +} diff --git a/shared/lambda-http/src/index.ts b/shared/lambda-http/src/index.ts new file mode 100644 index 00000000..4275cfcb --- /dev/null +++ b/shared/lambda-http/src/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export { json } from './response'; +export { matchPattern } from './match'; +export { dispatch } from './dispatch'; diff --git a/shared/lambda-http/src/match.ts b/shared/lambda-http/src/match.ts new file mode 100644 index 00000000..73d0c806 --- /dev/null +++ b/shared/lambda-http/src/match.ts @@ -0,0 +1,26 @@ +/** + * Match a route pattern against a path. Patterns use `:name` for params, e.g. + * `/projects/:id/members`. Returns captured params on match, or `null` if no match. + * Segment counts must be equal (no greedy/optional segments). + */ +export function matchPattern( + pattern: string, + path: string, +): Record | null { + const pSeg = pattern.split('/').filter(Boolean); + const aSeg = path.split('/').filter(Boolean); + if (pSeg.length !== aSeg.length) return null; + + const params: Record = {}; + for (let i = 0; i < pSeg.length; i++) { + const p = pSeg[i]; + const a = aSeg[i]; + if (p.startsWith(':')) { + if (!a) return null; + params[p.slice(1)] = decodeURIComponent(a); + } else if (p !== a) { + return null; + } + } + return params; +} diff --git a/shared/lambda-http/src/response.ts b/shared/lambda-http/src/response.ts new file mode 100644 index 00000000..b4ff8626 --- /dev/null +++ b/shared/lambda-http/src/response.ts @@ -0,0 +1,15 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; + +/** JSON response with permissive CORS headers (browser calls cross-origin to API Gateway). */ +export function json(statusCode: number, body: unknown): APIGatewayProxyResult { + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', + }, + body: JSON.stringify(body), + }; +} diff --git a/shared/lambda-http/src/types.ts b/shared/lambda-http/src/types.ts new file mode 100644 index 00000000..0754a4d9 --- /dev/null +++ b/shared/lambda-http/src/types.ts @@ -0,0 +1,29 @@ +import type { APIGatewayProxyResult } from 'aws-lambda'; + +/** Context handed to a matched route handler. */ +export interface RouteCtx { + /** Raw Lambda event (API Gateway proxy or Function URL / dev-server shape). */ + event: any; + /** Path params captured from the route pattern (e.g. `:id` -> params.id). */ + params: Record; + /** Uppercased HTTP method. */ + method: string; + /** Canonical, full-prefixed request path (e.g. `/projects/7/members`). */ + path: string; +} + +export type RouteHandler = (ctx: RouteCtx) => Promise; + +export interface Route { + /** HTTP method, case-insensitive. */ + method: string; + /** Full prefixed path pattern with `:param` segments, e.g. `/projects/:id/members`. */ + pattern: string; + handler: RouteHandler; +} + +export interface DispatchOptions { + /** Service prefix without slashes, e.g. `auth`, `projects`. */ + prefix: string; + routes: Route[]; +} diff --git a/shared/lambda-http/tsconfig.json b/shared/lambda-http/tsconfig.json new file mode 100644 index 00000000..e57dbd9f --- /dev/null +++ b/shared/lambda-http/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 864e62268f6303e1f4b9f137b2cdac2295a9fc60 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Tue, 30 Jun 2026 00:13:20 -0400 Subject: [PATCH 2/6] fix: bundle @branch/lambda-http into lambda Docker images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each service Dockerfile copied + built shared/lambda-auth into /shared so its file: dep resolved; @branch/lambda-http (new runtime dep) needs the same or the container can't resolve it. Add the lambda-http copy+build to the 5 repo-root services, and switch auth's build context to the repo root (it was ./lambdas/auth, which can't see ../../../../shared) with a matching Dockerfile. Verified: docker compose build + up for auth and projects — /auth/health, /auth/login (routed, 400 not 404), /projects health/list/:id/members all 200, unknown path -> 404. Shared dev-server (npm run dev) verified separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/backend/docker-compose.yml | 4 ++-- apps/backend/lambdas/auth/Dockerfile | 20 ++++++++++---------- apps/backend/lambdas/donors/Dockerfile | 5 +++++ apps/backend/lambdas/expenditures/Dockerfile | 5 +++++ apps/backend/lambdas/projects/Dockerfile | 5 +++++ apps/backend/lambdas/reports/Dockerfile | 5 +++++ apps/backend/lambdas/users/Dockerfile | 5 +++++ 7 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index c8c50eb7..5a4a3c41 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -133,8 +133,8 @@ services: # Auth Service auth: build: - context: ./lambdas/auth - dockerfile: Dockerfile + context: ../.. + dockerfile: apps/backend/lambdas/auth/Dockerfile container_name: branch-auth restart: unless-stopped environment: diff --git a/apps/backend/lambdas/auth/Dockerfile b/apps/backend/lambdas/auth/Dockerfile index 46cb374c..9bfec062 100644 --- a/apps/backend/lambdas/auth/Dockerfile +++ b/apps/backend/lambdas/auth/Dockerfile @@ -1,17 +1,17 @@ FROM node:20-alpine -WORKDIR /app - -# Copy package files -COPY package*.json ./ +# Build context is the repo root (see docker-compose.yml) so the file: shared +# deps resolve. Build the shared HTTP router into /shared/lambda-http first. +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build -# Install dependencies -RUN npm install - -# Copy source files -COPY . . +WORKDIR /app +COPY apps/backend/lambdas/auth/package*.json ./ +RUN npm install --no-package-lock +COPY apps/backend/lambdas/auth/ . -# Expose port EXPOSE 3000 # Health check diff --git a/apps/backend/lambdas/donors/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index dec1aba3..c07158e3 100644 --- a/apps/backend/lambdas/donors/Dockerfile +++ b/apps/backend/lambdas/donors/Dockerfile @@ -5,6 +5,11 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/donors/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/expenditures/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index 17c7ce17..39b4b5cf 100644 --- a/apps/backend/lambdas/expenditures/Dockerfile +++ b/apps/backend/lambdas/expenditures/Dockerfile @@ -5,6 +5,11 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/expenditures/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/projects/Dockerfile b/apps/backend/lambdas/projects/Dockerfile index 09292832..ff004956 100644 --- a/apps/backend/lambdas/projects/Dockerfile +++ b/apps/backend/lambdas/projects/Dockerfile @@ -5,6 +5,11 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/projects/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/reports/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index a959f797..5134d50b 100644 --- a/apps/backend/lambdas/reports/Dockerfile +++ b/apps/backend/lambdas/reports/Dockerfile @@ -5,6 +5,11 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/reports/package*.json ./ RUN npm install --no-package-lock diff --git a/apps/backend/lambdas/users/Dockerfile b/apps/backend/lambdas/users/Dockerfile index aec6cb5e..0d9c2228 100644 --- a/apps/backend/lambdas/users/Dockerfile +++ b/apps/backend/lambdas/users/Dockerfile @@ -5,6 +5,11 @@ COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ RUN npm install && npm run build +WORKDIR /shared/lambda-http +COPY shared/lambda-http/package.json shared/lambda-http/tsconfig.json ./ +COPY shared/lambda-http/src ./src/ +RUN npm install && npm run build + WORKDIR /app COPY apps/backend/lambdas/users/package*.json ./ RUN npm install --no-package-lock From 0f3d16dd40c9b341cb249fddab8d35cba62081a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 30 Jun 2026 04:14:02 +0000 Subject: [PATCH 3/6] chore: auto-format terraform and update documentation - Auto-formatted .tf files with terraform fmt - Updated README.md with terraform-docs Co-authored-by: nourshoreibah --- infrastructure/aws/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index 4e8f4fde..1ce2d17c 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -26,8 +26,11 @@ No modules. | [aws_amplify_app.frontend](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/amplify_app) | resource | | [aws_amplify_branch.main](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/amplify_branch) | resource | | [aws_api_gateway_deployment.branch_deployment](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_deployment) | resource | -| [aws_api_gateway_integration.lambda_integrations](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_integration) | resource | -| [aws_api_gateway_method.lambda_methods](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_method) | resource | +| [aws_api_gateway_integration.lambda_proxy_any](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_integration) | resource | +| [aws_api_gateway_integration.lambda_root_any](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_integration) | resource | +| [aws_api_gateway_method.lambda_proxy_any](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_method) | resource | +| [aws_api_gateway_method.lambda_root_any](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_method) | resource | +| [aws_api_gateway_resource.lambda_proxy](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_resource) | resource | | [aws_api_gateway_resource.lambda_resources](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_resource) | resource | | [aws_api_gateway_rest_api.branch_api](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_rest_api) | resource | | [aws_api_gateway_stage.branch_stage](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_stage) | resource | From 5535eebe38a8c9e84ebbe397ae57bc198ad65342 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 30 Jun 2026 04:14:46 +0000 Subject: [PATCH 4/6] chore: regenerate lambda READMEs --- apps/backend/lambdas/auth/README.md | 14 +++++++------- apps/backend/lambdas/donors/README.md | 2 +- apps/backend/lambdas/projects/README.md | 6 ++++-- apps/backend/lambdas/users/README.md | 6 +++--- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/backend/lambdas/auth/README.md b/apps/backend/lambdas/auth/README.md index 4efd4f14..bb746176 100644 --- a/apps/backend/lambdas/auth/README.md +++ b/apps/backend/lambdas/auth/README.md @@ -9,13 +9,13 @@ Lambda for auth handler. | Method | Path | Description | |--------|------|-------------| | GET | /health | Health check | -| POST | /register | | -| POST | /login | | -| POST | /verify-email | | -| POST | /resend-code | | -| POST | /logout | | -| POST | /forgot-password | | -| POST | /reset-password | | +| POST | /auth/register | | +| POST | /auth/login | | +| POST | /auth/verify-email | | +| POST | /auth/resend-code | | +| POST | /auth/logout | | +| POST | /auth/forgot-password | | +| POST | /auth/reset-password | | ## Setup diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index a83b98a6..25c8dcfa 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -10,7 +10,7 @@ Lambda for managing donors. |--------|------|-------------| | GET | /health | Health check | | GET | /donors | | -| POST | /donations | | +| GET | /donors/donations | | | POST | /donors | | ## Setup diff --git a/apps/backend/lambdas/projects/README.md b/apps/backend/lambdas/projects/README.md index b50eca94..bd973c96 100644 --- a/apps/backend/lambdas/projects/README.md +++ b/apps/backend/lambdas/projects/README.md @@ -9,10 +9,12 @@ Lambda for managing projects. | Method | Path | Description | |--------|------|-------------| | GET | /health | Health check | -| GET | /dashboard | | +| GET | /projects/dashboard | | | GET | /projects/{id}/members | | -| GET | /projects | | | GET | /projects/{id}/donors | | +| GET | /projects/{id}/expenditures | | +| GET | /projects | | +| POST | /projects | | | GET | /projects/{id} | | | PUT | /projects/{id} | | diff --git a/apps/backend/lambdas/users/README.md b/apps/backend/lambdas/users/README.md index 80d91408..4cfcd7b7 100644 --- a/apps/backend/lambdas/users/README.md +++ b/apps/backend/lambdas/users/README.md @@ -10,10 +10,10 @@ Lambda for managing users. |--------|------|-------------| | GET | /health | Health check | | GET | /users | | -| GET | /{userId} | | -| PATCH | /{userId} | | -| DELETE | /users/{userId} | | | POST | /users | | +| GET | /users/{userId} | | +| PATCH | /users/{userId} | | +| DELETE | /users/{userId} | | ## Setup From fca60db440d3b8148b3b7c26d8675df4e2287e4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Jul 2026 19:41:57 +0000 Subject: [PATCH 5/6] chore: regenerate lambda READMEs --- apps/backend/lambdas/reports/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/lambdas/reports/README.md b/apps/backend/lambdas/reports/README.md index 50746b83..4ce54237 100644 --- a/apps/backend/lambdas/reports/README.md +++ b/apps/backend/lambdas/reports/README.md @@ -10,8 +10,8 @@ TODO: Add a description of the reports lambda. |--------|------|-------------| | GET | /health | Health check | | POST | /reports/generate | | -| GET | /reports | | | GET | /reports/upload-url | | +| GET | /reports | | | POST | /reports | | ## Setup From c26417cf5f34b0b1c2cb5a2adf23b3ed69e43944 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Tue, 7 Jul 2026 22:51:44 +0300 Subject: [PATCH 6/6] fix: dedupe merged lambda deps + single source of truth for shared builds Two issues surfaced packaging lambdas in the preview env: 1. The main merge left a duplicate `esbuild` devDependency in all six lambda package.json files (branch's ^0.25.12 + main's ^0.25.0). JSON tolerates duplicate keys (last wins) so it validated, but esbuild warns and the older pin would win. Kept ^0.25.12, dropped ^0.25.0. 2. `@branch/lambda-http` failed to resolve during `npm run package` because preview-env.yml built shared/lambda-auth but not shared/lambda-http. The shared-package list was hardcoded in three workflows (lambda-deploy, lambda-tests, preview-env), so adding lambda-http meant it silently broke wherever a line was missed. Replace the hardcoded lines with scripts/build-shared.sh, which builds every shared package that declares a `build` script (skips shared/types). Adding a shared package now needs no workflow edits. All three workflows call it. Verified: all six lambdas `npm run package` clean (no duplicate-key warning, lambda-http resolves); the three workflows parse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/lambda-deploy.yml | 6 ++--- .github/workflows/lambda-tests.yml | 6 ++--- .github/workflows/preview-env.yml | 2 +- apps/backend/lambdas/auth/package.json | 3 +-- apps/backend/lambdas/donors/package.json | 3 +-- .../backend/lambdas/expenditures/package.json | 3 +-- apps/backend/lambdas/projects/package.json | 3 +-- apps/backend/lambdas/reports/package.json | 3 +-- apps/backend/lambdas/users/package.json | 3 +-- scripts/build-shared.sh | 24 +++++++++++++++++++ 10 files changed, 35 insertions(+), 21 deletions(-) create mode 100755 scripts/build-shared.sh diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index a06595d8..0b13bcb8 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -94,10 +94,8 @@ jobs: with: node-version: '20' - - name: Build shared lambda-auth package - run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - - name: Build shared lambda-http package - run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + - name: Build shared packages + run: bash scripts/build-shared.sh - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index 18b0a433..70476892 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -49,10 +49,8 @@ jobs: node-version: '20' - name: Setup database schema run: psql postgres://branch_dev:password@localhost:5432/branch_db -f apps/backend/db/db_setup.sql - - name: Build shared lambda-auth package - run: npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth - - name: Build shared lambda-http package - run: npm ci --prefix shared/lambda-http && npm run build --prefix shared/lambda-http + - name: Build shared packages + run: bash scripts/build-shared.sh - name: Install dependencies working-directory: ${{ matrix.lambda }} run: npm ci --legacy-peer-deps diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index b0d5ae47..de74d3f9 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -184,7 +184,7 @@ jobs: if: steps.detect.outputs.lambdas != '' run: | set -euo pipefail - npm ci --prefix shared/lambda-auth && npm run build --prefix shared/lambda-auth + bash scripts/build-shared.sh for svc in ${{ steps.detect.outputs.lambdas }}; do echo "::group::lambda $svc" ( cd "apps/backend/lambdas/$svc" && npm ci --legacy-peer-deps && npm run package ) diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index 4f258e85..3de4004e 100644 --- a/apps/backend/lambdas/auth/package.json +++ b/apps/backend/lambdas/auth/package.json @@ -22,8 +22,7 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5", - "esbuild": "^0.25.0" + "typescript": "^5.4.5" }, "dependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.978.0", diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 43902627..d9f7131f 100644 --- a/apps/backend/lambdas/donors/package.json +++ b/apps/backend/lambdas/donors/package.json @@ -20,8 +20,7 @@ "js-yaml": "^4.1.0", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5", - "esbuild": "^0.25.0" + "typescript": "^5.4.5" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 0243a0eb..9d644e57 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -23,8 +23,7 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5", - "esbuild": "^0.25.0" + "typescript": "^5.4.5" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 2af5ad18..1fc2bd1f 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -21,8 +21,7 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5", - "esbuild": "^0.25.0" + "typescript": "^5.4.5" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 7553454c..c788a944 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -24,8 +24,7 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", - "typescript": "^5.4.5", - "esbuild": "^0.25.0" + "typescript": "^5.4.5" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index eae4540e..84d9d8ac 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -21,8 +21,7 @@ "start-server-and-test": "^2.1.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5", - "esbuild": "^0.25.0" + "typescript": "^5.4.5" }, "dependencies": { "@branch/lambda-auth": "file:../../../../shared/lambda-auth", diff --git a/scripts/build-shared.sh b/scripts/build-shared.sh new file mode 100755 index 00000000..b278bb2f --- /dev/null +++ b/scripts/build-shared.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# +# Build every shared package that declares a `build` script. +# +# Lambdas depend on shared code via `file:` deps (e.g. @branch/lambda-http), +# whose `main` points at a gitignored `dist/`. `npm ci` links a file: dep but +# does not run its build, so each shared package must be compiled before a +# lambda is packaged or tested. +# +# This is the single source of truth for that step: drop a new package under +# shared/ with a `build` script and it's picked up automatically — no workflow +# edits required. Packages without a build script (e.g. shared/types) are +# skipped. Run from the repo root. +set -euo pipefail + +for pkg in shared/*/; do + [ -f "${pkg}package.json" ] || continue + if node -e "process.exit(require('./${pkg}package.json').scripts?.build ? 0 : 1)"; then + echo "::group::build ${pkg}" + npm ci --prefix "$pkg" + npm run build --prefix "$pkg" + echo "::endgroup::" + fi +done