diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index a83b98a..30ce2bd 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -12,6 +12,8 @@ Lambda for managing donors. | GET | /donors | | | POST | /donations | | | POST | /donors | | +| DELETE | /donors/{id} | | +| DELETE | /donations/{id} | | ## Setup diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index 25282d7..721c0a1 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -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 => { try { @@ -188,12 +189,113 @@ export const handler = async (event: any): Promise => { } // POST /donors - if ((normalizedPath === '/' || normalizedPath === '/donors') && method === 'POST') { - const body = event.body ? JSON.parse(event.body) as Record : {}; - // 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 : {}; + + // 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) { diff --git a/apps/backend/lambdas/donors/openapi.yaml b/apps/backend/lambdas/donors/openapi.yaml index 53c52d1..30a9751 100644 --- a/apps/backend/lambdas/donors/openapi.yaml +++ b/apps/backend/lambdas/donors/openapi.yaml @@ -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: @@ -18,15 +18,12 @@ paths: properties: ok: type: boolean - /donors: get: summary: GET /donors responses: '200': description: OK - - /donors: post: summary: POST /donors requestBody: @@ -45,7 +42,6 @@ paths: responses: '201': description: Success - /donations: post: summary: POST /donations @@ -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 \ No newline at end of file diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index 58a3bbf..5a9c449 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -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", () => { diff --git a/apps/backend/lambdas/donors/validation-utils.ts b/apps/backend/lambdas/donors/validation-utils.ts new file mode 100644 index 0000000..87dec01 --- /dev/null +++ b/apps/backend/lambdas/donors/validation-utils.ts @@ -0,0 +1,153 @@ +export interface DonorInput { + organization: string; + contactName?: string; + contactEmail?: string; +} + +export interface DonationInput { + donorId: number; + projectId: number; + amount: number; + donatedAt?: string; +} + +export class DonorValidationUtils { + static validateOrganization(organization: unknown): string | Error { + if (organization === undefined || organization === null || organization === '') { + return new Error("Organization is required"); + } + + if (typeof organization !== 'string' || organization.trim() === '') { + return new Error("Organization must be a non-empty string"); + } + + return organization; + } + + static validateContactName(contactName: unknown): string | Error | undefined { + if (contactName === undefined || contactName === null) { + return undefined; + } + if (typeof contactName !== 'string') { + return new Error("Contact name must be a string"); + } + return contactName; + } + + static validateContactEmail(contactEmail: unknown): string | undefined | Error { + if (contactEmail === undefined || contactEmail === null) { + return undefined; + } + + if (typeof contactEmail !== 'string' || contactEmail.trim() === '') { + return new Error('contact_email must be a non-empty string'); + } + + // Basic format check — not exhaustive, just catches obvious junk + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contactEmail)) { + return new Error('contact_email must be a valid email address'); + } + + return contactEmail; + } + + static validateDonorInput(body: Record): DonorInput | Error { + const organization = this.validateOrganization(body.organization); + if (organization instanceof Error) { + return organization; + } + + const contactName = this.validateContactName(body.contact_name); + if (contactName instanceof Error) { + return contactName; + } + + const contactEmail = this.validateContactEmail(body.contact_email); + if (contactEmail instanceof Error) { + return contactEmail; + } + + return { organization, contactName, contactEmail }; + } + +} + + +export class DonationValidationUtils { + static validateDonorId(donorId: unknown): number | Error { + if (donorId === undefined || donorId === null || donorId === '') { + return new Error("donorId is required"); + } + + if (typeof donorId !== 'number' || !Number.isInteger(donorId) || donorId <= 0) { + return new Error("donorId must be a positive integer"); + } + + return donorId; + } + + static validateProjectId(projectId: unknown): number | Error { + if (projectId === undefined || projectId === null || projectId === '') { + return new Error("projectId is required"); + } + + if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { + return new Error("projectId must be a positive integer"); + } + + return projectId; + } + + static validateAmount(amount: unknown): number | Error { + if (amount === undefined || amount === null || amount === '') { + return new Error("amount is required"); + } + + if (typeof amount !== 'number') { + return new Error("amount must be a number"); + } + + if (amount < 0) { + return new Error("amount must be non-negative"); + } + + return amount; + } + + static validateDonatedAt(donatedAt: unknown): string | undefined | Error { + if (donatedAt === undefined || donatedAt === null) { + return undefined; + } + + if (typeof donatedAt !== 'string' || isNaN(Date.parse(donatedAt))) { + return new Error('donatedAt must be a valid ISO date string'); + } + + return donatedAt; + } + + static validateDonationInput(body: Record): DonationInput | Error { + const donorId = this.validateDonorId(body.donorId); + if (donorId instanceof Error) { + return donorId; + } + + const projectId = this.validateProjectId(body.projectId); + if (projectId instanceof Error) { + return projectId; + } + + const amount = this.validateAmount(body.amount); + if (amount instanceof Error) { + return amount; + } + + const donatedAt = this.validateDonatedAt(body.donatedAt); + if (donatedAt instanceof Error) { + return donatedAt; + } + + return { donorId, projectId, amount, donatedAt }; + } + +}