diff --git a/.github/workflows/lambda-deploy.yml b/.github/workflows/lambda-deploy.yml index 40f6371..0b13bcb 100644 --- a/.github/workflows/lambda-deploy.yml +++ b/.github/workflows/lambda-deploy.yml @@ -94,12 +94,12 @@ 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 packages + run: bash scripts/build-shared.sh - 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 e54870d..7047689 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -49,8 +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 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 b0d5ae4..de74d3f 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/.gitignore b/.gitignore index 7dcf7d0..5c090ae 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 32d59af..a424073 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 3b73fc4..88ff9f1 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/docker-compose.yml b/apps/backend/docker-compose.yml index 85aa3a3..dd5a3d7 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -135,8 +135,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/AGENTS.md b/apps/backend/lambdas/AGENTS.md index 62f3575..df208d8 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/Dockerfile b/apps/backend/lambdas/auth/Dockerfile index 46cb374..9bfec06 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/auth/README.md b/apps/backend/lambdas/auth/README.md index 4efd4f1..bb74617 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/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 5136c43..6384bc6 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,219 +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 fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /auth[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/auth(?=\/|$)/, '') || '/'; - 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; @@ -432,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 32b5a0d..1ee0297 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 cd29d33..5c13cea 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", "kysely": "^0.28.10", @@ -21,7 +22,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.16.0", - "esbuild": "^0.25.12", + "esbuild": "^0.25.0", "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", @@ -30,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", @@ -1205,6 +1215,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@branch/lambda-http": { + "resolved": "../../../../shared/lambda-http", + "link": true + }, "node_modules/@branch/types": { "resolved": "../../../../shared/types", "link": true diff --git a/apps/backend/lambdas/auth/package.json b/apps/backend/lambdas/auth/package.json index a91dec7..3de4004 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": "esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js && cd dist && zip -q ../lambda.zip handler.js" + "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", @@ -29,6 +29,7 @@ "amazon-cognito-identity-js": "^6.3.16", "dotenv": "^17.2.3", "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/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index dec1aba..c07158e 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/donors/README.md b/apps/backend/lambdas/donors/README.md index f04d735..25c8dcf 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 | | -| GET | /donations | | +| GET | /donors/donations | | | POST | /donors | | ## Setup diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index d81e1b9..257fdad 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -1,164 +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 fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /donors[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/donors(?=\/|$)/, '') || '/'; - 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 === '/' || 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 53c52d1..b702bdc 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 2d717ab..6f30a4e 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", "kysely": "^0.28.8", "pg": "^8.17.2" @@ -19,7 +20,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.16.0", - "esbuild": "^0.25.12", + "esbuild": "^0.25.0", "jest": "^30.2.0", "js-yaml": "^4.1.0", "ts-jest": "^29.4.5", @@ -38,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", @@ -543,6 +553,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 diff --git a/apps/backend/lambdas/donors/package.json b/apps/backend/lambdas/donors/package.json index 252aeb2..d9f7131 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": "esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js && cd dist && zip -q ../lambda.zip handler.js" + "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", @@ -26,6 +26,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "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/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index 17c7ce1..39b4b5c 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/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index b623129..155e16d 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -1,192 +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 fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /expenditures[/{proxy+}]; strip the - // mount prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/expenditures(?=\/|$)/, '') || '/'; - 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 a42f5b9..addb117 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 3f0a570..b068c80 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", "kysely": "^0.28.8", @@ -21,7 +22,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.6", - "esbuild": "^0.25.12", + "esbuild": "^0.25.0", "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", @@ -41,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", @@ -546,6 +556,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 diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 0d101d0..9d644e5 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": "esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js && cd dist && zip -q ../lambda.zip handler.js" + "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", @@ -30,6 +30,7 @@ "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "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/Dockerfile b/apps/backend/lambdas/projects/Dockerfile index 0929283..ff00495 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/projects/README.md b/apps/backend/lambdas/projects/README.md index b50eca9..bd973c9 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/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index f6a7415..fd63249 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -1,346 +1,306 @@ -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 fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /projects[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/projects(?=\/|$)/, '') || '/'; - 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' }); - 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 result = ProjectValidationUtils.buildUpdateValues(body); - if (!result.isValid) return json(400, { message: result.error }); - const updateValues = result.values!; - - if (Object.keys(updateValues).length === 0) { - return json(400, { message: 'No valid fields provided' }); - } - - const updatedProject = await db - .updateTable("branch.projects") - .set(updateValues) - .where("project_id", "=", Number(id)) - .returning(["project_id", "name", "description", "total_budget"]) - .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' }); + 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 result = ProjectValidationUtils.buildUpdateValues(body); + if (!result.isValid) return json(400, { message: result.error }); + const updateValues = result.values!; + + if (Object.keys(updateValues).length === 0) { + return json(400, { message: 'No valid fields provided' }); + } + + const updatedProject = await db + .updateTable('branch.projects') + .set(updateValues) + .where('project_id', '=', Number(id)) + .returning(['project_id', 'name', 'description', 'total_budget']) + .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 773e206..53478f9 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 3b75873..09a70b1 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", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -19,7 +20,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.6", - "esbuild": "^0.25.12", + "esbuild": "^0.25.0", "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", @@ -39,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", @@ -555,6 +565,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 diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 6d57426..1fc2bd1 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": "esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js && cd dist && zip -q ../lambda.zip handler.js" + "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", @@ -27,6 +27,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "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/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index a959f79..5134d50 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/reports/README.md b/apps/backend/lambdas/reports/README.md index 50746b8..4ce5423 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 diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 7b01f65..06a44f1 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -1,6 +1,7 @@ import { APIGatewayProxyResult } from 'aws-lambda'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { dispatch, json, RouteCtx } from '@branch/lambda-http'; import db from './db'; import { authenticateRequest } from './auth'; import { @@ -24,263 +25,232 @@ const MIME_TYPES: Record = { type FileType = typeof ALLOWED_EXTENSIONS[number]; -export const handler = async (event: any): Promise => { +// POST /reports/generate — server generates the report file and stores it. +async function generateReport({ 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 (!ALLOWED_EXTENSIONS.includes(fileType)) { + return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.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 fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /reports[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/reports(?=\/|$)/, '') || '/'; - const normalizedPath = rawPath.replace(/\/$/, ''); - const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); - - // CORS preflight — must return 2xx or the browser blocks the request. - if (method === 'OPTIONS') { - return json(200, {}); - } + 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' }); + } - if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { - return json(200, { ok: true, timestamp: new Date().toISOString() }); - } + 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' }); + } - // >>> ROUTES-START (do not remove this marker) - // CLI-generated routes will be inserted here - - // POST /reports/generate - if ((normalizedPath === '/reports/generate' || normalizedPath === '/generate') && 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 (!ALLOWED_EXTENSIONS.includes(fileType)) { - return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.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 title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; - const record = await saveReportRecord(projectId, objectUrl, title); - - return json(201, { - ok: true, - report_id: record.report_id, - object_url: record.object_url, - report_type: record.report_type, - }); - } + const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; + const record = await saveReportRecord(projectId, objectUrl, title); + + return json(201, { + ok: true, + report_id: record.report_id, + object_url: record.object_url, + report_type: record.report_type, + }); +} + +// GET /reports/upload-url — presigned S3 PUT URL for client-side upload. +async function getUploadUrl({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + + const queryParams = event.queryStringParameters || {}; + const { fileName, projectId: projectIdStr } = queryParams; + + if (!fileName || typeof fileName !== 'string') { + return json(400, { message: 'fileName is required' }); + } + const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; + if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) { + return json(400, { message: 'Only PDF and DOCX files are supported' }); + } + if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + const projectId = parseInt(projectIdStr, 10); + + const projectExists = await db.selectFrom('branch.projects') + .where('project_id', '=', projectId) + .select('project_id') + .executeTakeFirst(); + if (!projectExists) return json(404, { message: 'Project not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to upload reports for this project' }); + } + + const key = `reports/${projectId}/${Date.now()}-${fileName}`; + const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + ContentType: MIME_TYPES[ext], + }), { expiresIn: 3600 }); - // 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 }); + const objectUrl = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`; + + return json(200, { uploadUrl, objectUrl }); +} + +// POST /reports — record a report whose file was already uploaded (via upload-url). +async function createReportRecord({ event }: RouteCtx): Promise { + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + + const { user } = authContext; + + let body: Record; + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch { + return json(400, { message: 'Invalid JSON in request body' }); + } + + const { title, projectId, objectUrl, reportType } = body; + + if (!title || typeof title !== 'string' || title.trim().length === 0) { + return json(400, { message: 'title is required' }); + } + if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) { + return json(400, { message: 'projectId must be a positive integer' }); + } + if (!objectUrl || typeof objectUrl !== 'string') { + return json(400, { message: 'objectUrl is required' }); + } + const REPORT_TYPES = ['technical', 'narrative'] as const; + type ReportType = typeof REPORT_TYPES[number]; + const resolvedReportType: ReportType = (reportType && REPORT_TYPES.includes(reportType as ReportType)) ? reportType as ReportType : 'technical'; + + const projectExists = await db.selectFrom('branch.projects') + .where('project_id', '=', projectId as number) + .select('project_id') + .executeTakeFirst(); + if (!projectExists) return json(404, { message: 'Project not found' }); + + const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin); + if (!hasAccess) { + return json(403, { message: 'You do not have access to upload reports for this project' }); + } + + const report = await db + .insertInto('branch.reports') + .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) + .returningAll() + .executeTakeFirst(); + + return json(201, report); +} + +// GET /reports — list reports, optionally filtered by project and paginated. +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' }); } - - // GET /reports/upload-url - if ((normalizedPath === '/reports/upload-url' || normalizedPath === '/upload-url') && method === 'GET') { - const authContext = await authenticateRequest(event); - if (!authContext.isAuthenticated || !authContext.user) { - return json(401, { message: 'Authentication required' }); - } - - const { user } = authContext; - - const queryParams = event.queryStringParameters || {}; - const { fileName, projectId: projectIdStr } = queryParams; - - if (!fileName || typeof fileName !== 'string') { - return json(400, { message: 'fileName is required' }); - } - const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; - if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) { - return json(400, { message: 'Only PDF and DOCX files are supported' }); - } - if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - const projectId = parseInt(projectIdStr, 10); - - const projectExists = await db.selectFrom('branch.projects') - .where('project_id', '=', projectId) - .select('project_id') - .executeTakeFirst(); - if (!projectExists) return json(404, { message: 'Project not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to upload reports for this project' }); - } - - const key = `reports/${projectId}/${Date.now()}-${fileName}`; - const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - ContentType: MIME_TYPES[ext], - }), { expiresIn: 3600 }); - - const objectUrl = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`; - - return json(200, { uploadUrl, objectUrl }); + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); } + } - // 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; - - let body: Record; - try { - body = event.body ? JSON.parse(event.body) : {}; - } catch { - return json(400, { message: 'Invalid JSON in request body' }); - } - - const { title, projectId, objectUrl, reportType } = body; - - if (!title || typeof title !== 'string' || title.trim().length === 0) { - return json(400, { message: 'title is required' }); - } - if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) { - return json(400, { message: 'projectId must be a positive integer' }); - } - if (!objectUrl || typeof objectUrl !== 'string') { - return json(400, { message: 'objectUrl is required' }); - } - const REPORT_TYPES = ['technical', 'narrative'] as const; - type ReportType = typeof REPORT_TYPES[number]; - const resolvedReportType: ReportType = (reportType && REPORT_TYPES.includes(reportType as ReportType)) ? reportType as ReportType : 'technical'; - - const projectExists = await db.selectFrom('branch.projects') - .where('project_id', '=', projectId as number) - .select('project_id') - .executeTakeFirst(); - if (!projectExists) return json(404, { message: 'Project not found' }); - - const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin); - if (!hasAccess) { - return json(403, { message: 'You do not have access to upload reports for this project' }); - } - - const report = await db - .insertInto('branch.reports') - .values({ project_id: projectId, title: (title as string).trim(), object_url: objectUrl as string, report_type: resolvedReportType }) - .returningAll() - .executeTakeFirst(); - - return json(201, report); + 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/generate', handler: generateReport }, + { method: 'GET', pattern: '/reports/upload-url', handler: getUploadUrl }, + { method: 'GET', pattern: '/reports', handler: listReports }, + { method: 'POST', pattern: '/reports', handler: createReportRecord }, + ], + }); diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index a338e5f..3c81cab 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/s3-request-presigner": "^3.1029.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,7 +28,7 @@ "@types/node": "^20.11.30", "@types/pdfmake": "^0.3.1", "@types/pg": "^8.16.0", - "esbuild": "^0.25.12", + "esbuild": "^0.25.0", "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", @@ -47,6 +48,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", @@ -1435,6 +1445,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 diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 9951c00..c788a94 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": "esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js && mkdir -p dist/fonts/Roboto && cp node_modules/pdfmake/build/fonts/Roboto/*.ttf dist/fonts/Roboto/ && cd dist && zip -qr ../lambda.zip handler.js fonts" + "package": "esbuild handler.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/handler.js --external:@aws-sdk/* && mkdir -p dist/fonts/Roboto && cp node_modules/pdfmake/build/fonts/Roboto/*.ttf dist/fonts/Roboto/ && cd dist && zip -q -r ../lambda.zip handler.js fonts" }, "devDependencies": { "@branch/types": "file:../../../../shared/types", @@ -36,6 +36,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 51c2013..1a67b5d 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/Dockerfile b/apps/backend/lambdas/users/Dockerfile index aec6cb5..0d9c222 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 diff --git a/apps/backend/lambdas/users/README.md b/apps/backend/lambdas/users/README.md index 80d9140..4cfcd7b 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 diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index e82348e..58a8c14 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,261 +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 fullPath = event.rawPath || event.path || '/'; - // API Gateway mounts this service at /users[/{proxy+}]; strip the mount - // prefix so routing below (rawPath and normalizedPath) sees the bare path. - const rawPath = fullPath.replace(/^\/users(?=\/|$)/, '') || '/'; - 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); - - // CORS preflight — must return 2xx before auth, or the browser blocks it. - if (method === 'OPTIONS') { - return json(200, {}); - } - - // 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 fa4d441..ebf7d05 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 6efa512..4da8401 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", "kysely": "^0.28.8", "pg": "^8.16.3" @@ -19,7 +20,7 @@ "@types/jest": "^30.0.0", "@types/node": "^20.11.30", "@types/pg": "^8.15.5", - "esbuild": "^0.25.12", + "esbuild": "^0.25.0", "jest": "^30.2.0", "js-yaml": "^4.1.0", "start-server-and-test": "^2.1.1", @@ -39,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", @@ -544,6 +554,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 diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 72a66e8..84d9d8a 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": "esbuild handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js && cd dist && zip -q ../lambda.zip handler.js" + "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", @@ -27,6 +27,7 @@ "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", - "pg": "^8.16.3" + "pg": "^8.16.3", + "@branch/lambda-http": "file:../../../../shared/lambda-http" } } diff --git a/infrastructure/AGENTS.md b/infrastructure/AGENTS.md index 81b328f..160f498 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. - `frontend_hosting.tf` — static frontend: private S3 bucket + CloudFront (OAC) with an SPA fallback (403/404 → `/index.html`) and an index-rewrite CloudFront Function. The Next.js app is exported (`output: 'export'`) and synced to S3 by the `frontend-deploy` workflow. - `oidc.tf` — GitHub OIDC provider + `branch-ci-plan` (read-only) / `branch-ci-apply` (write, `production` env only) roles for CI. diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index eab645c..2181b7d 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -25,10 +25,10 @@ No modules. |------|------| | [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_gateway_response.cors](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_gateway_response) | 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_integration.lambda_proxy_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 | diff --git a/infrastructure/aws/api_gateway.tf b/infrastructure/aws/api_gateway.tf index 4844cc2..76653b4 100644 --- a/infrastructure/aws/api_gateway.tf +++ b/infrastructure/aws/api_gateway.tf @@ -24,27 +24,7 @@ resource "aws_api_gateway_gateway_response" "cors" { } } -# 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 -# OPTIONS is appended to every resource so browser CORS preflights are answered. -# The frontend sends Content-Type: application/json on every call (see -# apps/frontend/src/lib/api.ts), which makes even GETs non-simple and triggers a -# preflight; the frontend (CloudFront) and this API are cross-origin. Preflight -# is routed to the proxy lambda, which short-circuits OPTIONS with 200 + -# Access-Control-Allow-Origin: * (see each handler's json() helper). -locals { - base_methods = { - auth = ["GET", "POST"] - donors = ["GET"] - expenditures = ["GET", "POST"] - projects = ["GET", "POST"] - reports = ["GET"] - users = ["GET", "POST", "DELETE", "PATCH"] - } - lambda_methods = { for svc, methods in local.base_methods : svc => concat(methods, ["OPTIONS"]) } -} - -# 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 @@ -53,59 +33,42 @@ 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 -} - -# Greedy child resource so sub-paths reach the proxy lambda. The frontend calls -# full paths like /auth/login, /projects/{id}, /users/{id}; the single-level -# resources above only match the bare /service, so without {proxy+} these hit no -# method and API Gateway returns 403 (surfaced in the browser as a CORS error). -# Each handler strips its own /service prefix before routing (see handler.ts). -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+}" + uri = aws_lambda_function.functions[each.key].invoke_arn } -# ANY on the proxy resource covers every method, including OPTIONS preflight. +# ANY on the greedy proxy (//{proxy+}). resource "aws_api_gateway_method" "lambda_proxy_any" { for_each = local.lambda_functions @@ -115,7 +78,7 @@ resource "aws_api_gateway_method" "lambda_proxy_any" { authorization = "NONE" } -resource "aws_api_gateway_integration" "lambda_proxy_integrations" { +resource "aws_api_gateway_integration" "lambda_proxy_any" { for_each = local.lambda_functions rest_api_id = aws_api_gateway_rest_api.branch_api.id @@ -143,20 +106,22 @@ 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_proxy_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 - # Force a new deployment when routing changes (e.g. the OPTIONS methods added - # for CORS, or the {proxy+} sub-path routes) — otherwise the stage keeps - # serving the old method set. + # Redeploy the stage whenever the routing surface changes. triggers = { - redeploy = sha1(jsonencode({ - methods = local.lambda_methods - proxy = tolist(local.lambda_functions) - })) + 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 { @@ -175,4 +140,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/scripts/build-shared.sh b/scripts/build-shared.sh new file mode 100755 index 0000000..b278bb2 --- /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 diff --git a/shared/lambda-http/package-lock.json b/shared/lambda-http/package-lock.json new file mode 100644 index 0000000..9335e8c --- /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 0000000..1ecb40f --- /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 0000000..8170b63 --- /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 0000000..4275cfc --- /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 0000000..73d0c80 --- /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 0000000..b4ff862 --- /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 0000000..0754a4d --- /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 0000000..e57dbd9 --- /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"] +}