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
2 changes: 2 additions & 0 deletions apps/backend/lambdas/donors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Lambda for managing donors.
| GET | /donors | |
| POST | /donations | |
| POST | /donors | |
| DELETE | /donors/{id} | |
| DELETE | /donations/{id} | |

## Setup

Expand Down
112 changes: 107 additions & 5 deletions apps/backend/lambdas/donors/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { APIGatewayProxyResult } from 'aws-lambda';
import db from './db';
import { authenticateRequest } from './auth';
import { DonorValidationUtils, DonationValidationUtils } from './validation-utils';

export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
try {
Expand Down Expand Up @@ -188,12 +189,113 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
}

// POST /donors
if ((normalizedPath === '/' || normalizedPath === '/donors') && method === 'POST') {
const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};
// TODO: Add your business logic here
return json(201, { ok: true, route: 'POST /donors', body });
if ((normalizedPath === '/' || normalizedPath === '' || normalizedPath === '/donors') && method === 'POST') {
// Authenticate the request
const { user } = authContext;

if (!user) {
return json(401, { message: 'Authentication required' });
}
if (!user.isAdmin) {
return json(403, { message: 'Only admins can create donors' });
}

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

// Validate input
const validationResult = DonorValidationUtils.validateDonorInput(body);
if (validationResult instanceof Error) {
return json(400, { message: validationResult.message });
}

const { organization, contactName, contactEmail } = validationResult;

// Insert donor with authenticated user as entered_by
try {
await db
.insertInto('branch.donors')
.values({
organization,
contact_name: contactName ?? null,
contact_email: contactEmail ?? null,
})
.executeTakeFirst();
} catch (err) {
console.error('Database insert error:', err);
return json(500, { message: 'Failed to create donor' });
}

return json(201, {
ok: true,
route: 'POST /donors',
body: {
organization,
contactName: contactName ?? null,
contactEmail: contactEmail ?? null,
},
});
}

// DELETE /donors/{id}
if (/^\/[^\/]+$/.test(normalizedPath) && method === 'DELETE') {
const id = normalizedPath.split('/')[1];
if (!id || !/^\d+$/.test(id)) {
return json(400, { message: 'id must be a positive integer' });
}

if (!authContext.user?.isAdmin) {
return json(403, { message: 'Only admins can delete donors' });
}

const deleted = await db.deleteFrom('branch.donors').where('donor_id', '=', Number(id)).execute();
if (!deleted[0] || deleted[0].numDeletedRows === 0n) {
return json(404, { message: 'Donor not found' });
}

return json(200, { ok: true, route: 'DELETE /donors/{id}', pathParams: { id } });

}

// DELETE /donations/{id}
if (normalizedPath.startsWith('/donations/') && normalizedPath.split('/').length === 3 && method === 'DELETE') {
const id = normalizedPath.split('/')[2];
if (!id || !/^\d+$/.test(id)) {
return json(400, { message: 'id must be a positive integer' });
}

const donation = await db
.selectFrom('branch.project_donations')
.where('donation_id', '=', Number(id))
.selectAll()
.executeTakeFirst();

if (!donation) {
return json(404, { message: 'Donation not found' });
}

if (!authContext.user?.isAdmin) {
const userId = authContext.user!.userId as number;
const membership = await db
.selectFrom('branch.project_memberships')
.select('membership_id')
.where('project_id', '=', donation.project_id)
.where('user_id', '=', userId)
.executeTakeFirst();

if (!membership) {
return json(403, { message: 'You must be a member of this project to delete this donation' });
}
}

const deleted = await db.deleteFrom('branch.project_donations').where('donation_id', '=', Number(id)).execute();
if (!deleted[0] || deleted[0].numDeletedRows === 0n) {
return json(404, { message: 'Donation not found' });
}

return json(200, { ok: true, route: 'DELETE /donations/{id}', pathParams: { id } });
}
// <<< ROUTES-END

// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand Down
34 changes: 27 additions & 7 deletions apps/backend/lambdas/donors/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
openapi: 3.0.3
openapi: 3.0.3
info:
title: donors (Local)
version: 1.0.0
servers:
- url: http://localhost:3000/
- url: http://localhost:3000/donors
paths:
/donors/health:
/health:
get:
summary: Health check
responses:
Expand All @@ -18,15 +18,12 @@ paths:
properties:
ok:
type: boolean

/donors:
get:
summary: GET /donors
responses:
'200':
description: OK

/donors:
post:
summary: POST /donors
requestBody:
Expand All @@ -45,7 +42,6 @@ paths:
responses:
'201':
description: Success

/donations:
post:
summary: POST /donations
Expand All @@ -65,3 +61,27 @@ paths:
responses:
'201':
description: Success
/donors/{id}:
delete:
summary: DELETE /donors/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK
/donations/{id}:
delete:
summary: DELETE /donations/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK
165 changes: 165 additions & 0 deletions apps/backend/lambdas/donors/test/donors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,171 @@ describe("Donor API with data", () => {
}));
expect(res.statusCode).toBe(403);
});

// --- POST /donors ---

