Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion apps/backend/lambdas/expenditures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
159 changes: 156 additions & 3 deletions apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
@@ -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<typeof checkAuthorization>[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined {
const authCheck = checkAuthorization(authContext, level, resourceUserId);
if (!authCheck.allowed) {
Expand Down Expand Up @@ -179,6 +194,114 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
});
}

// 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];
Expand Down Expand Up @@ -211,18 +334,36 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
}
}

// "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}',
pathParams: { id },
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,
Expand Down Expand Up @@ -292,12 +433,16 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};

// 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')
Expand All @@ -312,7 +457,11 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
// 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();

Expand All @@ -327,7 +476,11 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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
Expand Down
Loading
Loading