Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,13 @@ DONORS_PORT=3003
EXPENDITURES_PORT=3004
REPORTS_PORT=3005
AUTH_PORT=3006

# Cognito Configuration
COGNITO_CLIENT_ID=secret
COGNITO_USER_POOL_ID=secret

# AWS Configuration
S3_BUCKET_NAME=name
AWS_ACCESS_KEY_ID=key
AWS_SECRET_ACCESS_KEY=secret
AWS_REGION=region or us-east-2
13 changes: 7 additions & 6 deletions apps/backend/db/db_setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ CREATE TABLE expenditures (
CREATE TABLE reports (
report_id SERIAL PRIMARY KEY,
project_id INT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
object_url TEXT NOT NULL,
date_created DATE NOT NULL DEFAULT CURRENT_DATE
);
Expand Down Expand Up @@ -98,9 +99,9 @@ INSERT INTO expenditures (project_id, entered_by, amount, category, description,
(2, 2, 3000, 'Equipment', 'Purchase of recording devices', '2025-04-05'),
(3, 3, 2500, 'Supplies', 'Educational materials', '2025-07-12');

INSERT INTO reports (project_id, object_url) VALUES
(1, 'https://s3.amazonaws.com/branch-reports/clinician_communication_study_report.pdf'),
(2, 'https://s3.amazonaws.com/branch-reports/health_education_initiative_report.pdf'),
(3, 'https://s3.amazonaws.com/branch-reports/policy_advocacy_program_report.pdf'),
(2, 'https://s3.amazonaws.com/branch-reports/research_program_reports.pdf'),
(3, 'https://s3.amazonaws.com/branch-reports/health_care_data_reports.pdf');
INSERT INTO reports (project_id, title, object_url) VALUES
(1, 'Clinician Communication Study Report', 'https://s3.amazonaws.com/branch-reports/clinician_communication_study_report.pdf'),
(2, 'Health Education Initiative Report', 'https://s3.amazonaws.com/branch-reports/health_education_initiative_report.pdf'),
(3, 'Policy Advocacy Program Report', 'https://s3.amazonaws.com/branch-reports/policy_advocacy_program_report.pdf'),
(2, 'Research Program Reports', 'https://s3.amazonaws.com/branch-reports/research_program_reports.pdf'),
(3, 'Health Care Data Reports', 'https://s3.amazonaws.com/branch-reports/health_care_data_reports.pdf');
2 changes: 2 additions & 0 deletions apps/backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-password}
DB_NAME: ${DB_NAME:-branch_db}
REPORTS_BUCKET_NAME: ${REPORTS_BUCKET_NAME:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AWS_REGION: ${AWS_REGION:-us-east-2}
COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID}
COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID}
Expand Down
1 change: 1 addition & 0 deletions apps/backend/lambdas/projects/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ module.exports = {
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
globals: { 'ts-jest': { isolatedModules: true } },
maxWorkers: 1,
};
4 changes: 3 additions & 1 deletion apps/backend/lambdas/reports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ TODO: Add a description of the reports lambda.
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| POST | /reports | |
| POST | /reports/generate | |
| GET | /reports | |
| GET | /reports/upload-url | |
| POST | /reports | |

## Setup

Expand Down
1 change: 1 addition & 0 deletions apps/backend/lambdas/reports/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface BranchReports {
object_url: string;
project_id: number;
report_id: Generated<number>;
title: string;
}

