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
9 changes: 5 additions & 4 deletions apps/backend/db/db_setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ CREATE TABLE users (
CREATE TABLE projects (
project_id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
total_budget NUMERIC(12,2),
start_date DATE,
end_date DATE,
Expand Down Expand Up @@ -71,10 +72,10 @@ INSERT INTO users (name, email, is_admin) VALUES
('Renee Reddy', 'renee@branch.org', TRUE),
('Nour Shoreibah', 'nour@branch.org', TRUE);

INSERT INTO projects (name, total_budget, start_date, end_date, currency) VALUES
('Clinician Communication Study', 500000, '2025-01-01', '2026-01-01', 'USD'),
('Health Education Initiative', 300000, '2025-03-01', '2026-03-01', 'USD'),
('Policy Advocacy Program', 200000, '2025-06-01', '2026-06-01', 'USD');
INSERT INTO projects (name, description, total_budget, start_date, end_date, currency) VALUES
('Clinician Communication Study', 'Study of clinician-patient communication patterns', 500000, '2025-01-01', '2026-01-01', 'USD'),
('Health Education Initiative', 'Community health education and outreach', 300000, '2025-03-01', '2026-03-01', 'USD'),
('Policy Advocacy Program', 'Advocacy and policy change efforts', 200000, '2025-06-01', '2026-06-01', 'USD');

INSERT INTO donors (organization, contact_name, contact_email) VALUES
('NIH', 'Dr. Sarah Lee', 'sarah@nih.gov'),
Expand Down
1 change: 1 addition & 0 deletions apps/backend/lambdas/auth/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface BranchProjects {
created_at: Generated<Timestamp | null>;
currency: Generated<string | null>;
end_date: Timestamp | null;
description: string;
name: string;
project_id: Generated<number>;
start_date: Timestamp | null;
Expand Down
1 change: 1 addition & 0 deletions apps/backend/lambdas/donors/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface BranchProjects {
created_at: Generated<Timestamp | null>;
currency: Generated<string | null>;
end_date: Timestamp | null;
description: string;
name: string;
project_id: Generated<number>;
start_date: Timestamp | null;
Expand Down
1 change: 1 addition & 0 deletions apps/backend/lambdas/expenditures/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface BranchProjects {
created_at: Generated<Timestamp | null>;
currency: Generated<string | null>;
end_date: Timestamp | null;
description: string;
name: string;
project_id: Generated<number>;
start_date: Timestamp | null;
Expand Down
1 change: 1 addition & 0 deletions apps/backend/lambdas/projects/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface BranchProjects {
created_at: Generated<Timestamp | null>;
currency: Generated<string | null>;
end_date: Timestamp | null;
description: string;
name: string;
project_id: Generated<number>;
start_date: Timestamp | null;
Expand Down
8 changes: 6 additions & 2 deletions apps/backend/lambdas/projects/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
.updateTable("branch.projects")
.set(body)
.where("project_id", "=", Number(id))
.returning(["project_id", "name", "total_budget"]) // control returned fields
.returning(["project_id", "name", "description", "total_budget"]) // control returned fields
.executeTakeFirst();
if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` });
return json(200, updatedProject);
Expand Down Expand Up @@ -73,11 +73,15 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
if (!currencyResult.isValid) return json(400, { message: currencyResult.error });
if (currencyResult.value !== null) values.currency = currencyResult.value;

const descriptionResult = ProjectValidationUtils.validateDescription(body.description);
if (!descriptionResult.isValid) return json(400, { message: descriptionResult.error });
values.description = descriptionResult.value;

try {
const inserted = await db
.insertInto('branch.projects')
.values(values)
.returning(['project_id','name','total_budget','currency','start_date','end_date','created_at'])
.returning(['project_id','name','description','total_budget','currency','start_date','end_date','created_at'])
.executeTakeFirst();

return json(201, inserted);
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/lambdas/projects/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ paths:
type: string
total_budget:
type: number
description:
type: string
responses:
'200':
description: OK
19 changes: 19 additions & 0 deletions apps/backend/lambdas/projects/test/crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ test("get projects test 🌞", async () => {
let body = await res.json();
console.log(body);
expect(body.length).toBeGreaterThan(0);
body.forEach((project: any) => {
expect(project.description).toBeDefined();
expect(project.description).not.toBeNull();
expect(typeof project.description).toBe('string');
});
});
test("update project test 🌞", async () => {
let res = await fetch("http://localhost:3000/projects/1", {
Expand All @@ -49,6 +54,20 @@ test("update project test 🌞", async () => {
expect(body.project_id).toBe(1);
expect(body.name).toContain("Project 1 Updated");
expect(Number(body.total_budget)).toBe(Number(2000.00));
expect(body.description).toBeDefined();
expect(body.description).not.toBeNull();
expect(typeof body.description).toBe('string');
});

test("update project with new description test 🌞", async () => {
const newDesc = "Updated project description";
let res = await fetch("http://localhost:3000/projects/1", {
method: "PUT",
body: JSON.stringify({ name: "Project 1", description: newDesc }),
});
expect(res.status).toBe(200);
let body = await res.json();
expect(body.description).toBe(newDesc);
});

test("project put 404 test 🌞", async () => {
Expand Down
39 changes: 39 additions & 0 deletions apps/backend/lambdas/projects/test/projects.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('POST /projects (e2e)', () => {
start_date: '2025-03-01',
end_date: '2025-09-30',
currency: 'EUR',
description: 'End-to-end project description',
}),
});
expect(res.status).toBe(201);
Expand All @@ -45,6 +46,7 @@ describe('POST /projects (e2e)', () => {
expect(json.start_date).toContain('2025-03-01');
expect(json.end_date).toContain('2025-09-30');
expect(json.currency).toBe('EUR');
expect(json.description).toBe('End-to-end project description');
});

test('400 when name missing', async () => {
Expand Down Expand Up @@ -72,5 +74,42 @@ describe('POST /projects (e2e)', () => {
body: JSON.stringify({ name: 'Minimal' }),
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.description).toBe(''); // description defaults to empty string
});

test('201: creates project with empty string description', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'EmptyDesc', description: '' }),
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.description).toBe('');
});

test('400: description exceeds 1000 characters', async () => {
const longDesc = 'a'.repeat(1001);
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'LongDesc', description: longDesc }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json.message).toContain('1000');
});

test('201: creates project with exactly 1000 character description', async () => {
const desc1000 = 'a'.repeat(1000);
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'MaxDesc', description: desc1000 }),
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.description).toBe(desc1000);
});
});
38 changes: 36 additions & 2 deletions apps/backend/lambdas/projects/test/projects.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ test('201: creates project with numeric string budget', async () => {
test('201: creates minimal project with only name', async () => {
const res = await handler(event({ name: 'Minimal' }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.description).toBe('');
});

test('201: creates project with empty string description', async () => {
const res = await handler(event({ name: 'EmptyDesc', description: '' }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.description).toBe('');
});

test('201: creates project with whitespace-only description', async () => {
const res = await handler(event({ name: 'WhitespaceDesc', description: ' ' }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.description).toBe('');
});

test('201: creates project with all fields', async () => {
Expand All @@ -45,8 +61,9 @@ test('201: creates project with all fields', async () => {
name: 'AllFieldsUnit',
total_budget: 12345.67,
start_date: '2025-01-01',
end_date: '2025-12-31',
currency: 'USD',
end_date: '2025-12-31',
currency: 'USD',
description: 'Unit test project description',
}),
} as any);
expect(res.statusCode).toBe(201);
Expand All @@ -56,6 +73,15 @@ test('201: creates project with all fields', async () => {
expect(json.start_date).toContain('2025-01-01');
expect(json.end_date).toContain('2025-12-31');
expect(json.currency).toBe('USD');
expect(json.description).toBe('Unit test project description');
});

test('201: creates project with exactly 1000 character description', async () => {
const desc1000 = 'a'.repeat(1000);
const res = await handler(event({ name: 'MaxDesc', description: desc1000 }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.description).toBe(desc1000);
});

// Validation errors (400)
Expand Down Expand Up @@ -86,3 +112,11 @@ test('400: currency empty or too long', async () => {
const tooLong = await handler(event({ name: 'X', currency: 'ABCDEFGHIJK' })); // 11 chars
expect(tooLong.statusCode).toBe(400);
});

test('400: description exceeds 1000 characters', async () => {
const longDesc = 'a'.repeat(1001);
const res = await handler(event({ name: 'LongDesc', description: longDesc }));
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.message).toContain('1000');
});
15 changes: 15 additions & 0 deletions apps/backend/lambdas/projects/validation-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,19 @@ export class ProjectValidationUtils {
}
return { isValid: true, value: c };
}

// validates description field - required, defaults to empty string if not provided
static validateDescription(input: unknown): ValidationResult<string> {
if (input === undefined || input === null || input === '') {
return { isValid: true, value: '' };
}
if (typeof input !== 'string') {
return { isValid: true, value: '' };
}
const d = input.trim();
if (d.length > 1000) {
return { isValid: false, error: "'description' must be <= 1000 chars" };
}
return { isValid: true, value: d.length === 0 ? '' : d };
}
}
1 change: 1 addition & 0 deletions apps/backend/lambdas/users/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface BranchProjects {
created_at: Generated<Timestamp | null>;
currency: Generated<string | null>;
end_date: Timestamp | null;
description: string;
name: string;
project_id: Generated<number>;
start_date: Timestamp | null;
Expand Down