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
155 changes: 141 additions & 14 deletions apps/backend/lambdas/donors/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,102 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

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 });
}

Expand All @@ -104,11 +176,47 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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();

Expand All @@ -117,6 +225,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

const donations = await db
.selectFrom('branch.project_donations')
.where('project_id', 'in', projectIds)
.selectAll()
.orderBy('donation_id', 'asc')
.limit(limit)
Expand All @@ -129,8 +238,26 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
});
}

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 });
Expand Down
32 changes: 18 additions & 14 deletions apps/backend/lambdas/donors/test/donors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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');
});

Expand All @@ -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 () => {
Expand All @@ -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);
});

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading
Loading