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
+
+ )}
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ {saveError && (
+
{saveError}
+ )}
+
+ )}
+
+
+
+
+ {isAdmin && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/app/components/StatusBadge.tsx b/apps/frontend/src/app/components/StatusBadge.tsx
new file mode 100644
index 00000000..212e271f
--- /dev/null
+++ b/apps/frontend/src/app/components/StatusBadge.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { EXPENDITURE_STATUS_LABELS, type ExpenditureStatus } from '@/types';
+
+const STATUS_COLORS: Record = {
+ approved: 'var(--color-accent-light-green)',
+ pending: 'var(--color-status-pending)',
+ needs_more_info: 'var(--color-error-light-red)',
+};
+
+/**
+ * The DB check constraint still permits the legacy `denied` value, so rows
+ * written before this flow existed must render as something rather than an
+ * empty pill.
+ */
+function describe(status: ExpenditureStatus) {
+ return {
+ color: STATUS_COLORS[status] ?? 'var(--color-black-200)',
+ label: EXPENDITURE_STATUS_LABELS[status] ?? String(status).replace(/_/g, ' '),
+ };
+}
+
+interface StatusBadgeProps {
+ status: ExpenditureStatus;
+ /** Renders as a button when the badge is a choice, e.g. Admin Decision. */
+ onClick?: () => void;
+ selected?: boolean;
+}
+
+export default function StatusBadge({ status, onClick, selected }: StatusBadgeProps) {
+ const { color, label } = describe(status);
+
+ const style: React.CSSProperties = {
+ backgroundColor: color,
+ color: 'var(--color-core-black)',
+ fontFamily: 'var(--font-body)',
+ fontSize: '16px',
+ width: '81px',
+ height: '29px',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ whiteSpace: 'nowrap',
+ };
+
+ if (!onClick) {
+ return {label};
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx
index e6432961..e56dcda4 100644
--- a/apps/frontend/src/app/expenses/page.tsx
+++ b/apps/frontend/src/app/expenses/page.tsx
@@ -11,12 +11,20 @@ import {
Button,
} from '@chakra-ui/react';
import DropdownSelector from '../components/DropdownSelector';
+import ExpenseFilterMenu, { type FilterGroup } from '../components/ExpenseFilterMenu';
+import ReviewExpenseModal from '../components/ReviewExpenseModal';
import { useApi } from '@/hooks/useApi';
-import { CiFilter } from 'react-icons/ci';
import { LuArrowDownUp } from 'react-icons/lu';
import { FaPlus } from 'react-icons/fa';
+import { IoClose } from 'react-icons/io5';
import ExpensesTable from '../components/ExpensesTable';
-import { Expenditure, Project } from '@/types';
+import { getReceiptDownloadUrl } from '@/lib/expenditures';
+import {
+ EXPENDITURE_STATUSES,
+ EXPENDITURE_STATUS_LABELS,
+ Expenditure,
+ Project,
+} from '@/types';
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
@@ -26,7 +34,8 @@ const MONTHS = [
const SORT_OPTIONS = ['Amount', 'Date'];
const ROWS_PER_PAGE = 10;
-export const EXPENSE_CATEGORIES = [
+// Not exported: Next.js rejects non-page exports from a page module.
+const EXPENSE_CATEGORIES = [
'General',
'Travel',
'Travel Foreign',
@@ -56,6 +65,7 @@ function ExpensePageContent() {
months: [] as string[],
types: [] as string[],
projects: [] as string[],
+ statuses: [] as string[],
sort: '',
page: '',
});
@@ -64,17 +74,16 @@ function ExpensePageContent() {
const selectedMonths = filters.months;
const selectedTypes = filters.types;
const selectedProjects = filters.projects;
+ const selectedStatuses = filters.statuses;
const sortOption = filters.sort;
const currentPage = parseInt(filters.page, 10) || 1;
// Dropdown visibility
- const [showMonthFilter, setShowMonthFilter] = useState(false);
- const [showTypeFilter, setShowTypeFilter] = useState(false);
- const [showProjectFilter, setShowProjectFilter] = useState(false);
const [showSortBy, setShowSortBy] = useState(false);
- // Modal
+ // Modals
const [showNewExpense, setShowNewExpense] = useState(false);
+ const [reviewExpenditureId, setReviewExpenditureId] = useState(null);
// Fetch expenditures
@@ -107,6 +116,51 @@ function ExpensePageContent() {
const uniqueCategories = EXPENSE_CATEGORIES;
+ const projectNames = Object.fromEntries(projects.map((p) => [p.project_id, p.name]));
+
+ const filterGroups: FilterGroup[] = [
+ {
+ key: 'months',
+ label: 'Month',
+ options: MONTHS.map((m) => ({ value: m, label: m })),
+ selected: selectedMonths,
+ onChange: (next) => setFilter({ months: next, page: '' }),
+ },
+ {
+ key: 'projects',
+ label: 'Project',
+ options: projects.map((p) => ({ value: p.name, label: p.name })),
+ selected: selectedProjects,
+ onChange: (next) => setFilter({ projects: next, page: '' }),
+ },
+ {
+ key: 'types',
+ label: 'Type',
+ options: uniqueCategories.map((c) => ({ value: c, label: c })),
+ selected: selectedTypes,
+ onChange: (next) => setFilter({ types: next, page: '' }),
+ },
+ {
+ key: 'statuses',
+ label: 'Status',
+ options: EXPENDITURE_STATUSES.map((s) => ({ value: s, label: EXPENDITURE_STATUS_LABELS[s] })),
+ selected: selectedStatuses,
+ onChange: (next) => setFilter({ statuses: next, page: '' }),
+ },
+ ];
+
+ const activeFilterCount =
+ selectedMonths.length + selectedProjects.length + selectedTypes.length + selectedStatuses.length;
+
+ async function handleViewReceipt(expenditure: Expenditure) {
+ try {
+ const { downloadUrl } = await getReceiptDownloadUrl(expenditure.expenditure_id);
+ window.open(downloadUrl, '_blank', 'noopener,noreferrer');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to open receipt');
+ }
+ }
+
// Filtered + sorted data
const filteredData = expenditures
.filter((e) => {
@@ -128,6 +182,9 @@ function ExpensePageContent() {
const projectName = projects.find((p) => p.project_id === e.project_id)?.name;
if (!projectName || !selectedProjects.includes(projectName)) return false;
}
+ if (selectedStatuses.length > 0) {
+ if (!selectedStatuses.includes(e.status)) return false;
+ }
return true;
})
.sort((a, b) => {
@@ -175,104 +232,32 @@ function ExpensePageContent() {
{/* Toolbar */}
-
+
setFilter({ q: e.target.value, page: '' })}
/>
-
-
- {/* Project Filter */}
-
-
- {showProjectFilter && (
-
- p.name)}
- multiSelect={true}
- hideTrigger={true}
- value={selectedProjects}
- onChange={(val) => setFilter({ projects: Array.isArray(val) ? val : [val], page: '' })}
- />
-
- )}
-
-
- {/* Month Filter */}
-
+ {activeFilterCount > 0 && (
- {showMonthFilter && (
-
- setFilter({ months: Array.isArray(val) ? val : [val], page: '' })}
- />
-
- )}
-
-
- {/* Type Filter */}
-
-
- {showTypeFilter && (
-
- setFilter({ types: Array.isArray(val) ? val : [val], page: '' })}
- />
-
- )}
-
+ )}
+
+
+ {/* Filter By */}
+
{/* Sort By */}
@@ -281,12 +266,7 @@ function ExpensePageContent() {
color="var(--color-core-black)"
border="1px solid"
borderColor="var(--color-black-500)"
- onClick={() => {
- setShowSortBy((prev) => !prev);
- setShowProjectFilter(false);
- setShowMonthFilter(false);
- setShowTypeFilter(false);
- }}
+ onClick={() => setShowSortBy((prev) => !prev)}
>
Sort By
@@ -322,7 +302,12 @@ function ExpensePageContent() {
{/* Table */}
{!loading && !error && (
-
+ setReviewExpenditureId(e.expenditure_id)}
+ />
)}
{/* Pagination */}
@@ -344,6 +329,17 @@ function ExpensePageContent() {
categories={uniqueCategories}
projects={projects}
/>
+
+ {/* Review Expense Modal */}
+ setReviewExpenditureId(null)}
+ onReviewed={async () => {
+ setReviewExpenditureId(null);
+ await fetchExpenditures();
+ }}
+ />
);
diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css
index c3cbe694..52fcdd82 100644
--- a/apps/frontend/src/app/globals.css
+++ b/apps/frontend/src/app/globals.css
@@ -33,7 +33,10 @@
/* Errors */
--color-error-red: #AD1A00;
- --color-error-light-red: #D68C80;
+ --color-error-light-red: #E17070;
+
+ /* Status pills */
+ --color-status-pending: #FFD167;
/* Font Families */
--font-heading: 'Roboto Slab', serif;
diff --git a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
index 96cf4d7d..62c71009 100644
--- a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
+++ b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
@@ -133,7 +133,7 @@ export default function ProjectPage() {
)}
diff --git a/apps/frontend/src/lib/expenditures.ts b/apps/frontend/src/lib/expenditures.ts
new file mode 100644
index 00000000..4becb9ac
--- /dev/null
+++ b/apps/frontend/src/lib/expenditures.ts
@@ -0,0 +1,65 @@
+import { authedFetch } from './authClient';
+import type { ExpenditureDetail, ExpenditureStatus } from '@/types';
+
+export async function getExpenditure(id: number): Promise {
+ const res = await authedFetch<{ body: ExpenditureDetail }>(`/expenditures/${id}`);
+ return res.body;
+}
+
+export async function getReceiptUploadUrl(
+ fileName: string,
+ projectId: number,
+): Promise<{ uploadUrl: string; objectUrl: string }> {
+ return authedFetch(
+ `/expenditures/upload-url?fileName=${encodeURIComponent(fileName)}&projectId=${projectId}`,
+ );
+}
+
+/**
+ * The bucket is not publicly readable, so a receipt is fetched through a
+ * short-lived presigned URL rather than linking at its object URL directly.
+ */
+export async function getReceiptDownloadUrl(
+ id: number,
+): Promise<{ downloadUrl: string; fileName: string }> {
+ return authedFetch(`/expenditures/${id}/receipt`);
+}
+
+export async function reviewExpenditure(
+ id: number,
+ status: ExpenditureStatus,
+ adminNotes: string,
+): Promise {
+ await authedFetch(`/expenditures/${id}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status, adminNotes }),
+ });
+}
+
+/**
+ * Uploads the receipt straight to S3 and reports real progress, which needs
+ * XHR: fetch cannot report upload progress.
+ */
+export function uploadReceiptToS3(
+ uploadUrl: string,
+ file: File,
+ onProgress?: (transferredBytes: number) => void,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('PUT', uploadUrl);
+ xhr.setRequestHeader('Content-Type', file.type || 'application/pdf');
+
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) onProgress?.(e.loaded);
+ };
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) resolve();
+ else reject(new Error('File failed to upload'));
+ };
+ xhr.onerror = () => reject(new Error('File failed to upload'));
+ xhr.onabort = () => reject(new Error('File failed to upload'));
+
+ xhr.send(file);
+ });
+}
diff --git a/apps/frontend/src/lib/routes.ts b/apps/frontend/src/lib/routes.ts
index 610a3003..a6addeb1 100644
--- a/apps/frontend/src/lib/routes.ts
+++ b/apps/frontend/src/lib/routes.ts
@@ -19,8 +19,15 @@ const PUBLIC_PREFIXES = [
'/reset-password',
] as const;
-/** Require `isAdmin` on top of authentication. */
-const ADMIN_PREFIXES = ['/expenses', '/reports', '/accounts'] as const;
+/**
+ * Require `isAdmin` on top of authentication.
+ *
+ * `/expenses` is deliberately not here: non-admins submit expenses and read
+ * their own submissions there. Only the approve/deny controls and admin notes
+ * inside the review modal are admin-gated, and the backend already lets any
+ * authenticated user list expenditures.
+ */
+const ADMIN_PREFIXES = ['/reports', '/accounts'] as const;
/**
* Strips the trailing slash and lowercases.
diff --git a/apps/frontend/src/types/expenditure.ts b/apps/frontend/src/types/expenditure.ts
index c0560780..e8579042 100644
--- a/apps/frontend/src/types/expenditure.ts
+++ b/apps/frontend/src/types/expenditure.ts
@@ -1,3 +1,14 @@
+export const EXPENDITURE_STATUSES = ['pending', 'approved', 'needs_more_info'] as const;
+
+export type ExpenditureStatus = typeof EXPENDITURE_STATUSES[number];
+
+/** Figma labels the `needs_more_info` pill "Needs Info". */
+export const EXPENDITURE_STATUS_LABELS: Record = {
+ pending: 'Pending',
+ approved: 'Approved',
+ needs_more_info: 'Needs Info',
+};
+
export interface Expenditure {
expenditure_id: number;
project_id: number;
@@ -5,6 +16,26 @@ export interface Expenditure {
amount: string;
category: string | null;
description: string | null;
+ status: ExpenditureStatus;
+ receipt_url: string | null;
+ admin_notes: string | null;
spent_on: string;
created_at: string | null;
- };
\ No newline at end of file
+ };
+
+/** Shape of GET /expenditures/{id}, which joins in the submitter and project. */
+export interface ExpenditureDetail {
+ expenditureId: number;
+ projectId: number;
+ projectName: string | null;
+ enteredBy: number | null;
+ submittedByName: string | null;
+ amount: string;
+ category: string | null;
+ description: string | null;
+ status: ExpenditureStatus;
+ adminNotes: string | null;
+ receiptUrl: string | null;
+ spent_on: string;
+ createdAt: string | null;
+}
diff --git a/apps/frontend/test/components/AccountsPage.test.tsx b/apps/frontend/test/components/AccountsPage.test.tsx
index 1f9e6c90..2273c25f 100644
--- a/apps/frontend/test/components/AccountsPage.test.tsx
+++ b/apps/frontend/test/components/AccountsPage.test.tsx
@@ -1,5 +1,6 @@
import { render, screen } from '../utils';
-import AccountsPage, { facilitationTeam, teamMembers } from '@/app/accounts/page';
+import AccountsPage from '@/app/accounts/page';
+import { facilitationTeam, teamMembers } from '@/app/accounts/mockUsers';
describe('AccountsPage', () => {
it('renders the headings', () => {
diff --git a/apps/frontend/test/components/AddExpenseModal.test.tsx b/apps/frontend/test/components/AddExpenseModal.test.tsx
index bfb55d8c..b670f84c 100644
--- a/apps/frontend/test/components/AddExpenseModal.test.tsx
+++ b/apps/frontend/test/components/AddExpenseModal.test.tsx
@@ -37,18 +37,21 @@ jest.mock('../../src/app/components/DropdownSelector', () => {
};
});
-// Mock FileUpload — expose a button that selects a fake file
+// Mock FileUpload — expose a button that reports a finished upload
jest.mock('../../src/app/components/FileUpload', () => {
return function MockFileUpload({
onChange,
}: {
- onChange: (file: File | null) => void;
+ onChange: (file: File | null, objectUrl: string | null) => void;
}) {
return (