diff --git a/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts b/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts new file mode 100644 index 000000000..58d7f70fa --- /dev/null +++ b/apps/backend/src/foodManufacturers/dtos/update-manufacturer-application.dto.ts @@ -0,0 +1,98 @@ +import { + ArrayNotEmpty, + IsBoolean, + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + IsString, + Length, + MaxLength, +} from 'class-validator'; +import { Allergen, DonateWastedFood, ManufacturerAttribute } from '../types'; + +export class UpdateFoodManufacturerApplicationDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + secondaryContactFirstName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + secondaryContactLastName?: string; + + @IsOptional() + @IsEmail() + @IsNotEmpty() + @MaxLength(255) + secondaryContactEmail?: string; + + @IsOptional() + @IsString() + @IsPhoneNumber('US', { + message: + 'secondaryContactPhone must be a valid phone number (make sure all the digits are correct)', + }) + @IsNotEmpty() + secondaryContactPhone?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + @Length(1, 255) + foodManufacturerName?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + @Length(1, 255) + foodManufacturerWebsite?: string; + + @IsOptional() + @ArrayNotEmpty() + @IsEnum(Allergen, { each: true }) + unlistedProductAllergens?: Allergen[]; + + @IsOptional() + @ArrayNotEmpty() + @IsEnum(Allergen, { each: true }) + facilityFreeAllergens?: Allergen[]; + + @IsOptional() + @IsBoolean() + productsGlutenFree?: boolean; + + @IsOptional() + @IsBoolean() + productsContainSulfites?: boolean; + + @IsOptional() + @IsString() + @IsNotEmpty() + productsSustainableExplanation?: string; + + @IsOptional() + @IsBoolean() + inKindDonations?: boolean; + + @IsOptional() + @IsEnum(DonateWastedFood) + donateWastedFood?: DonateWastedFood; + + @IsOptional() + @IsEnum(ManufacturerAttribute) + manufacturerAttribute?: ManufacturerAttribute; + + @IsOptional() + @IsString() + @IsNotEmpty() + additionalComments?: string; + + @IsOptional() + @IsBoolean() + newsletterSubscription?: boolean; +} diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index f80216e0c..cb47bebd6 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -7,6 +7,9 @@ import { Allergen, DonateWastedFood } from './types'; import { ApplicationStatus } from '../shared/types'; import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; import { Donation } from '../donations/donations.entity'; +import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; +import { NotFoundException } from '@nestjs/common'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; const mockManufacturersService = mock(); @@ -134,6 +137,60 @@ describe('FoodManufacturersController', () => { }); }); + describe('PATCH /:manufacturerId/application', () => { + const req = { user: { id: 1 } }; + + it('should update a food manufacturer application', async () => { + const manufacturerId = 1; + const mockUpdateData: UpdateFoodManufacturerApplicationDto = { + secondaryContactFirstName: 'Bob', + secondaryContactLastName: 'Smith', + secondaryContactEmail: 'bob.smith@example.com', + productsGlutenFree: false, + inKindDonations: true, + donateWastedFood: DonateWastedFood.ALWAYS, + additionalComments: 'Updated application notes.', + }; + + mockManufacturersService.updateFoodManufacturerApplication.mockResolvedValue( + mockManufacturer1 as FoodManufacturer, + ); + + const result = await controller.updateFoodManufacturerApplication( + req as AuthenticatedRequest, + manufacturerId, + mockUpdateData, + ); + + expect( + mockManufacturersService.updateFoodManufacturerApplication, + ).toHaveBeenCalledWith(manufacturerId, mockUpdateData, 1); + + expect(result).toEqual(mockManufacturer1); + }); + + it('should throw error if manufacturer does not exist', async () => { + const mockUpdateData: UpdateFoodManufacturerApplicationDto = { + secondaryContactFirstName: 'John', + }; + + mockManufacturersService.updateFoodManufacturerApplication.mockRejectedValueOnce( + new NotFoundException('Food Manufacturer 999 not found'), + ); + + await expect( + controller.updateFoodManufacturerApplication( + req as AuthenticatedRequest, + 999, + mockUpdateData, + ), + ).rejects.toThrow(); + expect( + mockManufacturersService.updateFoodManufacturerApplication, + ).toHaveBeenCalledWith(999, mockUpdateData, 1); + }); + }); + describe('PATCH /:id/approve', () => { it('should approve a food manufacturer', async () => { mockManufacturersService.approve.mockResolvedValue(); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index 28a22fa1c..e6b91c608 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -6,6 +6,7 @@ import { ParseIntPipe, Patch, Post, + Req, ValidationPipe, } from '@nestjs/common'; import { FoodManufacturersService } from './manufacturers.service'; @@ -15,6 +16,10 @@ import { ApiBody } from '@nestjs/swagger'; import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; import { Donation } from '../donations/donations.entity'; import { Public } from '../auth/public.decorator'; +import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; @Controller('manufacturers') export class FoodManufacturersController { @@ -169,6 +174,21 @@ export class FoodManufacturersController { ); } + @Roles(Role.FOODMANUFACTURER) + @Patch('/:manufacturerId/application') + async updateFoodManufacturerApplication( + @Req() req: AuthenticatedRequest, + @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + @Body(new ValidationPipe()) + foodManufacturerData: UpdateFoodManufacturerApplicationDto, + ): Promise { + return this.foodManufacturersService.updateFoodManufacturerApplication( + manufacturerId, + foodManufacturerData, + req.user.id, + ); + } + @Patch('/:manufacturerId/approve') async approveManufacturer( @Param('manufacturerId', ParseIntPipe) manufacturerId: number, diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index ecab69bd3..644271936 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, NotFoundException, ConflictException, @@ -14,6 +15,7 @@ import { ApplicationStatus } from '../shared/types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; import { Donation } from '../donations/donations.entity'; +import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; @Injectable() export class FoodManufacturersService { @@ -121,6 +123,36 @@ export class FoodManufacturersService { await this.repo.save(foodManufacturer); } + async updateFoodManufacturerApplication( + manufacturerId: number, + foodManufacturerData: UpdateFoodManufacturerApplicationDto, + currentUserId: number, + ) { + validateId(manufacturerId, 'Food Manufacturer'); + validateId(currentUserId, 'User'); + + const manufacturer = await this.repo.findOne({ + where: { foodManufacturerId: manufacturerId }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${manufacturerId} not found`, + ); + } + + if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { + throw new BadRequestException( + `User ${currentUserId} is not allowed to edit application for Food Manufacturer ${manufacturerId}`, + ); + } + + Object.assign(manufacturer, foodManufacturerData); + + return await this.repo.save(manufacturer); + } + async approve(id: number) { validateId(id, 'Food Manufacturer'); diff --git a/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts b/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts new file mode 100644 index 000000000..efb1d9f6b --- /dev/null +++ b/apps/backend/src/pantries/dtos/update-pantry-application.dto.ts @@ -0,0 +1,185 @@ +import { + ArrayNotEmpty, + IsBoolean, + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + IsString, + Length, + MaxLength, +} from 'class-validator'; +import { + RefrigeratedDonation, + ReserveFoodForAllergic, + ClientVisitFrequency, + AllergensConfidence, + ServeAllergicChildren, + Activity, +} from '../types'; + +export class UpdatePantryApplicationDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + secondaryContactFirstName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + secondaryContactLastName?: string; + + @IsOptional() + @IsEmail() + @IsNotEmpty() + @MaxLength(255) + secondaryContactEmail?: string; + + @IsOptional() + @IsString() + @IsPhoneNumber('US', { + message: + 'secondaryContactPhone must be a valid phone number (make sure all the digits are correct)', + }) + @IsNotEmpty() + secondaryContactPhone?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + shipmentAddressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @IsNotEmpty() + shipmentAddressLine2?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + shipmentAddressCity?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + shipmentAddressState?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + shipmentAddressZip?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @IsNotEmpty() + shipmentAddressCountry?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + mailingAddressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @IsNotEmpty() + mailingAddressLine2?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + mailingAddressCity?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + mailingAddressState?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + mailingAddressZip?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @IsNotEmpty() + mailingAddressCountry?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 25) + allergenClients?: string; + + @ArrayNotEmpty() + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @MaxLength(255, { each: true }) + restrictions?: string[]; + + @IsEnum(RefrigeratedDonation) + @IsOptional() + refrigeratedDonation?: RefrigeratedDonation; + + @IsEnum(ReserveFoodForAllergic) + @IsOptional() + reserveFoodForAllergic?: ReserveFoodForAllergic; + + @IsBoolean() + @IsOptional() + dedicatedAllergyFriendly?: boolean; + + @IsOptional() + @IsEnum(ClientVisitFrequency) + clientVisitFrequency?: ClientVisitFrequency; + + @IsOptional() + @IsEnum(AllergensConfidence) + identifyAllergensConfidence?: AllergensConfidence; + + @IsOptional() + @IsEnum(ServeAllergicChildren) + serveAllergicChildren?: ServeAllergicChildren; + + @ArrayNotEmpty() + @IsOptional() + @IsEnum(Activity, { each: true }) + activities?: Activity[]; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + activitiesComments?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + itemsInStock?: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @Length(1, 255) + needMoreOptions?: string; + + @IsOptional() + @IsBoolean() + newsletterSubscription?: boolean; +} diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 0d61827f2..0f2bf64de 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -20,6 +20,7 @@ import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; import { User } from '../users/users.entity'; import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; const mockPantriesService = mock(); const mockOrdersService = mock(); @@ -224,6 +225,66 @@ describe('PantriesController', () => { ); }); }); + + describe('PATCH /:pantryId/application', () => { + const req = { user: { id: 1 } }; + + it('should update a pantry application', async () => { + const pantryId = 1; + + const mockUpdateData: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'John', + secondaryContactLastName: 'Doe', + secondaryContactEmail: 'john.doe@example.com', + refrigeratedDonation: RefrigeratedDonation.NO, + reserveFoodForAllergic: ReserveFoodForAllergic.NO, + newsletterSubscription: false, + itemsInStock: 'Canned beans, rice', + }; + + mockPantriesService.updatePantryApplication.mockResolvedValue( + mockPantry as Pantry, + ); + + const result = await controller.updatePantryApplication( + req as AuthenticatedRequest, + pantryId, + mockUpdateData, + ); + + expect(mockPantriesService.updatePantryApplication).toHaveBeenCalledWith( + pantryId, + mockUpdateData, + 1, + ); + + expect(result).toEqual(mockPantry); + }); + + it('should throw error if pantry does not exist', async () => { + const mockUpdateData: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'John', + }; + + mockPantriesService.updatePantryApplication.mockRejectedValueOnce( + new Error('Pantry 999 not found'), + ); + + await expect( + controller.updatePantryApplication( + req as AuthenticatedRequest, + 999, + mockUpdateData, + ), + ).rejects.toThrow(); + expect(mockPantriesService.updatePantryApplication).toHaveBeenCalledWith( + 999, + mockUpdateData, + 1, + ); + }); + }); + describe('getOrders', () => { it('should return orders for a pantry', async () => { const pantryId = 24; diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index f99e0d35d..10aa4afe6 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -33,6 +33,7 @@ import { EmailsService } from '../emails/email.service'; import { SendEmailDTO } from '../emails/dto/send-email.dto'; import { Public } from '../auth/public.decorator'; import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; @Controller('pantries') export class PantriesController { @@ -344,6 +345,21 @@ export class PantriesController { return this.pantriesService.addPantry(pantryData); } + @Roles(Role.PANTRY) + @Patch('/:pantryId/update') + async updatePantryApplication( + @Req() req: AuthenticatedRequest, + @Param('pantryId', ParseIntPipe) pantryId: number, + @Body(new ValidationPipe()) + pantryData: UpdatePantryApplicationDto, + ): Promise { + return this.pantriesService.updatePantryApplication( + pantryId, + pantryData, + req.user.id, + ); + } + @Roles(Role.ADMIN) @Patch('/:pantryId/approve') async approvePantry( diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index e6ed91f03..06b129b59 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -27,6 +27,7 @@ import { Donation } from '../donations/donations.entity'; import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { User } from '../users/users.entity'; +import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; jest.setTimeout(60000); @@ -286,6 +287,72 @@ describe('PantriesService', () => { }); }); + describe('updatePantryApplication', () => { + it('updates an existing pantry successfully', async () => { + const dto: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'John', + secondaryContactLastName: 'Doe', + refrigeratedDonation: RefrigeratedDonation.YES, + reserveFoodForAllergic: ReserveFoodForAllergic.SOME, + newsletterSubscription: true, + itemsInStock: 'Canned beans, rice', + }; + + const updatedPantry = await service.updatePantryApplication(1, dto, 10); + + expect(updatedPantry.secondaryContactFirstName).toBe('John'); + expect(updatedPantry.secondaryContactLastName).toBe('Doe'); + expect(updatedPantry.refrigeratedDonation).toBe(RefrigeratedDonation.YES); + expect(updatedPantry.reserveFoodForAllergic).toBe( + ReserveFoodForAllergic.SOME, + ); + expect(updatedPantry.newsletterSubscription).toBe(true); + expect(updatedPantry.itemsInStock).toBe('Canned beans, rice'); + }); + + it('throws NotFoundException when pantry does not exist', async () => { + const dto: UpdatePantryApplicationDto = { + secondaryContactFirstName: 'Jane', + }; + + await expect( + service.updatePantryApplication(9999, dto, 1), + ).rejects.toThrow(new NotFoundException('Pantry 9999 not found')); + }); + + it('updates only the provided fields and keeps others intact', async () => { + const original = await service.findOne(2); + + const dto: UpdatePantryApplicationDto = { + itemsInStock: 'Rice and beans', + }; + + const updated = await service.updatePantryApplication(2, dto, 11); + + expect(updated.itemsInStock).toBe('Rice and beans'); + expect(updated.pantryName).toBe(original.pantryName); + expect(updated.secondaryContactEmail).toBe( + original.secondaryContactEmail, + ); + }); + + it('throws BadRequestException when user is not authorized to update pantry', async () => { + const dto: UpdatePantryApplicationDto = { + itemsInStock: 'Rice and beans', + }; + + const invalidUserId = 999; + + await expect( + service.updatePantryApplication(1, dto, invalidUserId), + ).rejects.toThrow( + new BadRequestException( + `User ${invalidUserId} is not allowed to edit application for Pantry 1`, + ), + ); + }); + }); + describe('getPantryStats (single pantry)', () => { it('throws NotFoundException for non-existent pantry names', async () => { await expect( diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 542cf100a..dcce43ae0 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -18,6 +18,7 @@ import { Role } from '../users/types'; import { PantryStats, TotalStats } from './types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; +import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto'; @Injectable() export class PantriesService { @@ -315,6 +316,34 @@ export class PantriesService { await this.repo.save(pantry); } + async updatePantryApplication( + pantryId: number, + pantryData: UpdatePantryApplicationDto, + currentUserId: number, + ) { + validateId(pantryId, 'Pantry'); + validateId(currentUserId, 'User'); + + const pantry = await this.repo.findOne({ + where: { pantryId }, + relations: ['pantryUser'], + }); + + if (!pantry) { + throw new NotFoundException(`Pantry ${pantryId} not found`); + } + + if (pantry.pantryUser.id !== currentUserId) { + throw new BadRequestException( + `User ${currentUserId} is not allowed to edit application for Pantry ${pantryId}`, + ); + } + + Object.assign(pantry, pantryData); + + return await this.repo.save(pantry); + } + async approve(id: number) { validateId(id, 'Pantry');