diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 3f93b5f..6eb35fe 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -5,6 +5,7 @@ import { checkProjectAccess, fetchReportData, generatePdf, + generateDocx, uploadToS3, saveReportRecord, } from './report-service'; @@ -40,6 +41,11 @@ export const handler = async (event: any): Promise => { return json(400, { message: 'project_id must be a positive integer' }); } + const fileType = body.file_type ?? 'pdf'; + if (fileType !== 'pdf' && fileType !== 'docx') { + return json(400, { message: 'file_type must be "pdf" or "docx"' }); + } + const reportData = await fetchReportData(projectId); if (!reportData) { return json(404, { message: 'Project not found' }); @@ -50,17 +56,17 @@ export const handler = async (event: any): Promise => { return json(403, { message: 'You do not have access to generate reports for this project' }); } - let pdfBuffer: Buffer; + let fileBuffer: Buffer; try { - pdfBuffer = await generatePdf(reportData); + fileBuffer = fileType === 'docx' ? await generateDocx(reportData) : await generatePdf(reportData); } catch (err) { - console.error('PDF generation error:', err); - return json(500, { message: 'Failed to generate report PDF' }); + console.error('Report generation error:', err); + return json(500, { message: 'Failed to generate report' }); } let objectUrl: string; try { - objectUrl = await uploadToS3(pdfBuffer, projectId); + objectUrl = await uploadToS3(fileBuffer, projectId, fileType); } catch (err) { console.error('S3 upload error:', err); return json(500, { message: 'Failed to upload report' }); diff --git a/apps/backend/lambdas/reports/openapi.yaml b/apps/backend/lambdas/reports/openapi.yaml index 24d2e80..a14423b 100644 --- a/apps/backend/lambdas/reports/openapi.yaml +++ b/apps/backend/lambdas/reports/openapi.yaml @@ -83,12 +83,13 @@ paths: '401': description: Unauthorized post: - summary: Generate a project report PDF + summary: Generate a project report (PDF or DOCX) description: > - Generates a PDF report for the given project containing project info, - participants and roles, donations, and expenditures. Uploads the PDF to - S3 and records it in the database. Requires the caller to be a member - of the project or a global admin. + Generates a report for the given project containing project info, + participants and roles, donations, and expenditures. Supports PDF and + DOCX output via the `file_type` field (defaults to `pdf`). Uploads the + file to S3 and records it in the database. Requires the caller to be a + member of the project or a global admin. requestBody: required: true content: @@ -102,9 +103,15 @@ paths: type: integer description: The ID of the project to generate a report for example: 1 + file_type: + type: string + enum: [pdf, docx] + default: pdf + description: Output format: "pdf" (default) or "docx" + example: docx responses: '201': - description: Report generated successfully + description: Report generated and uploaded successfully content: application/json: schema: diff --git a/apps/backend/lambdas/reports/package-lock.json b/apps/backend/lambdas/reports/package-lock.json index 5ef4442..134a4d2 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-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", + "docx": "^9.5.0", "dotenv": "^16.4.7", "jest": "^30.2.0", "kysely": "^0.28.11", @@ -1347,7 +1348,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 +1361,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 +2707,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 +3147,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 +3160,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 +3228,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": { @@ -3871,11 +3872,16 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/create-require": { "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,12 +3984,41 @@ "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" } }, + "node_modules/docx": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", + "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -4615,6 +4650,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4648,6 +4692,11 @@ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -5583,6 +5632,22 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/kysely": { "version": "0.28.14", "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz", @@ -5611,6 +5676,14 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -5702,7 +5775,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": { @@ -5780,6 +5853,11 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5820,6 +5898,23 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6298,6 +6393,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6358,6 +6458,20 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6404,6 +6518,11 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -6453,6 +6572,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6596,6 +6720,14 @@ "duplexer": "~0.1.1" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6966,7 +7098,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 +7169,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", @@ -7174,6 +7306,11 @@ "which-typed-array": "^1.1.2" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", @@ -7187,7 +7324,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": { @@ -7396,6 +7533,30 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xml-js/node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -7535,7 +7696,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..104252d 100644 --- a/apps/backend/lambdas/reports/package.json +++ b/apps/backend/lambdas/reports/package.json @@ -30,6 +30,7 @@ "dotenv": "^16.4.7", "jest": "^30.2.0", "kysely": "^0.28.11", + "docx": "^9.5.0", "pdfmake": "^0.3.4", "pg": "^8.18.0" } diff --git a/apps/backend/lambdas/reports/report-service.ts b/apps/backend/lambdas/reports/report-service.ts index 616fefa..988fbc7 100644 --- a/apps/backend/lambdas/reports/report-service.ts +++ b/apps/backend/lambdas/reports/report-service.ts @@ -1,6 +1,18 @@ import db from './db'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import type { TDocumentDefinitions, Content, TableCell } from 'pdfmake/interfaces'; +import { + Document, + Packer, + Paragraph, + Table, + TableRow, + TableCell as DocxTableCell, + TextRun, + HeadingLevel, + AlignmentType, + WidthType, +} from 'docx'; import path from 'path'; // pdfmake's server-side Printer has no TS declarations; use require @@ -316,18 +328,174 @@ export async function generatePdf(data: ReportData): Promise { }); } -export async function uploadToS3(pdfBuffer: Buffer, projectId: number): Promise { +export async function generateDocx(data: ReportData): Promise { + const makeHeaderRow = (headers: string[]) => + new TableRow({ + tableHeader: true, + children: headers.map( + (h) => + new DocxTableCell({ + children: [new Paragraph({ children: [new TextRun({ text: h, bold: true })] })], + }), + ), + }); + + const makeDataRow = (cells: string[]) => + new TableRow({ + children: cells.map( + (v) => new DocxTableCell({ children: [new Paragraph({ text: v })] }), + ), + }); + + const makeTotalRow = (colSpan: number, totalCols: number, formattedTotal: string) => { + const cells: DocxTableCell[] = [ + new DocxTableCell({ + columnSpan: colSpan, + children: [new Paragraph({ children: [new TextRun({ text: 'Total', bold: true })] })], + }), + new DocxTableCell({ + children: [new Paragraph({ children: [new TextRun({ text: formattedTotal, bold: true })] })], + }), + ]; + for (let i = colSpan + 1; i < totalCols; i++) { + cells.push(new DocxTableCell({ children: [new Paragraph({})] })); + } + return new TableRow({ children: cells }); + }; + + const sectionHeading = (text: string) => + new Paragraph({ text, heading: HeadingLevel.HEADING_1 }); + + const docChildren: (Paragraph | Table)[] = []; + + // Title + docChildren.push(new Paragraph({ text: data.project.name, heading: HeadingLevel.TITLE })); + + // Subtitle + const subtitleParts: string[] = []; + if (data.project.start_date) { + subtitleParts.push(`${formatDate(data.project.start_date)} – ${formatDate(data.project.end_date)}`); + } + if (data.project.total_budget) { + subtitleParts.push(`Budget: ${formatCurrency(data.project.total_budget, data.project.currency)}`); + } + if (subtitleParts.length > 0) { + docChildren.push( + new Paragraph({ + children: [new TextRun({ text: subtitleParts.join(' | '), color: '666666', size: 22 })], + }), + ); + } + + // Description + docChildren.push(new Paragraph({})); + docChildren.push(sectionHeading('Description')); + docChildren.push(new Paragraph({ text: data.project.description })); + + // Members + docChildren.push(new Paragraph({})); + docChildren.push(sectionHeading('Project Participants')); + if (data.members.length === 0) { + docChildren.push(new Paragraph({ text: 'No participants assigned.' })); + } else { + docChildren.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + makeHeaderRow(['Name', 'Email', 'Role', 'Hours']), + ...data.members.map((m) => makeDataRow([m.name, m.email, m.role, m.hours ?? '—'])), + ], + }), + ); + } + + // Donations + docChildren.push(new Paragraph({})); + docChildren.push(sectionHeading('Donations')); + if (data.donations.length === 0) { + docChildren.push(new Paragraph({ text: 'No donations recorded.' })); + } else { + const totalDonations = data.donations.reduce((sum, d) => sum + parseFloat(d.amount), 0); + docChildren.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + makeHeaderRow(['Donor Organization', 'Contact', 'Amount', 'Date']), + ...data.donations.map((d) => + makeDataRow([ + d.organization, + d.contact_name ?? '—', + formatCurrency(d.amount, data.project.currency), + formatDate(d.donated_at), + ]), + ), + makeTotalRow(2, 4, formatCurrency(totalDonations.toString(), data.project.currency)), + ], + }), + ); + } + + // Expenditures + docChildren.push(new Paragraph({})); + docChildren.push(sectionHeading('Expenditures')); + if (data.expenditures.length === 0) { + docChildren.push(new Paragraph({ text: 'No expenditures recorded.' })); + } else { + const totalExpenses = data.expenditures.reduce((sum, e) => sum + parseFloat(e.amount), 0); + docChildren.push( + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + makeHeaderRow(['Category', 'Description', 'Amount', 'Date', 'Entered By']), + ...data.expenditures.map((e) => + makeDataRow([ + e.category ?? '—', + e.description ?? '—', + formatCurrency(e.amount, data.project.currency), + formatDate(e.spent_on), + e.entered_by_name ?? '—', + ]), + ), + makeTotalRow(2, 5, formatCurrency(totalExpenses.toString(), data.project.currency)), + ], + }), + ); + } + + // Footer + docChildren.push(new Paragraph({})); + docChildren.push( + new Paragraph({ + children: [ + new TextRun({ + text: `Generated on ${new Date().toLocaleDateString('en-US')}`, + color: '999999', + size: 16, + }), + ], + alignment: AlignmentType.CENTER, + }), + ); + + const doc = new Document({ sections: [{ children: docChildren }] }); + return Packer.toBuffer(doc); +} + +export async function uploadToS3(fileBuffer: Buffer, projectId: number, fileType: 'pdf' | 'docx'): Promise { const bucketName = getBucketName(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const key = `reports/${projectId}/${timestamp}.pdf`; + const key = `reports/${projectId}/${timestamp}.${fileType}`; + const contentType = fileType === 'docx' + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : 'application/pdf'; await s3.send( new PutObjectCommand({ Bucket: bucketName, Key: key, - Body: pdfBuffer, - ContentType: 'application/pdf', + Body: fileBuffer, + ContentType: contentType, }), ); diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index 2e46619..a1949a4 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -2,13 +2,23 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; jest.mock('../db'); jest.mock('../auth'); +jest.mock('../report-service', () => ({ + checkProjectAccess: jest.fn(), + fetchReportData: jest.fn(), + generatePdf: jest.fn(), + generateDocx: jest.fn(), + uploadToS3: jest.fn(), + saveReportRecord: jest.fn(), +})); import { handler } from '../handler'; import db from '../db'; import { authenticateRequest } from '../auth'; +import * as reportService from '../report-service'; const mockDb = db as any; const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; +const mockReportService = reportService as jest.Mocked; function getEvent(queryStringParameters?: Record) { return { @@ -41,6 +51,86 @@ const fakeReports = [ { report_id: 1, project_id: 1, object_url: 'https://s3.amazonaws.com/reports/a.pdf', date_created: new Date('2025-01-01') }, ]; +function postEvent(body: Record) { + return { + rawPath: '/', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + body: JSON.stringify(body), + }; +} + +describe('POST /reports unit tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + mockReportService.fetchReportData.mockResolvedValue({ + project: { project_id: 1, name: 'Test', description: 'Desc', total_budget: null, start_date: null, end_date: null, currency: null }, + members: [], + donations: [], + expenditures: [], + } as any); + mockReportService.checkProjectAccess.mockResolvedValue(true); + mockReportService.generatePdf.mockResolvedValue(Buffer.from('pdf') as any); + mockReportService.generateDocx.mockResolvedValue(Buffer.from('docx') as any); + mockReportService.uploadToS3.mockResolvedValue('https://s3.example.com/reports/1/ts.pdf'); + mockReportService.saveReportRecord.mockResolvedValue({ report_id: 1, object_url: 'https://s3.example.com/reports/1/ts.pdf' }); + }); + + test('400: missing project_id returns 400', async () => { + const res = await handler(postEvent({})); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('project_id'); + }); + + test('400: non-integer project_id returns 400', async () => { + const res = await handler(postEvent({ project_id: 'abc' })); + expect(res.statusCode).toBe(400); + }); + + test('400: invalid file_type returns 400', async () => { + const res = await handler(postEvent({ project_id: 1, file_type: 'xlsx' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('file_type'); + }); + + test('201: defaults to pdf when file_type is omitted', async () => { + const res = await handler(postEvent({ project_id: 1 })); + expect(res.statusCode).toBe(201); + expect(mockReportService.generatePdf).toHaveBeenCalledTimes(1); + expect(mockReportService.generateDocx).not.toHaveBeenCalled(); + expect(mockReportService.uploadToS3).toHaveBeenCalledWith(expect.any(Buffer), 1, 'pdf'); + }); + + test('201: file_type=pdf calls generatePdf', async () => { + const res = await handler(postEvent({ project_id: 1, file_type: 'pdf' })); + expect(res.statusCode).toBe(201); + expect(mockReportService.generatePdf).toHaveBeenCalledTimes(1); + expect(mockReportService.generateDocx).not.toHaveBeenCalled(); + expect(mockReportService.uploadToS3).toHaveBeenCalledWith(expect.any(Buffer), 1, 'pdf'); + }); + + test('201: file_type=docx calls generateDocx', async () => { + const res = await handler(postEvent({ project_id: 1, file_type: 'docx' })); + expect(res.statusCode).toBe(201); + expect(mockReportService.generateDocx).toHaveBeenCalledTimes(1); + expect(mockReportService.generatePdf).not.toHaveBeenCalled(); + expect(mockReportService.uploadToS3).toHaveBeenCalledWith(expect.any(Buffer), 1, 'docx'); + }); + + test('404: project not found returns 404', async () => { + mockReportService.fetchReportData.mockResolvedValue(null); + const res = await handler(postEvent({ project_id: 999 })); + expect(res.statusCode).toBe(404); + }); + + test('403: no project access returns 403', async () => { + mockReportService.checkProjectAccess.mockResolvedValue(false); + const res = await handler(postEvent({ project_id: 1 })); + expect(res.statusCode).toBe(403); + }); +}); + describe('GET /reports unit tests', () => { beforeEach(() => { jest.clearAllMocks();