test("POST /donors returns 201 for admin with organization only", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('POST', '/', { organization: 'New Foundation' }));
const body = JSON.parse(res.body);

expect(res.statusCode).toBe(201);
expect(body.ok).toBe(true);
expect(body.body.organization).toBe('New Foundation');
expect(body.body.contactName).toBeNull();
expect(body.body.contactEmail).toBeNull();
});

test("POST /donors returns 201 for admin with full fields", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('POST', '/', {
organization: 'Gates Foundation',
contact_name: 'Bill Gates',
contact_email: 'bill@gatesfoundation.org',
}));
const body = JSON.parse(res.body);

expect(res.statusCode).toBe(201);
expect(body.body.organization).toBe('Gates Foundation');
expect(body.body.contactName).toBe('Bill Gates');
expect(body.body.contactEmail).toBe('bill@gatesfoundation.org');

const client = await pool.connect();
try {
const result = await client.query(
"SELECT * FROM branch.donors WHERE organization = 'Gates Foundation'"
);
expect(result.rows.length).toBe(1);
expect(result.rows[0].contact_email).toBe('bill@gatesfoundation.org');
} finally {
client.release();
}
});

test("POST /donors returns 400 when organization is missing", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('POST', '/', { contact_name: 'No Org' }));
expect(res.statusCode).toBe(400);
});

test("POST /donors returns 400 for invalid contact_email format", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('POST', '/', {
organization: 'Bad Email Org',
contact_email: 'not-an-email',
}));
expect(res.statusCode).toBe(400);
});

test("POST /donors returns 403 for non-admin authenticated user", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser);
const res = await handler(createEvent('POST', '/', { organization: 'Should Fail' }));
expect(res.statusCode).toBe(403);
});

test("POST /donors returns 401 when unauthenticated", async () => {
mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false });
const res = await handler(createEvent('POST', '/', { organization: 'Should Fail' }));
expect(res.statusCode).toBe(401);
});

// --- DELETE /donors/{id} ---

test("DELETE /donors/{id} returns 200 for admin and removes the donor", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
// Donor 2 (Harvard Medical) — note this cascades and also removes donation_id 2
const res = await handler(createEvent('DELETE', '/donors/2'));
const body = JSON.parse(res.body);

expect(res.statusCode).toBe(200);
expect(body.ok).toBe(true);
expect(body.pathParams.id).toBe('2');

const client = await pool.connect();
try {
const result = await client.query('SELECT * FROM branch.donors WHERE donor_id = 2');
expect(result.rows.length).toBe(0);
} finally {
client.release();
}
});

test("DELETE /donors/{id} returns 400 for non-numeric id", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('DELETE', '/donors/abc'));
expect(res.statusCode).toBe(400);
});

test("DELETE /donors/{id} returns 404 for a nonexistent donor", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('DELETE', '/donors/9999'));
expect(res.statusCode).toBe(404);
});

test("DELETE /donors/{id} returns 403 for non-admin authenticated user", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser);
const res = await handler(createEvent('DELETE', '/donors/1'));
expect(res.statusCode).toBe(403);
});

test("DELETE /donors/{id} returns 401 when unauthenticated", async () => {
mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false });
const res = await handler(createEvent('DELETE', '/donors/1'));
expect(res.statusCode).toBe(401);
});

// --- DELETE /donations/{id} ---

test("DELETE /donations/{id} returns 200 for admin", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
// donation_id 3 belongs to project_id 3, donor_id 3
const res = await handler(createEvent('DELETE', '/donations/3'));
const body = JSON.parse(res.body);

expect(res.statusCode).toBe(200);
expect(body.ok).toBe(true);

const client = await pool.connect();
try {
const result = await client.query('SELECT * FROM branch.project_donations WHERE donation_id = 3');
expect(result.rows.length).toBe(0);
} finally {
client.release();
}
});

test("DELETE /donations/{id} returns 200 for a project member (PI) deleting a donation on their project", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); // userId 1, PI on project 1
// donation_id 1 belongs to project_id 1
const res = await handler(createEvent('DELETE', '/donations/1'));
expect(res.statusCode).toBe(200);
});

test("DELETE /donations/{id} returns 403 for a user with no membership on that donation's project", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); // userId 1, only member of project 1
// donation_id 2 belongs to project_id 2 — user 1 has no membership there
const res = await handler(createEvent('DELETE', '/donations/2'));
expect(res.statusCode).toBe(403);
});

test("DELETE /donations/{id} returns 400 for non-numeric id", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('DELETE', '/donations/abc'));
expect(res.statusCode).toBe(400);
});

test("DELETE /donations/{id} returns 404 for a nonexistent donation", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(adminUser);
const res = await handler(createEvent('DELETE', '/donations/9999'));
expect(res.statusCode).toBe(404);
});

test("DELETE /donations/{id} returns 401 when unauthenticated", async () => {
mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false });
const res = await handler(createEvent('DELETE', '/donations/1'));
expect(res.statusCode).toBe(401);
});

});

describe("Donor API when DB is empty", () => {
Expand Down
Loading
Loading