diff --git a/apps/backend/.env.example b/apps/backend/.env.example index e30a65f..e3b0ff6 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -15,3 +15,13 @@ DONORS_PORT=3003 EXPENDITURES_PORT=3004 REPORTS_PORT=3005 AUTH_PORT=3006 + +# Cognito Configuration +COGNITO_CLIENT_ID=secret +COGNITO_USER_POOL_ID=secret + +# AWS Configuration +S3_BUCKET_NAME=name +AWS_ACCESS_KEY_ID=key +AWS_SECRET_ACCESS_KEY=secret +AWS_REGION=region or us-east-2 \ No newline at end of file diff --git a/apps/backend/db/db_setup.sql b/apps/backend/db/db_setup.sql index bd78f6c..7d10696 100644 --- a/apps/backend/db/db_setup.sql +++ b/apps/backend/db/db_setup.sql @@ -63,6 +63,7 @@ CREATE TABLE expenditures ( CREATE TABLE reports ( report_id SERIAL PRIMARY KEY, project_id INT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, object_url TEXT NOT NULL, date_created DATE NOT NULL DEFAULT CURRENT_DATE ); @@ -98,9 +99,9 @@ INSERT INTO expenditures (project_id, entered_by, amount, category, description, (2, 2, 3000, 'Equipment', 'Purchase of recording devices', '2025-04-05'), (3, 3, 2500, 'Supplies', 'Educational materials', '2025-07-12'); -INSERT INTO reports (project_id, object_url) VALUES -(1, 'https://s3.amazonaws.com/branch-reports/clinician_communication_study_report.pdf'), -(2, 'https://s3.amazonaws.com/branch-reports/health_education_initiative_report.pdf'), -(3, 'https://s3.amazonaws.com/branch-reports/policy_advocacy_program_report.pdf'), -(2, 'https://s3.amazonaws.com/branch-reports/research_program_reports.pdf'), -(3, 'https://s3.amazonaws.com/branch-reports/health_care_data_reports.pdf'); +INSERT INTO reports (project_id, title, object_url) VALUES +(1, 'Clinician Communication Study Report', 'https://s3.amazonaws.com/branch-reports/clinician_communication_study_report.pdf'), +(2, 'Health Education Initiative Report', 'https://s3.amazonaws.com/branch-reports/health_education_initiative_report.pdf'), +(3, 'Policy Advocacy Program Report', 'https://s3.amazonaws.com/branch-reports/policy_advocacy_program_report.pdf'), +(2, 'Research Program Reports', 'https://s3.amazonaws.com/branch-reports/research_program_reports.pdf'), +(3, 'Health Care Data Reports', 'https://s3.amazonaws.com/branch-reports/health_care_data_reports.pdf'); diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index a46d91f..4bfd711 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -113,6 +113,8 @@ services: DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} REPORTS_BUCKET_NAME: ${REPORTS_BUCKET_NAME:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} AWS_REGION: ${AWS_REGION:-us-east-2} COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} diff --git a/apps/backend/lambdas/projects/jest.config.js b/apps/backend/lambdas/projects/jest.config.js index 6bb00ee..69a1f4c 100644 --- a/apps/backend/lambdas/projects/jest.config.js +++ b/apps/backend/lambdas/projects/jest.config.js @@ -3,4 +3,5 @@ module.exports = { testEnvironment: 'node', testMatch: ['**/*.test.ts'], globals: { 'ts-jest': { isolatedModules: true } }, + maxWorkers: 1, }; \ No newline at end of file diff --git a/apps/backend/lambdas/reports/README.md b/apps/backend/lambdas/reports/README.md index 4850c84..50746b8 100644 --- a/apps/backend/lambdas/reports/README.md +++ b/apps/backend/lambdas/reports/README.md @@ -9,8 +9,10 @@ TODO: Add a description of the reports lambda. | Method | Path | Description | |--------|------|-------------| | GET | /health | Health check | -| POST | /reports | | +| POST | /reports/generate | | | GET | /reports | | +| GET | /reports/upload-url | | +| POST | /reports | | ## Setup diff --git a/apps/backend/lambdas/reports/db-types.d.ts b/apps/backend/lambdas/reports/db-types.d.ts index f5ce775..9b5c6d4 100644 --- a/apps/backend/lambdas/reports/db-types.d.ts +++ b/apps/backend/lambdas/reports/db-types.d.ts @@ -65,6 +65,7 @@ export interface BranchReports { object_url: string; project_id: number; report_id: Generated; + title: string; } export interface BranchUsers { diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 3f93b5f..36a260c 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -1,4 +1,6 @@ import { APIGatewayProxyResult } from 'aws-lambda'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import db from './db'; import { authenticateRequest } from './auth'; import { @@ -9,6 +11,16 @@ import { saveReportRecord, } from './report-service'; +const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); +const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; +const REGION = process.env.AWS_REGION ?? 'us-east-2'; + +const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const; +const MIME_TYPES: Record = { + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +}; + export const handler = async (event: any): Promise => { try { const rawPath = event.rawPath || event.path || '/'; @@ -22,8 +34,8 @@ export const handler = async (event: any): Promise => { // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here - // POST /reports - if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') { + // POST /reports/generate + if (normalizedPath === '/reports/generate' && method === 'POST') { const authContext = await authenticateRequest(event); if (!authContext.isAuthenticated || !authContext.user) { return json(401, { message: 'Authentication required' }); @@ -66,7 +78,8 @@ export const handler = async (event: any): Promise => { return json(500, { message: 'Failed to upload report' }); } - const record = await saveReportRecord(projectId, objectUrl); + 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, @@ -135,7 +148,102 @@ export const handler = async (event: any): Promise => { return json(200, { data: reports }); } - // <<< ROUTES-END + + // GET /reports/upload-url + if (normalizedPath === '/reports/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 }); + } + + // 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 } = 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 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.trim(), object_url: objectUrl as string }) + .returningAll() + .executeTakeFirst(); + + return json(201, report); + } + // <<< ROUTES-END return json(404, { message: 'Not Found', path: normalizedPath, method }); } catch (err) { diff --git a/apps/backend/lambdas/reports/openapi.yaml b/apps/backend/lambdas/reports/openapi.yaml index 24d2e80..3b06bc5 100644 --- a/apps/backend/lambdas/reports/openapi.yaml +++ b/apps/backend/lambdas/reports/openapi.yaml @@ -83,7 +83,81 @@ paths: '401': description: Unauthorized post: - summary: Generate a project report PDF + summary: POST /reports — save a manually uploaded report + description: > + Creates a report record using the S3 object URL obtained from + GET /reports/upload-url after the client has uploaded the file directly + to S3. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title + - projectId + - objectUrl + properties: + title: + type: string + projectId: + type: integer + minimum: 1 + objectUrl: + type: string + description: S3 object URL returned by GET /reports/upload-url + responses: + '201': + description: Report created + '400': + description: Validation error + '401': + description: Unauthorized + '404': + description: Project not found + + /reports/upload-url: + get: + summary: Get a pre-signed S3 URL for uploading a report file + parameters: + - in: query + name: fileName + required: true + schema: + type: string + description: File name with extension (pdf or docx) + - in: query + name: projectId + required: true + schema: + type: integer + minimum: 1 + description: Project the report belongs to + responses: + '200': + description: Pre-signed upload URL and final object URL + content: + application/json: + schema: + type: object + properties: + uploadUrl: + type: string + description: Pre-signed S3 PUT URL (expires in 1 hour) + objectUrl: + type: string + description: Permanent S3 URL to pass to POST /reports + '400': + description: Invalid fileName or projectId + '401': + description: Unauthorized + '404': + description: Project not found + + /reports/generate: + post: + summary: Auto-generate a PDF report from project data description: > Generates a PDF report for the given project containing project info, participants and roles, donations, and expenditures. Uploads the PDF to @@ -126,6 +200,7 @@ paths: description: Project not found '500': description: Internal server error + components: securitySchemes: BearerAuth: diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index 5ef4442..21fed38 100644 --- a/apps/backend/lambdas/reports/package-lock.json +++ b/apps/backend/lambdas/reports/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/s3-request-presigner": "^3.1029.0", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "dotenv": "^16.4.7", @@ -734,6 +735,24 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1029.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1029.0.tgz", + "integrity": "sha512-YbHPaha4DYgJWdPorGV5ZSCCqHafGj4GiyqXmXFlCJSsqlOd3xEcemhOZGjrB9epdiVEUtB3DDJXGYYj55ITdQ==", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "^3.996.16", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-format-url": "^3.972.9", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.16", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz", @@ -810,6 +829,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz", + "integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", @@ -1347,7 +1380,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -1360,7 +1393,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -2706,28 +2739,28 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -3146,7 +3179,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3159,7 +3192,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -3227,7 +3260,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -3875,7 +3908,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -3978,7 +4011,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -5702,7 +5735,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -6966,7 +6999,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -7037,7 +7070,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7187,7 +7220,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -7535,7 +7568,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/apps/backend/lambdas/reports/package.json b/apps/backend/lambdas/reports/package.json index 5d696be..5ebf7a2 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/s3-request-presigner": "^3.1029.0", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", "dotenv": "^16.4.7", diff --git a/apps/backend/lambdas/reports/report-service.ts b/apps/backend/lambdas/reports/report-service.ts index 616fefa..0c21583 100644 --- a/apps/backend/lambdas/reports/report-service.ts +++ b/apps/backend/lambdas/reports/report-service.ts @@ -337,12 +337,14 @@ export async function uploadToS3(pdfBuffer: Buffer, projectId: number): Promise< export async function saveReportRecord( projectId: number, objectUrl: string, + title: string, ): Promise<{ report_id: number; object_url: string }> { const row = await db .insertInto('branch.reports') .values({ project_id: projectId, object_url: objectUrl, + title, }) .returning(['report_id', 'object_url']) .executeTakeFirstOrThrow(); diff --git a/apps/backend/lambdas/reports/test/reports.e2e.test.ts b/apps/backend/lambdas/reports/test/reports.e2e.test.ts index d5cc974..56058e5 100644 --- a/apps/backend/lambdas/reports/test/reports.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/reports.e2e.test.ts @@ -4,6 +4,15 @@ import path from 'path'; import { Pool } from 'pg'; jest.mock('../auth'); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockReturnValue({} as any), + })), + PutObjectCommand: jest.fn().mockImplementation((params: unknown) => params), +})); +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn().mockReturnValue('https://presigned.example.com/upload' as any), +})); import { handler } from '../handler'; import { authenticateRequest } from '../auth'; @@ -187,4 +196,141 @@ describe('Reports e2e tests', () => { expect(res.statusCode).toBe(400); }); }); + + describe('GET /reports/upload-url', () => { + function uploadUrlEvent(queryStringParameters?: Record) { + return { + rawPath: '/reports/upload-url', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: queryStringParameters ?? {}, + }; + } + + test('200: returns uploadUrl and objectUrl for pdf', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'report.pdf', projectId: '1' })); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.uploadUrl).toBe('https://presigned.example.com/upload'); + expect(body.objectUrl).toContain('report.pdf'); + expect(body.objectUrl).toContain('reports/1/'); + }); + + test('200: returns uploadUrl and objectUrl for docx', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'doc.docx', projectId: '2' })); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.uploadUrl).toBeDefined(); + expect(body.objectUrl).toContain('doc.docx'); + }); + + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: '1' })); + expect(res.statusCode).toBe(401); + }); + + test('404: non-existent projectId returns 404', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: '99999' })); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Project not found'); + }); + + test('400: missing fileName returns 400', async () => { + const res = await handler(uploadUrlEvent({ projectId: '1' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('fileName is required'); + }); + + test('400: unsupported file extension returns 400', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.jpg', projectId: '1' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Only PDF and DOCX files are supported'); + }); + + test('400: missing projectId returns 400', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + }); + + describe('POST /reports', () => { + const fakeObjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/123-report.pdf'; + + function postEvent(body: unknown) { + return { + rawPath: '/reports', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + body: JSON.stringify(body), + }; + } + + test('201: creates a new report and persists to db', async () => { + const res = await handler(postEvent({ title: 'New Report', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.report_id).toBeDefined(); + expect(body.title).toBe('New Report'); + expect(body.project_id).toBe(1); + expect(body.object_url).toBe(fakeObjectUrl); + }); + + test('201: created report appears in subsequent GET /reports', async () => { + await handler(postEvent({ title: 'Verify Report', projectId: 2, objectUrl: fakeObjectUrl })); + const getRes = await handler(getEvent()); + const getBody = JSON.parse(getRes.body); + expect(getBody.data.some((r: any) => r.title === 'Verify Report')).toBe(true); + }); + + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(postEvent({ title: 'T', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(401); + }); + + test('404: non-existent projectId returns 404', async () => { + const res = await handler(postEvent({ title: 'T', projectId: 99999, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Project not found'); + }); + + test('400: missing title returns 400', async () => { + const res = await handler(postEvent({ projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('title is required'); + }); + + test('400: empty title returns 400', async () => { + const res = await handler(postEvent({ title: ' ', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('title is required'); + }); + + test('400: missing projectId returns 400', async () => { + const res = await handler(postEvent({ title: 'T', objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + + test('400: missing objectUrl returns 400', async () => { + const res = await handler(postEvent({ title: 'T', projectId: 1 })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('objectUrl is required'); + }); + + test('400: invalid JSON body returns 400', async () => { + const res = await handler({ + rawPath: '/reports', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + body: 'not json', + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Invalid JSON in request body'); + }); + }); }); diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index 2e46619..9f67db8 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -2,10 +2,29 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; jest.mock('../db'); jest.mock('../auth'); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockReturnValue({} as any), + })), + PutObjectCommand: jest.fn().mockImplementation((params: unknown) => params), +})); +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn().mockReturnValue('https://presigned.example.com/upload' as any), +})); +jest.mock('../report-service', () => ({ + checkProjectAccess: jest.fn(), + fetchReportData: jest.fn(), + generatePdf: jest.fn(), + uploadToS3: jest.fn(), + saveReportRecord: jest.fn(), +})); import { handler } from '../handler'; import db from '../db'; import { authenticateRequest } from '../auth'; +import { checkProjectAccess } from '../report-service'; + +const mockCheckProjectAccess = checkProjectAccess as jest.MockedFunction; const mockDb = db as any; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; @@ -204,3 +223,251 @@ describe('GET /reports unit tests', () => { }); }); }); + +describe('GET /reports/upload-url unit tests', () => { + function uploadUrlEvent(queryStringParameters?: Record) { + return { + rawPath: '/reports/upload-url', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: queryStringParameters ?? {}, + }; + } + + function setupProjectMock(project: Record | undefined) { + mockDb.selectFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(project as any), + }), + }), + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + mockCheckProjectAccess.mockReturnValue(true as any); + setupProjectMock({ project_id: 1 }); + }); + + describe('Authentication', () => { + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: '1' })); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toBe('Authentication required'); + }); + }); + + describe('Validation', () => { + test('400: missing fileName returns 400', async () => { + const res = await handler(uploadUrlEvent({ projectId: '1' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('fileName is required'); + }); + + test('400: unsupported file extension returns 400', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.txt', projectId: '1' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Only PDF and DOCX files are supported'); + }); + + test('400: missing projectId returns 400', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + + test('400: projectId=0 returns 400', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: '0' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + + test('400: non-integer projectId returns 400', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: 'abc' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + }); + + describe('Business logic', () => { + test('404: project not found returns 404', async () => { + setupProjectMock(undefined); + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: '999' })); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Project not found'); + }); + + test('403: user has no project access returns 403', async () => { + mockCheckProjectAccess.mockReturnValue(false as any); + const res = await handler(uploadUrlEvent({ fileName: 'f.pdf', projectId: '1' })); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('You do not have access to upload reports for this project'); + }); + + test('200: returns uploadUrl and objectUrl for pdf', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'report.pdf', projectId: '1' })); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.uploadUrl).toBe('https://presigned.example.com/upload'); + expect(body.objectUrl).toContain('report.pdf'); + expect(body.objectUrl).toContain('reports/1/'); + }); + + test('200: returns uploadUrl and objectUrl for docx', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'doc.docx', projectId: '2' })); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.uploadUrl).toBeDefined(); + expect(body.objectUrl).toContain('doc.docx'); + }); + }); +}); + +describe('POST /reports unit tests', () => { + const fakeObjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/123-report.pdf'; + + function postEvent(body: unknown) { + return { + rawPath: '/reports', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + body: JSON.stringify(body), + }; + } + + function setupInsertMock(report: Record) { + mockDb.insertInto = jest.fn().mockReturnValue({ + values: jest.fn().mockReturnValue({ + returningAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(report as any), + }), + }), + }); + } + + function setupProjectMock(project: Record | undefined) { + mockDb.selectFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(project as any), + }), + }), + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + mockCheckProjectAccess.mockReturnValue(true as any); + setupProjectMock({ project_id: 1 }); + }); + + describe('Authentication', () => { + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(postEvent({ title: 'T', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toBe('Authentication required'); + }); + }); + + describe('Validation', () => { + test('400: invalid JSON body returns 400', async () => { + const res = await handler({ + rawPath: '/reports', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + body: 'not json', + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Invalid JSON in request body'); + }); + + test('400: missing title returns 400', async () => { + const res = await handler(postEvent({ projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('title is required'); + }); + + test('400: empty title returns 400', async () => { + const res = await handler(postEvent({ title: ' ', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('title is required'); + }); + + test('400: missing projectId returns 400', async () => { + const res = await handler(postEvent({ title: 'T', objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + + test('400: string projectId returns 400', async () => { + const res = await handler(postEvent({ title: 'T', projectId: 'abc', objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + + test('400: projectId=0 returns 400', async () => { + const res = await handler(postEvent({ title: 'T', projectId: 0, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); + }); + + test('400: missing objectUrl returns 400', async () => { + const res = await handler(postEvent({ title: 'T', projectId: 1 })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('objectUrl is required'); + }); + }); + + describe('Business logic', () => { + test('403: user has no project access returns 403', async () => { + mockCheckProjectAccess.mockReturnValue(false as any); + const res = await handler(postEvent({ title: 'T', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('You do not have access to upload reports for this project'); + }); + + test('404: nonexistent project returns 404', async () => { + setupProjectMock(undefined); + const res = await handler(postEvent({ title: 'T', projectId: 999, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Project not found'); + }); + + test('201: creates report and returns created report', async () => { + const fakeReport = { report_id: 10, project_id: 1, title: 'My Report', object_url: fakeObjectUrl, date_created: new Date('2025-01-01') }; + setupInsertMock(fakeReport); + + const res = await handler(postEvent({ title: 'My Report', projectId: 1, objectUrl: fakeObjectUrl })); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.report_id).toBe(10); + expect(body.title).toBe('My Report'); + expect(body.project_id).toBe(1); + expect(body.object_url).toBe(fakeObjectUrl); + }); + + test('201: title is trimmed before inserting', async () => { + let capturedValues: Record = {}; + mockDb.insertInto = jest.fn().mockReturnValue({ + values: jest.fn().mockImplementation((vals: any) => { + capturedValues = vals; + return { + returningAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue({ report_id: 1, project_id: 1, title: vals.title, object_url: fakeObjectUrl, date_created: new Date() } as any), + }), + }; + }), + }); + + await handler(postEvent({ title: ' My Report ', projectId: 1, objectUrl: fakeObjectUrl })); + expect(capturedValues.title).toBe('My Report'); + }); + }); +});