diff --git a/apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql b/apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql new file mode 100644 index 00000000..8cf39f6a --- /dev/null +++ b/apps/backend/db/migrations/20260812012951_add_expenditure_admin_notes.sql @@ -0,0 +1,16 @@ +-- add_expenditure_admin_notes +-- +-- Every pending migration runs inside a SINGLE transaction, with +-- search_path = branch, public -- so table names can be unqualified, and +-- CREATE INDEX CONCURRENTLY / VACUUM will not work here. +-- +-- This migration is applied to PRODUCTION automatically when the PR merges, +-- BEFORE the new lambda code is deployed. It must be safe for the code that is +-- live right now: additive changes only. See apps/backend/db/README.md for the +-- expand/contract rules that destructive changes need. +-- +-- Forward-only: there is no rollback. Fix a mistake with a new migration, and +-- never edit a migration that has been merged -- someone has already run it. +-- Do not use IF NOT EXISTS: you want a failure, not silent drift. + +ALTER TABLE expenditures ADD COLUMN admin_notes TEXT; diff --git a/apps/backend/lambdas/expenditures/README.md b/apps/backend/lambdas/expenditures/README.md index 701dbe14..0f23e38a 100644 --- a/apps/backend/lambdas/expenditures/README.md +++ b/apps/backend/lambdas/expenditures/README.md @@ -10,7 +10,8 @@ Lambda for tracking project expenditures. |--------|------|-------------| | GET | /health | Health check | | GET | /expenditures | | -| POST | /expenditures | | +| GET | /expenditures/upload-url | | +| GET | /expenditures/{id}/receipt | | | GET | /expenditures/{id} | | | DELETE | /expenditures/{id} | | | PATCH | /expenditures/{id}/status | | diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index 10e993e6..4180ebee 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -1,8 +1,23 @@ import { APIGatewayProxyResult } from 'aws-lambda'; +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import db from './db'; import { ExpenditureValidationUtils } from './validation-utils'; import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; +const REGION = process.env.AWS_REGION ?? 'us-east-2'; +const BUCKET = process.env.REPORTS_BUCKET_NAME ?? ''; +const s3 = new S3Client({ region: REGION }); + +// Receipts are PDFs only, matching the dropzone in AddExpenseModal. +const RECEIPT_CONTENT_TYPE = 'application/pdf'; + +// Receipts live in the same bucket as reports, under their own prefix. +function receiptKeyFromUrl(objectUrl: string): string | null { + const match = objectUrl.match(/^https:\/\/[^/]+\/(receipts\/.+)$/); + return match ? decodeURIComponent(match[1]) : null; +} + function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { const authCheck = checkAuthorization(authContext, level, resourceUserId); if (!authCheck.allowed) { @@ -179,6 +194,114 @@ export const handler = async (event: any): Promise => { }); } + // GET /expenditures/upload-url — presigned PUT for a receipt PDF. + // Must be matched before GET /expenditures/{id}, which also matches one segment. + if ((normalizedPath === '/expenditures/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' }); + } + if (fileName.split('.').pop()?.toLowerCase() !== 'pdf') { + return json(400, { message: 'Only PDF receipts 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); + + // Same authorization as POST /expenditures: you may only attach a receipt + // to a project you are allowed to file an expenditure against. + 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 upload a receipt for this project' }); + } + } + + const key = `receipts/${projectId}/${Date.now()}-${fileName}`; + const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + ContentType: RECEIPT_CONTENT_TYPE, + }), { expiresIn: 3600 }); + + return json(200, { + uploadUrl, + objectUrl: `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`, + }); + } + + // GET /expenditures/{id}/receipt — presigned GET so the receipt can be read + // without the bucket being public. + const receiptSegments = normalizedPath.split('/').filter(Boolean); + if (receiptSegments.length >= 2 && receiptSegments[receiptSegments.length - 1] === 'receipt' && method === 'GET') { + const id = receiptSegments[receiptSegments.length - 2]; + if (!/^\d+$/.test(id) || parseInt(id, 10) < 1) { + return json(400, { message: 'id must be a positive integer' }); + } + + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + const { user } = authContext; + + const expenditure = await db + .selectFrom('branch.expenditures') + .where('expenditure_id', '=', Number(id)) + .selectAll() + .executeTakeFirst(); + + if (!expenditure) return json(404, { message: 'Expenditure not found' }); + + // Mirrors GET /expenditures/{id}: admin, or any membership on the project. + if (!user.isAdmin) { + const membership = await db + .selectFrom('branch.project_memberships') + .where('project_id', '=', expenditure.project_id) + .where('user_id', '=', user.userId!) + .select('role') + .executeTakeFirst(); + + if (!membership) { + return json(403, { message: 'Unable to view this receipt' }); + } + } + + if (!expenditure.receipt_url) { + return json(404, { message: 'Expenditure has no receipt' }); + } + + const key = receiptKeyFromUrl(expenditure.receipt_url); + if (!key) { + return json(422, { message: 'Receipt is not stored in the receipts bucket' }); + } + + const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + }), { expiresIn: 300 }); + + return json(200, { + downloadUrl, + fileName: key.split('/').pop(), + }); + } + // GET /expenditures/{id} if (/^\/[^\/]+$/.test(normalizedPath) && method === 'GET') { const id = normalizedPath.split('/')[1]; @@ -211,6 +334,21 @@ export const handler = async (event: any): Promise => { } } + // "Submitted By" in the review modal needs a name, not an id. + const submitter = expenditure.entered_by + ? await db + .selectFrom('branch.users') + .where('user_id', '=', expenditure.entered_by) + .select(['name']) + .executeTakeFirst() + : undefined; + + const project = await db + .selectFrom('branch.projects') + .where('project_id', '=', expenditure.project_id) + .select(['name']) + .executeTakeFirst(); + return json(200, { ok: true, route: 'GET /expenditures/{id}', @@ -218,11 +356,14 @@ export const handler = async (event: any): Promise => { body: { expenditureId: expenditure.expenditure_id, projectId: expenditure.project_id, + projectName: project?.name ?? null, enteredBy: expenditure.entered_by, + submittedByName: submitter?.name ?? null, amount: expenditure.amount, category: expenditure.category, description: expenditure.description, status: expenditure.status, + adminNotes: expenditure.admin_notes, receiptUrl: expenditure.receipt_url, spent_on: expenditure.spent_on, createdAt: expenditure.created_at, @@ -292,12 +433,16 @@ export const handler = async (event: any): Promise => { const body = event.body ? JSON.parse(event.body) as Record : {}; - // Only 'approved' or 'denied' may be set through this endpoint const statusResult = ExpenditureValidationUtils.validateApprovalStatus(body.status); if (statusResult instanceof Error) { return json(400, { message: statusResult.message }); } + const adminNotesResult = ExpenditureValidationUtils.validateAdminNotes(body.adminNotes); + if (adminNotesResult instanceof Error) { + return json(400, { message: adminNotesResult.message }); + } + // make sure expenditure exists const expenditure = await db .selectFrom('branch.expenditures') @@ -312,7 +457,11 @@ export const handler = async (event: any): Promise => { // update await db .updateTable('branch.expenditures') - .set({ status: statusResult }) + .set( + adminNotesResult === undefined + ? { status: statusResult } + : { status: statusResult, admin_notes: adminNotesResult }, + ) .where('expenditure_id', '=', Number(id)) .execute(); @@ -327,7 +476,11 @@ export const handler = async (event: any): Promise => { ok: true, route: 'PATCH /expenditures/{id}/status', pathParams: { id }, - body: { expenditureId: updated!.expenditure_id, status: updated!.status }, + body: { + expenditureId: updated!.expenditure_id, + status: updated!.status, + adminNotes: updated!.admin_notes, + }, }); } // <<< ROUTES-END diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index 3f0a570a..9c4e1788 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -8,6 +8,8 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", @@ -37,7 +39,11 @@ "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, @@ -46,6 +52,331 @@ "version": "1.0.0", "dev": true }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", + "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1108.0.tgz", + "integrity": "sha512-prdothEAFE1G8H0s0+zFGuNZdSj+Acg/siB1dFxPS181op9+hJ1GLr+b2anJch4B9a/xbWy7k8eG/enH3cNSjQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.27", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-node": "^3.972.79", + "@aws-sdk/middleware-sdk-s3": "^3.972.73", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", + "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1108.0.tgz", + "integrity": "sha512-X0lX/rlyhlpQY97cwM2Rebuuj4HRMkp1wVYjJx1DHMTUI6Fehsh3/mZOd9U2HIbp1/nSciBmoD0dkpc3uXlUfA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1629,6 +1960,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -2431,6 +2843,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -5981,7 +6399,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-detect": { diff --git a/apps/backend/lambdas/expenditures/package.json b/apps/backend/lambdas/expenditures/package.json index 0d101d06..e9f9896f 100644 --- a/apps/backend/lambdas/expenditures/package.json +++ b/apps/backend/lambdas/expenditures/package.json @@ -26,6 +26,8 @@ "typescript": "^5.4.5" }, "dependencies": { + "@aws-sdk/client-s3": "^3.995.0", + "@aws-sdk/s3-request-presigner": "^3.995.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "aws-lambda": "^1.0.7", diff --git a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts index 73b57b33..d14d8bd0 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.unit.test.ts @@ -4,6 +4,11 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; jest.mock('../db'); jest.mock('../auth'); +// Presigning must not reach AWS in unit tests. +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(async () => 'https://signed.example/url'), +})); + import { handler } from '../handler'; import db from '../db'; import { authenticateRequest, checkAuthorization } from '../auth'; @@ -90,12 +95,16 @@ const fakeExpenditure = { }; // Mocks the query chain used by the handler to fetch a single expenditure -function mockSelectExpenditure(result: any) { +function mockSelectExpenditure(result: any, name?: string) { return { where: jest.fn().mockReturnValue({ selectAll: jest.fn().mockReturnValue({ executeTakeFirst: jest.fn().mockReturnValue(result), }), + // GET /expenditures/{id} also looks up the submitter and project names. + select: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(name ? { name } : undefined), + }), }), }; } @@ -1004,4 +1013,168 @@ describe('PATCH /expenditures/{id}/status unit tests', () => { expect(res.statusCode).toBe(404); expect(JSON.parse(res.body).message).toContain('not found'); }); + + test('200: admin notes are persisted alongside the status', async () => { + const setSpy: any = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ execute: (jest.fn() as any).mockResolvedValue(undefined) }), + }); + mockDb.selectFrom.mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: (jest.fn() as any) + .mockResolvedValueOnce({ expenditure_id: 5, status: 'pending' }) + .mockResolvedValueOnce({ + expenditure_id: 5, + status: 'needs_more_info', + admin_notes: 'Need the itemised receipt', + }), + }), + }), + }); + mockDb.updateTable.mockReturnValue({ set: setSpy }); + + const res = await handler( + patchStatusEvent(5, { status: 'needs_more_info', adminNotes: 'Need the itemised receipt' }), + ); + + expect(res.statusCode).toBe(200); + expect(setSpy).toHaveBeenCalledWith({ + status: 'needs_more_info', + admin_notes: 'Need the itemised receipt', + }); + expect(JSON.parse(res.body).body.adminNotes).toBe('Need the itemised receipt'); + }); + + test('400: adminNotes present but blank', async () => { + const res = await handler(patchStatusEvent(5, { status: 'approved', adminNotes: ' ' })); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('adminNotes'); + }); +}); + +describe('GET /expenditures/upload-url unit tests', () => { + function uploadUrlEvent(queryStringParameters: Record) { + return { + rawPath: '/expenditures/upload-url', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + }); + + test('200: admin gets a presigned PUT and the object URL', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'receipt.pdf', projectId: '1' })); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.uploadUrl).toBe('https://signed.example/url'); + expect(json.objectUrl).toContain('/receipts/1/'); + expect(json.objectUrl).toContain('receipt.pdf'); + }); + + test('400: non-PDF is rejected', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'receipt.png', projectId: '1' })); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('PDF'); + }); + + test('400: missing fileName', async () => { + const res = await handler(uploadUrlEvent({ projectId: '1' })); + expect(res.statusCode).toBe(400); + }); + + test('400: invalid projectId', async () => { + const res = await handler(uploadUrlEvent({ fileName: 'receipt.pdf', projectId: 'abc' })); + expect(res.statusCode).toBe(400); + }); + + test('401: unauthenticated request', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false } as any); + + const res = await handler(uploadUrlEvent({ fileName: 'receipt.pdf', projectId: '1' })); + expect(res.statusCode).toBe(401); + }); + + test('403: non-admin without a qualifying role on the project', async () => { + mockAuthenticateRequest.mockResolvedValue(staffAuthContext); + mockDb.selectFrom.mockReturnValue(mockMembership({ role: 'Staff' })); + + const res = await handler(uploadUrlEvent({ fileName: 'receipt.pdf', projectId: '1' })); + + expect(res.statusCode).toBe(403); + }); +}); + +describe('GET /expenditures/{id}/receipt unit tests', () => { + function receiptEvent(id: string | number) { + return { + rawPath: `/expenditures/${id}/receipt`, + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + }); + + test('200: returns a presigned download URL for a stored receipt', async () => { + mockDb.selectFrom.mockReturnValue( + mockSelectExpenditure({ + ...fakeExpenditure, + receipt_url: 'https://bucket.s3.us-east-2.amazonaws.com/receipts/1/12345-receipt.pdf', + }), + ); + + const res = await handler(receiptEvent(5)); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBe('https://signed.example/url'); + expect(json.fileName).toBe('12345-receipt.pdf'); + }); + + test('404: expenditure has no receipt', async () => { + mockDb.selectFrom.mockReturnValue( + mockSelectExpenditure({ ...fakeExpenditure, receipt_url: null }), + ); + + const res = await handler(receiptEvent(5)); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toContain('no receipt'); + }); + + test('404: expenditure does not exist', async () => { + mockDb.selectFrom.mockReturnValue(mockSelectExpenditure(null)); + + const res = await handler(receiptEvent(999)); + expect(res.statusCode).toBe(404); + }); + + test('403: non-admin with no membership on the project', async () => { + mockAuthenticateRequest.mockResolvedValue(staffAuthContext); + mockDb.selectFrom + .mockReturnValueOnce(mockSelectExpenditure(fakeExpenditure)) + .mockReturnValueOnce(mockMembership(null)); + + const res = await handler(receiptEvent(5)); + + expect(res.statusCode).toBe(403); + }); + + test('401: unauthenticated request', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false } as any); + + const res = await handler(receiptEvent(5)); + expect(res.statusCode).toBe(401); + }); }); \ No newline at end of file diff --git a/apps/backend/lambdas/expenditures/validation-utils.ts b/apps/backend/lambdas/expenditures/validation-utils.ts index 7c6d5dc2..9fd78f6f 100644 --- a/apps/backend/lambdas/expenditures/validation-utils.ts +++ b/apps/backend/lambdas/expenditures/validation-utils.ts @@ -88,6 +88,18 @@ export class ExpenditureValidationUtils { return status as ExpenditureStatus; } + static validateAdminNotes(adminNotes: unknown): string | undefined | Error { + if (adminNotes === undefined || adminNotes === null) { + return undefined; + } + + if (typeof adminNotes !== 'string' || adminNotes.trim() === '') { + return new Error('adminNotes must be a non-empty string'); + } + + return adminNotes; + } + static validateReceiptUrl(receiptUrl: unknown): string | undefined | Error { if (receiptUrl === undefined || receiptUrl === null) { return undefined; @@ -140,7 +152,8 @@ export class ExpenditureValidationUtils { return status; } - const receiptUrl = this.validateReceiptUrl(body.receipt_url); + // Callers send camelCase; `receipt_url` is still accepted for older clients. + const receiptUrl = this.validateReceiptUrl(body.receiptUrl ?? body.receipt_url); if (receiptUrl instanceof Error) { return receiptUrl; } diff --git a/apps/frontend/src/app/accounts/mockUsers.ts b/apps/frontend/src/app/accounts/mockUsers.ts new file mode 100644 index 00000000..05bf53f8 --- /dev/null +++ b/apps/frontend/src/app/accounts/mockUsers.ts @@ -0,0 +1,24 @@ +import { User } from '@/types'; + +// Lives outside page.tsx because Next.js rejects non-page exports from a page +// module, and the accounts tests read these lists directly. +const mockUsers: User[] = [ + { user_id: 1, name: 'Mehana Nagarur', email: 'nagarur.m@northeastern.edu', is_admin: true }, + { user_id: 2, name: 'Alex Rivera', email: 'rivera.a@northeastern.edu', is_admin: true }, + { user_id: 3, name: 'Jordan Lee', email: 'lee.j@northeastern.edu', is_admin: true }, + { user_id: 4, name: 'Priya Sharma', email: 'sharma.p@northeastern.edu', is_admin: true }, + { user_id: 5, name: 'Chris Nguyen', email: 'nguyen.c@northeastern.edu', is_admin: true }, + { user_id: 6, name: 'Taylor Brooks', email: 'brooks.t@northeastern.edu', is_admin: false }, + { user_id: 7, name: 'Sam Patel', email: 'patel.s@northeastern.edu', is_admin: false }, + { user_id: 8, name: 'Morgan Clarke', email: 'clarke.m@northeastern.edu', is_admin: false }, + { user_id: 9, name: 'Jamie Wu', email: 'wu.j@northeastern.edu', is_admin: false }, + { user_id: 10, name: 'Riley Thompson', email: 'thompson.r@northeastern.edu',is_admin: false }, + { user_id: 11, name: 'Avery Johnson', email: 'johnson.a@northeastern.edu', is_admin: false }, + { user_id: 12, name: 'Casey Martinez', email: 'martinez.c@northeastern.edu',is_admin: false }, + { user_id: 13, name: 'Drew Hassan', email: 'hassan.d@northeastern.edu', is_admin: false }, + { user_id: 14, name: 'Quinn Okafor', email: 'okafor.q@northeastern.edu', is_admin: false }, + { user_id: 15, name: 'Blake Fernandez', email: 'fernandez.b@northeastern.edu',is_admin: false }, +]; + +export const facilitationTeam = mockUsers.filter(u => u.is_admin); +export const teamMembers = mockUsers.filter(u => !u.is_admin); diff --git a/apps/frontend/src/app/accounts/page.tsx b/apps/frontend/src/app/accounts/page.tsx index 1c5cd0f4..5157953a 100644 --- a/apps/frontend/src/app/accounts/page.tsx +++ b/apps/frontend/src/app/accounts/page.tsx @@ -2,29 +2,7 @@ import React from 'react'; import StaffCard from '../components/StaffCard'; -import { User } from '@/types'; - -const mockUsers: User[] = [ - { user_id: 1, name: 'Mehana Nagarur', email: 'nagarur.m@northeastern.edu', is_admin: true }, - { user_id: 2, name: 'Alex Rivera', email: 'rivera.a@northeastern.edu', is_admin: true }, - { user_id: 3, name: 'Jordan Lee', email: 'lee.j@northeastern.edu', is_admin: true }, - { user_id: 4, name: 'Priya Sharma', email: 'sharma.p@northeastern.edu', is_admin: true }, - { user_id: 5, name: 'Chris Nguyen', email: 'nguyen.c@northeastern.edu', is_admin: true }, - { user_id: 6, name: 'Taylor Brooks', email: 'brooks.t@northeastern.edu', is_admin: false }, - { user_id: 7, name: 'Sam Patel', email: 'patel.s@northeastern.edu', is_admin: false }, - { user_id: 8, name: 'Morgan Clarke', email: 'clarke.m@northeastern.edu', is_admin: false }, - { user_id: 9, name: 'Jamie Wu', email: 'wu.j@northeastern.edu', is_admin: false }, - { user_id: 10, name: 'Riley Thompson', email: 'thompson.r@northeastern.edu',is_admin: false }, - { user_id: 11, name: 'Avery Johnson', email: 'johnson.a@northeastern.edu', is_admin: false }, - { user_id: 12, name: 'Casey Martinez', email: 'martinez.c@northeastern.edu',is_admin: false }, - { user_id: 13, name: 'Drew Hassan', email: 'hassan.d@northeastern.edu', is_admin: false }, - { user_id: 14, name: 'Quinn Okafor', email: 'okafor.q@northeastern.edu', is_admin: false }, - { user_id: 15, name: 'Blake Fernandez', email: 'fernandez.b@northeastern.edu',is_admin: false }, -]; - -export const facilitationTeam = mockUsers.filter(u => u.is_admin); -export const teamMembers = mockUsers.filter(u => !u.is_admin); - +import { facilitationTeam, teamMembers } from './mockUsers'; export default function AccountsPage() { diff --git a/apps/frontend/src/app/components/AddExpenseModal.tsx b/apps/frontend/src/app/components/AddExpenseModal.tsx index 15dc707b..89cf7865 100644 --- a/apps/frontend/src/app/components/AddExpenseModal.tsx +++ b/apps/frontend/src/app/components/AddExpenseModal.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; import DropdownSelector from './DropdownSelector'; import { useApi } from '@/hooks/useApi'; import FileUpload from './FileUpload'; import { FiDollarSign } from 'react-icons/fi'; +import { getReceiptUploadUrl, uploadReceiptToS3 } from '@/lib/expenditures'; import { Project } from '@/types'; interface AddExpenseModalProps { open: boolean; @@ -30,6 +31,7 @@ export default function AddExpenseModal({ const [newAmount, setNewAmount] = useState(''); const [newProject, setNewProject] = useState(''); const [newFile, setNewFile] = useState(null); + const [receiptUrl, setReceiptUrl] = useState(null); const [dateError, setDateError] = useState(false); const [typeError, setTypeError] = useState(false); @@ -45,6 +47,8 @@ export default function AddExpenseModal({ setNewDescription(''); setNewAmount(''); setNewProject(''); + setNewFile(null); + setReceiptUrl(null); setDateError(false); setTypeError(false); setDescError(false); @@ -59,25 +63,41 @@ export default function AddExpenseModal({ onClose(); } + const selectedProject = projects.find((p) => p.name === newProject); + + // The receipt is stored under its project's prefix, so the project has to be + // chosen before the file can go anywhere. + const uploadReceipt = useCallback( + async (file: File, onProgress: (transferredBytes: number) => void) => { + if (!selectedProject) throw new Error('Select a project first'); + const { uploadUrl, objectUrl } = await getReceiptUploadUrl( + file.name, + selectedProject.project_id, + ); + await uploadReceiptToS3(uploadUrl, file, onProgress); + return objectUrl; + }, + [selectedProject], + ); + async function handleSubmit() { const hasDateError = !newDate.trim(); const hasTypeError = !newType.trim(); const hasDescError = !newDescription.trim(); const hasAmountError = !newAmount.trim() || isNaN(Number(newAmount)) || Number(newAmount) < 0; const hasProjectError = !newProject.trim(); - const hasFileError = !newFile; + const hasFileError = !newFile || !receiptUrl; setDateError(hasDateError); setTypeError(hasTypeError); setDescError(hasDescError); setAmountError(hasAmountError); setProjectError(hasProjectError); - setFileError(hasFileError ? 'File type not supported' : null); + setFileError(hasFileError ? 'Please upload an image of the receipt' : null); if (hasDateError || hasTypeError || hasDescError || hasAmountError || hasProjectError || hasFileError) return; - const selectedProject = projects.find((p) => p.name === newProject); if (!selectedProject) { setProjectError(true); return; @@ -90,6 +110,7 @@ export default function AddExpenseModal({ category: newType, description: newDescription, spentOn: newDate, + receiptUrl, }); resetForm(); @@ -299,8 +320,11 @@ export default function AddExpenseModal({ { + upload={uploadReceipt} + disabledReason={selectedProject ? undefined : 'Select a project first'} + onChange={(file, objectUrl) => { setNewFile(file); + setReceiptUrl(objectUrl); setFileError(null); }} onReject={() => setFileError('File type not supported')} diff --git a/apps/frontend/src/app/components/ExpenseFilterMenu.tsx b/apps/frontend/src/app/components/ExpenseFilterMenu.tsx new file mode 100644 index 00000000..2607eed4 --- /dev/null +++ b/apps/frontend/src/app/components/ExpenseFilterMenu.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { CiFilter } from 'react-icons/ci'; + +export interface FilterGroup { + key: string; + label: string; + options: { value: string; label: string }[]; + selected: string[]; + onChange: (selected: string[]) => void; +} + +interface ExpenseFilterMenuProps { + groups: FilterGroup[]; +} + +const PANEL_BORDER = '1px solid var(--color-black-200)'; + +function Checkbox({ checked }: { checked: boolean }) { + return ( + + + + + + ); +} + +export default function ExpenseFilterMenu({ groups }: ExpenseFilterMenuProps) { + const [open, setOpen] = useState(false); + const [expandedKey, setExpandedKey] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(event: MouseEvent) { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + setExpandedKey(null); + } + } + document.addEventListener('mousedown', onPointerDown); + return () => document.removeEventListener('mousedown', onPointerDown); + }, [open]); + + const expanded = groups.find((g) => g.key === expandedKey); + + function toggleOption(group: FilterGroup, value: string) { + const next = group.selected.includes(value) + ? group.selected.filter((v) => v !== value) + : [...group.selected, value]; + group.onChange(next); + } + + return ( +
+ + + {open && ( +
+
+ {groups.map((group, index) => { + const isExpanded = group.key === expandedKey; + return ( + + ); + })} +
+ + {expanded && ( +
+ {expanded.options.map((option, index) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/app/components/ExpensesTable.tsx b/apps/frontend/src/app/components/ExpensesTable.tsx index eaf134e7..3f5c693c 100644 --- a/apps/frontend/src/app/components/ExpensesTable.tsx +++ b/apps/frontend/src/app/components/ExpensesTable.tsx @@ -1,71 +1,114 @@ -import { - Table, - } from '@chakra-ui/react'; +import { Table } from '@chakra-ui/react'; import { Expenditure } from '@/types'; - - interface ExpensesTableProps { - expenditures: Expenditure[]; - showDescription?: boolean; - } - - export default function ExpensesTable({ - expenditures, - showDescription = true, - }: ExpensesTableProps) { - return ( - - - - - {showDescription && } - - - - - - -
Expense ID
-
Date
- {showDescription && ( -
Description
- )} -
Type of Expense
-
Amount
+import StatusBadge from './StatusBadge'; + +interface ExpensesTableProps { + expenditures: Expenditure[]; + /** Project detail already scopes to one project, so it hides this column. */ + showProject?: boolean; + projectNames?: Record; + onViewReceipt?: (expenditure: Expenditure) => void; + onRowClick?: (expenditure: Expenditure) => void; +} + +export default function ExpensesTable({ + expenditures, + showProject = true, + projectNames = {}, + onViewReceipt, + onRowClick, +}: ExpensesTableProps) { + const columnCount = showProject ? 7 : 6; + + return ( + + + + + + {showProject && } + + + + + + + +
Expense ID
+
Date
+
Type of Expense
+ {showProject && ( +
Project
+ )} +
Amount
+
Receipt
+
Status
+
+
+ + + {expenditures.length === 0 ? ( + + + No expenditures found. + -
- - - {expenditures.length === 0 ? ( - - - No expenditures found. + ) : ( + expenditures.map((e) => ( + onRowClick(e) : undefined} + style={onRowClick ? { cursor: 'pointer' } : undefined} + > + #{String(e.expenditure_id).padStart(6, '0')} + + {new Date(e.spent_on).toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + })} - - ) : ( - expenditures.map((e) => ( - - #{String(e.expenditure_id).padStart(6, '0')} - - {new Date(e.spent_on).toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - year: 'numeric', - })} - - {showDescription && ( - {e.description ?? '—'} + {e.category ?? '—'} + {showProject && ( + {projectNames[e.project_id] ?? '---'} + )} + + ${parseFloat(e.amount).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + {e.receipt_url ? ( + + ) : ( + '---' )} - {e.category ?? '—'} - - ${parseFloat(e.amount).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - - - )) - )} - -
- ); - } \ No newline at end of file + + + + + + )) + )} + + + ); +} diff --git a/apps/frontend/src/app/components/FileUpload.tsx b/apps/frontend/src/app/components/FileUpload.tsx index d83390de..33040e31 100644 --- a/apps/frontend/src/app/components/FileUpload.tsx +++ b/apps/frontend/src/app/components/FileUpload.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { FileRejection, useDropzone } from 'react-dropzone'; import UploadProgressBar from './UploadProgressBar'; import FilePreview from './FilePreview'; @@ -8,55 +8,54 @@ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB interface FileUploadProps { value: File | null; - onChange: (file: File | null) => void; + /** Called with the stored object URL once the upload lands, or null on clear. */ + onChange: (file: File | null, objectUrl: string | null) => void; onReject?: () => void; + /** Performs the upload and resolves to the stored object URL. */ + upload: (file: File, onProgress: (transferredBytes: number) => void) => Promise; + /** When set, the dropzone is inert and explains why. */ + disabledReason?: string; } -export default function FileUpload({ value, onChange, onReject }: FileUploadProps) { +export default function FileUpload({ value, onChange, onReject, upload, disabledReason }: FileUploadProps) { const [isUploading, setIsUploading] = useState(false); const [transferredBytes, setTransferredBytes] = useState(0); const [pendingFile, setPendingFile] = useState(null); - const intervalRef = useRef | null>(null); + const [uploadFailed, setUploadFailed] = useState(false); - {/*Simulated progress for testing - TODO: update to use real progress of uploaded file */} - const simulateUpload = useCallback( - (file: File) => { + const startUpload = useCallback( + async (file: File) => { setPendingFile(file); setIsUploading(true); + setUploadFailed(false); setTransferredBytes(0); - const total = file.size; - const step = total / 15; // ~15 ticks to finish - let transferred = 0; - - intervalRef.current = setInterval(() => { - transferred += step; - if (transferred >= total) { - transferred = total; - if (intervalRef.current) clearInterval(intervalRef.current); - setTransferredBytes(total); + try { + const objectUrl = await upload(file, setTransferredBytes); + setTransferredBytes(file.size); + onChange(file, objectUrl); + } catch { + setUploadFailed(true); + onChange(null, null); + } finally { setIsUploading(false); setPendingFile(null); - onChange(file); // flip to selected state - } else { - setTransferredBytes(transferred); - } - }, 100); + } }, - [onChange], + [onChange, upload], ); {/*When the file is dropped, check if it's accepted*/} const onDrop = useCallback( (accepted: File[], rejections: FileRejection[]) => { if (rejections.length > 0) { + setUploadFailed(false); onReject?.(); return; } - if (accepted[0]) simulateUpload(accepted[0]); + if (accepted[0]) startUpload(accepted[0]); }, - [simulateUpload, onReject], + [startUpload, onReject], ); {/*Dropzone component to allow user to drop in files*/} @@ -66,12 +65,14 @@ export default function FileUpload({ value, onChange, onReject }: FileUploadProp maxSize: MAX_FILE_SIZE_BYTES, maxFiles: 1, multiple: false, - disabled: isUploading, + disabled: isUploading || Boolean(disabledReason), }); const borderColor = isDragActive ? 'var(--color-core-green)' - : 'var(--color-black-200)'; + : uploadFailed + ? 'var(--color-error-red)' + : 'var(--color-black-200)'; if (isUploading && pendingFile) { return ( @@ -90,7 +91,7 @@ export default function FileUpload({ value, onChange, onReject }: FileUploadProp onChange(null)} + onRemove={() => onChange(null, null)} onReplace={open} /> @@ -105,7 +106,8 @@ export default function FileUpload({ value, onChange, onReject }: FileUploadProp border: `1px dashed ${borderColor}`, borderRadius: '6px', padding: '32px 16px', - cursor: 'pointer', + cursor: disabledReason ? 'not-allowed' : 'pointer', + opacity: disabledReason ? 0.6 : 1, display: 'flex', flexDirection: 'column', alignItems: 'center', @@ -127,9 +129,14 @@ export default function FileUpload({ value, onChange, onReject }: FileUploadProp

- PDF only + {disabledReason ?? 'PDF only'}

- + + {uploadFailed && ( +

+ File failed to upload +

+ )} ); -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/components/Navbar.tsx b/apps/frontend/src/app/components/Navbar.tsx index b54fd96d..331cac6b 100644 --- a/apps/frontend/src/app/components/Navbar.tsx +++ b/apps/frontend/src/app/components/Navbar.tsx @@ -24,7 +24,7 @@ const NAV_ITEMS: NavItem[] = [ { label: "Projects", href: "/projects" }, { label: "Donors", href: "/donors" }, { label: "Donations", href: "/donations" }, - { label: "Expenses", href: "/expenses", roles: ["admin"] }, + { label: "Expenses", href: "/expenses" }, { label: "Reports", href: "/reports", roles: ["admin"] }, { label: "Accounts", href: "/accounts", roles: ["admin"] }, { label: "Log Out", action: "logout" }, diff --git a/apps/frontend/src/app/components/ReviewExpenseModal.tsx b/apps/frontend/src/app/components/ReviewExpenseModal.tsx new file mode 100644 index 00000000..4b524157 --- /dev/null +++ b/apps/frontend/src/app/components/ReviewExpenseModal.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button, Dialog, Portal, CloseButton } from '@chakra-ui/react'; +import { useAuth } from '@/context/AuthContext'; +import { + getExpenditure, + getReceiptDownloadUrl, + reviewExpenditure, +} from '@/lib/expenditures'; +import { + EXPENDITURE_STATUSES, + type ExpenditureDetail, + type ExpenditureStatus, +} from '@/types'; +import StatusBadge from './StatusBadge'; + +interface ReviewExpenseModalProps { + expenditureId: number | null; + open: boolean; + onClose: () => void; + onReviewed: () => void; +} + +const LABEL_STYLE: React.CSSProperties = { + fontFamily: 'var(--font-body)', + fontWeight: 700, + fontSize: '16px', + color: 'var(--color-core-black)', +}; + +const VALUE_STYLE: React.CSSProperties = { + fontFamily: 'var(--font-body)', + fontSize: '16px', + color: 'var(--color-core-black)', +}; + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function formatDate(value: string | null): string { + if (!value) return '---'; + return new Date(value).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +export default function ReviewExpenseModal({ + expenditureId, + open, + onClose, + onReviewed, +}: ReviewExpenseModalProps) { + const { isAdmin } = useAuth(); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + + const [decision, setDecision] = useState(null); + const [adminNotes, setAdminNotes] = useState(''); + const [decisionError, setDecisionError] = useState(false); + const [notesError, setNotesError] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + useEffect(() => { + if (!open || expenditureId === null) return; + + let cancelled = false; + setLoading(true); + setLoadError(null); + setSaveError(null); + setDecisionError(false); + setNotesError(false); + + getExpenditure(expenditureId) + .then((data) => { + if (cancelled) return; + setDetail(data); + setDecision(data.status); + setAdminNotes(data.adminNotes ?? ''); + }) + .catch((err) => { + if (cancelled) return; + setLoadError(err instanceof Error ? err.message : 'Failed to load expense'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, expenditureId]); + + async function openReceipt() { + if (expenditureId === null) return; + try { + const { downloadUrl } = await getReceiptDownloadUrl(expenditureId); + window.open(downloadUrl, '_blank', 'noopener,noreferrer'); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to open receipt'); + } + } + + async function handleSave() { + if (expenditureId === null) return; + + const hasDecisionError = decision === null; + const hasNotesError = !adminNotes.trim(); + setDecisionError(hasDecisionError); + setNotesError(hasNotesError); + if (hasDecisionError || hasNotesError) return; + + setSaving(true); + setSaveError(null); + try { + await reviewExpenditure(expenditureId, decision, adminNotes.trim()); + onReviewed(); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save changes'); + } finally { + setSaving(false); + } + } + + return ( + { if (!e.open) onClose(); }}> + + + + + + + Review Expense + + + + + + {loading &&

Loading expense...

} + {loadError &&

{loadError}

} + + {!loading && !loadError && detail && ( +
+ {formatDate(detail.spent_on)} + {detail.category ?? '---'} + {detail.submittedByName ?? '---'} + {detail.description ?? '---'} + + ${parseFloat(detail.amount).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + +
+ View Receipt: + {detail.receiptUrl ? ( +
+ + {detail.receiptUrl.split('/').pop()} + + + + + +
+ ) : ( + --- + )} +
+ + {/* Admin decision and notes are admin-only; everyone else sees + the expense read-only. */} + {isAdmin ? ( + <> +
+ Admin Decision* +
+ {EXPENDITURE_STATUSES.map((status) => ( + { + setDecision(status); + setDecisionError(false); + }} + /> + ))} +
+ {decisionError && ( + + Select a decision + + )} +
+ +
+ +