export interface BranchUsers {
Expand Down
116 changes: 112 additions & 4 deletions apps/backend/lambdas/reports/handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { APIGatewayProxyResult } from 'aws-lambda';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import db from './db';
import { authenticateRequest } from './auth';
import {
Expand All @@ -9,6 +11,16 @@ import {
saveReportRecord,
} from './report-service';

const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' });
const BUCKET = process.env.REPORTS_BUCKET_NAME ?? '';
const REGION = process.env.AWS_REGION ?? 'us-east-2';

const ALLOWED_EXTENSIONS = ['pdf', 'docx'] as const;
const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
};

export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
try {
const rawPath = event.rawPath || event.path || '/';
Expand All @@ -22,8 +34,8 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
// >>> ROUTES-START (do not remove this marker)
// CLI-generated routes will be inserted here

// POST /reports
if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') {
// POST /reports/generate
if (normalizedPath === '/reports/generate' && method === 'POST') {
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
Expand Down Expand Up @@ -66,7 +78,8 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(500, { message: 'Failed to upload report' });
}

const record = await saveReportRecord(projectId, objectUrl);
const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`;
const record = await saveReportRecord(projectId, objectUrl, title);

return json(201, {
ok: true,
Expand Down Expand Up @@ -135,7 +148,102 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

return json(200, { data: reports });
}
// <<< ROUTES-END

// GET /reports/upload-url
if (normalizedPath === '/reports/upload-url' && method === 'GET') {
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
}

const { user } = authContext;

const queryParams = event.queryStringParameters || {};
const { fileName, projectId: projectIdStr } = queryParams;

if (!fileName || typeof fileName !== 'string') {
return json(400, { message: 'fileName is required' });
}
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
if (!ALLOWED_EXTENSIONS.includes(ext as typeof ALLOWED_EXTENSIONS[number])) {
return json(400, { message: 'Only PDF and DOCX files are supported' });
}
if (!projectIdStr || !/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) {
return json(400, { message: 'projectId must be a positive integer' });
}
const projectId = parseInt(projectIdStr, 10);

const projectExists = await db.selectFrom('branch.projects')
.where('project_id', '=', projectId)
.select('project_id')
.executeTakeFirst();
if (!projectExists) return json(404, { message: 'Project not found' });

const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin);
if (!hasAccess) {
return json(403, { message: 'You do not have access to upload reports for this project' });
}

const key = `reports/${projectId}/${Date.now()}-${fileName}`;
const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: MIME_TYPES[ext],
}), { expiresIn: 3600 });

const objectUrl = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`;

return json(200, { uploadUrl, objectUrl });
}

// POST /reports
if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') {
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
}

const { user } = authContext;

let body: Record<string, unknown>;
try {
body = event.body ? JSON.parse(event.body) : {};
} catch {
return json(400, { message: 'Invalid JSON in request body' });
}

const { title, projectId, objectUrl } = body;

if (!title || typeof title !== 'string' || title.trim().length === 0) {
return json(400, { message: 'title is required' });
}
if (!projectId || typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId < 1) {
return json(400, { message: 'projectId must be a positive integer' });
}
if (!objectUrl || typeof objectUrl !== 'string') {
return json(400, { message: 'objectUrl is required' });
}

const projectExists = await db.selectFrom('branch.projects')
.where('project_id', '=', projectId as number)
.select('project_id')
.executeTakeFirst();
if (!projectExists) return json(404, { message: 'Project not found' });

const hasAccess = await checkProjectAccess(user.userId!, projectId as number, user.isAdmin);
if (!hasAccess) {
return json(403, { message: 'You do not have access to upload reports for this project' });
}

const report = await db
.insertInto('branch.reports')
.values({ project_id: projectId, title: title.trim(), object_url: objectUrl as string })
.returningAll()
.executeTakeFirst();

return json(201, report);
}
// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand Down
77 changes: 76 additions & 1 deletion apps/backend/lambdas/reports/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,81 @@ paths:
'401':
description: Unauthorized
post:
summary: Generate a project report PDF
summary: POST /reports — save a manually uploaded report
description: >
Creates a report record using the S3 object URL obtained from
GET /reports/upload-url after the client has uploaded the file directly
to S3.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- title
- projectId
- objectUrl
properties:
title:
type: string
projectId:
type: integer
minimum: 1
objectUrl:
type: string
description: S3 object URL returned by GET /reports/upload-url
responses:
'201':
description: Report created
'400':
description: Validation error
'401':
description: Unauthorized
'404':
description: Project not found

/reports/upload-url:
get:
summary: Get a pre-signed S3 URL for uploading a report file
parameters:
- in: query
name: fileName
required: true
schema:
type: string
description: File name with extension (pdf or docx)
- in: query
name: projectId
required: true
schema:
type: integer
minimum: 1
description: Project the report belongs to
responses:
'200':
description: Pre-signed upload URL and final object URL
content:
application/json:
schema:
type: object
properties:
uploadUrl:
type: string
description: Pre-signed S3 PUT URL (expires in 1 hour)
objectUrl:
type: string
description: Permanent S3 URL to pass to POST /reports
'400':
description: Invalid fileName or projectId
'401':
description: Unauthorized
'404':
description: Project not found

/reports/generate:
post:
summary: Auto-generate a PDF report from project data
description: >
Generates a PDF report for the given project containing project info,
participants and roles, donations, and expenditures. Uploads the PDF to
Expand Down Expand Up @@ -126,6 +200,7 @@ paths:
description: Project not found
'500':
description: Internal server error

components:
securitySchemes:
BearerAuth:
Expand Down
Loading
Loading