Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
381ee0f
post report endpoint logic + tests
tsudhakar87 Apr 12, 2026
d38171d
add a get /reports/upload-url
tsudhakar87 Apr 12, 2026
b3d75fa
chore: regenerate lambda READMEs
github-actions[bot] Apr 12, 2026
ee2f667
Merge branch 'main' into 181-upload-report-endpoint
Vaibhav978 Apr 12, 2026
0c695e5
Merge branch 'main' into 181-upload-report-endpoint
tsudhakar87 Apr 12, 2026
7e7a9e1
Merge branch 'main' into 181-upload-report-endpoint
tsudhakar87 Apr 12, 2026
a24f236
Merge branch 'main' into 181-upload-report-endpoint
tsudhakar87 Apr 12, 2026
4aef13f
fix for failing lambda test in ci
tsudhakar87 Apr 12, 2026
d50970e
Merge branch '181-upload-report-endpoint' of https://github.com/Code-…
tsudhakar87 Apr 12, 2026
e7cbb40
Merge main into 181-upload-report-endpoint, resolve conflicts
tsudhakar87 Apr 12, 2026
c6b3582
chore: regenerate lambda READMEs
github-actions[bot] Apr 12, 2026
f43feb9
add auth checks (same as generating a report) + updated tests
tsudhakar87 Apr 13, 2026
a5ae6e0
add access check if user is a project admin or member
tsudhakar87 Apr 16, 2026
a71efc1
change path to be POST /reports instead of POST /reports/reports
tsudhakar87 Apr 18, 2026
506304a
Merge branch 'main' into 181-upload-report-endpoint
tsudhakar87 Jun 3, 2026
6319ab5
fix failing tests
tsudhakar87 Jun 3, 2026
43fcccf
Merge branch 'main' into 181-upload-report-endpoint
tsudhakar87 Jun 3, 2026
08188ad
fix route matching
tsudhakar87 Jun 22, 2026
efb32c4
Merge branch 'main' into 181-upload-report-endpoint
tsudhakar87 Jun 22, 2026
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 @@ -66,6 +66,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 @@ -104,9 +105,9 @@ INSERT INTO expenditures (project_id, entered_by, amount, category, description,
(3, 3, 2500, 'General', 'Educational materials', '2025-07-12'),
(3, 3, 1800, 'Travel', 'Local outreach travel', '2025-08-03');

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 @@ -121,6 +121,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
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
123 changes: 115 additions & 8 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 @@ -10,8 +12,17 @@ import {
saveReportRecord,
} from './report-service';

const FILE_TYPES = ['pdf', 'docx'] as const;
type FileType = typeof FILE_TYPES[number];
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',
};

type FileType = typeof ALLOWED_EXTENSIONS[number];

export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
try {
Expand All @@ -26,8 +37,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' || normalizedPath === '/generate') && method === 'POST') {
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
return json(401, { message: 'Authentication required' });
Expand All @@ -45,8 +56,8 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
}

const fileType = (body.file_type ?? 'pdf') as FileType;
if (!FILE_TYPES.includes(fileType)) {
return json(400, { message: `file_type must be one of: ${FILE_TYPES.join(', ')}` });
if (!ALLOWED_EXTENSIONS.includes(fileType)) {
return json(400, { message: `file_type must be one of: ${ALLOWED_EXTENSIONS.join(', ')}` });
}

const reportData = await fetchReportData(projectId);
Expand Down Expand Up @@ -75,7 +86,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 @@ -144,7 +156,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' || 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' });
}
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
79 changes: 77 additions & 2 deletions 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 or DOCX)
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 report for the given project containing project info,
participants and roles, donations, and expenditures. Supports PDF and
Expand All @@ -107,7 +181,7 @@ paths:
type: string
enum: [pdf, docx]
default: pdf
description: Output format: "pdf" (default) or "docx"
description: 'Output format: pdf (default) or docx'
example: docx
responses:
'201':
Expand All @@ -133,6 +207,7 @@ paths:
description: Project not found
'500':
description: Internal server error

components:
securitySchemes:
BearerAuth:
Expand Down
33 changes: 33 additions & 0 deletions apps/backend/lambdas/reports/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/backend/lambdas/reports/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"dependencies": {
"@branch/lambda-auth": "file:../../../../shared/lambda-auth",
"@aws-sdk/client-s3": "^3.995.0",
"@aws-sdk/s3-request-presigner": "^3.1029.0",
"aws-jwt-verify": "^5.1.1",
"aws-lambda": "^1.0.7",
"dotenv": "^16.4.7",
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/lambdas/reports/report-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,12 +505,14 @@ export async function uploadToS3(fileBuffer: Buffer, projectId: number, fileType
export async function saveReportRecord(
projectId: number,
objectUrl: string,
title: string,
): Promise<{ report_id: number; object_url: string }> {
const row = await db
.insertInto('branch.reports')
.values({
project_id: projectId,
object_url: objectUrl,
title,
})
.returning(['report_id', 'object_url'])
.executeTakeFirstOrThrow();
Expand Down
Loading
Loading