diff --git a/apps/backend/src/donations/donations.controller.spec.ts b/apps/backend/src/donations/donations.controller.spec.ts index 20400d45a..486987de8 100644 --- a/apps/backend/src/donations/donations.controller.spec.ts +++ b/apps/backend/src/donations/donations.controller.spec.ts @@ -9,7 +9,10 @@ import { DonationStatus, RecurrenceEnum } from './types'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { FoodType } from '../donationItems/types'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +const mockFoodManufacturersService = mock(); const mockDonationService = mock(); const donation1: Partial = { @@ -35,6 +38,10 @@ describe('DonationsController', () => { provide: DonationService, useValue: mockDonationService, }, + { + provide: FoodManufacturersService, + useValue: mockFoodManufacturersService, + }, ], }).compile(); @@ -86,7 +93,6 @@ describe('DonationsController', () => { describe('POST /', () => { it('should call donationService.create and return the created donation', async () => { const createBody: Partial = { - foodManufacturerId: 1, recurrence: RecurrenceEnum.MONTHLY, recurrenceFreq: 3, occurrencesRemaining: 2, @@ -100,6 +106,12 @@ describe('DonationsController', () => { ] as CreateDonationItemDto[], }; + const mockReq = { user: { id: 1 } }; + + mockFoodManufacturersService.findByUserId.mockResolvedValueOnce({ + foodManufacturerId: 1, + } as any); + const createdDonation: Partial = { donationId: 1, ...createBody, @@ -112,11 +124,12 @@ describe('DonationsController', () => { ); const result = await controller.createDonation( + mockReq as AuthenticatedRequest, createBody as CreateDonationDto, ); expect(result).toEqual(createdDonation); - expect(mockDonationService.create).toHaveBeenCalledWith(createBody); + expect(mockDonationService.create).toHaveBeenCalledWith(createBody, 1); }); }); diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 19657b089..3b9a05795 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -9,6 +9,7 @@ import { ParseArrayPipe, Put, Delete, + Req, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; @@ -23,10 +24,14 @@ import { Role } from '../users/types'; import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; @Controller('donations') export class DonationsController { - constructor(private donationService: DonationService) {} + constructor( + private donationService: DonationService, + private foodManufacturersService: FoodManufacturersService, + ) {} @Get() async getAllDonations(): Promise { @@ -51,7 +56,6 @@ export class DonationsController { schema: { type: 'object', properties: { - foodManufacturerId: { type: 'integer', example: 1 }, recurrence: { type: 'string', enum: Object.values(RecurrenceEnum), @@ -93,11 +97,16 @@ export class DonationsController { }, }, }) + @Roles(Role.FOODMANUFACTURER) async createDonation( - @Body() - body: CreateDonationDto, + @Req() req: AuthenticatedRequest, + @Body() body: CreateDonationDto, ): Promise { - return this.donationService.create(body); + const manufacturer = await this.foodManufacturersService.findByUserId( + req.user.id, + ); + const foodManufacturerId = manufacturer.foodManufacturerId; + return this.donationService.create(body, foodManufacturerId); } @Patch('/:donationId/fulfill') diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 7fc1b0b67..aa6a4b4d7 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -6,7 +6,11 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { RecurrenceEnum, DayOfWeek, DonationStatus } from './types'; import { RepeatOnDaysDto } from './dtos/create-donation.dto'; import { testDataSource } from '../config/typeormTestDataSource'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; import { DonationItem } from '../donationItems/donationItems.entity'; import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto'; import { DonationItemsService } from '../donationItems/donationItems.service'; @@ -924,11 +928,13 @@ describe('DonationService', () => { ]; it('successfully creates a donation with items', async () => { - const donation = await service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.NONE, - items: validItems, - }); + const donation = await service.create( + { + recurrence: RecurrenceEnum.NONE, + items: validItems, + }, + 1, + ); expect(donation).toBeDefined(); expect(donation.donationId).toBeDefined(); @@ -960,13 +966,15 @@ describe('DonationService', () => { const before = new Date(); before.setHours(0, 0, 0, 0); - const donation = await service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.MONTHLY, - recurrenceFreq: 1, - occurrencesRemaining: 3, - items: validItems, - }); + const donation = await service.create( + { + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 1, + occurrencesRemaining: 3, + items: validItems, + }, + 1, + ); const rows = await testDataSource.query( `SELECT next_donation_dates, occurrences_remaining, recurrence, recurrence_freq @@ -997,11 +1005,13 @@ describe('DonationService', () => { it('throws when foodManufacturerId does not exist', async () => { expect( - service.create({ - foodManufacturerId: 99999, - recurrence: RecurrenceEnum.NONE, - items: validItems, - }), + service.create( + { + recurrence: RecurrenceEnum.NONE, + items: validItems, + }, + 99999, + ), ).rejects.toThrow( new NotFoundException('Food Manufacturer 99999 not found'), ); @@ -1011,20 +1021,22 @@ describe('DonationService', () => { let donations = await testDataSource.query(`SELECT * FROM donations`); expect(donations).toHaveLength(4); await expect( - service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.WEEKLY, - repeatOnDays: { - Sunday: false, - Monday: true, - Tuesday: false, - Wednesday: false, - Thursday: false, - Friday: false, - Saturday: false, + service.create( + { + recurrence: RecurrenceEnum.WEEKLY, + repeatOnDays: { + Sunday: false, + Monday: true, + Tuesday: false, + Wednesday: false, + Thursday: false, + Friday: false, + Saturday: false, + }, + items: validItems, }, - items: validItems, - }), + 1, + ), ).rejects.toThrow( new BadRequestException( 'recurrenceFreq is required for recurring donations', @@ -1040,24 +1052,40 @@ describe('DonationService', () => { expect(donations).toHaveLength(4); await expect( - service.create({ - foodManufacturerId: 1, - recurrence: RecurrenceEnum.NONE, - items: [ - ...validItems, - { - itemName: 'a'.repeat(1000), - quantity: 5, - foodType: FoodType.DAIRY_FREE_ALTERNATIVES, - foodRescue: false, - }, - ], - }), + service.create( + { + recurrence: RecurrenceEnum.NONE, + items: [ + ...validItems, + { + itemName: 'a'.repeat(1000), + quantity: 5, + foodType: FoodType.DAIRY_FREE_ALTERNATIVES, + foodRescue: false, + }, + ], + }, + 1, + ), ).rejects.toThrow(); donations = await testDataSource.query(`SELECT * FROM donations`); expect(donations).toHaveLength(4); }); + + it('throws ConflictException when foodManufacturerId not approved', async () => { + await expect( + service.create( + { + recurrence: RecurrenceEnum.NONE, + items: validItems, + }, + 3, + ), + ).rejects.toThrow( + new ConflictException('Food Manufacturer 3 not approved'), + ); + }); }); describe('replaceDonationItems', () => { diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 88263b43c..543f113b1 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, Logger, NotFoundException, @@ -18,6 +19,7 @@ import { DonationItemsService } from '../donationItems/donationItems.service'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; import { DonationItem } from '../donationItems/donationItems.entity'; import { Allocation } from '../allocations/allocations.entity'; +import { ApplicationStatus } from '../shared/types'; @Injectable() export class DonationService { @@ -58,15 +60,24 @@ export class DonationService { return this.repo.count(); } - async create(donationData: CreateDonationDto): Promise { - validateId(donationData.foodManufacturerId, 'Food Manufacturer'); + async create( + donationData: CreateDonationDto, + foodManufacturerId: number, + ): Promise { + validateId(foodManufacturerId, 'Food Manufacturer'); const manufacturer = await this.manufacturerRepo.findOne({ - where: { foodManufacturerId: donationData.foodManufacturerId }, + where: { foodManufacturerId }, }); if (!manufacturer) { throw new NotFoundException( - `Food Manufacturer ${donationData.foodManufacturerId} not found`, + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + + if (manufacturer.status !== ApplicationStatus.APPROVED) { + throw new ConflictException( + `Food Manufacturer ${foodManufacturerId} not approved`, ); } diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts index 523e6c085..04e7b2b25 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -65,10 +65,6 @@ export class RepeatOnDaysDto { } export class CreateDonationDto { - @IsInt() - @Min(1) - foodManufacturerId!: number; - @IsNotEmpty() @IsEnum(RecurrenceEnum) recurrence!: RecurrenceEnum; diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index ae3d08e50..b289d70b9 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -30,6 +30,7 @@ import { PantriesService } from '../pantries/pantries.service'; import { Pantry } from '../pantries/pantries.entity'; import { Allocation } from '../allocations/allocations.entity'; import { RecurrenceEnum } from '../donations/types'; +import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; jest.setTimeout(60000); @@ -616,6 +617,7 @@ describe('FoodManufacturersService', () => { futureDate1.setMilliseconds(0); futureDate1.setDate(futureDate1.getDate() + 30); clampDay(futureDate1); + const futureDate2 = new Date(); futureDate2.setMilliseconds(0); futureDate2.setDate(futureDate2.getDate() + 60); @@ -876,5 +878,93 @@ describe('FoodManufacturersService', () => { new NotFoundException('Food Manufacturer 9999 not found'), ); }); + + it('throws ConflictException for pending manufacturer', async () => { + await expect(service.getUpcomingDonationReminders(3)).rejects.toThrow( + new ConflictException( + 'Cannot get donation reminders for a pending food manufacturer', + ), + ); + }); + }); + + describe(`updateFoodManufacturerApplication`, () => { + it('updates an existing food manufacturer successfully', async () => { + const dto: UpdateFoodManufacturerApplicationDto = { + secondaryContactFirstName: 'John', + secondaryContactLastName: 'Doe', + secondaryContactEmail: 'johndoe@gmail.com', + secondaryContactPhone: '1234567890', + }; + + const updatedFoodManufacturer = + await service.updateFoodManufacturerApplication(1, dto, 3); + expect(updatedFoodManufacturer.secondaryContactFirstName).toBe('John'); + expect(updatedFoodManufacturer.secondaryContactLastName).toBe('Doe'); + expect(updatedFoodManufacturer.secondaryContactEmail).toBe( + 'johndoe@gmail.com', + ); + expect(updatedFoodManufacturer.secondaryContactPhone).toBe('1234567890'); + }); + + it('throws NotFoundException when food manufacturer does not exist', async () => { + const dto: UpdateFoodManufacturerApplicationDto = { + secondaryContactFirstName: 'Jane', + }; + + await expect( + service.updateFoodManufacturerApplication(9999, dto, 3), + ).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + + it('updates only the provided fields and keeps others intact', async () => { + const original = await service.findOne(1); + + const dto: UpdateFoodManufacturerApplicationDto = { + unlistedProductAllergens: [Allergen.MILK], + }; + + const updated = await service.updateFoodManufacturerApplication( + 1, + dto, + 3, + ); + expect(updated.unlistedProductAllergens).toStrictEqual([Allergen.MILK]); + expect(updated.foodManufacturerName).toBe(original.foodManufacturerName); + expect(updated.secondaryContactEmail).toBe( + original.secondaryContactEmail, + ); + }); + + it('throws ForbiddenException when user is not authorized to update pantry', async () => { + const dto: UpdateFoodManufacturerApplicationDto = { + foodManufacturerName: 'FoodCorp Industries LLC', + }; + + const invalidUserId = 999; + + await expect( + service.updateFoodManufacturerApplication(1, dto, invalidUserId), + ).rejects.toThrow( + new ForbiddenException( + `User ${invalidUserId} is not allowed to edit application for Food Manufacturer 1`, + ), + ); + }); + + it('throws ConflictException for a pending manufacturer', async () => { + const dto: UpdateFoodManufacturerApplicationDto = { + secondaryContactFirstName: 'Jane', + }; + await expect( + service.updateFoodManufacturerApplication(3, dto, 5), + ).rejects.toThrow( + new ConflictException( + 'Cannot update application for a pending manufacturer', + ), + ); + }); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index bc7a415f6..1157f0d13 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -168,6 +168,12 @@ export class FoodManufacturersService { ); } + if (manufacturer.status != ApplicationStatus.APPROVED) { + throw new ConflictException( + `Cannot get donation reminders for a ${manufacturer.status} food manufacturer`, + ); + } + const donations = await this.donationsRepo.find({ where: { foodManufacturer: { foodManufacturerId } }, }); @@ -341,6 +347,12 @@ export class FoodManufacturersService { ); } + if (manufacturer.status !== ApplicationStatus.APPROVED) { + throw new ConflictException( + `Cannot update application for a ${manufacturer.status} manufacturer`, + ); + } + Object.assign(manufacturer, foodManufacturerData); return this.repo.save(manufacturer); diff --git a/apps/backend/src/foodRequests/request.service.spec.ts b/apps/backend/src/foodRequests/request.service.spec.ts index 67f2ce891..849da2478 100644 --- a/apps/backend/src/foodRequests/request.service.spec.ts +++ b/apps/backend/src/foodRequests/request.service.spec.ts @@ -14,6 +14,7 @@ import { BadRequestException, InternalServerErrorException, NotFoundException, + ConflictException, } from '@nestjs/common'; import { EmailsService } from '../emails/email.service'; import { mock } from 'jest-mock-extended'; @@ -264,8 +265,12 @@ describe('RequestsService', () => { }); it('should send emails to nobody if request creation succeeds wthout any volunteers', async () => { - // Harbor Community Center - no volunteers assigned - const pantryId = 5; + // update the database so that approved pantry 2 has no volunteers + const pantryId = 2; + await testDataSource.query(` + DELETE FROM volunteer_assignments WHERE pantry_id = ${pantryId} + `); + const pantry = await testDataSource.getRepository(Pantry).findOne({ where: { pantryId }, relations: ['pantryUser', 'volunteers'], @@ -319,6 +324,28 @@ describe('RequestsService', () => { ), ).rejects.toThrow(new NotFoundException('Pantry 999 not found')); }); + + it('should throw ConflictException for denied pantry', async () => { + await expect( + service.create( + 4, + RequestSize.MEDIUM, + [FoodType.DRIED_BEANS, FoodType.REFRIGERATED_MEALS], + 'Additional info', + ), + ).rejects.toThrow(new ConflictException('Pantry 4 not approved')); + }); + + it('should throw ConflictException for pending pantry', async () => { + await expect( + service.create( + 5, + RequestSize.MEDIUM, + [FoodType.DRIED_BEANS, FoodType.REFRIGERATED_MEALS], + 'Additional info', + ), + ).rejects.toThrow(new ConflictException('Pantry 5 not approved')); + }); }); describe('findAllForPantry', () => { @@ -593,7 +620,16 @@ describe('RequestsService', () => { }); it('returns empty matchingItems array for no available matching items', async () => { - const result = await service.getAvailableItems(2, 3); + // update FM ID 2 to have none of the food types requested in request ID 4 + await testDataSource.query(` + UPDATE donation_items di + SET reserved_quantity = quantity + FROM donations d + WHERE di.donation_id = d.donation_id + AND d.food_manufacturer_id = 2 + AND di.food_type IN ('Whole-Grain Cookies', 'Dairy-Free Alternatives', 'Nut-Free Granola Bars') + `); + const result = await service.getAvailableItems(4, 2); expect(result.matchingItems).toHaveLength(0); }); @@ -613,6 +649,12 @@ describe('RequestsService', () => { new NotFoundException('Food Manufacturer 999 not found'), ); }); + + it('throws ConflictException for non-approved manufacturer', async () => { + await expect(service.getAvailableItems(1, 3)).rejects.toThrow( + new ConflictException('Food Manufacturer 3 not approved'), + ); + }); }); describe('update', () => { diff --git a/apps/backend/src/foodRequests/request.service.ts b/apps/backend/src/foodRequests/request.service.ts index 12a4dd511..df251a5fc 100644 --- a/apps/backend/src/foodRequests/request.service.ts +++ b/apps/backend/src/foodRequests/request.service.ts @@ -3,6 +3,7 @@ import { Injectable, InternalServerErrorException, NotFoundException, + ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -24,6 +25,7 @@ import { DonationItem } from '../donationItems/donationItems.entity'; import { EmailsService } from '../emails/email.service'; import { emailTemplates } from '../emails/emailTemplates'; import { UpdateRequestDto } from './dtos/update-request.dto'; +import { ApplicationStatus } from '../shared/types'; @Injectable() export class RequestsService { @@ -182,12 +184,19 @@ export class RequestsService { const manufacturer = await this.foodManufacturerRepo.findOne({ where: { foodManufacturerId }, }); + if (!manufacturer) { throw new NotFoundException( `Food Manufacturer ${foodManufacturerId} not found`, ); } + if (manufacturer.status !== ApplicationStatus.APPROVED) { + throw new ConflictException( + `Food Manufacturer ${foodManufacturerId} not approved`, + ); + } + const availableItems = await this.donationItemRepo .createQueryBuilder('di') .select([ @@ -231,10 +240,15 @@ export class RequestsService { where: { pantryId }, relations: ['pantryUser', 'volunteers'], }); + if (!pantry) { throw new NotFoundException(`Pantry ${pantryId} not found`); } + if (pantry.status !== ApplicationStatus.APPROVED) { + throw new ConflictException(`Pantry ${pantryId} not approved`); + } + const foodRequest = this.repo.create({ pantryId, requestedSize, diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 1ce14b2b3..81bc22a7c 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -875,7 +875,46 @@ describe('OrdersService', () => { }); expect(donationItem1?.reservedQuantity).toBe(10); }); + + it('throw BadRequestException if pantry is denied', async () => { + const pantryId = 4; + await testDataSource.query(` + UPDATE food_requests + SET pantry_id = ${pantryId} + WHERE request_id = 1; + `); + await expect( + service.create( + 1, + validCreateOrderDto.manufacturerId, + parsedAllocations, + 3, + ), + ).rejects.toThrow( + new BadRequestException(`Pantry ${pantryId} is not approved`), + ); + }); + + it('throw BadRequestException if pantry is pending', async () => { + const pantryId = 5; + await testDataSource.query(` + UPDATE food_requests + SET pantry_id = ${pantryId} + WHERE request_id = 1; + `); + await expect( + service.create( + 1, + validCreateOrderDto.manufacturerId, + parsedAllocations, + 3, + ), + ).rejects.toThrow( + new BadRequestException(`Pantry ${pantryId} is not approved`), + ); + }); }); + describe('getAllOrdersForVolunteer', () => { it('should return all orders across all pantries and assignees, with required actions for assigned orders', async () => { const volunteerId = 6; diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 8ac255843..77f84d670 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -25,6 +25,7 @@ import { AllocationsService } from '../allocations/allocations.service'; import { ApplicationStatus } from '../shared/types'; import { VolunteerOrder } from '../volunteers/types'; import { OrderSummary } from '../pantries/types'; +import { PantriesService } from '../pantries/pantries.service'; @Injectable() export class OrdersService { @@ -32,13 +33,12 @@ export class OrdersService { @InjectRepository(Order) private repo: Repository, @InjectRepository(Pantry) private pantryRepo: Repository, @InjectRepository(Donation) private donationRepo: Repository, - @InjectRepository(DonationItem) - private donationItemRepo: Repository, private requestsService: RequestsService, private donationService: DonationService, private manufacturerService: FoodManufacturersService, private donationItemsService: DonationItemsService, private allocationsService: AllocationsService, + private pantriesService: PantriesService, @InjectDataSource() private dataSource: DataSource, ) {} @@ -209,6 +209,14 @@ export class OrdersService { ); } + const pantry = await this.pantriesService.findOne(request.pantryId); + + if (pantry.status !== ApplicationStatus.APPROVED) { + throw new BadRequestException( + `Pantry ${request.pantryId} is not approved`, + ); + } + const fmDonations = await this.donationRepo.find({ where: { foodManufacturer: { foodManufacturerId: manufacturerId } }, select: ['donationId'], @@ -365,6 +373,7 @@ export class OrdersService { pantry: { pantryId: true, pantryName: true, + status: true, }, }, }, @@ -399,6 +408,7 @@ export class OrdersService { if (!order) { throw new NotFoundException(`Order ${orderId} not found`); } + return order.foodManufacturer; } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 0c9cc4dcb..b941eb5d7 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -422,13 +422,13 @@ describe('PantriesService', () => { }); it('updates only the provided fields and keeps others intact', async () => { - const original = await service.findOne(2); + const original = await service.findOne(1); const dto: UpdatePantryApplicationDto = { itemsInStock: 'Rice and beans', }; - const updated = await service.updatePantryApplication(2, dto, 11); + const updated = await service.updatePantryApplication(1, dto, 10); expect(updated.itemsInStock).toBe('Rice and beans'); expect(updated.pantryName).toBe(original.pantryName); expect(updated.secondaryContactEmail).toBe( @@ -444,10 +444,34 @@ describe('PantriesService', () => { const invalidUserId = 999; await expect( - service.updatePantryApplication(1, dto, invalidUserId), + service.updatePantryApplication(5, dto, invalidUserId), ).rejects.toThrow( new ForbiddenException( - `User ${invalidUserId} is not allowed to edit application for Pantry 1`, + `User ${invalidUserId} is not allowed to edit application for Pantry 5`, + ), + ); + }); + + it('throws ConflictException when pantry application is for a denied pantry', async () => { + const dto: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'Jane', + }; + + await expect(service.updatePantryApplication(4, dto, 13)).rejects.toThrow( + new ConflictException( + 'Cannot update application for a denied application', + ), + ); + }); + + it('throws ConflictException when pantry application is for a pending pantry', async () => { + const dto: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'Jane', + }; + + await expect(service.updatePantryApplication(5, dto, 14)).rejects.toThrow( + new ConflictException( + 'Cannot update application for a pending application', ), ); }); @@ -1146,6 +1170,24 @@ describe('PantriesService', () => { .findOne({ where: { pantryId: 1 }, relations: ['volunteers'] }); expect(pantryBefore?.volunteers).toEqual(pantryAfter?.volunteers); }); + + it(`throws 'Pantry with ID {pantryId} not approved' ConflictException when updating volunteers for a pending pantry`, async () => { + await expect( + service.updatePantryVolunteers(5, { + addVolunteerIds: [6], + removeVolunteerIds: [], + }), + ).rejects.toThrow(ConflictException); + }); + + it(`throws ConflictException when updating volunteers for a denied pantry`, async () => { + await expect( + service.updatePantryVolunteers(4, { + addVolunteerIds: [6], + removeVolunteerIds: [], + }), + ).rejects.toThrow(ConflictException); + }); }); describe('getDashboardStats', () => { diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 078325316..b82013b71 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -395,6 +395,12 @@ export class PantriesService { ); } + if (pantry.status !== ApplicationStatus.APPROVED) { + throw new ConflictException( + `Cannot update application for a ${pantry.status} application`, + ); + } + Object.assign(pantry, pantryData); return this.repo.save(pantry); @@ -525,6 +531,10 @@ export class PantriesService { throw new NotFoundException(`Pantry with ID ${pantryId} not found`); } + if (pantry.status !== ApplicationStatus.APPROVED) { + throw new ConflictException(`Pantry with ID ${pantryId} not approved`); + } + const uniqueVolunteerIds = new Set([ ...addVolunteerIds, ...removeVolunteerIds, diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index a39c7faae..b0557649e 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -349,7 +349,6 @@ describe('UsersService', () => { const now = new Date(); const createDonationBody: Partial = { - foodManufacturerId: 1, recurrence: RecurrenceEnum.MONTHLY, recurrenceFreq: 3, occurrencesRemaining: 2, @@ -363,7 +362,7 @@ describe('UsersService', () => { ], }; - await donationService.create(createDonationBody as CreateDonationDto); + await donationService.create(createDonationBody as CreateDonationDto, 1); // updating existing request to have a current month requested at date const existingRequest = await foodRequestService.findOne(1); diff --git a/apps/frontend/src/components/foodRequestManagement.tsx b/apps/frontend/src/components/foodRequestManagement.tsx index 1db69e7df..a520ef926 100644 --- a/apps/frontend/src/components/foodRequestManagement.tsx +++ b/apps/frontend/src/components/foodRequestManagement.tsx @@ -51,8 +51,7 @@ const RequestManagement: React.FC = ({ const [selectedCreateOrderRequest, setSelectedCreateOrderRequest] = useState(null); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const navigate = useNavigate(); const location = useLocation(); @@ -62,9 +61,9 @@ const RequestManagement: React.FC = ({ const data = await fetchData(); setRequests(data); } catch { - setErrorMessage('Error fetching requests'); + setAlertMessage('Error fetching requests', 'error'); } - }, [fetchData, setErrorMessage]); + }, [fetchData, setAlertMessage]); useEffect(() => { loadRequests(); @@ -150,19 +149,11 @@ const RequestManagement: React.FC = ({ Food Request Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -414,7 +405,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCloseRequest} onSuccess={() => { - setSuccessMessage('Request Closed'); + setAlertMessage('Request Closed', 'success'); loadRequests(); }} /> @@ -426,7 +417,7 @@ const RequestManagement: React.FC = ({ isOpen={true} onClose={clearCreateOrder} onSuccess={() => { - setSuccessMessage('Order Created'); + setAlertMessage('Order Created', 'success'); loadRequests(); }} /> diff --git a/apps/frontend/src/components/forms/assignVolunteersModal.tsx b/apps/frontend/src/components/forms/assignVolunteersModal.tsx index a4aeb70ba..332ba34ab 100644 --- a/apps/frontend/src/components/forms/assignVolunteersModal.tsx +++ b/apps/frontend/src/components/forms/assignVolunteersModal.tsx @@ -70,7 +70,7 @@ const AssignVolunteersModal: React.FC = ({ setVolunteers(normalized); setSelectedIds(new Set(assignedIds)); } catch { - setAlertMessage('Error fetching volunteers'); + setAlertMessage('Error fetching volunteers', 'error'); } }; @@ -112,7 +112,7 @@ const AssignVolunteersModal: React.FC = ({ onSuccess(); onClose(); } catch { - setAlertMessage('Error saving volunteer assignments'); + setAlertMessage('Error saving volunteer assignments', 'error'); } }; diff --git a/apps/frontend/src/components/forms/changePasswordModal.tsx b/apps/frontend/src/components/forms/changePasswordModal.tsx index 7893aa340..2409fd0b7 100644 --- a/apps/frontend/src/components/forms/changePasswordModal.tsx +++ b/apps/frontend/src/components/forms/changePasswordModal.tsx @@ -39,12 +39,12 @@ const ChangePasswordModal: React.FC = ({ const handleChangePassword = async () => { if (password.length < 8) { - setAlertMessage('Password must be at least 8 characters'); + setAlertMessage('Password must be at least 8 characters', 'error'); return; } if (password !== confirmPassword) { - setAlertMessage('Passwords must match'); + setAlertMessage('Passwords must match', 'error'); return; } @@ -58,11 +58,14 @@ const ChangePasswordModal: React.FC = ({ onSuccess(); } catch (err: any) { if (err.name === 'LimitExceededException') { - setAlertMessage('Limit exceeded, please try again later'); + setAlertMessage('Limit exceeded, please try again later', 'error'); } else if (err.name === 'NotAuthorizedException') { - setAlertMessage('Failed to update password, old password is incorrect'); + setAlertMessage( + 'Failed to update password, old password is incorrect', + 'error', + ); } else { - setAlertMessage('Failed to update password, please try again'); + setAlertMessage('Failed to update password, please try again', 'error'); } } }; @@ -93,7 +96,7 @@ const ChangePasswordModal: React.FC = ({ open={open} onOpenChange={(e: { open: boolean }) => { if (!e.open) { - setAlertMessage(''); + setAlertMessage('', 'error'); onClose(); } }} diff --git a/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx b/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx index 6c7543d99..c9427f3dd 100644 --- a/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/completeRequiredActionsModal.tsx @@ -46,7 +46,7 @@ const CompleteRequiredActionsModal: React.FC< await ApiClient.completeOrderAction(order.orderId, action); onActionCompleted(order.orderId, action); } catch { - setAlertMessage('Error completing action. Please try again.'); + setAlertMessage('Error completing action. Please try again.', 'error'); } finally { setLoadingAction(null); } diff --git a/apps/frontend/src/components/forms/createNewOrderModal.tsx b/apps/frontend/src/components/forms/createNewOrderModal.tsx index f08af90e3..763ff6259 100644 --- a/apps/frontend/src/components/forms/createNewOrderModal.tsx +++ b/apps/frontend/src/components/forms/createNewOrderModal.tsx @@ -62,7 +62,7 @@ const CreateNewOrderModal: React.FC = ({ ); setManufacturers(data); } catch { - setAlertMessage('Error fetching manufacturers'); + setAlertMessage('Error fetching manufacturers', 'error'); } }; fetchManufacturers(); @@ -114,7 +114,7 @@ const CreateNewOrderModal: React.FC = ({ ); setManufacturerItems(data); } catch { - setAlertMessage('Error fetching manufacturer items'); + setAlertMessage('Error fetching manufacturer items', 'error'); } }; @@ -140,7 +140,7 @@ const CreateNewOrderModal: React.FC = ({ onClose(); onSuccess(); } catch { - setAlertMessage('Error creating new order'); + setAlertMessage('Error creating new order', 'error'); } }; diff --git a/apps/frontend/src/components/forms/donationDetailsModal.tsx b/apps/frontend/src/components/forms/donationDetailsModal.tsx index 3b61ab4b7..6f5733924 100644 --- a/apps/frontend/src/components/forms/donationDetailsModal.tsx +++ b/apps/frontend/src/components/forms/donationDetailsModal.tsx @@ -36,7 +36,7 @@ const DonationDetailsModal: React.FC = ({ setItems(itemsData); } catch { - setAlertMessage('Error fetching donation details'); + setAlertMessage('Error fetching donation details', 'error'); } }; diff --git a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx index 20e76d0ff..fca8aeddc 100644 --- a/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx +++ b/apps/frontend/src/components/forms/fmCompleteRequiredActionsModal.tsx @@ -192,12 +192,14 @@ const FmCompleteRequiredActionsModal: React.FC< if (shippingCost !== '' && !isValidShippingCost(shippingCost)) { setAlertMessage( `Shipping cost for order ${order.orderId} must be a positive number with up to 2 decimal places.`, + 'error', ); return; } if (trackingLink.trim() !== '' && !isValidUrl(trackingLink)) { setAlertMessage( `Tracking link for order ${order.orderId} must be a valid http or https URL.`, + 'error', ); return; } @@ -296,6 +298,7 @@ const FmCompleteRequiredActionsModal: React.FC< msg ? msg.replace(/^orders\.\d+\./, '') : 'Error completing required actions. Please try again.', + 'error', ); } finally { setIsSubmitting(false); diff --git a/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx b/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx index e104581cf..dd44ab55c 100644 --- a/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx +++ b/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx @@ -76,7 +76,7 @@ const ManufacturerApplicationForm: React.FC = () => { useEffect(() => { if (actionData?.error) { - setAlertMessage(actionData.error); + setAlertMessage(actionData.error, 'error'); } }, [actionData, setAlertMessage]); diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index d9bf495a8..034ec1603 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -32,7 +32,6 @@ import { useAlert } from '../../hooks/alert'; import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; interface NewDonationFormModalProps { - foodManufacturerId: number; onDonationSuccess: () => void; isOpen: boolean; onClose: () => void; @@ -104,7 +103,6 @@ const getFirstValidationError = ( }; const NewDonationFormModal: React.FC = ({ - foodManufacturerId, onDonationSuccess, isOpen, onClose, @@ -204,12 +202,14 @@ const NewDonationFormModal: React.FC = ({ repeatInterval === RecurrenceEnum.WEEKLY && !Object.values(repeatOn).some(Boolean) ) { - setAlertMessage('Please select at least one day for weekly recurrence.'); + setAlertMessage( + 'Please select at least one day for weekly recurrence.', + 'error', + ); return; } const donationBody: CreateDonationDto = { - foodManufacturerId, recurrenceFreq: isRecurring ? parseInt(repeatEvery) : undefined, recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE, repeatOnDays: @@ -248,7 +248,7 @@ const NewDonationFormModal: React.FC = ({ setRepeatInterval(RecurrenceEnum.NONE); onClose(); } catch { - setAlertMessage('Error submitting new donation'); + setAlertMessage('Error submitting new donation', 'error'); } }; diff --git a/apps/frontend/src/components/forms/orderDetailsModal.tsx b/apps/frontend/src/components/forms/orderDetailsModal.tsx index 2d0b85d62..94e8185ef 100644 --- a/apps/frontend/src/components/forms/orderDetailsModal.tsx +++ b/apps/frontend/src/components/forms/orderDetailsModal.tsx @@ -52,7 +52,7 @@ const OrderDetailsModal: React.FC = ({ ); setFoodRequest(foodRequestData); } catch { - setAlertMessage('Error fetching food request details'); + setAlertMessage('Error fetching food request details', 'error'); } }; @@ -67,7 +67,7 @@ const OrderDetailsModal: React.FC = ({ const orderDetailsData = await ApiClient.getOrder(orderId); setOrderDetails(orderDetailsData); } catch { - setAlertMessage('Error fetching order details'); + setAlertMessage('Error fetching order details', 'error'); } }; diff --git a/apps/frontend/src/components/forms/orderReceivedActionModal.tsx b/apps/frontend/src/components/forms/orderReceivedActionModal.tsx index a8b002b03..88d12166a 100644 --- a/apps/frontend/src/components/forms/orderReceivedActionModal.tsx +++ b/apps/frontend/src/components/forms/orderReceivedActionModal.tsx @@ -223,7 +223,7 @@ const OrderReceivedActionModal: React.FC = ({ if (words.length <= 250) { setFeedback(e.target.value); } else { - setAlertMessage('Exceeded word limit'); + setAlertMessage('Exceeded word limit', 'error'); } }} /> diff --git a/apps/frontend/src/components/forms/pantryApplicationForm.tsx b/apps/frontend/src/components/forms/pantryApplicationForm.tsx index 526de1d1d..66921ad0b 100644 --- a/apps/frontend/src/components/forms/pantryApplicationForm.tsx +++ b/apps/frontend/src/components/forms/pantryApplicationForm.tsx @@ -111,7 +111,7 @@ const PantryApplicationForm: React.FC = () => { useEffect(() => { if (actionData?.error) { - setAlertMessage(actionData.error); + setAlertMessage(actionData.error, 'error'); } }, [actionData, setAlertMessage]); diff --git a/apps/frontend/src/components/forms/profileLeftPanel.tsx b/apps/frontend/src/components/forms/profileLeftPanel.tsx index bbaa830a9..85b3b5bf3 100644 --- a/apps/frontend/src/components/forms/profileLeftPanel.tsx +++ b/apps/frontend/src/components/forms/profileLeftPanel.tsx @@ -86,7 +86,9 @@ const ProfileLeftPanel: React.FC = ({ setAlertMessage('Password successfully changed')} + onSuccess={() => + setAlertMessage('Password successfully changed', 'success') + } > ); diff --git a/apps/frontend/src/components/forms/requestFormModal.tsx b/apps/frontend/src/components/forms/requestFormModal.tsx index 4de9e6061..3b27ec73e 100644 --- a/apps/frontend/src/components/forms/requestFormModal.tsx +++ b/apps/frontend/src/components/forms/requestFormModal.tsx @@ -42,8 +42,7 @@ const FoodRequestFormModal: React.FC = ({ const [selectedFoodTypes, setSelectedFoodTypes] = useState([]); const [requestedSize, setRequestedSize] = useState(''); const [additionalNotes, setAdditionalNotes] = useState(''); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const isFormValid = requestedSize !== '' && selectedFoodTypes.length > 0; @@ -58,10 +57,9 @@ const FoodRequestFormModal: React.FC = ({ setRequestedSize(''); setAdditionalNotes(''); } - setErrorMessage(''); - setSuccessMessage(''); + setAlertMessage('', 'error'); } - }, [isOpen, previousRequest, setErrorMessage, setSuccessMessage]); + }, [isOpen, previousRequest, setAlertMessage]); const handleSubmit = async () => { const foodRequestData: CreateFoodRequestBody = { @@ -73,11 +71,11 @@ const FoodRequestFormModal: React.FC = ({ try { await apiClient.createFoodRequest(foodRequestData); - setSuccessMessage('Request submitted'); + setAlertMessage('Request submitted', 'success'); onClose(); onSuccess(); } catch { - setErrorMessage('Request could not be submitted.'); + setAlertMessage('Request could not be submitted.', 'error'); } }; @@ -90,19 +88,11 @@ const FoodRequestFormModal: React.FC = ({ }} closeOnInteractOutside > - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -283,7 +273,7 @@ const FoodRequestFormModal: React.FC = ({ if (words.length <= 250) { setAdditionalNotes(e.target.value); } else { - setErrorMessage('Exceeded word limit'); + setAlertMessage('Exceeded word limit', 'error'); } }} /> diff --git a/apps/frontend/src/components/forms/resetPasswordModal.tsx b/apps/frontend/src/components/forms/resetPasswordModal.tsx index 557d8fbe5..8b642ebfd 100644 --- a/apps/frontend/src/components/forms/resetPasswordModal.tsx +++ b/apps/frontend/src/components/forms/resetPasswordModal.tsx @@ -43,7 +43,7 @@ const ResetPasswordModal: React.FC = () => { await resetPassword({ username: email }); setStep('new'); } catch { - setAlertMessage('Failed to send verification code'); + setAlertMessage('Failed to send verification code', 'error'); } }; @@ -51,18 +51,18 @@ const ResetPasswordModal: React.FC = () => { try { await resetPassword({ username: email }); } catch { - setAlertMessage('Failed to send verification code'); + setAlertMessage('Failed to send verification code', 'error'); } }; const handleResetPassword = async () => { if (password !== confirmPassword) { - setAlertMessage('Passwords need to match'); + setAlertMessage('Passwords need to match', 'error'); return; } if (password.length < 8) { - setAlertMessage('Password needs to be at least 8 characters'); + setAlertMessage('Password needs to be at least 8 characters', 'error'); return; } @@ -74,7 +74,7 @@ const ResetPasswordModal: React.FC = () => { }); navigate(ROUTES.LOGIN); } catch { - setAlertMessage('Failed to set new password'); + setAlertMessage('Failed to set new password', 'error'); } }; diff --git a/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx b/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx index 8d36592e4..792fbe083 100644 --- a/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx +++ b/apps/frontend/src/components/forms/volunteerCloseRequestModal.tsx @@ -34,7 +34,7 @@ const VolunteerCloseRequestActionModal: React.FC< onClose(); onSuccess(); } catch { - setAlertMessage('Error completing action. Please try again.'); + setAlertMessage('Error completing action. Please try again.', 'error'); } }; diff --git a/apps/frontend/src/containers/adminDashboard.tsx b/apps/frontend/src/containers/adminDashboard.tsx index 7ce01914e..0cced5f81 100644 --- a/apps/frontend/src/containers/adminDashboard.tsx +++ b/apps/frontend/src/containers/adminDashboard.tsx @@ -34,7 +34,7 @@ const AdminDashboard: React.FC = () => { await ApiClient.getRecentPendingApplications(); setPendingApplications(pendingApplications); } catch { - setAlertMessage('Error fetching pending applications'); + setAlertMessage('Error fetching pending applications', 'error'); } }; @@ -48,7 +48,7 @@ const AdminDashboard: React.FC = () => { const recentOrders = sortedOrders.slice(0, 2); setRecentOrders(recentOrders); } catch { - setAlertMessage('Error fetching orders'); + setAlertMessage('Error fetching orders', 'error'); } }; @@ -62,7 +62,7 @@ const AdminDashboard: React.FC = () => { const recentDonations = sortedDonations.slice(0, 2); setRecentDonations(recentDonations); } catch { - setAlertMessage('Error fetching donations'); + setAlertMessage('Error fetching donations', 'error'); } }; @@ -72,7 +72,10 @@ const AdminDashboard: React.FC = () => { user = await ApiClient.getMe(); setCurrentUser(user); } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + 'error', + ); return; } }; diff --git a/apps/frontend/src/containers/adminDonation.tsx b/apps/frontend/src/containers/adminDonation.tsx index 62d547bf2..1256bcca6 100644 --- a/apps/frontend/src/containers/adminDonation.tsx +++ b/apps/frontend/src/containers/adminDonation.tsx @@ -44,7 +44,7 @@ const AdminDonation: React.FC = () => { const data = await ApiClient.getAllDonations(); setDonations(data); } catch { - setAlertMessage('Error fetching donations'); + setAlertMessage('Error fetching donations', 'error'); } }; fetchDonations(); diff --git a/apps/frontend/src/containers/adminDonationStats.tsx b/apps/frontend/src/containers/adminDonationStats.tsx index 22f1d5c93..d877a4b82 100644 --- a/apps/frontend/src/containers/adminDonationStats.tsx +++ b/apps/frontend/src/containers/adminDonationStats.tsx @@ -46,14 +46,14 @@ const AdminDonationStats: React.FC = () => { const names = await ApiClient.getApprovedPantryNames(); setPantryNameOptions(names); } catch { - setAlertMessage('Error fetching pantry names'); + setAlertMessage('Error fetching pantry names', 'error'); } try { const years = await ApiClient.getPantryOrderYears(); setAvailableYears(years); } catch { - setAlertMessage('Error fetching available years'); + setAlertMessage('Error fetching available years', 'error'); } }; fetchInitialData(); @@ -70,7 +70,7 @@ const AdminDonationStats: React.FC = () => { ); setTotalStats(stats); } catch { - setAlertMessage('Error fetching total stats'); + setAlertMessage('Error fetching total stats', 'error'); } }; fetchTotalStats(); @@ -86,7 +86,7 @@ const AdminDonationStats: React.FC = () => { }); setPantryStats(stats); } catch { - setAlertMessage('Error fetching pantry stats'); + setAlertMessage('Error fetching pantry stats', 'error'); } }; fetchStats(); diff --git a/apps/frontend/src/containers/adminOrderManagement.tsx b/apps/frontend/src/containers/adminOrderManagement.tsx index 84e09c3f8..df7cbad90 100644 --- a/apps/frontend/src/containers/adminOrderManagement.tsx +++ b/apps/frontend/src/containers/adminOrderManagement.tsx @@ -133,7 +133,7 @@ const AdminOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setAlertMessage('Error fetching orders'); + setAlertMessage('Error fetching orders', 'error'); } }; diff --git a/apps/frontend/src/containers/adminPantryManagement.tsx b/apps/frontend/src/containers/adminPantryManagement.tsx index e807b5ce4..dfbdb3e68 100644 --- a/apps/frontend/src/containers/adminPantryManagement.tsx +++ b/apps/frontend/src/containers/adminPantryManagement.tsx @@ -38,7 +38,6 @@ const AdminPantryManagement: React.FC = () => { const [selectedPantries, setSelectedPantries] = useState([]); const [alertState, setAlertMessage] = useAlert(); - const [isAlertSuccess, setIsAlertSuccess] = useState(false); const [isFilterOpen, setIsFilterOpen] = useState(false); const [ selectedPantryToAssignVolunteers, @@ -52,8 +51,7 @@ const AdminPantryManagement: React.FC = () => { const allApprovedPantries = await ApiClient.getApprovedPantries(); setPantries(allApprovedPantries); } catch { - setIsAlertSuccess(false); - setAlertMessage('Error fetching pantries'); + setAlertMessage('Error fetching pantries', 'error'); } }; @@ -62,8 +60,7 @@ const AdminPantryManagement: React.FC = () => { }, [setAlertMessage]); const handleAssignVolunteersSuccess = () => { - setIsAlertSuccess(true); - setAlertMessage('Successfully assigned volunteers'); + setAlertMessage('Successfully assigned volunteers', 'success'); fetchPantries(); }; @@ -110,7 +107,7 @@ const AdminPantryManagement: React.FC = () => { )} diff --git a/apps/frontend/src/containers/approveFoodManufacturers.tsx b/apps/frontend/src/containers/approveFoodManufacturers.tsx index 786e13137..b07505bb5 100644 --- a/apps/frontend/src/containers/approveFoodManufacturers.tsx +++ b/apps/frontend/src/containers/approveFoodManufacturers.tsx @@ -36,8 +36,7 @@ const ApproveFoodManufacturers: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [isFilterOpen, setIsFilterOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); useEffect(() => { const fetchFoodManufacturers = async () => { @@ -45,12 +44,12 @@ const ApproveFoodManufacturers: React.FC = () => { const data = await ApiClient.getAllPendingFoodManufacturers(); setFoodManufacturers(data); } catch { - setErrorMessage('Error fetching food manufacturers'); + setAlertMessage('Error fetching food manufacturers', 'error'); } }; fetchFoodManufacturers(); - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { setCurrentPage(1); @@ -116,29 +115,21 @@ const ApproveFoodManufacturers: React.FC = () => { ? `${name} - Application Accepted` : `${name} - Application Rejected`; - setSuccessMessage(message); + setAlertMessage(message, 'success'); setSearchParams({}); } - }, [searchParams, setSearchParams, setErrorMessage, setSuccessMessage]); + }, [searchParams, setSearchParams, setAlertMessage]); return ( Application Review - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index 75dd9245a..5a7ac4ebe 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -32,8 +32,7 @@ const ApprovePantries: React.FC = () => { const [currentPage, setCurrentPage] = useState(1); const [isFilterOpen, setIsFilterOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); useEffect(() => { const fetchPantries = async () => { @@ -41,12 +40,12 @@ const ApprovePantries: React.FC = () => { const data = await ApiClient.getAllPendingPantries(); setPantries(data); } catch { - setErrorMessage('Error fetching pantries'); + setAlertMessage('Error fetching pantries', 'error'); } }; fetchPantries(); - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { setCurrentPage(1); @@ -107,29 +106,21 @@ const ApprovePantries: React.FC = () => { ? `${name} - Application Accepted` : `${name} - Application Rejected`; - setSuccessMessage(message); + setAlertMessage(message, 'success'); setSearchParams({}); } - }, [searchParams, setSearchParams, setErrorMessage, setSuccessMessage]); + }, [searchParams, setSearchParams, setAlertMessage]); return ( Application Review - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} diff --git a/apps/frontend/src/containers/donationManagement.tsx b/apps/frontend/src/containers/donationManagement.tsx index e7d038f4c..9cb25c49d 100644 --- a/apps/frontend/src/containers/donationManagement.tsx +++ b/apps/frontend/src/containers/donationManagement.tsx @@ -43,7 +43,7 @@ const DonationManagement: React.FC = () => { }); setDonations(sortedDonations); } catch { - setAlertMessage('Error fetching donations'); + setAlertMessage('Error fetching donations', 'error'); } }; @@ -62,7 +62,7 @@ const DonationManagement: React.FC = () => { })); }); } catch { - setAlertMessage('Error fetching donation items'); + setAlertMessage('Error fetching donation items', 'error'); } }; @@ -82,7 +82,7 @@ const DonationManagement: React.FC = () => { await ApiClient.fulfillDonation(donationId); fetchDonations(); } catch { - setAlertMessage('Failed to fulfill donation'); + setAlertMessage('Failed to fulfill donation', 'error'); } }; @@ -103,7 +103,6 @@ const DonationManagement: React.FC = () => { {manufacturerId !== null && ( { application.foodManufacturerName, ); } catch { - setAlertMessage('Error approving application'); + setAlertMessage('Error approving application', 'error'); } } }; @@ -198,7 +198,7 @@ const FoodManufacturerApplicationDetails: React.FC = () => { application.foodManufacturerName, ); } catch { - setAlertMessage('Error denying application'); + setAlertMessage('Error denying application', 'error'); } } }; diff --git a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx index a5b1b2a85..78e84fe95 100644 --- a/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx +++ b/apps/frontend/src/containers/foodManufacturerDonationManagement.tsx @@ -22,8 +22,7 @@ import { useAlert } from '../hooks/alert'; const MAX_PER_STATUS = 5; const FoodManufacturerDonationManagement: React.FC = () => { - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const [isLogDonationOpen, setIsLogDonationOpen] = useState(false); const [manufacturerId, setManufacturerId] = useState(null); const [selectedActionDonation, setSelectedActionDonation] = @@ -84,10 +83,15 @@ const FoodManufacturerDonationManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setErrorMessage('Error fetching donations'); + setAlertMessage('Error fetching donations', 'error'); } }; + const handleLogNewDonationSuccess = () => { + setAlertMessage('Successfully logged new donation', 'success'); + if (manufacturerId !== null) fetchDonations(manufacturerId); + }; + // On page load, get the food manufacturer id and all appropriate donations useEffect(() => { const init = async () => { @@ -96,7 +100,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { setManufacturerId(fmId); await fetchDonations(fmId); } catch { - setErrorMessage('Error initializing donation management'); + setAlertMessage('Error initializing donation management', 'error'); } }; init(); @@ -111,19 +115,11 @@ const FoodManufacturerDonationManagement: React.FC = () => { return ( - {errorAlertState && ( - - )} - {successAlertState && ( + {alertState && ( )} @@ -150,8 +146,7 @@ const FoodManufacturerDonationManagement: React.FC = () => { {isLogDonationOpen && manufacturerId !== null && ( fetchDonations(manufacturerId)} + onDonationSuccess={handleLogNewDonationSuccess} isOpen={isLogDonationOpen} onClose={() => setIsLogDonationOpen(false)} /> @@ -165,8 +160,9 @@ const FoodManufacturerDonationManagement: React.FC = () => { onSuccess={() => { setSelectedActionDonation(null); if (manufacturerId !== null) fetchDonations(manufacturerId); - setSuccessMessage( + setAlertMessage( 'Your details have been saved. Actions are complete once all shipment and item details are confirmed.', + 'success', ); }} /> diff --git a/apps/frontend/src/containers/formRequests.tsx b/apps/frontend/src/containers/formRequests.tsx index 5dc980bc7..ce25c5665 100644 --- a/apps/frontend/src/containers/formRequests.tsx +++ b/apps/frontend/src/containers/formRequests.tsx @@ -58,10 +58,10 @@ const FormRequests: React.FC = () => { setPreviousRequest(sortedData[0]); } } catch { - setAlertMessage('Error fetching requests'); + setAlertMessage('Error fetching requests', 'error'); } } else { - setAlertMessage('No pantry associated with this account.'); + setAlertMessage('No pantry associated with this account.', 'error'); } }, [setAlertMessage]); diff --git a/apps/frontend/src/containers/loginPage.tsx b/apps/frontend/src/containers/loginPage.tsx index d0933c365..bedd8cb7e 100644 --- a/apps/frontend/src/containers/loginPage.tsx +++ b/apps/frontend/src/containers/loginPage.tsx @@ -78,7 +78,10 @@ const LoginPage: React.FC = () => { error.name === 'NotAuthorizedException' || error.name === 'UserNotFoundException' ) { - setAlertMessage('Incorrect email or password. Please try again.'); + setAlertMessage( + 'Incorrect email or password. Please try again.', + 'error', + ); return; } } @@ -86,6 +89,7 @@ const LoginPage: React.FC = () => { navigator.onLine ? 'Login failed. The server may be unavailable. Please try again later.' : 'No internet connection. Please check your network and try again.', + 'error', ); } }; @@ -93,11 +97,11 @@ const LoginPage: React.FC = () => { // Sets the new password for the first time const handleSetNewPassword = async () => { if (newPassword !== confirmNewPassword) { - setAlertMessage('Passwords need to match'); + setAlertMessage('Passwords need to match', 'error'); return; } if (newPassword.length < 8) { - setAlertMessage('Password needs to be at least 8 characters'); + setAlertMessage('Password needs to be at least 8 characters', 'error'); return; } @@ -107,7 +111,7 @@ const LoginPage: React.FC = () => { await fetchAuthSession({ forceRefresh: true }); navigate(from, { replace: true }); } catch { - setAlertMessage('Failed to set new password'); + setAlertMessage('Failed to set new password', 'error'); } }; diff --git a/apps/frontend/src/containers/pantryApplicationDetails.tsx b/apps/frontend/src/containers/pantryApplicationDetails.tsx index 5838b7336..dfeb44c3d 100644 --- a/apps/frontend/src/containers/pantryApplicationDetails.tsx +++ b/apps/frontend/src/containers/pantryApplicationDetails.tsx @@ -179,7 +179,7 @@ const PantryApplicationDetails: React.FC = () => { application.pantryName, ); } catch { - setAlertMessage('Error approving application'); + setAlertMessage('Error approving application', 'error'); } } }; @@ -196,7 +196,7 @@ const PantryApplicationDetails: React.FC = () => { application.pantryName, ); } catch { - setAlertMessage('Error denying application'); + setAlertMessage('Error denying application', 'error'); } } }; diff --git a/apps/frontend/src/containers/pantryDashboard.tsx b/apps/frontend/src/containers/pantryDashboard.tsx index 182c9a9ef..d3cca1d86 100644 --- a/apps/frontend/src/containers/pantryDashboard.tsx +++ b/apps/frontend/src/containers/pantryDashboard.tsx @@ -31,7 +31,7 @@ const PantryDashboard: React.FC = () => { const pantryData = await ApiClient.getPantry(pantryId); setPantry(pantryData); } catch { - setAlertMessage('Error fetching pantry information'); + setAlertMessage('Error fetching pantry information', 'error'); return; } @@ -44,7 +44,7 @@ const PantryDashboard: React.FC = () => { ); setRecentFoodRequests(sortedFoodRequests.slice(0, 2)); } catch { - setAlertMessage('Error fetching pantry food requests'); + setAlertMessage('Error fetching pantry food requests', 'error'); } try { @@ -55,7 +55,7 @@ const PantryDashboard: React.FC = () => { ); setRecentOrders(sortedOrders.slice(0, 4)); } catch { - setAlertMessage('Error fetching orders'); + setAlertMessage('Error fetching orders', 'error'); } }; fetchDashboardData(); diff --git a/apps/frontend/src/containers/pantryOrderManagement.tsx b/apps/frontend/src/containers/pantryOrderManagement.tsx index c231b87a4..9d7176f4b 100644 --- a/apps/frontend/src/containers/pantryOrderManagement.tsx +++ b/apps/frontend/src/containers/pantryOrderManagement.tsx @@ -56,8 +56,7 @@ const PantryOrderManagement: React.FC = () => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); // State to hold filter state per status type FilterState = { @@ -110,9 +109,9 @@ const PantryOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setErrorMessage('Failed to fetch orders'); + setAlertMessage('Failed to fetch orders', 'error'); } - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { fetchOrders(); @@ -149,19 +148,11 @@ const PantryOrderManagement: React.FC = () => { Order Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -228,10 +219,10 @@ const PantryOrderManagement: React.FC = () => { onClose={() => setSelectedActionOrder(null)} onSuccess={() => { fetchOrders(); - setSuccessMessage('Delivery Confirmed'); + setAlertMessage('Delivery Confirmed', 'success'); }} onError={() => { - setErrorMessage('Delivery could not be confirmed.'); + setAlertMessage('Delivery could not be confirmed.', 'error'); }} /> )} diff --git a/apps/frontend/src/containers/profilePage.tsx b/apps/frontend/src/containers/profilePage.tsx index 2079f7167..755fc482a 100644 --- a/apps/frontend/src/containers/profilePage.tsx +++ b/apps/frontend/src/containers/profilePage.tsx @@ -33,7 +33,7 @@ const ProfilePage: React.FC = () => { const pantry = await ApiClient.getPantry(pantryId); setOrgName(pantry.pantryName); } catch { - setAlertMessage('Failed to fetch pantry data.'); + setAlertMessage('Failed to fetch pantry data.', 'error'); } } else if (user.role === Role.FOODMANUFACTURER) { try { @@ -42,11 +42,14 @@ const ProfilePage: React.FC = () => { const fm = await ApiClient.getFoodManufacturer(foodManufacturerId); setOrgName(fm.foodManufacturerName); } catch { - setAlertMessage('Failed to fetch food manufacturer data.'); + setAlertMessage('Failed to fetch food manufacturer data.', 'error'); } } } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + 'error', + ); } finally { setIsLoading(false); } @@ -56,7 +59,7 @@ const ProfilePage: React.FC = () => { const handleSave = async (fields: UpdateProfileFields): Promise => { if (!profile) { - setAlertMessage('Profile not found.'); + setAlertMessage('Profile not found.', 'error'); return false; } @@ -68,14 +71,18 @@ const ProfilePage: React.FC = () => { if (axios.isAxiosError(error)) { const status = error.response?.status; if (status === 400 || status === 404) { - setAlertMessage(error.response?.data?.message); + setAlertMessage(error.response?.data?.message, 'error'); } else { setAlertMessage( 'Profile unable to be edited. Please try again later.', + 'error', ); } } else { - setAlertMessage('An unexpected error occurred. Please try again.'); + setAlertMessage( + 'An unexpected error occurred. Please try again.', + 'error', + ); } return false; } diff --git a/apps/frontend/src/containers/volunteerAssignedPantries.tsx b/apps/frontend/src/containers/volunteerAssignedPantries.tsx index 53e37450a..dfae4710d 100644 --- a/apps/frontend/src/containers/volunteerAssignedPantries.tsx +++ b/apps/frontend/src/containers/volunteerAssignedPantries.tsx @@ -38,7 +38,10 @@ const AssignedPantries: React.FC = () => { user = await ApiClient.getMe(); userId = user.id; } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + 'error', + ); setIsLoading(false); return; } @@ -47,7 +50,7 @@ const AssignedPantries: React.FC = () => { const data = await ApiClient.getVolunteerPantries(userId); setPantries(data); } catch { - setAlertMessage('Error fetching assigned pantries'); + setAlertMessage('Error fetching assigned pantries', 'error'); } finally { setIsLoading(false); } diff --git a/apps/frontend/src/containers/volunteerDashboard.tsx b/apps/frontend/src/containers/volunteerDashboard.tsx index c33843670..d04205b06 100644 --- a/apps/frontend/src/containers/volunteerDashboard.tsx +++ b/apps/frontend/src/containers/volunteerDashboard.tsx @@ -25,7 +25,7 @@ const VolunteerDashboard: React.FC = () => { const currentUser = await ApiClient.getMe(); setUser(currentUser); } catch { - setAlertMessage('Error fetching user information'); + setAlertMessage('Error fetching user information', 'error'); return; } @@ -38,14 +38,14 @@ const VolunteerDashboard: React.FC = () => { ); setRecentFoodRequests(sorted.slice(0, 2)); } catch { - setAlertMessage('Error fetching food requests'); + setAlertMessage('Error fetching food requests', 'error'); } try { const orders = await ApiClient.getVolunteerRecentOrders(); setRecentOrders(orders); } catch { - setAlertMessage('Error fetching orders'); + setAlertMessage('Error fetching orders', 'error'); } }; fetchDashboardData(); diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/volunteerManagement.tsx index 54707fe94..2142973a6 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/volunteerManagement.tsx @@ -26,8 +26,7 @@ const VolunteerManagement: React.FC = () => { const [volunteers, setVolunteers] = useState([]); const [searchName, setSearchName] = useState(''); - const [errorAlertState, setErrorMessage] = useAlert(); - const [successAlertState, setSuccessMessage] = useAlert(); + const [alertState, setAlertMessage] = useAlert(); const pageSize = 8; @@ -37,12 +36,12 @@ const VolunteerManagement: React.FC = () => { const allVolunteers = await ApiClient.getVolunteers(); setVolunteers(allVolunteers); } catch { - setErrorMessage('Error fetching volunteers'); + setAlertMessage('Error fetching volunteers', 'error'); } }; fetchVolunteers(); - }, [setErrorMessage]); + }, [setAlertMessage]); useEffect(() => { setCurrentPage(1); @@ -69,19 +68,11 @@ const VolunteerManagement: React.FC = () => { Volunteer Management - {errorAlertState && ( + {alertState && ( - )} - {successAlertState && ( - )} @@ -115,10 +106,10 @@ const VolunteerManagement: React.FC = () => { { - setSuccessMessage('Volunteer added.'); + setAlertMessage('Volunteer added.', 'success'); }} onSubmitFail={() => { - setErrorMessage('Volunteer could not be added.'); + setAlertMessage('Volunteer could not be added.', 'error'); }} /> diff --git a/apps/frontend/src/containers/volunteerOrderManagement.tsx b/apps/frontend/src/containers/volunteerOrderManagement.tsx index 591981416..2197e5e5c 100644 --- a/apps/frontend/src/containers/volunteerOrderManagement.tsx +++ b/apps/frontend/src/containers/volunteerOrderManagement.tsx @@ -125,7 +125,10 @@ const VolunteerOrderManagement: React.FC = () => { userId = user.id; setCurrentUser(user); } catch { - setAlertMessage('Authentication error. Please log in and try again.'); + setAlertMessage( + 'Authentication error. Please log in and try again.', + 'error', + ); setIsLoading(false); return; } @@ -162,7 +165,7 @@ const VolunteerOrderManagement: React.FC = () => { }; setCurrentPages(initialPages); } catch { - setAlertMessage('Error fetching assigned orders'); + setAlertMessage('Error fetching assigned orders', 'error'); } finally { setIsLoading(false); } diff --git a/apps/frontend/src/hooks/alert.ts b/apps/frontend/src/hooks/alert.ts index 0a2c609b3..034c8462a 100644 --- a/apps/frontend/src/hooks/alert.ts +++ b/apps/frontend/src/hooks/alert.ts @@ -2,16 +2,23 @@ import { useCallback, useRef, useState } from 'react'; export interface AlertState { message: string; + status: 'success' | 'error'; id: number; } -export function useAlert(): [AlertState | null, (message: string) => void] { +export function useAlert(): [ + AlertState | null, + (message: string, status: 'success' | 'error') => void, +] { const [alertState, setAlertState] = useState(null); const idRef = useRef(0); - const setAlertMessage = useCallback((message: string) => { - setAlertState({ message, id: idRef.current++ }); - }, []); + const setAlertMessage = useCallback( + (message: string, status: 'success' | 'error') => { + setAlertState({ message, status, id: idRef.current++ }); + }, + [], + ); return [alertState, setAlertMessage]; } diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 2a1592b91..0fe34520c 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -465,7 +465,6 @@ export interface CreateFoodRequestBody { } export interface CreateDonationDto { - foodManufacturerId: number; recurrenceFreq?: number; recurrence: RecurrenceEnum; repeatOnDays?: RepeatOnState;