From ac622e1eada903df53d58e70235412cd2f2b2b2b Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:21:11 -0500 Subject: [PATCH 1/2] fix pantry api body and add manufacturer app backend --- apps/backend/src/app.module.ts | 2 +- apps/backend/src/config/typeorm.ts | 2 + .../backend/src/donations/donations.entity.ts | 2 +- .../backend/src/donations/donations.module.ts | 4 +- .../src/donations/donations.service.spec.ts | 2 +- .../src/donations/donations.service.ts | 2 +- .../dtos/manufacturer-application.dto.ts | 112 +++++++++++ .../foodManufacturers/manufacturer.entity.ts | 29 --- .../foodManufacturers/manufacturer.module.ts | 8 - .../manufacturers.controller.spec.ts | 131 +++++++++++++ .../manufacturers.controller.ts | 177 ++++++++++++++++++ .../foodManufacturers/manufacturers.entity.ts | 151 +++++++++++++++ .../foodManufacturers/manufacturers.module.ts | 12 ++ .../manufacturers.service.ts | 115 ++++++++++++ apps/backend/src/foodManufacturers/types.ts | 32 ++++ .../1768680807820-UpdateManufacturerEntity.ts | 96 ++++++++++ apps/backend/src/orders/order.controller.ts | 2 +- apps/backend/src/orders/order.entity.ts | 2 +- apps/backend/src/orders/order.service.ts | 2 +- .../src/pantries/pantries.controller.ts | 108 +++++++++-- 20 files changed, 932 insertions(+), 59 deletions(-) create mode 100644 apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts delete mode 100644 apps/backend/src/foodManufacturers/manufacturer.entity.ts delete mode 100644 apps/backend/src/foodManufacturers/manufacturer.module.ts create mode 100644 apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts create mode 100644 apps/backend/src/foodManufacturers/manufacturers.controller.ts create mode 100644 apps/backend/src/foodManufacturers/manufacturers.entity.ts create mode 100644 apps/backend/src/foodManufacturers/manufacturers.module.ts create mode 100644 apps/backend/src/foodManufacturers/manufacturers.service.ts create mode 100644 apps/backend/src/migrations/1768680807820-UpdateManufacturerEntity.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 3c0ce87a..9fc65b58 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -10,7 +10,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { MulterModule } from '@nestjs/platform-express'; import typeorm from './config/typeorm'; import { OrdersModule } from './orders/order.module'; -import { ManufacturerModule } from './foodManufacturers/manufacturer.module'; +import { ManufacturerModule } from './foodManufacturers/manufacturers.module'; import { DonationModule } from './donations/donations.module'; import { DonationItemsModule } from './donationItems/donationItems.module'; import { AllocationModule } from './allocations/allocations.module'; diff --git a/apps/backend/src/config/typeorm.ts b/apps/backend/src/config/typeorm.ts index 82384673..8f5cd5cb 100644 --- a/apps/backend/src/config/typeorm.ts +++ b/apps/backend/src/config/typeorm.ts @@ -27,6 +27,7 @@ import { RemoveMultipleVolunteerTypes1764811878152 } from '../migrations/1764811 import { RemoveUnusedStatuses1764816885341 } from '../migrations/1764816885341-RemoveUnusedStatuses'; import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields'; import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; +import { UpdateManufacturerEntity1768680807820 } from '../migrations/1768680807820-UpdateManufacturerEntity'; const config = { type: 'postgres', @@ -67,6 +68,7 @@ const config = { RemoveMultipleVolunteerTypes1764811878152, RemoveUnusedStatuses1764816885341, PopulateDummyData1768501812134, + UpdateManufacturerEntity1768680807820, ], }; diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index 1c40a7c0..3666c56b 100644 --- a/apps/backend/src/donations/donations.entity.ts +++ b/apps/backend/src/donations/donations.entity.ts @@ -6,7 +6,7 @@ import { JoinColumn, ManyToOne, } from 'typeorm'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationStatus } from './types'; @Entity('donations') diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 311971d1..5e2c8abf 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -5,8 +5,8 @@ import { AuthService } from '../auth/auth.service'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { DonationsController } from './donations.controller'; -import { ManufacturerModule } from '../foodManufacturers/manufacturer.module'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { ManufacturerModule } from '../foodManufacturers/manufacturers.module'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Module({ imports: [ diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 98e3cb62..16165536 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -4,7 +4,7 @@ import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { mock } from 'jest-mock-extended'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; const mockDonationRepository = mock>(); const mockFoodManufacturerRepository = mock>(); diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 6afaaee4..40254434 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationStatus } from './types'; @Injectable() diff --git a/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts b/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts new file mode 100644 index 00000000..e1df268b --- /dev/null +++ b/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts @@ -0,0 +1,112 @@ +import { + ArrayNotEmpty, + IsBoolean, + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + IsString, + Length, + MaxLength, +} from 'class-validator'; +import { Allergen, DonateWastedFood, ManufacturerAttribute } from '../types'; + +export class FoodManufacturerApplicationDto { + @IsString() + @IsNotEmpty() + @Length(1, 255) + foodManufacturerName: string; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + foodManufacturerWebsite: string; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + contactFirstName: string; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + contactLastName: string; + + @IsEmail() + @Length(1, 255) + contactEmail: string; + + @IsString() + @IsNotEmpty() + @IsPhoneNumber('US', { + message: + 'contactPhone must be a valid phone number (make sure all the digits are correct)', + }) + contactPhone: string; + + @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; + + @ArrayNotEmpty() + @IsEnum(Allergen, { each: true }) + unlistedProductAllergens: Allergen[]; + + @ArrayNotEmpty() + @IsEnum(Allergen, { each: true }) + facilityFreeAllergens: Allergen[]; + + @IsBoolean() + productsGlutenFree: boolean; + + @IsBoolean() + productsContainSulfites: boolean; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + productsSustainableExplanation: string; + + @IsBoolean() + inKindDonations: boolean; + + @IsEnum(DonateWastedFood) + donateWastedFood: DonateWastedFood; + + @IsEnum(ManufacturerAttribute) + manufacturerAttribute?: ManufacturerAttribute; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + additionalComments?: string; + + @IsOptional() + @IsBoolean() + newsletterSubscription?: boolean; +} diff --git a/apps/backend/src/foodManufacturers/manufacturer.entity.ts b/apps/backend/src/foodManufacturers/manufacturer.entity.ts deleted file mode 100644 index 895b84b8..00000000 --- a/apps/backend/src/foodManufacturers/manufacturer.entity.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - Entity, - Column, - PrimaryGeneratedColumn, - OneToOne, - OneToMany, - JoinColumn, -} from 'typeorm'; -import { User } from '../users/user.entity'; -import { Donation } from '../donations/donations.entity'; - -@Entity('food_manufacturers') -export class FoodManufacturer { - @PrimaryGeneratedColumn({ name: 'food_manufacturer_id' }) - foodManufacturerId: number; - - @Column({ name: 'food_manufacturer_name', type: 'varchar', length: 255 }) - foodManufacturerName: string; - - @OneToOne(() => User, { nullable: false }) - @JoinColumn({ - name: 'food_manufacturer_representative_id', - referencedColumnName: 'id', - }) - foodManufacturerRepresentative: User; - - @OneToMany(() => Donation, (donation) => donation.foodManufacturer) - donations: Donation[]; -} diff --git a/apps/backend/src/foodManufacturers/manufacturer.module.ts b/apps/backend/src/foodManufacturers/manufacturer.module.ts deleted file mode 100644 index 2ba2b117..00000000 --- a/apps/backend/src/foodManufacturers/manufacturer.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { FoodManufacturer } from './manufacturer.entity'; - -@Module({ - imports: [TypeOrmModule.forFeature([FoodManufacturer])], -}) -export class ManufacturerModule {} diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts new file mode 100644 index 00000000..694b1aca --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -0,0 +1,131 @@ +import { mock } from 'jest-mock-extended'; +import { FoodManufacturersService } from './manufacturers.service'; +import { FoodManufacturersController } from './manufacturers.controller'; +import { Test, TestingModule } from '@nestjs/testing'; +import { FoodManufacturer } from './manufacturers.entity'; +import { Allergen, DonateWastedFood, ManufacturerStatus } from './types'; +import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; + +const mockManufacturersService = mock(); + +const mockManufacturer1: Partial = { + foodManufacturerId: 1, + foodManufacturerName: 'Good Foods Inc', + status: ManufacturerStatus.PENDING, +}; + +const mockManufacturer2: Partial = { + foodManufacturerId: 2, + foodManufacturerName: 'Healthy Eats LLC', + status: ManufacturerStatus.PENDING, +}; + +describe('FoodManufacturersController', () => { + let controller: FoodManufacturersController; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FoodManufacturersController], + providers: [ + { + provide: FoodManufacturersService, + useValue: mockManufacturersService, + }, + ], + }).compile(); + + controller = module.get( + FoodManufacturersController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('GET /pending', () => { + it('should return pending food manufacturers', async () => { + const mockManufacturers: Partial[] = [ + mockManufacturer1, + mockManufacturer2, + ]; + + mockManufacturersService.getPendingManufacturers.mockResolvedValue( + mockManufacturers as FoodManufacturer[], + ); + + const result = await controller.getPendingManufacturers(); + + expect(result).toEqual(mockManufacturers); + expect(result).toHaveLength(2); + expect(result[0].foodManufacturerId).toBe(1); + expect(result[1].foodManufacturerId).toBe(2); + expect( + mockManufacturersService.getPendingManufacturers, + ).toHaveBeenCalled(); + }); + }); + + describe('GET /:id', () => { + it('should return a food manufacturer by id', async () => { + mockManufacturersService.findOne.mockResolvedValue( + mockManufacturer1 as FoodManufacturer, + ); + + const result = await controller.getFoodManufacturer(1); + + expect(result).toEqual(mockManufacturer1); + expect(mockManufacturersService.findOne).toHaveBeenCalledWith(1); + }); + }); + + describe('POST /api/manufacturers', () => { + it('should submit a food manufacturer application', async () => { + const mockApplicationData: FoodManufacturerApplicationDto = { + foodManufacturerName: 'Good Foods Inc', + foodManufacturerWebsite: 'https://goodfoods.example.com', + contactFirstName: 'Alice', + contactLastName: 'Johnson', + contactEmail: 'alice.johnson@goodfoods.example.com', + contactPhone: '555-123-4567', + unlistedProductAllergens: [Allergen.EGG], + facilityFreeAllergens: [Allergen.EGG], + productsGlutenFree: true, + productsContainSulfites: false, + productsSustainableExplanation: 'We use eco-friendly packaging.', + inKindDonations: true, + donateWastedFood: DonateWastedFood.SOMETIMES, + }; + + mockManufacturersService.addFoodManufacturer.mockResolvedValue(); + + await controller.submitFoodManufacturerApplication(mockApplicationData); + + expect(mockManufacturersService.addFoodManufacturer).toHaveBeenCalledWith( + mockApplicationData, + ); + }); + }); + + describe('POST /approve/:id', () => { + it('should approve a food manufacturer', async () => { + mockManufacturersService.approve.mockResolvedValue(); + + await controller.approveManufacturer(1); + + expect(mockManufacturersService.approve).toHaveBeenCalledWith(1); + }); + }); + + describe('POST /deny/:id', () => { + it('should deny a food manufacturer', async () => { + mockManufacturersService.deny.mockResolvedValue(); + + await controller.denyManufacturer(1); + + expect(mockManufacturersService.deny).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts new file mode 100644 index 00000000..5c756a6d --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -0,0 +1,177 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + ValidationPipe, +} from '@nestjs/common'; +import { FoodManufacturersService } from './manufacturers.service'; +import { FoodManufacturer } from './manufacturers.entity'; +import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; +import { ApiBody } from '@nestjs/swagger'; +import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; + +@Controller('manufacturers') +export class FoodManufacturersController { + constructor(private foodManufacturersService: FoodManufacturersService) {} + + @Get('/pending') + async getPendingManufacturers(): Promise { + return this.foodManufacturersService.getPendingManufacturers(); + } + + @Get('/:foodManufacturerId') + async getFoodManufacturer( + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + ): Promise { + return this.foodManufacturersService.findOne(foodManufacturerId); + } + + @ApiBody({ + description: 'Details for submitting a manufacturer application', + schema: { + type: 'object', + properties: { + foodManufacturerName: { + type: 'string', + minLength: 1, + maxLength: 255, + example: 'Healthy Foods Co', + }, + foodManufacturerWebsite: { + type: 'string', + minLength: 1, + maxLength: 255, + example: 'https://www.healthyfoodsco.com', + }, + contactFirstName: { + type: 'string', + example: 'John', + }, + contactLastName: { + type: 'string', + example: 'Smith', + }, + contactEmail: { + type: 'string', + format: 'email', + example: 'john.smith@example.com', + }, + contactPhone: { + type: 'string', + format: 'phone', + example: '(508) 508-6789', + description: 'Must be a valid US phone number', + }, + secondaryContactFirstName: { + type: 'string', + example: 'Jane', + }, + secondaryContactLastName: { + type: 'string', + example: 'Smith', + }, + secondaryContactEmail: { + type: 'string', + format: 'email', + example: 'jane.smith@example.com', + }, + secondaryContactPhone: { + type: 'string', + format: 'phone', + example: '(508) 528-6789', + description: 'Must be a valid US phone number', + }, + unlistedProductAllergens: { + type: 'array', + enum: Object.values(Allergen), + items: { type: 'string' }, + example: [Allergen.EGG, Allergen.PEANUT], + }, + facilityFreeAllergens: { + type: 'array', + enum: Object.values(Allergen), + items: { type: 'string' }, + example: [Allergen.PEANUT, Allergen.TREE_NUTS], + }, + productsGlutenFree: { + type: 'boolean', + example: true, + }, + productsContainSulfites: { + type: 'boolean', + example: false, + }, + productsSustainableExplanation: { + type: 'string', + minLength: 1, + maxLength: 255, + example: 'Our products are environmentally conscious.', + }, + inKindDonations: { + type: 'boolean', + example: true, + }, + donateWastedFood: { + type: 'string', + enum: Object.values(DonateWastedFood), + example: DonateWastedFood.ALWAYS, + }, + manufacturerAttribute: { + type: 'string', + enum: Object.values(ManufacturerAttribute), + example: ManufacturerAttribute.ORGANIC, + }, + additionalComments: { + type: 'string', + maxLength: 255, + example: 'Nope!', + }, + newsletterSubscription: { + type: 'boolean', + example: true, + }, + }, + required: [ + 'foodManufacturerName', + 'foodManufacturerWebsite', + 'contactFirstName', + 'contactLastName', + 'contactEmail', + 'contactPhone', + 'unlistedProductAllergens', + 'facilityFreeAllergens', + 'productsGlutenFree', + 'productsContainSulfites', + 'productsSustainableExplanation', + 'inKindDonations', + 'donateWastedFood', + ], + }, + }) + @Post() + async submitFoodManufacturerApplication( + @Body(new ValidationPipe()) + foodManufacturerData: FoodManufacturerApplicationDto, + ): Promise { + return this.foodManufacturersService.addFoodManufacturer( + foodManufacturerData, + ); + } + + @Post('/approve/:manufacturerId') + async approveManufacturer( + @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + ): Promise { + return this.foodManufacturersService.approve(manufacturerId); + } + + @Post('/deny/:manufacturerId') + async denyManufacturer( + @Param('manufacturerId', ParseIntPipe) manufacturerId: number, + ): Promise { + return this.foodManufacturersService.deny(manufacturerId); + } +} diff --git a/apps/backend/src/foodManufacturers/manufacturers.entity.ts b/apps/backend/src/foodManufacturers/manufacturers.entity.ts new file mode 100644 index 00000000..50f4c2ed --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturers.entity.ts @@ -0,0 +1,151 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { User } from '../users/user.entity'; +import { Donation } from '../donations/donations.entity'; +import { + Allergen, + DonateWastedFood, + ManufacturerAttribute, + ManufacturerStatus, +} from './types'; + +@Entity('food_manufacturers') +export class FoodManufacturer { + @PrimaryGeneratedColumn({ name: 'food_manufacturer_id' }) + foodManufacturerId: number; + + @Column({ name: 'food_manufacturer_name', type: 'varchar', length: 255 }) + foodManufacturerName: string; + + @Column({ name: 'food_manufacturer_website', type: 'varchar', length: 255 }) + foodManufacturerWebsite: string; + + @OneToOne(() => User, { + nullable: false, + cascade: ['insert'], + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'food_manufacturer_representative_id', + referencedColumnName: 'id', + }) + foodManufacturerRepresentative: User; + + @Column({ + name: 'secondary_contact_first_name', + type: 'varchar', + length: 255, + nullable: true, + }) + secondaryContactFirstName?: string; + + @Column({ + name: 'secondary_contact_last_name', + type: 'varchar', + length: 255, + nullable: true, + }) + secondaryContactLastName?: string; + + @Column({ + name: 'secondary_contact_email', + type: 'varchar', + length: 255, + nullable: true, + }) + secondaryContactEmail?: string; + + @Column({ + name: 'secondary_contact_phone', + type: 'varchar', + length: 20, + nullable: true, + }) + secondaryContactPhone?: string; + + @Column({ + name: 'unlisted_product_allergens', + type: 'enum', + enum: Allergen, + enumName: 'unlisted_product_allergens_enum', + array: true, + }) + unlistedProductAllergens: Allergen[]; + + @Column({ + name: 'facility_free_allergens', + type: 'enum', + enum: Allergen, + enumName: 'facility_free_allergens_enum', + array: true, + }) + facilityFreeAllergens: Allergen[]; + + @Column({ name: 'products_gluten_free', type: 'boolean' }) + productsGlutenFree: boolean; + + @Column({ name: 'products_contain_sulfites', type: 'boolean' }) + productsContainSulfites: boolean; + + @Column({ + name: 'products_sustainable_explanation', + type: 'varchar', + length: 255, + }) + productsSustainableExplanation: string; + + @Column({ name: 'in_kind_donations', type: 'boolean' }) + inKindDonations: boolean; + + @Column({ + name: 'donate_wasted_food', + type: 'enum', + enum: DonateWastedFood, + enumName: 'donate_wasted_food_enum', + }) + donateWastedFood: DonateWastedFood; + + @Column({ + name: 'manufacturer_attribute', + type: 'enum', + enum: ManufacturerAttribute, + enumName: 'manufacturer_attribute_enum', + nullable: true, + }) + manufacturerAttribute?: ManufacturerAttribute; + + @Column({ + name: 'additional_comments', + type: 'varchar', + length: 255, + nullable: true, + }) + additionalComments?: string; + + @Column({ name: 'newsletter_subscription', type: 'boolean', nullable: true }) + newsletterSubscription?: boolean; + + @OneToMany(() => Donation, (donation) => donation.foodManufacturer) + donations: Donation[]; + + @Column({ + name: 'status', + type: 'enum', + enum: ManufacturerStatus, + enumName: 'manufacturers_status_enum', + }) + status: ManufacturerStatus; + + @Column({ + name: 'date_applied', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + }) + dateApplied: Date; +} diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts new file mode 100644 index 00000000..2d9da5dc --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FoodManufacturer } from './manufacturers.entity'; +import { FoodManufacturersController } from './manufacturers.controller'; +import { FoodManufacturersService } from './manufacturers.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([FoodManufacturer])], + controllers: [FoodManufacturersController], + providers: [FoodManufacturersService], +}) +export class ManufacturerModule {} diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts new file mode 100644 index 00000000..68147a90 --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -0,0 +1,115 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FoodManufacturer } from './manufacturers.entity'; +import { Repository } from 'typeorm'; +import { validateId } from '../utils/validation.utils'; +import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; +import { User } from '../users/user.entity'; +import { Role } from '../users/types'; +import { ManufacturerStatus } from './types'; + +@Injectable() +export class FoodManufacturersService { + constructor( + @InjectRepository(FoodManufacturer) + private repo: Repository, + ) {} + + async findOne(foodManufacturerId: number): Promise { + validateId(foodManufacturerId, 'Food Manufacturer'); + + const foodManufacturer = await this.repo.findOne({ + where: { foodManufacturerId }, + }); + + if (!foodManufacturer) { + throw new NotFoundException( + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + return foodManufacturer; + } + + async getPendingManufacturers(): Promise { + return await this.repo.find({ + where: { status: ManufacturerStatus.PENDING }, + relations: ['foodManufacturerRepresentative'], + }); + } + + async addFoodManufacturer( + foodManufacturerData: FoodManufacturerApplicationDto, + ) { + const foodManufacturerContact: User = new User(); + const foodManufacturer: FoodManufacturer = new FoodManufacturer(); + + // primary contact information + foodManufacturerContact.role = Role.FOODMANUFACTURER; + foodManufacturerContact.firstName = foodManufacturerData.contactFirstName; + foodManufacturerContact.lastName = foodManufacturerData.contactLastName; + foodManufacturerContact.email = foodManufacturerData.contactEmail; + foodManufacturerContact.phone = foodManufacturerData.contactPhone; + + foodManufacturer.foodManufacturerRepresentative = foodManufacturerContact; + + // secondary contact information + foodManufacturer.secondaryContactFirstName = + foodManufacturerData.secondaryContactFirstName; + foodManufacturer.secondaryContactLastName = + foodManufacturerData.secondaryContactLastName; + foodManufacturer.secondaryContactEmail = + foodManufacturerData.secondaryContactEmail; + foodManufacturer.secondaryContactPhone = + foodManufacturerData.secondaryContactPhone; + + // food manufacturer details information + foodManufacturer.foodManufacturerName = + foodManufacturerData.foodManufacturerName; + foodManufacturer.foodManufacturerWebsite = + foodManufacturerData.foodManufacturerWebsite; + foodManufacturer.unlistedProductAllergens = + foodManufacturerData.unlistedProductAllergens; + foodManufacturer.facilityFreeAllergens = + foodManufacturerData.facilityFreeAllergens; + foodManufacturer.productsGlutenFree = + foodManufacturerData.productsGlutenFree; + foodManufacturer.productsContainSulfites = + foodManufacturerData.productsContainSulfites; + foodManufacturer.productsSustainableExplanation = + foodManufacturerData.productsSustainableExplanation; + foodManufacturer.inKindDonations = foodManufacturerData.inKindDonations; + foodManufacturer.donateWastedFood = foodManufacturerData.donateWastedFood; + foodManufacturer.additionalComments = + foodManufacturerData.additionalComments; + foodManufacturer.newsletterSubscription = + foodManufacturerData.newsletterSubscription; + + await this.repo.save(foodManufacturer); + } + + async approve(id: number) { + validateId(id, 'Food Manufacturer'); + + const foodManufacturer = await this.repo.findOne({ + where: { foodManufacturerId: id }, + }); + if (!foodManufacturer) { + throw new NotFoundException(`Food Manufacturer ${id} not found`); + } + + await this.repo.update(id, { status: ManufacturerStatus.APPROVED }); + } + + async deny(id: number) { + validateId(id, 'Food Manufacturer'); + + const foodManufacturer = await this.repo.findOne({ + where: { foodManufacturerId: id }, + }); + if (!foodManufacturer) { + throw new NotFoundException(`Food Manufacturer ${id} not found`); + } + + await this.repo.update(id, { status: ManufacturerStatus.DENIED }); + } +} diff --git a/apps/backend/src/foodManufacturers/types.ts b/apps/backend/src/foodManufacturers/types.ts index c7e0d4dc..4e42dcf4 100644 --- a/apps/backend/src/foodManufacturers/types.ts +++ b/apps/backend/src/foodManufacturers/types.ts @@ -5,3 +5,35 @@ export enum DonationFrequency { QUARTERLY = 'quarterly', WEEKLY = 'weekly', } + +export enum DonateWastedFood { + ALWAYS = 'Always', + SOMETIMES = 'Sometimes', + NEVER = 'Never', +} + +export enum ManufacturerStatus { + APPROVED = 'approved', + DENIED = 'denied', + PENDING = 'pending', +} + +export enum Allergen { + MILK = 'Milk', + EGG = 'Egg', + PEANUT = 'Peanut', + TREE_NUTS = 'Tree nuts', + WHEAT = 'Wheat', + SOY = 'Soy', + FISH = 'Fish', + SHELLFISH = 'Shellfish', + SESAME = 'Sesame', + GLUTEN = 'Gluten', +} + +export enum ManufacturerAttribute { + FEMALE = 'Female-founded or women-led', + NON_GMO = 'Non-GMO Project Verified', + ORGANIC = 'USDA Certified Organic', + NONE = 'None of the above', +} diff --git a/apps/backend/src/migrations/1768680807820-UpdateManufacturerEntity.ts b/apps/backend/src/migrations/1768680807820-UpdateManufacturerEntity.ts new file mode 100644 index 00000000..906b6065 --- /dev/null +++ b/apps/backend/src/migrations/1768680807820-UpdateManufacturerEntity.ts @@ -0,0 +1,96 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateManufacturerEntity1768680807820 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "allergen_enum" AS ENUM ( + 'Milk', + 'Egg', + 'Peanut', + 'Tree nuts', + 'Wheat', + 'Soy', + 'Fish', + 'Shellfish', + 'Sesame', + 'Gluten' + ); + + CREATE TYPE "donate_wasted_food_enum" AS ENUM ( + 'Always', + 'Sometimes', + 'Never' + ); + + CREATE TYPE "manufacturer_attribute_enum" AS ENUM ( + 'Female-founded or women-led', + 'Non-GMO Project Verified', + 'USDA Certified Organic', + 'None of the above' + ); + + CREATE TYPE "manufacturer_status_enum" AS ENUM ( + 'approved', + 'denied', + 'pending' + ); + + ALTER TABLE food_manufacturers + ADD COLUMN food_manufacturer_website VARCHAR(255) NOT NULL DEFAULT 'https://example.com', + ADD COLUMN secondary_contact_first_name VARCHAR(255), + ADD COLUMN secondary_contact_last_name VARCHAR(255), + ADD COLUMN secondary_contact_email VARCHAR(255), + ADD COLUMN secondary_contact_phone VARCHAR(20), + ADD COLUMN unlisted_product_allergens allergen_enum[] NOT NULL DEFAULT ARRAY['Gluten']::allergen_enum[], + ADD COLUMN facility_free_allergens allergen_enum[] NOT NULL DEFAULT ARRAY['Gluten']::allergen_enum[], + ADD COLUMN products_gluten_free BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN products_contain_sulfites BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN products_sustainable_explanation VARCHAR(255) NOT NULL DEFAULT 'Not provided', + ADD COLUMN in_kind_donations BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN donate_wasted_food donate_wasted_food_enum NOT NULL DEFAULT 'Never', + ADD COLUMN manufacturer_attribute manufacturer_attribute_enum, + ADD COLUMN additional_comments VARCHAR(255), + ADD COLUMN newsletter_subscription BOOLEAN, + ADD COLUMN status manufacturer_status_enum NOT NULL DEFAULT 'pending', + ADD COLUMN date_applied TIMESTAMP NOT NULL DEFAULT NOW(); + + ALTER TABLE food_manufacturers + DROP CONSTRAINT IF EXISTS fk_food_manufacturer_representative_id, + ADD CONSTRAINT fk_food_manufacturer_representative_id FOREIGN KEY(food_manufacturer_representative_id) REFERENCES users(user_id) ON DELETE CASCADE; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE food_manufacturers + DROP CONSTRAINT IF EXISTS fk_food_manufacturer_representative_id, + ADD CONSTRAINT fk_food_manufacturer_representative_id FOREIGN KEY(food_manufacturer_representative_id) REFERENCES users(user_id); + + ALTER TABLE food_manufacturers + DROP COLUMN date_applied, + DROP COLUMN status, + DROP COLUMN newsletter_subscription, + DROP COLUMN additional_comments, + DROP COLUMN manufacturer_attribute, + DROP COLUMN donate_wasted_food, + DROP COLUMN in_kind_donations, + DROP COLUMN products_sustainable_explanation, + DROP COLUMN products_contain_sulfites, + DROP COLUMN products_gluten_free, + DROP COLUMN facility_free_allergens, + DROP COLUMN unlisted_product_allergens, + DROP COLUMN secondary_contact_phone, + DROP COLUMN secondary_contact_email, + DROP COLUMN secondary_contact_last_name, + DROP COLUMN secondary_contact_first_name, + DROP COLUMN food_manufacturer_website; + + DROP TYPE IF EXISTS "manufacturer_status_enum"; + DROP TYPE IF EXISTS "manufacturer_attribute_enum"; + DROP TYPE IF EXISTS "donate_wasted_food_enum"; + DROP TYPE IF EXISTS "allergen_enum"; + `); + } +} diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 870dc1ef..1b4e8c38 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -11,7 +11,7 @@ import { import { OrdersService } from './order.service'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; diff --git a/apps/backend/src/orders/order.entity.ts b/apps/backend/src/orders/order.entity.ts index 4c38457b..4283ce64 100644 --- a/apps/backend/src/orders/order.entity.ts +++ b/apps/backend/src/orders/order.entity.ts @@ -8,7 +8,7 @@ import { } from 'typeorm'; import { FoodRequest } from '../foodRequests/request.entity'; import { Pantry } from '../pantries/pantries.entity'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { OrderStatus } from './types'; @Entity('orders') diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 05f37bca..421e874e 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; -import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { FoodRequest } from '../foodRequests/request.entity'; import { validateId } from '../utils/validation.utils'; import { OrderStatus } from './types'; diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index ee8287ce..b2c56b81 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -72,42 +72,104 @@ export class PantriesController { example: '(508) 508-6789', description: 'Must be a valid US phone number', }, + hasEmailContact: { + type: 'boolean', + example: true, + }, + emailContactOther: { + type: 'string', + maxLength: 255, + example: 'No we do not use email', + }, + secondaryContactFirstName: { + type: 'string', + example: 'Jane', + }, + secondaryContactLastName: { + type: 'string', + example: 'Smith', + }, + secondaryContactEmail: { + type: 'string', + format: 'email', + example: 'jane.smith@example.com', + }, + secondaryContactPhone: { + type: 'string', + format: 'phone', + example: '(508) 528-6789', + description: 'Must be a valid US phone number', + }, pantryName: { type: 'string', minLength: 1, maxLength: 255, example: 'Community Food Pantry', }, - addressLine1: { + shipmentAddressLine1: { type: 'string', minLength: 1, maxLength: 255, example: '123 Main Street', }, - addressLine2: { + shipmentAddressLine2: { + type: 'string', + maxLength: 255, + example: 'Suite 200', + }, + shipmentAddressCity: { + type: 'string', + minLength: 1, + maxLength: 255, + example: 'Boston', + }, + shipmentAddressState: { + type: 'string', + minLength: 1, + maxLength: 255, + example: 'MA', + }, + shipmentAddressZip: { + type: 'string', + minLength: 1, + maxLength: 255, + example: '02101', + }, + shipmentAddressCountry: { + type: 'string', + maxLength: 255, + example: 'United States', + }, + mailingAddressLine1: { + type: 'string', + minLength: 1, + maxLength: 255, + example: '456 Main Street', + }, + mailingAddressLine2: { type: 'string', maxLength: 255, example: 'Suite 200', }, - addressCity: { + mailingAddressCity: { type: 'string', minLength: 1, maxLength: 255, example: 'Boston', }, - addressState: { + mailingAddressState: { type: 'string', minLength: 1, maxLength: 255, example: 'MA', }, - addressZip: { + mailingAddressZip: { type: 'string', minLength: 1, maxLength: 255, example: '02101', }, - addressCountry: { + mailingAddressCountry: { type: 'string', maxLength: 255, example: 'United States', @@ -128,6 +190,15 @@ export class PantriesController { enum: Object.values(RefrigeratedDonation), example: RefrigeratedDonation.YES, }, + acceptFoodDeliveries: { + type: 'boolean', + example: true, + }, + deliveryWindowInstructions: { + type: 'string', + maxLength: 255, + example: 'Deliveries can be made between 9 AM and 5 PM on weekdays.', + }, reserveFoodForAllergic: { type: 'string', enum: Object.values(ReserveFoodForAllergic), @@ -135,6 +206,7 @@ export class PantriesController { }, reservationExplanation: { type: 'string', + maxLength: 255, example: 'We keep a dedicated section for clients with severe allergies', }, @@ -167,21 +239,25 @@ export class PantriesController { }, activitiesComments: { type: 'string', + maxLength: 255, example: 'We would be willing to implement these activities over the next quarter', }, itemsInStock: { type: 'string', + minLength: 1, + maxLength: 255, example: 'Rice, pasta, canned vegetables, gluten-free bread', }, needMoreOptions: { type: 'string', + minLength: 1, + maxLength: 255, example: 'Quite often', }, newsletterSubscription: { - type: 'string', - enum: ['Yes', 'No'], - example: 'Yes', + type: 'boolean', + example: true, }, }, required: [ @@ -189,13 +265,19 @@ export class PantriesController { 'contactLastName', 'contactEmail', 'contactPhone', + 'hasEmailContact', 'pantryName', - 'addressLine1', - 'addressCity', - 'addressState', - 'addressZip', + 'shipmentAddressLine1', + 'shipmentAddressCity', + 'shipmentAddressState', + 'shipmentAddressZip', + 'mailingAddressLine1', + 'mailingAddressCity', + 'mailingAddressState', + 'mailingAddressZip', 'allergenClients', 'refrigeratedDonation', + 'acceptFoodDeliveries', 'reserveFoodForAllergic', 'dedicatedAllergyFriendly', 'activities', From 08a983af0a140435f0a8b6ece1a555f4ea802811 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:11:35 -0500 Subject: [PATCH 2/2] make manufacturer attribute optional --- .../src/foodManufacturers/dtos/manufacturer-application.dto.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts b/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts index e1df268b..7d442b69 100644 --- a/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts +++ b/apps/backend/src/foodManufacturers/dtos/manufacturer-application.dto.ts @@ -97,6 +97,7 @@ export class FoodManufacturerApplicationDto { @IsEnum(DonateWastedFood) donateWastedFood: DonateWastedFood; + @IsOptional() @IsEnum(ManufacturerAttribute) manufacturerAttribute?: ManufacturerAttribute;