diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 0d61827f..2c166f57 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -14,6 +14,7 @@ import { RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, + ApprovedPantryResponse, TotalStats, } from './types'; import { EmailsService } from '../emails/email.service'; @@ -255,6 +256,60 @@ describe('PantriesController', () => { }); }); + describe('getApprovedPantries', () => { + it('should return approved pantries with volunteers', async () => { + const mockApprovedPantries: ApprovedPantryResponse[] = [ + { + pantryId: 1, + pantryName: 'Community Food Pantry', + refrigeratedDonation: RefrigeratedDonation.YES, + volunteers: [ + { + userId: 10, + firstName: 'Alice', + lastName: 'Johnson', + email: 'alice.johnson@example.com', + phone: '(617) 555-0100', + }, + { + userId: 11, + firstName: 'Bob', + lastName: 'Williams', + email: 'bob.williams@example.com', + phone: '(617) 555-0101', + }, + ], + }, + ]; + + mockPantriesService.getApprovedPantriesWithVolunteers.mockResolvedValue( + mockApprovedPantries, + ); + + const result = await controller.getApprovedPantries(); + + expect(result).toEqual(mockApprovedPantries); + expect( + mockPantriesService.getApprovedPantriesWithVolunteers, + ).toHaveBeenCalledTimes(1); + }); + }); + + describe('updatePantryVolunteers', () => { + it('should overwrite the set of volunteers assigned to a pantry', async () => { + const pantryId = 1; + const volunteerIds = [10, 11, 12]; + + mockPantriesService.updatePantryVolunteers.mockResolvedValue(undefined); + + await controller.updatePantryVolunteers(pantryId, volunteerIds); + + expect(mockPantriesService.updatePantryVolunteers).toHaveBeenCalledWith( + pantryId, + volunteerIds, + ); + }); + }); describe('getCurrentUserPantryId', () => { it('returns pantryId for authenticated user', async () => { const req = { user: { id: 1 } }; diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index f99e0d35..3df980c6 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -5,15 +5,16 @@ import { Param, ParseIntPipe, Patch, + Put, Post, Query, Req, + ValidationPipe, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; import { PantriesService } from './pantries.service'; import { Role } from '../users/types'; import { Roles } from '../auth/roles.decorator'; -import { ValidationPipe } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ApiBody } from '@nestjs/swagger'; import { @@ -24,6 +25,7 @@ import { RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, + ApprovedPantryResponse, TotalStats, } from './types'; import { Order } from '../orders/order.entity'; @@ -75,6 +77,12 @@ export class PantriesController { return this.pantriesService.getPendingPantries(); } + @Roles(Role.ADMIN) + @Get('/approved') + async getApprovedPantries(): Promise { + return this.pantriesService.getApprovedPantriesWithVolunteers(); + } + @CheckOwnership({ idParam: 'pantryId', resolver: async ({ entityId, services }) => { @@ -371,4 +379,13 @@ export class PantriesController { attachments, ); } + + @Roles(Role.ADMIN) + @Put('/:pantryId/volunteers') + async updatePantryVolunteers( + @Param('pantryId', ParseIntPipe) pantryId: number, + @Body('volunteerIds') volunteerIds: number[], + ): Promise { + return this.pantriesService.updatePantryVolunteers(pantryId, volunteerIds); + } } diff --git a/apps/backend/src/pantries/pantries.entity.ts b/apps/backend/src/pantries/pantries.entity.ts index 5760acfc..b13c81df 100644 --- a/apps/backend/src/pantries/pantries.entity.ts +++ b/apps/backend/src/pantries/pantries.entity.ts @@ -208,7 +208,7 @@ export class Pantry { name: 'status', type: 'enum', enum: ApplicationStatus, - enumName: 'application_status_enum', + enumName: 'pantries_status_enum', }) status!: ApplicationStatus; diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index e6ed91f0..772028e2 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -205,6 +205,7 @@ describe('PantriesService', () => { shipmentAddressCity: 'Testville', shipmentAddressState: 'TX', shipmentAddressZip: '11111', + shipmentAddressCountry: 'USA', mailingAddressLine1: '1 Test St', mailingAddressCity: 'Testville', mailingAddressState: 'TX', @@ -575,4 +576,114 @@ describe('PantriesService', () => { ); }); }); + + describe('getApprovedPantriesWithVolunteers', () => { + it('should return approved pantries with mapped volunteer info', async () => { + const result = await service.getApprovedPantriesWithVolunteers(); + + expect(result.length).toBeGreaterThan(0); + expect(result.every((p) => p.pantryId)).toBe(true); + expect(result.every((p) => p.pantryName)).toBe(true); + result.forEach((p) => { + expect(p.volunteers).toBeDefined(); + p.volunteers.forEach((v) => { + expect(v.userId).toBeDefined(); + expect(v.firstName).toBeDefined(); + expect(v.lastName).toBeDefined(); + expect(v.email).toBeDefined(); + expect(v.phone).toBeDefined(); + }); + }); + }); + + it('should return empty volunteers array when pantry has no volunteers', async () => { + await service.addPantry({ + contactFirstName: 'Test', + contactLastName: 'Pantry', + contactEmail: 'test.novolunteers@example.com', + contactPhone: '555-000-9999', + hasEmailContact: false, + pantryName: 'No Volunteer Pantry', + shipmentAddressLine1: '1 Test St', + shipmentAddressCity: 'Boston', + shipmentAddressState: 'MA', + shipmentAddressZip: '02101', + mailingAddressLine1: '1 Test St', + mailingAddressCity: 'Boston', + mailingAddressState: 'MA', + mailingAddressZip: '02101', + allergenClients: 'none', + restrictions: ['none'], + refrigeratedDonation: RefrigeratedDonation.NO, + acceptFoodDeliveries: false, + reserveFoodForAllergic: ReserveFoodForAllergic.NO, + dedicatedAllergyFriendly: false, + activities: [Activity.CREATE_LABELED_SHELF], + itemsInStock: 'none', + needMoreOptions: 'none', + } as PantryApplicationDto); + + const saved = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryName: 'No Volunteer Pantry' }, + }); + await testDataSource.getRepository(Pantry).update(saved!.pantryId, { + status: ApplicationStatus.APPROVED, + }); + + const result = await service.getApprovedPantriesWithVolunteers(); + const pantryWithNoVolunteers = result.find( + (p) => p.pantryName === 'No Volunteer Pantry', + ); + expect(pantryWithNoVolunteers).toBeDefined(); + expect(pantryWithNoVolunteers?.volunteers).toEqual([]); + }); + + it('should return empty array when no approved pantries exist', async () => { + await testDataSource.query( + `UPDATE pantries SET status = 'pending' WHERE status = 'approved'`, + ); + const result = await service.getApprovedPantriesWithVolunteers(); + expect(result).toEqual([]); + }); + }); + + describe('updatePantryVolunteers', () => { + const getVolunteerId = async (email: string) => + ( + await testDataSource.query( + `SELECT user_id FROM users WHERE email = $1 LIMIT 1`, + [email], + ) + )[0].user_id; + + it('replaces volunteer set', async () => { + const williamId = Number(await getVolunteerId('william.m@volunteer.org')); + await service.updatePantryVolunteers(1, [williamId]); + const pantry = await testDataSource + .getRepository(Pantry) + .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] }); + expect(pantry?.volunteers).toHaveLength(1); + expect(pantry?.volunteers?.[0].id).toBe(williamId); + }); + + it('throws NotFoundException when pantry not found', async () => { + const williamId = Number(await getVolunteerId('william.m@volunteer.org')); + await expect( + service.updatePantryVolunteers(9999, [williamId]), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when volunteer id does not exist', async () => { + await expect(service.updatePantryVolunteers(1, [99999])).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws BadRequestException when user is not a volunteer', async () => { + const adminId = Number(await getVolunteerId('john.smith@ssf.org')); + await expect( + service.updatePantryVolunteers(1, [adminId]), + ).rejects.toThrow(BadRequestException); + }); + }); }); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 542cf100..35b78df5 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -15,6 +15,7 @@ import { validateId } from '../utils/validation.utils'; import { ApplicationStatus } from '../shared/types'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; +import { ApprovedPantryResponse } from './types'; import { PantryStats, TotalStats } from './types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; @@ -362,6 +363,64 @@ export class PantriesService { await this.repo.update(id, { status: ApplicationStatus.DENIED }); } + async getApprovedPantriesWithVolunteers(): Promise { + const pantries = await this.repo.find({ + where: { status: ApplicationStatus.APPROVED }, + relations: ['volunteers', 'pantryUser'], + }); + + return pantries.map((pantry) => ({ + pantryId: pantry.pantryId, + pantryName: pantry.pantryName, + refrigeratedDonation: pantry.refrigeratedDonation, + volunteers: (pantry.volunteers || []).map((volunteer) => ({ + userId: volunteer.id, + firstName: volunteer.firstName, + lastName: volunteer.lastName, + email: volunteer.email, + phone: volunteer.phone, + })), + })); + } + + async updatePantryVolunteers( + pantryId: number, + volunteerIds: number[], + ): Promise { + validateId(pantryId, 'Pantry'); + volunteerIds.forEach((id) => validateId(id, 'Volunteer')); + + const pantry = await this.repo.findOne({ + where: { pantryId }, + relations: ['volunteers'], + }); + + if (!pantry) { + throw new NotFoundException(`Pantry with ID ${pantryId} not found`); + } + + const users = await Promise.all( + volunteerIds.map((id) => this.usersService.findOne(id)), + ); + + if (users.length !== volunteerIds.length) { + throw new NotFoundException('One or more users not found'); + } + + const nonVolunteers = users.filter((user) => user.role !== Role.VOLUNTEER); + + if (nonVolunteers.length > 0) { + throw new BadRequestException( + `Users ${nonVolunteers + .map((user) => user.id) + .join(', ')} are not volunteers`, + ); + } + + pantry.volunteers = users; + await this.repo.save(pantry); + } + async findByIds(pantryIds: number[]): Promise { pantryIds.forEach((id) => validateId(id, 'Pantry')); diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index 0fb3f495..a2927d7d 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -1,3 +1,18 @@ +export interface ApprovedPantryResponse { + pantryId: number; + pantryName: string; + refrigeratedDonation: RefrigeratedDonation; + volunteers: AssignedVolunteer[]; +} + +export interface AssignedVolunteer { + userId: number; + firstName: string; + lastName: string; + email: string; + phone: string; +} + export enum RefrigeratedDonation { YES = 'Yes, always', NO = 'No',