From 62ca4fbd852c129a0e06048a3750aa3c089caac8 Mon Sep 17 00:00:00 2001 From: skewalia <145241312+swarkewalia@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:47:06 -0500 Subject: [PATCH 1/3] get approved and update volunteer endpoints --- .../src/pantries/pantries.controller.ts | 19 +++++ apps/backend/src/pantries/pantries.service.ts | 78 +++++++++++++++++++ apps/backend/src/pantries/types.ts | 32 ++++++++ 3 files changed, 129 insertions(+) create mode 100644 apps/backend/src/pantries/types.ts diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 9ce5fdf6..46503dc7 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -5,11 +5,13 @@ import { Param, ParseIntPipe, Post, + Put, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; import { PantriesService } from './pantries.service'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ApiBody } from '@nestjs/swagger'; +import { ApprovedPantryResponse } from './types'; @Controller('pantries') export class PantriesController { @@ -20,6 +22,11 @@ export class PantriesController { return this.pantriesService.getPendingPantries(); } + @Get('/approved') + async getApprovedPantries(): Promise { + return this.pantriesService.getApprovedPantriesWithVolunteers(); + } + @Get('/:pantryId') async getPantry( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -229,4 +236,16 @@ export class PantriesController { ): Promise { return this.pantriesService.deny(pantryId); } + + @Put('/:pantryId/volunteers') + async updatePantryVolunteers( + @Param('pantryId', ParseIntPipe) pantryId: number, + @Body() body: { volunteerIds: number[] }, + ): Promise<{ message: string }> { + await this.pantriesService.updatePantryVolunteers( + pantryId, + body.volunteerIds, + ); + return { message: 'Volunteers updated successfully' }; + } } diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 05d5913a..87f9eac0 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -6,6 +6,8 @@ import { User } from '../users/user.entity'; import { validateId } from '../utils/validation.utils'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; +import { Assignments } from '../volunteerAssignments/volunteerAssignments.entity'; +import { ApprovedPantryResponse } from './types'; @Injectable() export class PantriesService { @@ -89,4 +91,80 @@ export class PantriesService { await this.repo.update(id, { status: 'denied' }); } + + async getApprovedPantriesWithVolunteers(): Promise { + const [pantries, assignments] = await Promise.all([ + this.repo.find({ + where: { status: 'approved' }, + relations: ['pantryUser'], + }), + this.repo.manager.find(Assignments, { + relations: ['volunteer', 'pantry'], + }), + ]); + + const assignmentsByPantry = assignments.reduce((acc, assignment) => { + const pantryId = assignment.pantry?.pantryId; + if (pantryId) { + if (!acc[pantryId]) acc[pantryId] = []; + acc[pantryId].push(assignment); + } + return acc; + }, {} as Record); + + return pantries.map((pantry) => ({ + pantryId: pantry.pantryId, + pantryName: pantry.pantryName, + address: { + line1: pantry.addressLine1, + line2: pantry.addressLine2, + city: pantry.addressCity, + state: pantry.addressState, + zip: pantry.addressZip, + country: pantry.addressCountry, + }, + contactInfo: { + firstName: pantry.pantryUser.firstName, + lastName: pantry.pantryUser.lastName, + email: pantry.pantryUser.email, + phone: pantry.pantryUser.phone, + }, + refrigeratedDonation: pantry.refrigeratedDonation, + allergenClients: pantry.allergenClients, + status: pantry.status, + dateApplied: pantry.dateApplied, + assignedVolunteers: (assignmentsByPantry[pantry.pantryId] || []).map( + (assignment) => ({ + assignmentId: assignment.assignmentId, + userId: assignment.volunteer.id, + name: `${assignment.volunteer.firstName} ${assignment.volunteer.lastName}`, + email: assignment.volunteer.email, + phone: assignment.volunteer.phone, + role: assignment.volunteer.role, + }), + ), + })); + } + + async updatePantryVolunteers( + pantryId: number, + volunteerIds: number[], + ): Promise { + validateId(pantryId, 'Pantry'); + + await this.findOne(pantryId); + + await this.repo.manager.delete(Assignments, { pantry: { pantryId } }); + + if (volunteerIds.length > 0) { + const newAssignments = volunteerIds.map((volunteerId) => + this.repo.manager.create(Assignments, { + volunteer: { id: volunteerId }, + pantry: { pantryId }, + }), + ); + + await this.repo.manager.save(Assignments, newAssignments); + } + } } diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts new file mode 100644 index 00000000..51ab869e --- /dev/null +++ b/apps/backend/src/pantries/types.ts @@ -0,0 +1,32 @@ +export interface ApprovedPantryResponse { + pantryId: number; + pantryName: string; + address: { + line1: string; + line2: string | null; + city: string; + state: string; + zip: string; + country: string | null; + }; + contactInfo: { + firstName: string; + lastName: string; + email: string; + phone: string; + }; + refrigeratedDonation: string; + allergenClients: string; + status: string; + dateApplied: Date; + assignedVolunteers: AssignedVolunteer[]; +} + +export interface AssignedVolunteer { + assignmentId: number; + userId: number; + name: string; + email: string; + phone: string; + role: string; +} From 08d4047d6bb20c028bed1f883dfad5535a54846b Mon Sep 17 00:00:00 2001 From: skewalia <145241312+swarkewalia@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:37:44 -0500 Subject: [PATCH 2/3] merge --- apps/backend/src/app.module.ts | 2 - apps/backend/src/auth/auth.module.ts | 11 +- apps/backend/src/config/typeorm.ts | 4 + .../donationItems/donationItems.controller.ts | 9 +- .../src/donations/donations.controller.ts | 4 +- apps/backend/src/donations/types.ts | 2 +- .../src/foodRequests/request.controller.ts | 9 +- ...4668-AddVolunteerPantryUniqueConstraint.ts | 38 + .../1763762628431-UpdatePantryFields.ts | 81 + ...1763963056712-AllergyFriendlyToBoolType.ts | 20 +- apps/backend/src/orders/order.controller.ts | 1 + apps/backend/src/orders/order.service.spec.ts | 29 +- .../pantries/dtos/pantry-application.dto.ts | 114 +- .../src/pantries/pantries.controller.ts | 12 - apps/backend/src/pantries/pantries.entity.ts | 109 +- apps/backend/src/pantries/pantries.module.ts | 8 +- apps/backend/src/pantries/pantries.service.ts | 122 +- apps/backend/src/pantries/types.ts | 3 +- apps/backend/src/users/dtos/userSchema.dto.ts | 5 +- apps/backend/src/users/user.entity.ts | 23 +- .../src/users/users.controller.spec.ts | 126 +- apps/backend/src/users/users.controller.ts | 29 +- apps/backend/src/users/users.module.ts | 3 +- apps/backend/src/users/users.service.spec.ts | 21 +- apps/backend/src/users/users.service.ts | 75 +- .../volunteerAssignments.controller.ts | 13 - .../volunteerAssignments.entity.ts | 30 - .../volunteerAssignments.module.ts | 15 - .../volunteerAssignments.service.ts | 36 - apps/frontend/src/api/apiClient.ts | 5 - apps/frontend/src/app.tsx | 5 + .../forms/pantryApplicationForm.tsx | 1454 +++++++++++------ .../forms/pantryApplicationModal.tsx | 24 +- .../src/components/forms/requestFormModal.tsx | 373 +++-- .../src/components/forms/usPhoneInput.tsx | 12 +- .../containers/pantryApplicationSubmitted.tsx | 44 + apps/frontend/src/theme.ts | 29 +- apps/frontend/src/types/pantryEnums.ts | 4 +- apps/frontend/src/types/types.ts | 82 +- 39 files changed, 2077 insertions(+), 909 deletions(-) create mode 100644 apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts create mode 100644 apps/backend/src/migrations/1763762628431-UpdatePantryFields.ts delete mode 100644 apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts delete mode 100644 apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts delete mode 100644 apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts delete mode 100644 apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts create mode 100644 apps/frontend/src/containers/pantryApplicationSubmitted.tsx diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 5c0294a8..3c0ce87a 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RequestsModule } from './foodRequests/request.module'; import { PantriesModule } from './pantries/pantries.module'; -import { AssignmentsModule } from './volunteerAssignments/volunteerAssignments.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; @@ -34,7 +33,6 @@ import { AllocationModule } from './allocations/allocations.module'; AuthModule, PantriesModule, RequestsModule, - AssignmentsModule, DonationModule, DonationItemsModule, OrdersModule, diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 09f5965c..eac8a5b6 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,19 +1,14 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; -import { UsersService } from '../users/users.service'; -import { User } from '../users/user.entity'; import { JwtStrategy } from './jwt.strategy'; +import { UsersModule } from '../users/users.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([User]), - PassportModule.register({ defaultStrategy: 'jwt' }), - ], + imports: [UsersModule, PassportModule.register({ defaultStrategy: 'jwt' })], controllers: [AuthController], - providers: [AuthService, UsersService, JwtStrategy], + providers: [AuthService, JwtStrategy], }) export class AuthModule {} diff --git a/apps/backend/src/config/typeorm.ts b/apps/backend/src/config/typeorm.ts index b356b698..154c311e 100644 --- a/apps/backend/src/config/typeorm.ts +++ b/apps/backend/src/config/typeorm.ts @@ -20,8 +20,10 @@ import { AddingEnumValues1760538239997 } from '../migrations/1760538239997-Addin import { UpdateColsToUseEnumType1760886499863 } from '../migrations/1760886499863-UpdateColsToUseEnumType'; import { UpdatePantriesTable1742739750279 } from '../migrations/1742739750279-updatePantriesTable'; import { RemoveOrdersDonationId1761500262238 } from '../migrations/1761500262238-RemoveOrdersDonationId'; +import { AddVolunteerPantryUniqueConstraint1760033134668 } from '../migrations/1760033134668-AddVolunteerPantryUniqueConstraint'; import { AllergyFriendlyToBoolType1763963056712 } from '../migrations/1763963056712-AllergyFriendlyToBoolType'; import { UpdatePantryUserFieldsFixed1764350314832 } from '../migrations/1764350314832-UpdatePantryUserFieldsFixed'; +import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields'; const config = { type: 'postgres', @@ -55,6 +57,8 @@ const config = { UpdateColsToUseEnumType1760886499863, UpdatePantriesTable1742739750279, RemoveOrdersDonationId1761500262238, + UpdatePantryFields1763762628431, + AddVolunteerPantryUniqueConstraint1760033134668, AllergyFriendlyToBoolType1763963056712, UpdatePantryUserFieldsFixed1764350314832, ], diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index 96381fc0..96be5673 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -38,8 +38,8 @@ export class DonationItemsController { status: { type: 'string', example: 'available' }, ozPerItem: { type: 'integer', example: 5 }, estimatedValue: { type: 'integer', example: 100 }, - foodType: { - type: 'string', + foodType: { + type: 'string', enum: Object.values(FoodType), example: FoodType.DAIRY_FREE_ALTERNATIVES, }, @@ -59,7 +59,10 @@ export class DonationItemsController { foodType: FoodType; }, ): Promise { - if (body.foodType && !Object.values(FoodType).includes(body.foodType as FoodType)) { + if ( + body.foodType && + !Object.values(FoodType).includes(body.foodType as FoodType) + ) { throw new BadRequestException('Invalid foodtype'); } return this.donationItemsService.create( diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index d748df66..6bcd2a7e 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -46,8 +46,8 @@ export class DonationsController { type: 'string', format: 'date-time', }, - status: { - type: 'string', + status: { + type: 'string', enum: Object.values(DonationStatus), example: DonationStatus.AVAILABLE, }, diff --git a/apps/backend/src/donations/types.ts b/apps/backend/src/donations/types.ts index 549ee6c6..16387987 100644 --- a/apps/backend/src/donations/types.ts +++ b/apps/backend/src/donations/types.ts @@ -2,4 +2,4 @@ export enum DonationStatus { AVAILABLE = 'available', FULFILLED = 'fulfilled', MATCHING = 'matching', -} \ No newline at end of file +} diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index c01eda5b..e3a93727 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -58,8 +58,8 @@ export class FoodRequestsController { type: 'object', properties: { pantryId: { type: 'integer', example: 1 }, - requestedSize: { - type: 'string', + requestedSize: { + type: 'string', enum: Object.values(RequestSize), example: RequestSize.LARGE, }, @@ -166,7 +166,10 @@ export class FoodRequestsController { ); const request = await this.requestsService.findOne(requestId); - await this.ordersService.updateStatus(request.order.orderId, OrderStatus.DELIVERED); + await this.ordersService.updateStatus( + request.order.orderId, + OrderStatus.DELIVERED, + ); return this.requestsService.updateDeliveryDetails( requestId, diff --git a/apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts b/apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts new file mode 100644 index 00000000..bc45bd3e --- /dev/null +++ b/apps/backend/src/migrations/1760033134668-AddVolunteerPantryUniqueConstraint.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVolunteerPantryUniqueConstraint1760033134668 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE volunteer_assignments DROP COLUMN assignment_id; + + ALTER TABLE volunteer_assignments + ADD PRIMARY KEY (volunteer_id, pantry_id); + + ALTER TABLE volunteer_assignments DROP CONSTRAINT IF EXISTS fk_volunteer_id; + + ALTER TABLE volunteer_assignments DROP CONSTRAINT IF EXISTS fk_pantry_id; + + ALTER TABLE volunteer_assignments + ADD CONSTRAINT fk_volunteer_id FOREIGN KEY (volunteer_id) REFERENCES users(user_id) ON DELETE CASCADE, + ADD CONSTRAINT fk_pantry_id FOREIGN KEY (pantry_id) REFERENCES pantries(pantry_id) ON DELETE CASCADE; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE volunteer_assignments DROP CONSTRAINT fk_volunteer_id; + + ALTER TABLE volunteer_assignments DROP CONSTRAINT fk_pantry_id; + + ALTER TABLE volunteer_assignments DROP CONSTRAINT volunteer_assignments_pkey; + + ALTER TABLE volunteer_assignments ADD COLUMN assignment_id SERIAL PRIMARY KEY; + + ALTER TABLE volunteer_assignments + ADD CONSTRAINT fk_volunteer_id FOREIGN KEY(volunteer_id) REFERENCES users(user_id), + ADD CONSTRAINT fk_pantry_id FOREIGN KEY(pantry_id) REFERENCES pantries(pantry_id); + `); + } +} diff --git a/apps/backend/src/migrations/1763762628431-UpdatePantryFields.ts b/apps/backend/src/migrations/1763762628431-UpdatePantryFields.ts new file mode 100644 index 00000000..54ac6e94 --- /dev/null +++ b/apps/backend/src/migrations/1763762628431-UpdatePantryFields.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdatePantryFields1763762628431 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE pantries + ADD COLUMN accept_food_deliveries boolean NOT NULL DEFAULT false, + ADD COLUMN delivery_window_instructions text, + ADD COLUMN mailing_address_line_1 varchar(255) NOT NULL DEFAULT 'A', + ADD COLUMN mailing_address_line_2 varchar(255), + ADD COLUMN mailing_address_city varchar(255) NOT NULL DEFAULT 'A', + ADD COLUMN mailing_address_state varchar(255) NOT NULL DEFAULT 'A', + ADD COLUMN mailing_address_zip varchar(255) NOT NULL DEFAULT 'A', + ADD COLUMN mailing_address_country varchar(255), + ALTER COLUMN newsletter_subscription DROP NOT NULL, + ADD COLUMN has_email_contact BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN email_contact_other TEXT, + 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); + + ALTER TABLE pantries + RENAME COLUMN address_line_1 TO shipment_address_line_1; + + ALTER TABLE pantries + RENAME COLUMN address_line_2 TO shipment_address_line_2; + + ALTER TABLE pantries + RENAME COLUMN address_city TO shipment_address_city; + + ALTER TABLE pantries + RENAME COLUMN address_state TO shipment_address_state; + + ALTER TABLE pantries + RENAME COLUMN address_zip TO shipment_address_zip; + + ALTER TABLE pantries + RENAME COLUMN address_country TO shipment_address_country; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "pantries" + DROP COLUMN IF EXISTS delivery_window_instructions, + DROP COLUMN IF EXISTS accept_food_deliveries, + DROP COLUMN IF EXISTS mailing_address_line_1, + DROP COLUMN IF EXISTS mailing_address_line_2, + DROP COLUMN IF EXISTS mailing_address_city, + DROP COLUMN IF EXISTS mailing_address_state, + DROP COLUMN IF EXISTS mailing_address_zip, + DROP COLUMN IF EXISTS mailing_address_country, + ALTER COLUMN newsletter_subscription SET NOT NULL, + DROP COLUMN IF EXISTS has_email_contact, + DROP COLUMN IF EXISTS email_contact_other, + DROP COLUMN IF EXISTS secondary_contact_first_name, + DROP COLUMN IF EXISTS secondary_contact_last_name, + DROP COLUMN IF EXISTS secondary_contact_email, + DROP COLUMN IF EXISTS secondary_contact_phone; + + ALTER TABLE pantries + RENAME COLUMN shipment_address_line_1 TO address_line_1; + + ALTER TABLE pantries + RENAME COLUMN shipment_address_line_2 TO address_line_2; + + ALTER TABLE pantries + RENAME COLUMN shipment_address_city TO address_city; + + ALTER TABLE pantries + RENAME COLUMN shipment_address_state TO address_state; + + ALTER TABLE pantries + RENAME COLUMN shipment_address_zip TO address_zip; + + ALTER TABLE pantries + RENAME COLUMN shipment_address_country TO address_country; + `); + } +} diff --git a/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts b/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts index 079362d6..3a14c4d4 100644 --- a/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts +++ b/apps/backend/src/migrations/1763963056712-AllergyFriendlyToBoolType.ts @@ -1,21 +1,21 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AllergyFriendlyToBoolType1763963056712 implements MigrationInterface { - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` +export class AllergyFriendlyToBoolType1763963056712 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` ALTER TABLE pantries ALTER COLUMN dedicated_allergy_friendly TYPE BOOLEAN USING (FALSE), ALTER COLUMN dedicated_allergy_friendly SET NOT NULL; `); - } + } - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(` + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` ALTER TABLE pantries ALTER COLUMN dedicated_allergy_friendly TYPE VARCHAR(255), ALTER COLUMN dedicated_allergy_friendly DROP NOT NULL; `); - } - + } } diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index 31ea1eb7..b43b8501 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -13,6 +13,7 @@ import { Order } from './order.entity'; import { Pantry } from '../pantries/pantries.entity'; import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; import { FoodRequest } from '../foodRequests/request.entity'; +import { Donation } from '../donations/donations.entity'; import { AllocationsService } from '../allocations/allocations.service'; import { OrderStatus } from './types'; diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 4c64ac10..ce653481 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -6,20 +6,20 @@ import { OrdersService } from './order.service'; import { mock } from 'jest-mock-extended'; import { Pantry } from '../pantries/pantries.entity'; import { User } from '../users/user.entity'; -import { AllergensConfidence, ClientVisitFrequency, PantryStatus, RefrigeratedDonation, ServeAllergicChildren } from '../pantries/types'; +import { + AllergensConfidence, + ClientVisitFrequency, + PantryStatus, + RefrigeratedDonation, + ServeAllergicChildren, +} from '../pantries/types'; import { OrderStatus } from './types'; const mockOrdersRepository = mock>(); -const mockPantry: Pantry = { +const mockPantry: Partial = { pantryId: 1, pantryName: 'Test Pantry', - addressLine1: '123 Test St', - addressLine2: 'Apt. 1', - addressCity: 'Boston', - addressState: 'MA', - addressZip: '02115', - addressCountry: 'US', allergenClients: '', refrigeratedDonation: RefrigeratedDonation.NO, reserveFoodForAllergic: 'Yes', @@ -37,6 +37,7 @@ const mockPantry: Pantry = { activitiesComments: '', itemsInStock: '', needMoreOptions: '', + volunteers: [], }; describe('OrdersService', () => { @@ -107,17 +108,17 @@ describe('OrdersService', () => { { orderId: 3, status: OrderStatus.DELIVERED, - pantry: { ...mockPantry, pantryName: 'Test Pantry' }, + pantry: { ...(mockPantry as Pantry), pantryName: 'Test Pantry' }, }, { orderId: 4, status: OrderStatus.DELIVERED, - pantry: { ...mockPantry, pantryName: 'Test Pantry 2' }, + pantry: { ...(mockPantry as Pantry), pantryName: 'Test Pantry 2' }, }, { orderId: 5, status: OrderStatus.DELIVERED, - pantry: { ...mockPantry, pantryName: 'Test Pantry 3' }, + pantry: { ...(mockPantry as Pantry), pantryName: 'Test Pantry 3' }, }, ]; @@ -155,17 +156,17 @@ describe('OrdersService', () => { { orderId: 3, status: OrderStatus.DELIVERED, - pantry: { ...mockPantry, pantryName: 'Test Pantry 1' }, + pantry: { ...(mockPantry as Pantry), pantryName: 'Test Pantry 1' }, }, { orderId: 4, status: OrderStatus.DELIVERED, - pantry: { ...mockPantry, pantryName: 'Test Pantry 2' }, + pantry: { ...(mockPantry as Pantry), pantryName: 'Test Pantry 2' }, }, { orderId: 5, status: OrderStatus.DELIVERED, - pantry: { ...mockPantry, pantryName: 'Test Pantry 2' }, + pantry: { ...(mockPantry as Pantry), pantryName: 'Test Pantry 2' }, }, ]; diff --git a/apps/backend/src/pantries/dtos/pantry-application.dto.ts b/apps/backend/src/pantries/dtos/pantry-application.dto.ts index fff83b4d..42510915 100644 --- a/apps/backend/src/pantries/dtos/pantry-application.dto.ts +++ b/apps/backend/src/pantries/dtos/pantry-application.dto.ts @@ -3,7 +3,6 @@ import { IsBoolean, IsEmail, IsEnum, - IsIn, IsNotEmpty, IsOptional, IsPhoneNumber, @@ -23,71 +22,164 @@ import { export class PantryApplicationDto { @IsString() @IsNotEmpty() + @Length(1, 255) contactFirstName: string; @IsString() @IsNotEmpty() + @Length(1, 255) contactLastName: string; @IsEmail() + @Length(1, 255) contactEmail: string; // This validation is very strict and won't accept phone numbers // that look right but aren't actually possible phone numbers + @IsString() + @IsNotEmpty() @IsPhoneNumber('US', { message: 'contactPhone must be a valid phone number (make sure all the digits are correct)', }) contactPhone: string; + @IsBoolean() + hasEmailContact: boolean; + + @IsOptional() @IsString() + @IsNotEmpty() + @MaxLength(255) + emailContactOther?: 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; + + @IsString() + @IsNotEmpty() @Length(1, 255) pantryName: string; @IsString() + @IsNotEmpty() @Length(1, 255) - addressLine1: string; + shipmentAddressLine1: string; @IsOptional() @IsString() @MaxLength(255) - addressLine2?: string; + @IsNotEmpty() + shipmentAddressLine2?: string; @IsString() + @IsNotEmpty() @Length(1, 255) - addressCity: string; + shipmentAddressCity: string; @IsString() + @IsNotEmpty() @Length(1, 255) - addressState: string; + shipmentAddressState: string; @IsString() + @IsNotEmpty() @Length(1, 255) - addressZip: string; + shipmentAddressZip: string; @IsOptional() @IsString() @MaxLength(255) - addressCountry?: string; + @IsNotEmpty() + shipmentAddressCountry?: string; @IsString() + @IsNotEmpty() + @Length(1, 255) + mailingAddressLine1: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @IsNotEmpty() + mailingAddressLine2?: string; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + mailingAddressCity: string; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + mailingAddressState: string; + + @IsString() + @IsNotEmpty() + @Length(1, 255) + mailingAddressZip: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @IsNotEmpty() + mailingAddressCountry?: string; + + @IsString() + @IsNotEmpty() @Length(1, 25) allergenClients: string; @IsOptional() @IsString({ each: true }) @IsNotEmpty({ each: true }) + @MaxLength(255, { each: true }) restrictions?: string[]; @IsEnum(RefrigeratedDonation) refrigeratedDonation: RefrigeratedDonation; + @IsBoolean() + acceptFoodDeliveries: boolean; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(255) + deliveryWindowInstructions?: string; + @IsEnum(ReserveFoodForAllergic) reserveFoodForAllergic: ReserveFoodForAllergic; // TODO: Really, this validation should be different depending on the value of reserveFoodForAllergic @IsOptional() @IsString() + @IsNotEmpty() + @MaxLength(255) reservationExplanation?: string; @IsBoolean() @@ -111,17 +203,21 @@ export class PantryApplicationDto { @IsOptional() @IsString() + @IsNotEmpty() + @MaxLength(255) activitiesComments?: string; @IsString() @IsNotEmpty() + @Length(1, 255) itemsInStock: string; @IsString() @IsNotEmpty() + @Length(1, 255) needMoreOptions: string; @IsOptional() - @IsIn(['Yes', 'No']) - newsletterSubscription?: string; + @IsBoolean() + newsletterSubscription?: boolean; } diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index d064e50e..f1f7941e 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -232,16 +232,4 @@ export class PantriesController { ): Promise { return this.pantriesService.deny(pantryId); } - - @Put('/:pantryId/volunteers') - async updatePantryVolunteers( - @Param('pantryId', ParseIntPipe) pantryId: number, - @Body() body: { volunteerIds: number[] }, - ): Promise<{ message: string }> { - await this.pantriesService.updatePantryVolunteers( - pantryId, - body.volunteerIds, - ); - return { message: 'Volunteers updated successfully' }; - } } diff --git a/apps/backend/src/pantries/pantries.entity.ts b/apps/backend/src/pantries/pantries.entity.ts index 20ddacff..2ad5c4ff 100644 --- a/apps/backend/src/pantries/pantries.entity.ts +++ b/apps/backend/src/pantries/pantries.entity.ts @@ -4,6 +4,7 @@ import { PrimaryGeneratedColumn, OneToOne, JoinColumn, + ManyToMany, } from 'typeorm'; import { User } from '../users/user.entity'; import { @@ -24,33 +25,61 @@ export class Pantry { @Column({ name: 'pantry_name', type: 'varchar', length: 255 }) pantryName: string; - @Column({ name: 'address_line_1', type: 'varchar', length: 255 }) - addressLine1: string; + @Column({ name: 'shipment_address_line_1', type: 'varchar', length: 255 }) + shipmentAddressLine1: string; @Column({ - name: 'address_line_2', + name: 'shipment_address_line_2', type: 'varchar', length: 255, nullable: true, }) - addressLine2?: string; + shipmentAddressLine2?: string; - @Column({ name: 'address_city', type: 'varchar', length: 255 }) - addressCity: string; + @Column({ name: 'shipment_address_city', type: 'varchar', length: 255 }) + shipmentAddressCity: string; - @Column({ name: 'address_state', type: 'varchar', length: 255 }) - addressState: string; + @Column({ name: 'shipment_address_state', type: 'varchar', length: 255 }) + shipmentAddressState: string; - @Column({ name: 'address_zip', type: 'varchar', length: 255 }) - addressZip: string; + @Column({ name: 'shipment_address_zip', type: 'varchar', length: 255 }) + shipmentAddressZip: string; @Column({ - name: 'address_country', + name: 'shipment_address_country', type: 'varchar', length: 255, nullable: true, }) - addressCountry?: string; + shipmentAddressCountry?: string; + + @Column({ name: 'mailing_address_line_1', type: 'varchar', length: 255 }) + mailingAddressLine1: string; + + @Column({ + name: 'mailing_address_line_2', + type: 'varchar', + length: 255, + nullable: true, + }) + mailingAddressLine2?: string; + + @Column({ name: 'mailing_address_city', type: 'varchar', length: 255 }) + mailingAddressCity: string; + + @Column({ name: 'mailing_address_state', type: 'varchar', length: 255 }) + mailingAddressState: string; + + @Column({ name: 'mailing_address_zip', type: 'varchar', length: 255 }) + mailingAddressZip: string; + + @Column({ + name: 'mailing_address_country', + type: 'varchar', + length: 255, + nullable: true, + }) + mailingAddressCountry?: string; @Column({ name: 'allergen_clients', type: 'varchar', length: 25 }) allergenClients: string; @@ -63,6 +92,16 @@ export class Pantry { }) refrigeratedDonation: RefrigeratedDonation; + @Column({ name: 'accept_food_deliveries', type: 'boolean' }) + acceptFoodDeliveries: boolean; + + @Column({ + name: 'delivery_window_instructions', + type: 'text', + nullable: true, + }) + deliveryWindowInstructions?: string; + @Column({ name: 'reserve_food_for_allergic', type: 'enum', @@ -103,15 +142,54 @@ export class Pantry { type: 'enum', enum: ServeAllergicChildren, enumName: 'serve_allergic_children_enum', + nullable: true, }) serveAllergicChildren?: ServeAllergicChildren; - @Column({ name: 'newsletter_subscription', type: 'boolean' }) - newsletterSubscription: boolean; + @Column({ name: 'newsletter_subscription', type: 'boolean', nullable: true }) + newsletterSubscription?: boolean; @Column({ name: 'restrictions', type: 'text', array: true }) restrictions: string[]; + @Column({ name: 'has_email_contact', type: 'boolean' }) + hasEmailContact: boolean; + + @Column({ name: 'email_contact_other', type: 'text', nullable: true }) + emailContactOther?: string; + + @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; + // cascade: ['insert'] means that when we create a new // pantry, the pantry user will automatically be added // to the User table @@ -158,4 +236,7 @@ export class Pantry { @Column({ name: 'need_more_options', type: 'text' }) needMoreOptions: string; + + @ManyToMany(() => User, (user) => user.pantries) + volunteers?: User[]; } diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 5653396d..3de2a4c5 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -1,16 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from '../users/user.entity'; import { PantriesService } from './pantries.service'; import { PantriesController } from './pantries.controller'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { Pantry } from './pantries.entity'; import { OrdersModule } from '../orders/order.module'; @Module({ - imports: [TypeOrmModule.forFeature([Pantry, User]), OrdersModule], + imports: [TypeOrmModule.forFeature([Pantry]), OrdersModule], controllers: [PantriesController], - providers: [PantriesService, AuthService, JwtStrategy], + providers: [PantriesService], + exports: [PantriesService], }) export class PantriesModule {} diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 5acafd45..0d692595 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -1,13 +1,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Pantry } from './pantries.entity'; import { User } from '../users/user.entity'; import { validateId } from '../utils/validation.utils'; import { PantryStatus } from './types'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; -import { Assignments } from '../volunteerAssignments/volunteerAssignments.entity'; import { ApprovedPantryResponse } from './types'; @Injectable() @@ -36,6 +35,7 @@ export class PantriesService { const pantryContact: User = new User(); const pantry: Pantry = new Pantry(); + // primary contact information pantryContact.role = Role.PANTRY; pantryContact.firstName = pantryData.contactFirstName; pantryContact.lastName = pantryData.contactLastName; @@ -43,20 +43,39 @@ export class PantriesService { pantryContact.phone = pantryData.contactPhone; pantry.pantryUser = pantryContact; - + pantry.hasEmailContact = pantryData.hasEmailContact; + pantry.emailContactOther = pantryData.emailContactOther; + + // secondary contact information + pantry.secondaryContactFirstName = pantryData.secondaryContactFirstName; + pantry.secondaryContactLastName = pantryData.secondaryContactLastName; + pantry.secondaryContactEmail = pantryData.secondaryContactEmail; + pantry.secondaryContactPhone = pantryData.secondaryContactPhone; + + // food shipment address information + pantry.shipmentAddressLine1 = pantryData.shipmentAddressLine1; + pantry.shipmentAddressLine2 = pantryData.shipmentAddressLine2; + pantry.shipmentAddressCity = pantryData.shipmentAddressCity; + pantry.shipmentAddressState = pantryData.shipmentAddressState; + pantry.shipmentAddressZip = pantryData.shipmentAddressZip; + pantry.shipmentAddressCountry = pantryData.shipmentAddressCountry; + + // mailing address information + pantry.mailingAddressLine1 = pantryData.mailingAddressLine1; + pantry.mailingAddressLine2 = pantryData.mailingAddressLine2; + pantry.mailingAddressCity = pantryData.mailingAddressCity; + pantry.mailingAddressState = pantryData.mailingAddressState; + pantry.mailingAddressZip = pantryData.mailingAddressZip; + pantry.mailingAddressCountry = pantryData.mailingAddressCountry; + + // pantry details information pantry.pantryName = pantryData.pantryName; - pantry.addressLine1 = pantryData.addressLine1; - pantry.addressLine2 = pantryData.addressLine2; - pantry.addressCity = pantryData.addressCity; - pantry.addressState = pantryData.addressState; - pantry.addressZip = pantryData.addressZip; - pantry.addressCountry = pantryData.addressCountry; pantry.allergenClients = pantryData.allergenClients; pantry.restrictions = pantryData.restrictions; pantry.refrigeratedDonation = pantryData.refrigeratedDonation; + pantry.dedicatedAllergyFriendly = pantryData.dedicatedAllergyFriendly; pantry.reserveFoodForAllergic = pantryData.reserveFoodForAllergic; pantry.reservationExplanation = pantryData.reservationExplanation; - pantry.dedicatedAllergyFriendly = pantryData.dedicatedAllergyFriendly; pantry.clientVisitFrequency = pantryData.clientVisitFrequency; pantry.identifyAllergensConfidence = pantryData.identifyAllergensConfidence; pantry.serveAllergicChildren = pantryData.serveAllergicChildren; @@ -64,8 +83,7 @@ export class PantriesService { pantry.activitiesComments = pantryData.activitiesComments; pantry.itemsInStock = pantryData.itemsInStock; pantry.needMoreOptions = pantryData.needMoreOptions; - pantry.newsletterSubscription = - pantryData?.newsletterSubscription === 'Yes'; + pantry.newsletterSubscription = pantryData.newsletterSubscription; // pantry contact is automatically added to User table await this.repo.save(pantry); @@ -93,36 +111,23 @@ export class PantriesService { await this.repo.update(id, { status: PantryStatus.DENIED }); } + async getApprovedPantriesWithVolunteers(): Promise { - const [pantries, assignments] = await Promise.all([ - this.repo.find({ - where: { status: PantryStatus.APPROVED }, - relations: ['pantryUser'], - }), - this.repo.manager.find(Assignments, { - relations: ['volunteer', 'pantry'], - }), - ]); - - const assignmentsByPantry = assignments.reduce((acc, assignment) => { - const pantryId = assignment.pantry?.pantryId; - if (pantryId) { - if (!acc[pantryId]) acc[pantryId] = []; - acc[pantryId].push(assignment); - } - return acc; - }, {} as Record); - + const pantries = await this.repo.find({ + where: { status: PantryStatus.APPROVED }, + relations: ['pantryUser', 'volunteers'], + }); + return pantries.map((pantry) => ({ pantryId: pantry.pantryId, pantryName: pantry.pantryName, address: { - line1: pantry.addressLine1, - line2: pantry.addressLine2, - city: pantry.addressCity, - state: pantry.addressState, - zip: pantry.addressZip, - country: pantry.addressCountry, + line1: pantry.shipmentAddressLine1, + line2: pantry.shipmentAddressLine2, + city: pantry.shipmentAddressCity, + state: pantry.shipmentAddressState, + zip: pantry.shipmentAddressZip, + country: pantry.shipmentAddressCountry, }, contactInfo: { firstName: pantry.pantryUser.firstName, @@ -134,38 +139,29 @@ export class PantriesService { allergenClients: pantry.allergenClients, status: pantry.status, dateApplied: pantry.dateApplied, - assignedVolunteers: (assignmentsByPantry[pantry.pantryId] || []).map( - (assignment) => ({ - assignmentId: assignment.assignmentId, - userId: assignment.volunteer.id, - name: `${assignment.volunteer.firstName} ${assignment.volunteer.lastName}`, - email: assignment.volunteer.email, - phone: assignment.volunteer.phone, - role: assignment.volunteer.role, - }), - ), + assignedVolunteers: (pantry.volunteers || []).map((volunteer) => ({ + userId: volunteer.id, + name: `${volunteer.firstName} ${volunteer.lastName}`, + email: volunteer.email, + phone: volunteer.phone, + role: volunteer.role, + })), })); } - async updatePantryVolunteers( - pantryId: number, - volunteerIds: number[], - ): Promise { - validateId(pantryId, 'Pantry'); - - await this.findOne(pantryId); + async findByIds(pantryIds: number[]): Promise { + pantryIds.forEach((id) => validateId(id, 'Pantry')); - await this.repo.manager.delete(Assignments, { pantry: { pantryId } }); + const pantries = await this.repo.findBy({ pantryId: In(pantryIds) }); - if (volunteerIds.length > 0) { - const newAssignments = volunteerIds.map((volunteerId) => - this.repo.manager.create(Assignments, { - volunteer: { id: volunteerId }, - pantry: { pantryId }, - }), + if (pantries.length !== pantryIds.length) { + const foundIds = pantries.map((p) => p.pantryId); + const missingIds = pantryIds.filter((id) => !foundIds.includes(id)); + throw new NotFoundException( + `Pantries not found: ${missingIds.join(', ')}`, ); - - await this.repo.manager.save(Assignments, newAssignments); } + + return pantries; } } diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index c2d2d128..16c41f4f 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -23,7 +23,6 @@ export interface ApprovedPantryResponse { } export interface AssignedVolunteer { - assignmentId: number; userId: number; name: string; email: string; @@ -65,7 +64,7 @@ export enum PantryStatus { export enum Activity { CREATE_LABELED_SHELF = 'Create labeled shelf', PROVIDE_EDUCATIONAL_PAMPHLETS = 'Provide educational pamphlets', - TRACK_DIETARY_NEEDS ='Spreadsheet to track dietary needs', + TRACK_DIETARY_NEEDS = 'Spreadsheet to track dietary needs', POST_RESOURCE_FLYERS = 'Post allergen-free resource flyers', SURVEY_CLIENTS = 'Survey clients to determine medical dietary needs', COLLECT_FEEDBACK = 'Collect feedback from allergen-avoidant clients', diff --git a/apps/backend/src/users/dtos/userSchema.dto.ts b/apps/backend/src/users/dtos/userSchema.dto.ts index b6905ea2..ac8cd1ec 100644 --- a/apps/backend/src/users/dtos/userSchema.dto.ts +++ b/apps/backend/src/users/dtos/userSchema.dto.ts @@ -3,22 +3,25 @@ import { IsEnum, IsNotEmpty, IsString, - IsOptional, IsPhoneNumber, + Length, } from 'class-validator'; import { Role } from '../types'; export class userSchemaDto { @IsEmail() @IsNotEmpty() + @Length(1, 255) email: string; @IsString() @IsNotEmpty() + @Length(1, 255) firstName: string; @IsString() @IsNotEmpty() + @Length(1, 255) lastName: string; @IsString() diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index b0d95316..e2b9a958 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -1,6 +1,13 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; import { Role } from './types'; +import { Pantry } from '../pantries/pantries.entity'; @Entity() export class User { @@ -30,4 +37,18 @@ export class User { length: 20, }) phone: string; + + @ManyToMany(() => Pantry, (pantry) => pantry.volunteers) + @JoinTable({ + name: 'volunteer_assignments', + joinColumn: { + name: 'volunteer_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'pantry_id', + referencedColumnName: 'pantryId', + }, + }) + pantries?: Pantry[]; } diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 78d116a3..8a22cf3a 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -7,27 +7,40 @@ import { userSchemaDto } from './dtos/userSchema.dto'; import { Test, TestingModule } from '@nestjs/testing'; import { mock } from 'jest-mock-extended'; +import { Pantry } from '../pantries/pantries.entity'; const mockUserService = mock(); -const mockUser1: User = { +const mockUser1: Partial = { id: 1, - email: 'john@example.com', - firstName: 'John', - lastName: 'Doe', - phone: '1234567890', role: Role.STANDARD_VOLUNTEER, }; -const mockUser2: User = { +const mockUser2: Partial = { id: 2543210, - email: 'bobsmith@example.com', - firstName: 'Bob', - lastName: 'Smith', - phone: '9876', role: Role.LEAD_VOLUNTEER, }; +const mockUser3: Partial = { + id: 3, + role: Role.STANDARD_VOLUNTEER, +}; + +const mockPantries: Partial[] = [ + { + pantryId: 1, + pantryUser: mockUser1 as User, + }, + { + pantryId: 2, + pantryUser: mockUser1 as User, + }, + { + pantryId: 3, + pantryUser: mockUser2 as User, + }, +]; + describe('UsersController', () => { let controller: UsersController; @@ -55,27 +68,9 @@ describe('UsersController', () => { expect(controller).toBeDefined(); }); - describe('GET /volunteers', () => { - it('should return all volunteers', async () => { - const volunteers = [mockUser1, mockUser2]; - mockUserService.findUsersByRoles.mockResolvedValue(volunteers); - - const result = await controller.getAllVolunteers(); - - const hasAdmin = result.some((user) => user.role === Role.ADMIN); - expect(hasAdmin).toBe(false); - - expect(result).toEqual(volunteers); - expect(mockUserService.findUsersByRoles).toHaveBeenCalledWith([ - Role.LEAD_VOLUNTEER, - Role.STANDARD_VOLUNTEER, - ]); - }); - }); - describe('GET /:id', () => { it('should return a user by id', async () => { - mockUserService.findOne.mockResolvedValue(mockUser1); + mockUserService.findOne.mockResolvedValue(mockUser1 as User); const result = await controller.getUser(1); @@ -86,7 +81,7 @@ describe('UsersController', () => { describe('DELETE /:id', () => { it('should remove a user by id', async () => { - mockUserService.remove.mockResolvedValue(mockUser1); + mockUserService.remove.mockResolvedValue(mockUser1 as User); const result = await controller.removeUser(1); @@ -95,10 +90,10 @@ describe('UsersController', () => { }); }); - describe('PUT :id/role', () => { + describe('PUT /:id/role', () => { it('should update user role with valid role', async () => { const updatedUser = { ...mockUser1, role: Role.ADMIN }; - mockUserService.update.mockResolvedValue(updatedUser); + mockUserService.update.mockResolvedValue(updatedUser as User); const result = await controller.updateRole(1, Role.ADMIN); @@ -126,7 +121,7 @@ describe('UsersController', () => { role: Role.ADMIN, }; - const createdUser = { ...createUserSchema, id: 2 }; + const createdUser = { ...createUserSchema, id: 2 } as User; mockUserService.create.mockResolvedValue(createdUser); const result = await controller.createUser(createUserSchema); @@ -158,4 +153,69 @@ describe('UsersController', () => { ); }); }); + + describe('GET /volunteers', () => { + it('should return all volunteers with their pantry assignments', async () => { + const assignments: (User & { pantryIds: number[] })[] = [ + { ...(mockUser1 as User), pantryIds: [1, 2] }, + { ...(mockUser2 as User), pantryIds: [1] }, + { ...(mockUser3 as User), pantryIds: [] }, + ]; + + mockUserService.getVolunteersAndPantryAssignments.mockResolvedValue( + assignments, + ); + + const result = await controller.getAllVolunteers(); + + expect(result).toEqual(assignments); + expect(result).toHaveLength(3); + expect(result[0].id).toBe(1); + expect(result[0].pantryIds).toEqual([1, 2]); + expect(result[1].id).toBe(2543210); + expect(result[1].pantryIds).toEqual([1]); + expect(result[2].id).toBe(3); + expect(result[2].pantryIds).toEqual([]); + expect( + mockUserService.getVolunteersAndPantryAssignments, + ).toHaveBeenCalled(); + }); + }); + + describe('GET /:id/pantries', () => { + it('should return pantries assigned to a user', async () => { + mockUserService.getVolunteerPantries.mockResolvedValue( + mockPantries.slice(0, 2) as Pantry[], + ); + + const result = await controller.getVolunteerPantries(1); + + expect(result).toHaveLength(2); + expect(result).toEqual(mockPantries.slice(0, 2)); + expect(mockUserService.getVolunteerPantries).toHaveBeenCalledWith(1); + }); + }); + + describe('POST /:id/pantries', () => { + it('should assign pantries to a volunteer and return result', async () => { + const pantryIds = [1, 3]; + const updatedUser = { + ...mockUser3, + pantries: [mockPantries[0] as Pantry, mockPantries[2] as Pantry], + } as User; + + mockUserService.assignPantriesToVolunteer.mockResolvedValue(updatedUser); + + const result = await controller.assignPantries(3, pantryIds); + + expect(result).toEqual(updatedUser); + expect(result.pantries).toHaveLength(2); + expect(result.pantries[0].pantryId).toBe(1); + expect(result.pantries[1].pantryId).toBe(3); + expect(mockUserService.assignPantriesToVolunteer).toHaveBeenCalledWith( + 3, + pantryIds, + ); + }); + }); }); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 93a4dc01..6f11265d 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -15,8 +15,8 @@ import { UsersService } from './users.service'; //import { AuthGuard } from '@nestjs/passport'; import { User } from './user.entity'; import { Role } from './types'; -import { VOLUNTEER_ROLES } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; +import { Pantry } from '../pantries/pantries.entity'; //import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Controller('users') @@ -25,8 +25,10 @@ export class UsersController { constructor(private usersService: UsersService) {} @Get('/volunteers') - async getAllVolunteers(): Promise { - return this.usersService.findUsersByRoles(VOLUNTEER_ROLES); + async getAllVolunteers(): Promise< + (Omit & { pantryIds: number[] })[] + > { + return this.usersService.getVolunteersAndPantryAssignments(); } // @UseGuards(AuthGuard('jwt')) @@ -35,16 +37,23 @@ export class UsersController { return this.usersService.findOne(userId); } + @Get('/:id/pantries') + async getVolunteerPantries( + @Param('id', ParseIntPipe) id: number, + ): Promise { + return this.usersService.getVolunteerPantries(id); + } + @Delete('/:id') - removeUser(@Param('id', ParseIntPipe) userId: number) { + removeUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.remove(userId); } - @Put(':id/role') + @Put('/:id/role') async updateRole( @Param('id', ParseIntPipe) id: number, @Body('role') role: string, - ) { + ): Promise { if (!Object.values(Role).includes(role as Role)) { throw new BadRequestException('Invalid role'); } @@ -56,4 +65,12 @@ export class UsersController { const { email, firstName, lastName, phone, role } = createUserDto; return this.usersService.create(email, firstName, lastName, phone, role); } + + @Post('/:id/pantries') + async assignPantries( + @Param('id', ParseIntPipe) id: number, + @Body('pantryIds') pantryIds: number[], + ): Promise { + return this.usersService.assignPantriesToVolunteer(id, pantryIds); + } } diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 2f78bb05..6a780a8d 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -6,9 +6,10 @@ import { User } from './user.entity'; import { JwtStrategy } from '../auth/jwt.strategy'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthService } from '../auth/auth.service'; +import { PantriesModule } from '../pantries/pantries.module'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), PantriesModule], exports: [UsersService], controllers: [UsersController], providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 8388a79a..db28dc0e 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -8,22 +8,31 @@ import { Role } from './types'; import { mock } from 'jest-mock-extended'; import { In } from 'typeorm'; import { BadRequestException } from '@nestjs/common'; +import { PantriesService } from '../pantries/pantries.service'; const mockUserRepository = mock>(); +const mockPantriesService = mock(); -const mockUser: User = { +const mockUser = { id: 1, email: 'test@example.com', firstName: 'John', lastName: 'Doe', phone: '1234567890', role: Role.STANDARD_VOLUNTEER, -}; +} as User; describe('UsersService', () => { let service: UsersService; beforeAll(async () => { + mockUserRepository.create.mockReset(); + mockUserRepository.save.mockReset(); + mockUserRepository.findOneBy.mockReset(); + mockUserRepository.find.mockReset(); + mockUserRepository.remove.mockReset(); + mockPantriesService.findByIds.mockReset(); + const module = await Test.createTestingModule({ providers: [ UsersService, @@ -31,6 +40,10 @@ describe('UsersService', () => { provide: getRepositoryToken(User), useValue: mockUserRepository, }, + { + provide: PantriesService, + useValue: mockPantriesService, + }, ], }).compile(); @@ -43,6 +56,7 @@ describe('UsersService', () => { mockUserRepository.findOneBy.mockReset(); mockUserRepository.find.mockReset(); mockUserRepository.remove.mockReset(); + mockPantriesService.findByIds.mockReset(); }); afterEach(() => { @@ -61,7 +75,7 @@ describe('UsersService', () => { lastName: 'Smith', phone: '9876543210', role: Role.ADMIN, - }; + } as User; const createdUser = { ...userData, id: 1 }; mockUserRepository.create.mockReturnValue(createdUser); @@ -198,6 +212,7 @@ describe('UsersService', () => { expect(result).toEqual(users); expect(mockUserRepository.find).toHaveBeenCalledWith({ where: { role: In(roles) }, + relations: ['pantries'], }); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index f10a0237..1f0984e3 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,14 +1,25 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { User } from './user.entity'; -import { Role } from './types'; +import { Role, VOLUNTEER_ROLES } from './types'; import { validateId } from '../utils/validation.utils'; +import { Pantry } from '../pantries/pantries.entity'; +import { PantriesService } from '../pantries/pantries.service'; @Injectable() export class UsersService { - constructor(@InjectRepository(User) private repo: Repository) {} + constructor( + @InjectRepository(User) + private repo: Repository, + + private pantriesService: PantriesService, + ) {} async create( email: string, @@ -39,6 +50,22 @@ export class UsersService { return user; } + async findVolunteer(volunteerId: number): Promise { + validateId(volunteerId, 'Volunteer'); + + const volunteer = await this.repo.findOne({ + where: { id: volunteerId }, + relations: ['pantries'], + }); + + if (!volunteer) + throw new NotFoundException(`User ${volunteerId} not found`); + if (!VOLUNTEER_ROLES.includes(volunteer.role)) { + throw new BadRequestException(`User ${volunteerId} is not a volunteer`); + } + return volunteer; + } + find(email: string) { return this.repo.find({ where: { email } }); } @@ -70,6 +97,46 @@ export class UsersService { } async findUsersByRoles(roles: Role[]): Promise { - return this.repo.find({ where: { role: In(roles) } }); + return this.repo.find({ + where: { role: In(roles) }, + relations: ['pantries'], + }); + } + + async getVolunteersAndPantryAssignments(): Promise< + (Omit & { pantryIds: number[] })[] + > { + const volunteers = await this.findUsersByRoles(VOLUNTEER_ROLES); + + return volunteers.map((v) => { + const { pantries, ...volunteerWithoutPantries } = v; + return { + ...volunteerWithoutPantries, + pantryIds: pantries.map((p) => p.pantryId), + }; + }); + } + + async getVolunteerPantries(volunteerId: number): Promise { + const volunteer = await this.findVolunteer(volunteerId); + return volunteer.pantries; + } + + async assignPantriesToVolunteer( + volunteerId: number, + pantryIds: number[], + ): Promise { + pantryIds.forEach((id) => validateId(id, 'Pantry')); + + const volunteer = await this.findVolunteer(volunteerId); + + const pantries = await this.pantriesService.findByIds(pantryIds); + const existingPantryIds = volunteer.pantries.map((p) => p.pantryId); + const newPantries = pantries.filter( + (p) => !existingPantryIds.includes(p.pantryId), + ); + + volunteer.pantries = [...volunteer.pantries, ...newPantries]; + return this.repo.save(volunteer); } } diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts deleted file mode 100644 index 84912f9f..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AssignmentsService } from './volunteerAssignments.service'; -import { Assignments } from './volunteerAssignments.entity'; - -@Controller('assignments') -export class AssignmentsController { - constructor(private assignmentsService: AssignmentsService) {} - - @Get('') - async getAssignments(): Promise { - return this.assignmentsService.getAssignments(); - } -} diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts deleted file mode 100644 index d9929fe5..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.entity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - OneToOne, - JoinColumn, - ManyToOne, - Column, -} from 'typeorm'; -import { User } from '../users/user.entity'; -import { Pantry } from '../pantries/pantries.entity'; - -@Entity('volunteer_assignments') -export class Assignments { - @PrimaryGeneratedColumn({ name: 'assignment_id' }) - assignmentId: number; - - @ManyToOne(() => User, { nullable: false }) - @JoinColumn({ - name: 'volunteer_id', - referencedColumnName: 'id', - }) - volunteer: User; - - @OneToOne(() => Pantry, { nullable: true }) - @JoinColumn({ - name: 'pantry_id', - referencedColumnName: 'pantryId', - }) - pantry: Pantry; -} diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts deleted file mode 100644 index dc0382e8..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; -import { Assignments } from './volunteerAssignments.entity'; -import { AssignmentsController } from './volunteerAssignments.controller'; -import { AssignmentsService } from './volunteerAssignments.service'; -import { UsersModule } from '../users/users.module'; - -@Module({ - imports: [TypeOrmModule.forFeature([Assignments]), UsersModule], - controllers: [AssignmentsController], - providers: [AssignmentsService, AuthService, JwtStrategy], -}) -export class AssignmentsModule {} diff --git a/apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts b/apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts deleted file mode 100644 index d6ef19a1..00000000 --- a/apps/backend/src/volunteerAssignments/volunteerAssignments.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Assignments } from './volunteerAssignments.entity'; -import { UsersService } from '../users/users.service'; - -@Injectable() -export class AssignmentsService { - constructor( - @InjectRepository(Assignments) private repo: Repository, - private usersService: UsersService, - ) {} - - // Gets the assignment id, volunteer details and the corresponding pantry - async getAssignments() { - const results = await this.repo.find({ - relations: ['volunteer', 'pantry'], - select: { - assignmentId: true, - volunteer: { - id: true, - firstName: true, - lastName: true, - email: true, - phone: true, - role: true, - }, - pantry: { - pantryId: true, - pantryName: true, - }, - }, - }); - return results; - } -} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 74d2c812..a548bfa5 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -7,7 +7,6 @@ import { DonationItem, Donation, Allocation, - VolunteerPantryAssignment, CreateFoodRequestBody, Pantry, PantryApplicationDto, @@ -130,10 +129,6 @@ export class ApiClient { .then((response) => response.data); } - public async getAllAssignments(): Promise { - return this.get('/api/assignments') as Promise; - } - public async getVolunteers(): Promise { return this.get('/api/users/volunteers') as Promise; } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 89e0fccf..90be09d1 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -14,6 +14,7 @@ import { submitFoodRequestFormModal } from '@components/forms/requestFormModal'; import { submitDeliveryConfirmationFormModal } from '@components/forms/deliveryConfirmationModal'; import FormRequests from '@containers/FormRequests'; import PantryApplication from '@containers/pantryApplication'; +import PantryApplicationSubmitted from '@containers/pantryApplicationSubmitted'; import { submitPantryApplicationForm } from '@components/forms/pantryApplicationForm'; import ApprovePantries from '@containers/approvePantries'; import VolunteerManagement from '@containers/volunteerManagement'; @@ -59,6 +60,10 @@ const router = createBrowserRouter([ element: , action: submitPantryApplicationForm, }, + { + path: '/pantry-application/submitted', + element: , + }, { path: '/food-manufacturer-order-dashboard', element: , diff --git a/apps/frontend/src/components/forms/pantryApplicationForm.tsx b/apps/frontend/src/components/forms/pantryApplicationForm.tsx index 4c7325bf..94abfe3a 100644 --- a/apps/frontend/src/components/forms/pantryApplicationForm.tsx +++ b/apps/frontend/src/components/forms/pantryApplicationForm.tsx @@ -1,8 +1,6 @@ import { Box, Button, - Checkbox, - CheckboxGroup, Heading, Input, RadioGroup, @@ -10,7 +8,14 @@ import { Text, Field, Textarea, - Fieldset, + SimpleGrid, + NativeSelect, + NativeSelectIndicator, + Tag, + Separator, + Checkbox, + Menu, + Flex, } from '@chakra-ui/react'; import { ActionFunction, @@ -18,491 +23,1012 @@ import { Form, redirect, } from 'react-router-dom'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { USPhoneInput } from '@components/forms/usPhoneInput'; import { PantryApplicationDto } from '../../types/types'; import ApiClient from '@api/apiClient'; import { Activity } from '../../types/pantryEnums'; import axios from 'axios'; +import { ChevronDownIcon } from 'lucide-react'; -const PantryApplicationForm: React.FC = () => { - const [contactPhone, setContactPhone] = useState(''); - - // We need to keep track of the activities selected so we can provide custom - // validation (at least one activity chosen). - const [activities, setActivities] = useState([]); +const otherRestrictionsOptions: string[] = [ + 'Other allergy (e.g., yeast, sunflower, etc.)', + 'Other allergic illness (e.g., eosinophilic esophagitis, FPIES, oral allergy syndrome)', + 'Other dietary restriction', +]; - const noActivitiesSelected: boolean = activities.length === 0; +const dietaryRestrictionOptions = [ + 'Egg allergy', + 'Fish allergy', + 'Milk allergy', + 'Lactose intolerance/dairy sensitivity', + 'Peanut allergy', + 'Shellfish allergy', + 'Soy allergy', + 'Sesame allergy', + 'Tree nut allergy', + 'Wheat allergy', + 'Celiac disease', + 'Gluten sensitivity (not celiac disease)', + "Gastrointestinal illness (IBS, Crohn's, gastroparesis, etc.)", + ...otherRestrictionsOptions, + 'Unsure', +]; - // Option values and state below are for options that, when selected +const activityOptions = [ + 'Create a labeled, allergy-friendly shelf or shelves', + 'Provide clients and staff/volunteers with educational pamphlets', + "Use a spreadsheet to track clients' medical dietary needs and distribution of SSF items per month", + 'Post allergen-free resource flyers throughout pantry', + 'Survey your clients to determine their medical dietary needs', + 'Collect feedback from allergen-avoidant clients on SSF foods', + 'Something else', +]; +const PantryApplicationForm: React.FC = () => { + const [contactPhone, setContactPhone] = useState(''); + const [secondaryContactPhone, setSecondaryContactPhone] = useState(''); + const [activities, setActivities] = useState([]); const allergenClientsExactOption: string = 'I have an exact number'; - const otherRestrictionsOptions: string[] = [ - 'Other allergy (e.g., yeast, sunflower, etc.)', - 'Other allergic illness (e.g., eosinophilic esophagitis, FPIES, oral allergy syndrome)', - 'Other dietary restriction', - ]; - const reserveFoodForAllergicYesOption: string = 'Yes'; - const reserveFoodForAllergicSomeOption: string = 'Some'; const [allergenClients, setAllergenClients] = useState(); const [restrictions, setRestrictions] = useState([]); - const [reserveFoodForAllergic, setReserveFoodForAllergic] = useState< - string | undefined - >(); + const [reserveFoodForAllergic, setReserveFoodForAllergic] = useState(); + const [differentMailingAddress, setDifferentMailingAddress] = useState(); + const [otherEmailContact, setOtherEmailContact] = useState(false); + + const sectionTitleStyles = { + fontFamily: "inter", + fontWeight: '600', + fontSize: 'md', + color: 'gray.dark', + mb: '1.75em', + }; + + const sectionSubtitleStyles = { + fontFamily: "inter", + fontWeight: '400', + color: 'gray.light', + mb: '2.25em', + fontSize: 'sm', + } + + const fieldHeaderStyles = { + color: 'neutral.800', + fontFamily: "inter", + fontSize: 'sm', + fontWeight: '600', + }; return ( - -
- - SSF Pantry Sign-Up Form + + + + Partner Pantry Application - - - Welcome! We are excited to have you join us in our mission to secure - allergen-safe food and promote food equity. - - Please fill out the following information to get started. - - - - First Name - - - - Whom should we contact at your pantry? - - - - - - Last Name - - - - Whom should we contact at your pantry? - - - - - - Email Address - - - - Please provide the email address of the pantry contact listed above. - - - - - - Phone Number - - - - Please provide the phone number of the pantry contact listed above. - - - - - - - Food Pantry Name - - - - - -
- - Address * + + Thank you for your interest in partnering with Securing Safe Food (SSF) to help + serve clients with food allergies and other adverse reactions to foods. + + + + + + Pantry Application Form - - Please list your address for food shipments. + + + This application helps us understand your pantry’s capacity and interest in + distributing allergen-friendly food. We’ll ask about your pantry’s current + practices, storage capabilities, and communication preferences. + + + Please answer as accurately as possible. If you have any questions or need help, + don’t hesitate to contact the SSF team. + + + + + + Primary Contact Information + + + + First Name + + + + + + + Last Name + + + + + + + Phone Number + + + + + + + Email Address + + + + + + + + Is there someone at your pantry who can regularly check and respond to emails from SSF as needed?{' '} + + + setOtherEmailContact(e.value === 'Other')} + > + + {['Yes', 'No', 'Other'].map((value) => ( + + + + + + + {value} + + + ))} + + + + + + + + + + + Secondary Contact Information + + + + + First Name + + + + + + Last Name + + + + + + Phone Number + + + + + + Email Address + + + + + + + + + Food Shipment Address + + + Please list your address for food shipments. + + + + + Address Line 1 + + + + + + + Address Line 2 + + + + + + City/Town + + + + + + + State/Region/Province + + + + + + + Zip/Post Code + + + + + + + Country + + + + - - Address Line 1 - + + Does this address differ from your pantry's mailing address for documents?{' '} + - + setDifferentMailingAddress(e.value === 'Yes')} + name="differentMailingAddress" + > + + {['Yes', 'No'].map((value) => ( + + + + + + + {value} + + + ))} + + - - - Address Line 2 + + + Would your pantry be able to accept food deliveries + during standard business hours Mon-Fri?{' '} + + + + + {['Yes', 'No'].map((value) => ( + + + + + + + {value} + + + ))} + + + + + + Please note any delivery window restrictions. - +