diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index f1e5fed..3872a95 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -56,30 +56,102 @@ export const handler = async (event: any): Promise => { if (page && limit) { const offset = (page - 1) * limit; + // Apply scoping: admins see all donors; members see donors for their projects + const user = authContext.user; + if (!user) return json(401, { message: 'Authentication required' }); + + let donorIds: { donor_id: number }[] = []; + if (user.isAdmin) { + const totalCount = await db + .selectFrom('branch.donors') + .select(db.fn.count('donor_id').as('count')) + .executeTakeFirst(); - const totalCount = await db - .selectFrom('branch.donors') - .select(db.fn.count('donor_id').as('count')) - .executeTakeFirst(); + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donors = await db + .selectFrom('branch.donors') + .selectAll() + .orderBy('donor_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donors, + pagination: { page, limit, totalItems, totalPages }, + }); + } - const totalItems = Number(totalCount?.count || 0); - const totalPages = Math.ceil(totalItems / limit); + // Non-admin: find projects user is a member of + const memberships = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); - const donors = await db - .selectFrom('branch.donors') - .selectAll() + const projectIds = memberships.map((m) => m.project_id); + if (projectIds.length === 0) { + return json(200, { data: [], pagination: { page, limit, totalItems: 0, totalPages: 0 } }); + } + + donorIds = await db + .selectFrom('branch.project_donations') + .where('project_id', 'in', projectIds) + .select('donor_id') + .distinct() .orderBy('donor_id', 'asc') .limit(limit) .offset(offset) .execute(); - return json(200, { - data: donors, - pagination: { page, limit, totalItems, totalPages }, - }); + // compute distinct donor count for these projects + const donorIdsAllForCount = await db + .selectFrom('branch.project_donations') + .where('project_id', 'in', projectIds) + .select('donor_id') + .distinct() + .execute(); + + const totalItems = donorIdsAllForCount.length; + const totalPages = Math.ceil(totalItems / limit); + + const donors = donorIds.length > 0 + ? await db.selectFrom('branch.donors').where('donor_id', 'in', donorIds.map((d) => d.donor_id)).selectAll().execute() + : []; + + return json(200, { data: donors, pagination: { page, limit, totalItems, totalPages } }); + } + // Non-paginated: apply scoping similarly + const user = authContext.user; + if (!user) return json(401, { message: 'Authentication required' }); + + if (user.isAdmin) { + const donors = await db.selectFrom('branch.donors').selectAll().execute(); + return json(200, { data: donors }); } - const donors = await db.selectFrom('branch.donors').selectAll().execute(); + const memberships = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + + const projectIds = memberships.map((m) => m.project_id); + if (projectIds.length === 0) return json(200, { data: [] }); + + const donorIdsAll = await db + .selectFrom('branch.project_donations') + .where('project_id', 'in', projectIds) + .select('donor_id') + .distinct() + .execute(); + + const donors = donorIdsAll.length > 0 + ? await db.selectFrom('branch.donors').where('donor_id', 'in', donorIdsAll.map((d) => d.donor_id)).selectAll().execute() + : []; + return json(200, { data: donors }); } @@ -104,11 +176,47 @@ export const handler = async (event: any): Promise => { const page = pageStr ? parseInt(pageStr, 10) : null; const limit = limitStr ? parseInt(limitStr, 10) : null; + const user = authContext.user; + if (!user) return json(401, { message: 'Authentication required' }); + if (page && limit) { const offset = (page - 1) * limit; + if (user.isAdmin) { + const totalCount = await db + .selectFrom('branch.project_donations') + .select(db.fn.count('donation_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .orderBy('donation_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donations, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const memberships = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + + const projectIds = memberships.map((m) => m.project_id); + if (projectIds.length === 0) return json(200, { data: [], pagination: { page, limit, totalItems: 0, totalPages: 0 } }); + const totalCount = await db .selectFrom('branch.project_donations') + .where('project_id', 'in', projectIds) .select(db.fn.count('donation_id').as('count')) .executeTakeFirst(); @@ -117,6 +225,7 @@ export const handler = async (event: any): Promise => { const donations = await db .selectFrom('branch.project_donations') + .where('project_id', 'in', projectIds) .selectAll() .orderBy('donation_id', 'asc') .limit(limit) @@ -129,8 +238,26 @@ export const handler = async (event: any): Promise => { }); } + if (user.isAdmin) { + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .execute(); + return json(200, { data: donations }); + } + + const membershipsAll = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + + const projectIdsAll = membershipsAll.map((m) => m.project_id); + if (projectIdsAll.length === 0) return json(200, { data: [] }); + const donations = await db .selectFrom('branch.project_donations') + .where('project_id', 'in', projectIdsAll) .selectAll() .execute(); return json(200, { data: donations }); diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index 4451d69..0ac5718 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -93,7 +93,9 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); expect(Array.isArray(body.data)).toBe(true); - expect(body.data.length).toBe(3); + // Non-admin user is a member only of project 1, so they should see only + // donors linked to that project (seed has 1 donor on project 1). + expect(body.data.length).toBe(1); }); test("401 when missing authorization header", async () => { @@ -115,8 +117,8 @@ describe("Donor API with data", () => { expect(body.pagination).toBeDefined(); expect(body.pagination.page).toBe(1); expect(body.pagination.limit).toBe(1); - expect(body.pagination.totalItems).toBe(3); - expect(body.pagination.totalPages).toBe(3); + expect(body.pagination.totalItems).toBe(1); + expect(body.pagination.totalPages).toBe(1); expect(body.data[0].organization).toBe('NIH'); }); @@ -126,9 +128,9 @@ describe("Donor API with data", () => { const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); - expect(body.data.length).toBe(1); + // With scoping a non-admin has only 1 total item, so page 2 returns no data. + expect(body.data.length).toBe(0); expect(body.pagination.page).toBe(2); - expect(body.data[0].organization).toBe('Harvard Medical'); }); test("GET /donors with limit larger than total returns all donors", async () => { @@ -137,8 +139,8 @@ describe("Donor API with data", () => { const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); - expect(body.data.length).toBe(3); - expect(body.pagination.totalItems).toBe(3); + expect(body.data.length).toBe(1); + expect(body.pagination.totalItems).toBe(1); expect(body.pagination.totalPages).toBe(1); }); @@ -149,7 +151,7 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); expect(body.pagination).toBeUndefined(); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(1); }); test("GET /donors with only limit returns all donors without pagination", async () => { @@ -159,7 +161,7 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); expect(body.pagination).toBeUndefined(); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(1); }); test("GET /donors returns 400 for page=0", async () => { @@ -201,7 +203,9 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); expect(Array.isArray(body.data)).toBe(true); - expect(body.data.length).toBe(3); + // Non-admin user sees only donations for their project (seed has 1 donation + // on project 1). + expect(body.data.length).toBe(1); }); test("GET /donations with page and limit returns paginated response", async () => { @@ -214,8 +218,8 @@ describe("Donor API with data", () => { expect(body.pagination).toBeDefined(); expect(body.pagination.page).toBe(1); expect(body.pagination.limit).toBe(1); - expect(body.pagination.totalItems).toBe(3); - expect(body.pagination.totalPages).toBe(3); + expect(body.pagination.totalItems).toBe(1); + expect(body.pagination.totalPages).toBe(1); }); test("GET /donations with only page returns all without pagination", async () => { @@ -225,7 +229,7 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); expect(body.pagination).toBeUndefined(); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(1); }); test("GET /donations with only limit returns all without pagination", async () => { @@ -235,7 +239,7 @@ describe("Donor API with data", () => { expect(res.statusCode).toBe(200); expect(body.pagination).toBeUndefined(); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(1); }); test("GET /donations returns 400 for page=0", async () => { diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index 10e993e..c55dbed 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -3,6 +3,31 @@ import db from './db'; import { ExpenditureValidationUtils } from './validation-utils'; import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; + +async function canAccessProjectLocal(userId: number, projectId: number): Promise { + try { + const user = await db + .selectFrom('branch.users') + .where('user_id', '=', userId) + .select('is_admin') + .executeTakeFirst(); + + if (user?.is_admin) return true; + + const membership = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', userId) + .where('project_id', '=', projectId) + .selectAll() + .executeTakeFirst(); + + return !!membership; + } catch (error) { + console.error('Error checking project access (local):', error); + return false; + } +} + function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { const authCheck = checkAuthorization(authContext, level, resourceUserId); if (!authCheck.allowed) { @@ -44,6 +69,9 @@ export const handler = async (event: any): Promise => { return json(401, { message: 'Authentication required' }); } + const user = authContext.user; + if (!user) return json(401, { message: 'Authentication required' }); + const queryParams = event.queryStringParameters || {}; const pageStr = queryParams.page as string | undefined; const limitStr = queryParams.limit as string | undefined; @@ -74,27 +102,81 @@ export const handler = async (event: any): Promise => { if (page && limit) { const offset = (page - 1) * limit; - const totalCount = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); + if (projectId !== null) { + // Access check for specific project + if (!user.isAdmin) { + const ok = await canAccessProjectLocal(user.userId!, projectId); + if (!ok) return json(403, { message: 'You do not have access to this project' }); + } + + const totalCount = await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const expenditures = await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); + + return json(200, { + data: expenditures, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + // No projectId: admins see all, others see expenditures for projects they belong to + if (user.isAdmin) { + const totalCount = await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const expenditures = await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); + return json(200, { + data: expenditures, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const memberships = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + const projectIds = memberships.map((m: { project_id: number }) => m.project_id); + if (projectIds.length === 0) return json(200, { data: [], pagination: { page, limit, totalItems: 0, totalPages: 0 } }); + + const totalCount = await db.selectFrom('branch.expenditures').where('project_id', 'in', projectIds).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst(); const totalItems = Number(totalCount?.count || 0); const totalPages = Math.ceil(totalItems / limit); - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); + const expenditures = await db.selectFrom('branch.expenditures').where('project_id', 'in', projectIds).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute(); + + return json(200, { data: expenditures, pagination: { page, limit, totalItems, totalPages } }); + } + + // Non-paginated + if (projectId !== null) { + if (!user.isAdmin) { + const ok = await canAccessProjectLocal(user.userId!, projectId); + if (!ok) return json(403, { message: 'You do not have access to this project' }); + } - return json(200, { - data: expenditures, - pagination: { page, limit, totalItems, totalPages }, - }); + const expenditures = await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute(); + return json(200, { data: expenditures }); } - const expenditures = projectId !== null - ? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute() - : await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); + if (user.isAdmin) { + const expenditures = await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute(); + return json(200, { data: expenditures }); + } + + const membershipsAll = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + const projectIdsAll = membershipsAll.map((m: { project_id: number }) => m.project_id); + if (projectIdsAll.length === 0) return json(200, { data: [] }); + const expenditures = await db.selectFrom('branch.expenditures').where('project_id', 'in', projectIdsAll).selectAll().orderBy('spent_on', 'desc').execute(); return json(200, { data: expenditures }); } diff --git a/apps/backend/lambdas/reports/handler.ts b/apps/backend/lambdas/reports/handler.ts index 8ce9b37..52f95ec 100644 --- a/apps/backend/lambdas/reports/handler.ts +++ b/apps/backend/lambdas/reports/handler.ts @@ -117,6 +117,7 @@ export const handler = async (event: any): Promise => { if ((normalizedPath === '/reports' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') { const authResult = await requireAuth(event); if ('errorResponse' in authResult) return authResult.errorResponse; + const { user } = authResult; const queryParams = event.queryStringParameters || {}; const pageStr = queryParams.page as string | undefined; @@ -148,27 +149,68 @@ export const handler = async (event: any): Promise => { if (page && limit) { const offset = (page - 1) * limit; - const totalCount = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst() - : await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); + if (projectId !== null) { + // check access for specific project + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); + if (!hasAccess) return json(403, { message: 'You do not have access to this project' }); + + const totalCount = await db.selectFrom('branch.reports').where('project_id', '=', projectId).select(db.fn.count('report_id').as('count')).executeTakeFirst(); + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const reports = await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + return json(200, { data: reports, pagination: { page, limit, totalItems, totalPages } }); + } + + // No projectId filter — admins see all, others only their projects + if (user.isAdmin) { + const totalCount = await db.selectFrom('branch.reports').select(db.fn.count('report_id').as('count')).executeTakeFirst(); + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const reports = await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + return json(200, { data: reports, pagination: { page, limit, totalItems, totalPages } }); + } + const memberships = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + const projectIds = memberships.map((m) => m.project_id); + if (projectIds.length === 0) return json(200, { data: [], pagination: { page, limit, totalItems: 0, totalPages: 0 } }); + + const totalCount = await db.selectFrom('branch.reports').where('project_id', 'in', projectIds).select(db.fn.count('report_id').as('count')).executeTakeFirst(); const totalItems = Number(totalCount?.count || 0); const totalPages = Math.ceil(totalItems / limit); - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + const reports = await db.selectFrom('branch.reports').where('project_id', 'in', projectIds).selectAll().orderBy('date_created', 'desc').limit(limit).offset(offset).execute(); + return json(200, { data: reports, pagination: { page, limit, totalItems, totalPages } }); + } + + // Non-paginated + if (projectId !== null) { + const hasAccess = await checkProjectAccess(user.userId!, projectId, user.isAdmin ?? false); + if (!hasAccess) return json(403, { message: 'You do not have access to this project' }); - return json(200, { - data: reports, - pagination: { page, limit, totalItems, totalPages }, - }); + const reports = await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute(); + return json(200, { data: reports }); } - const reports = projectId !== null - ? await db.selectFrom('branch.reports').where('project_id', '=', projectId).selectAll().orderBy('date_created', 'desc').execute() - : await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); + if (user.isAdmin) { + const reports = await db.selectFrom('branch.reports').selectAll().orderBy('date_created', 'desc').execute(); + return json(200, { data: reports }); + } + + const membershipsAll = await db + .selectFrom('branch.project_memberships') + .where('user_id', '=', user.userId!) + .select('project_id') + .execute(); + const projectIdsAll = membershipsAll.map((m) => m.project_id); + if (projectIdsAll.length === 0) return json(200, { data: [] }); + const reports = await db.selectFrom('branch.reports').where('project_id', 'in', projectIdsAll).selectAll().orderBy('date_created', 'desc').execute(); return json(200, { data: reports }); }