diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 3b5c0eff6..62de42177 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -33,6 +33,7 @@ import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-Upda import { DonationItemFoodTypeNotNull1771524930613 } from '../migrations/1771524930613-DonationItemFoodTypeNotNull'; import { MoveRequestFieldsToOrders1770571145350 } from '../migrations/1770571145350-MoveRequestFieldsToOrders'; import { RenameDonationMatchingStatus1771260403657 } from '../migrations/1771260403657-RenameDonationMatchingStatus'; +import { DropDonationTotalColumns1772241115031 } from '../migrations/1772241115031-DropDonationTotalColumns'; import { FixTrackingLinks1773041840374 } from '../migrations/1773041840374-FixTrackingLinks'; import { CleanupRequestsAndAllocations1771821377918 } from '../migrations/1771821377918-CleanupRequestsAndAllocations'; @@ -72,6 +73,7 @@ const schemaMigrations = [ DonationItemFoodTypeNotNull1771524930613, MoveRequestFieldsToOrders1770571145350, RenameDonationMatchingStatus1771260403657, + DropDonationTotalColumns1772241115031, FixTrackingLinks1773041840374, CleanupRequestsAndAllocations1771821377918, ]; diff --git a/apps/backend/src/donationItems/donationItems.controller.spec.ts b/apps/backend/src/donationItems/donationItems.controller.spec.ts index 1517c3912..784a3dc5a 100644 --- a/apps/backend/src/donationItems/donationItems.controller.spec.ts +++ b/apps/backend/src/donationItems/donationItems.controller.spec.ts @@ -11,29 +11,6 @@ const mockDonationItemsService = mock(); describe('DonationItemsController', () => { let controller: DonationItemsController; - const mockDonationItemsCreateData: Partial[] = [ - { - itemId: 1, - donationId: 1, - itemName: 'Canned Beans', - quantity: 100, - reservedQuantity: 0, - ozPerItem: 15, - estimatedValue: 200, - foodType: FoodType.DAIRY_FREE_ALTERNATIVES, - }, - { - itemId: 2, - donationId: 1, - itemName: 'Rice', - quantity: 50, - reservedQuantity: 0, - ozPerItem: 20, - estimatedValue: 150, - foodType: FoodType.GLUTEN_FREE_BAKING_PANCAKE_MIXES, - }, - ]; - beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DonationItemsController], diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index dd662651e..95a7363b8 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -42,9 +42,6 @@ export class DonationsController { type: 'object', properties: { foodManufacturerId: { type: 'integer', example: 1 }, - totalItems: { type: 'integer', example: 100 }, - totalOz: { type: 'number', example: 100.5 }, - totalEstimatedValue: { type: 'number', example: 100.5 }, recurrence: { type: 'string', enum: Object.values(RecurrenceEnum), diff --git a/apps/backend/src/donations/donations.entity.ts b/apps/backend/src/donations/donations.entity.ts index daa5659c7..7f9bf7d05 100644 --- a/apps/backend/src/donations/donations.entity.ts +++ b/apps/backend/src/donations/donations.entity.ts @@ -36,15 +36,6 @@ export class Donation { }) status!: DonationStatus; - @Column({ name: 'total_items', type: 'int', nullable: true }) - totalItems!: number | null; - - @Column({ name: 'total_oz', type: 'numeric', nullable: true }) - totalOz!: number | null; - - @Column({ name: 'total_estimated_value', type: 'numeric', nullable: true }) - totalEstimatedValue!: number | null; - @Column({ name: 'recurrence', type: 'enum', diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 85cf8f3c2..3e58fcc98 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -78,9 +78,6 @@ export class DonationService { foodManufacturer: manufacturer, dateDonated: new Date(), status: DonationStatus.AVAILABLE, - totalItems: donationData.totalItems, - totalOz: donationData.totalOz, - totalEstimatedValue: donationData.totalEstimatedValue, recurrence: donationData.recurrence, recurrenceFreq: donationData.recurrenceFreq, nextDonationDates: nextDonationDates, diff --git a/apps/backend/src/donations/dtos/create-donation.dto.ts b/apps/backend/src/donations/dtos/create-donation.dto.ts index 553de4096..fca118c16 100644 --- a/apps/backend/src/donations/dtos/create-donation.dto.ts +++ b/apps/backend/src/donations/dtos/create-donation.dto.ts @@ -63,21 +63,6 @@ export class CreateDonationDto { @Min(1) foodManufacturerId!: number; - @IsOptional() - @IsNumber() - @Min(1) - totalItems?: number; - - @IsNumber({ maxDecimalPlaces: 2 }) - @Min(0.01) - @IsOptional() - totalOz?: number; - - @IsNumber({ maxDecimalPlaces: 2 }) - @Min(0.01) - @IsOptional() - totalEstimatedValue?: number; - @IsNotEmpty() @IsEnum(RecurrenceEnum) recurrence!: RecurrenceEnum; diff --git a/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts b/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts new file mode 100644 index 000000000..89e80c439 --- /dev/null +++ b/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropDonationTotalColumns1772241115031 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "donations" + + DROP COLUMN total_items, + DROP COLUMN total_oz, + DROP COLUMN total_estimated_value; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "donations" + + ADD COLUMN total_items INTEGER, + ADD COLUMN total_oz NUMERIC(20,2), + ADD COLUMN total_estimated_value NUMERIC(20,2); + `); + } +} diff --git a/apps/backend/src/orders/order.entity.ts b/apps/backend/src/orders/order.entity.ts index 6a7636bec..b8b3046a2 100644 --- a/apps/backend/src/orders/order.entity.ts +++ b/apps/backend/src/orders/order.entity.ts @@ -93,6 +93,10 @@ export class Order { precision: 10, scale: 2, nullable: true, + transformer: { + to: (v: number | null) => v, + from: (v: string | null) => (v !== null ? parseFloat(v) : null), + }, }) shippingCost!: number | null; } diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 78dc5a7be..d0e3da93a 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -346,6 +346,31 @@ describe('OrdersService', () => { expect(orders).toEqual([]); }); + it('honors year filter (no results for future year)', async () => { + const pantryId = 1; + const orders = await service.getOrdersByPantry(pantryId, [2025]); + expect(orders).toEqual([]); + }); + + it('returns orders when a valid year filter is provided', async () => { + const pantryId = 1; + + // Change some order dates so we have 2024, 2025 and 2026 values + await testDataSource.query( + `UPDATE "orders" SET created_at='2025-01-01' WHERE order_id = 1`, + ); + await testDataSource.query( + `UPDATE "orders" SET created_at='2026-01-01' WHERE order_id = 2`, + ); + + const orders = await service.getOrdersByPantry(pantryId, [2024, 2025]); + expect(orders.length).toBeGreaterThan(0); + + const years = orders.map((o) => new Date(o.createdAt).getFullYear()); + expect(years).toContain(2025); + expect(years.every((y) => y === 2024 || y === 2025)).toBe(true); + }); + it('throws NotFoundException for non-existent pantry', async () => { const pantryId = 9999; @@ -396,7 +421,7 @@ describe('OrdersService', () => { const order = await service.findOne(3); expect(order.shippingCost).toBeDefined(); - expect(order.shippingCost).toEqual('12.99'); + expect(order.shippingCost).toEqual(12.99); }); it('updates both shipping cost and tracking link (sanitized)', async () => { @@ -409,7 +434,7 @@ describe('OrdersService', () => { const order = await service.findOne(3); expect(order.trackingLink).toEqual('https://testtracking.com/'); - expect(order.shippingCost).toEqual('7.50'); + expect(order.shippingCost).toEqual(7.5); }); it('throws BadRequestException for delivered order', async () => { diff --git a/apps/backend/src/orders/order.service.ts b/apps/backend/src/orders/order.service.ts index 8c381499c..74d620fed 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -259,7 +259,10 @@ export class OrdersService { return updatedOrder; } - async getOrdersByPantry(pantryId: number): Promise { + async getOrdersByPantry( + pantryId: number, + years?: number[], + ): Promise { validateId(pantryId, 'Pantry'); const pantry = await this.pantryRepo.findOneBy({ pantryId }); @@ -267,12 +270,20 @@ export class OrdersService { throw new NotFoundException(`Pantry ${pantryId} not found`); } - const orders = await this.repo.find({ - where: { request: { pantryId } }, - relations: ['request'], - }); + const qb = this.repo + .createQueryBuilder('order') + .leftJoinAndSelect('order.request', 'request') + .leftJoinAndSelect('order.allocations', 'allocations') + .leftJoinAndSelect('allocations.item', 'item') + .where('request.pantryId = :pantryId', { pantryId }); - return orders; + if (years && years.length > 0) { + qb.andWhere('EXTRACT(YEAR FROM order.createdAt) IN (:...years)', { + years, + }); + } + + return qb.getMany(); } async updateTrackingCostInfo(orderId: number, dto: TrackingCostDto) { diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 256151aad..574ee585f 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -10,9 +10,11 @@ import { Activity, AllergensConfidence, ClientVisitFrequency, + PantryStats, RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, + TotalStats, } from './types'; import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; @@ -268,4 +270,87 @@ describe('PantriesController', () => { expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(1); }); }); + + describe('getPantryStats', () => { + it('should return stats for all pantries', async () => { + const mockStats: PantryStats[] = [ + { + pantryId: 1, + totalItems: 100, + totalOz: 1600, + totalLbs: 100, + totalDonatedFoodValue: 500, + totalShippingCost: 50, + totalValue: 550, + percentageFoodRescueItems: 80, + }, + ]; + + mockPantriesService.getPantryStats.mockResolvedValueOnce(mockStats); + + const result = await controller.getPantryStats(); + + expect(result).toEqual(mockStats); + expect(mockPantriesService.getPantryStats).toHaveBeenCalled(); + }); + + it('should forward query parameters to service', async () => { + const mockStats: PantryStats[] = []; + mockPantriesService.getPantryStats.mockResolvedValueOnce(mockStats); + + const pantryNames = ['A', 'B']; + const years = [2024, 2025]; + const page = 3; + + const result = await controller.getPantryStats(pantryNames, years, page); + + expect(result).toEqual(mockStats); + expect(mockPantriesService.getPantryStats).toHaveBeenCalledWith( + pantryNames, + years, + page, + ); + }); + }); + + describe('getTotalStats', () => { + it('should return total stats across all pantries', async () => { + const mockTotalStats: TotalStats = { + totalItems: 500, + totalOz: 8000, + totalLbs: 500, + totalDonatedFoodValue: 2500, + totalShippingCost: 200, + totalValue: 2700, + percentageFoodRescueItems: 75, + }; + + mockPantriesService.getTotalStats.mockResolvedValueOnce(mockTotalStats); + + const result = await controller.getTotalStats(); + + expect(result).toEqual(mockTotalStats); + expect(mockPantriesService.getTotalStats).toHaveBeenCalled(); + }); + + it('should forward years query parameter to service', async () => { + const mockTotalStats: TotalStats = { + totalItems: 500, + totalOz: 8000, + totalLbs: 500, + totalDonatedFoodValue: 2500, + totalShippingCost: 200, + totalValue: 2700, + percentageFoodRescueItems: 75, + }; + + mockPantriesService.getTotalStats.mockResolvedValueOnce(mockTotalStats); + + const years = [2024, 2025]; + const result = await controller.getTotalStats(years); + + expect(result).toEqual(mockTotalStats); + expect(mockPantriesService.getTotalStats).toHaveBeenCalledWith(years); + }); + }); }); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index d5e252a05..f99e0d35d 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -6,6 +6,7 @@ import { ParseIntPipe, Patch, Post, + Query, Req, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; @@ -19,9 +20,11 @@ import { Activity, AllergensConfidence, ClientVisitFrequency, + PantryStats, RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, + TotalStats, } from './types'; import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; @@ -39,6 +42,22 @@ export class PantriesController { private emailsService: EmailsService, ) {} + @Roles(Role.ADMIN) + @Get('/stats-by-pantry') + async getPantryStats( + @Query('pantryNames') pantryNames?: string[], + @Query('years') years?: number[], + @Query('page', new ParseIntPipe({ optional: true })) page = 1, + ): Promise { + return this.pantriesService.getPantryStats(pantryNames, years, page); + } + + @Roles(Role.ADMIN) + @Get('/total-stats') + async getTotalStats(@Query('years') years?: number[]): Promise { + return this.pantriesService.getTotalStats(years); + } + @Roles(Role.PANTRY) @Get('/my-id') async getCurrentUserPantryId( diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 64b5ca7ce..f9ee5ce59 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -8,10 +8,11 @@ import { OrdersModule } from '../orders/order.module'; import { EmailsModule } from '../emails/email.module'; import { User } from '../users/users.entity'; import { UsersModule } from '../users/users.module'; +import { Order } from '../orders/order.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Pantry, User]), + TypeOrmModule.forFeature([Pantry, User, Order]), OrdersModule, forwardRef(() => UsersModule), EmailsModule, diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index aad4cf6ab..195c3f1e9 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -2,9 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PantriesService } from './pantries.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Pantry } from './pantries.entity'; -import { Repository, UpdateResult } from 'typeorm'; -import { NotFoundException } from '@nestjs/common'; -import { mock } from 'jest-mock-extended'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ClientVisitFrequency, @@ -15,77 +13,103 @@ import { AllergensConfidence, } from './types'; import { ApplicationStatus } from '../shared/types'; +import { testDataSource } from '../config/typeormTestDataSource'; +import { Order } from '../orders/order.entity'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { RequestsService } from '../foodRequests/request.service'; +import { OrdersService } from '../orders/order.service'; import { UsersService } from '../users/users.service'; +import { AuthService } from '../auth/auth.service'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationService } from '../donations/donations.service'; +import { Donation } from '../donations/donations.entity'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { User } from '../users/users.entity'; -import { Role } from '../users/types'; -const mockRepository = mock>(); -const mockUsersService = mock(); +jest.setTimeout(60000); + +// Minimal DTO factory to reduce repetition in bulk-creation tests +const makePantryDto = (i: number): PantryApplicationDto => + ({ + contactFirstName: `Bulk${i}`, + contactLastName: 'Tester', + contactEmail: `bulk${i}@example.com`, + contactPhone: '555-000-0000', + hasEmailContact: false, + pantryName: `BulkTest Pantry ${i}`, + shipmentAddressLine1: '1 Bulk St', + shipmentAddressCity: 'Testville', + shipmentAddressState: 'TS', + shipmentAddressZip: '00000', + mailingAddressLine1: '1 Bulk St', + mailingAddressCity: 'Testville', + mailingAddressState: 'TS', + mailingAddressZip: '00000', + allergenClients: 'none', + restrictions: ['none'], + refrigeratedDonation: RefrigeratedDonation.NO, + acceptFoodDeliveries: false, + reserveFoodForAllergic: ReserveFoodForAllergic.NO, + dedicatedAllergyFriendly: false, + activities: [Activity.CREATE_LABELED_SHELF], + itemsInStock: 'none', + needMoreOptions: 'none', + } as PantryApplicationDto); describe('PantriesService', () => { let service: PantriesService; - // Mock Pantry - const mockPendingPantry = { - pantryId: 1, - pantryName: 'Test Pantry', - status: ApplicationStatus.PENDING, - } as Pantry; - - // Mock Pantry Application - const mockPantryApplication = { - contactFirstName: 'Jane', - contactLastName: 'Smith', - contactEmail: 'jane.smith@example.com', - contactPhone: '(508) 222-2222', - hasEmailContact: true, - emailContactOther: undefined, - secondaryContactFirstName: 'John', - secondaryContactLastName: 'Doe', - secondaryContactEmail: 'john.doe@example.com', - secondaryContactPhone: '(508) 333-3333', - shipmentAddressLine1: '456 New St', - shipmentAddressLine2: 'Suite 200', - shipmentAddressCity: 'Cambridge', - shipmentAddressState: 'MA', - shipmentAddressZip: '02139', - shipmentAddressCountry: 'USA', - acceptFoodDeliveries: true, - deliveryWindowInstructions: 'Please deliver between 9am-5pm', - mailingAddressLine1: '456 New St', - mailingAddressLine2: 'Suite 200', - mailingAddressCity: 'Cambridge', - mailingAddressState: 'MA', - mailingAddressZip: '02139', - mailingAddressCountry: 'USA', - pantryName: 'New Community Pantry', - allergenClients: '10 to 20', - restrictions: ['Peanut allergy', 'Gluten'], - refrigeratedDonation: RefrigeratedDonation.YES, - dedicatedAllergyFriendly: true, - reserveFoodForAllergic: ReserveFoodForAllergic.SOME, - reservationExplanation: 'We have a dedicated allergen-free section', - clientVisitFrequency: ClientVisitFrequency.DAILY, - identifyAllergensConfidence: AllergensConfidence.VERY_CONFIDENT, - serveAllergicChildren: ServeAllergicChildren.YES_MANY, - activities: [Activity.CREATE_LABELED_SHELF, Activity.COLLECT_FEEDBACK], - activitiesComments: 'We provide nutritional counseling', - itemsInStock: 'Canned goods, pasta', - needMoreOptions: 'More fresh produce', - newsletterSubscription: true, - } as PantryApplicationDto; + beforeAll(async () => { + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); - beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PantriesService, + OrdersService, + RequestsService, + UsersService, + DonationItemsService, + DonationService, + FoodManufacturersService, + { + provide: AuthService, + useValue: { + adminCreateUser: jest.fn().mockResolvedValue('test-sub'), + }, + }, { provide: getRepositoryToken(Pantry), - useValue: mockRepository, + useValue: testDataSource.getRepository(Pantry), + }, + { + provide: getRepositoryToken(User), + useValue: testDataSource.getRepository(User), + }, + { + provide: getRepositoryToken(Order), + useValue: testDataSource.getRepository(Order), + }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), }, { - provide: UsersService, - useValue: mockUsersService, + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), }, ], }).compile(); @@ -93,237 +117,460 @@ describe('PantriesService', () => { service = module.get(PantriesService); }); - afterEach(() => { - jest.clearAllMocks(); + beforeEach(async () => { + await testDataSource.runMigrations(); + }); + + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); }); - it('should be defined', () => { + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); + + it('service should be defined', () => { expect(service).toBeDefined(); }); - // Find pantry by ID describe('findOne', () => { - it('should return a pantry by id', async () => { - mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); + it('returns pantry by existing ID', async () => { + const pantry = await service.findOne(1); + expect(pantry).toBeDefined(); + expect(pantry.pantryId).toBe(1); + }); - const result = await service.findOne(1); + it('throws NotFoundException for missing ID', async () => { + await expect(service.findOne(9999)).rejects.toThrow( + new NotFoundException('Pantry 9999 not found'), + ); + }); + }); - expect(result).toBe(mockPendingPantry); - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - relations: ['pantryUser'], - }); + describe('getPendingPantries', () => { + it('returns pantries with pending status', async () => { + const pending = await service.getPendingPantries(); + expect(pending.length).toBeGreaterThan(0); + expect(pending.every((p) => p.status === ApplicationStatus.PENDING)).toBe( + true, + ); }); + }); - it('should throw NotFoundException if pantry not found', async () => { - mockRepository.findOne.mockResolvedValueOnce(null); + describe('approve', () => { + it('approves a pending pantry', async () => { + const pantryBefore = await service.findOne(5); + expect(pantryBefore.status).toBe(ApplicationStatus.PENDING); + await service.approve(5); + const pantryAfter = await service.findOne(5); + expect(pantryAfter.status).toBe(ApplicationStatus.APPROVED); + }); - await expect(service.findOne(999)).rejects.toThrow(NotFoundException); - await expect(service.findOne(999)).rejects.toThrow( - 'Pantry 999 not found', + it('throws when approving non-existent', async () => { + await expect(service.approve(9999)).rejects.toThrow( + new NotFoundException('Pantry 9999 not found'), ); }); }); - // Get pantries with pending status - describe('getPendingPantries', () => { - it('should return only pending pantries', async () => { - const pendingPantries = [ - mockPendingPantry, - { ...mockPendingPantry, pantryId: 3 }, - ]; - mockRepository.find.mockResolvedValueOnce(pendingPantries); - - const result = await service.getPendingPantries(); - - expect(result).toEqual(pendingPantries); - expect(result).toHaveLength(2); - expect(result.every((pantry) => pantry.status === 'pending')).toBe(true); - expect(mockRepository.find).toHaveBeenCalledWith({ - where: { status: 'pending' }, - relations: ['pantryUser'], - }); + describe('deny', () => { + it('denies a pending pantry', async () => { + const pantryBefore = await service.findOne(6); + expect(pantryBefore.status).toBe(ApplicationStatus.PENDING); + await service.deny(6); + const pantryAfter = await service.findOne(6); + expect(pantryAfter.status).toBe(ApplicationStatus.DENIED); }); - it('should not return approved pantries', async () => { - mockRepository.find.mockResolvedValueOnce([mockPendingPantry]); + it('throws when denying non-existent', async () => { + await expect(service.deny(9999)).rejects.toThrow( + new NotFoundException('Pantry 9999 not found'), + ); + }); + }); - const result = await service.getPendingPantries(); + describe('addPantry', () => { + it('creates pantry with minimal required fields', async () => { + const dto: PantryApplicationDto = { + contactFirstName: 'Jane', + contactLastName: 'Doe', + contactEmail: 'jane.doe@example.com', + contactPhone: '555-555-5555', + hasEmailContact: true, + pantryName: 'Test Minimal Pantry', + shipmentAddressLine1: '1 Test St', + shipmentAddressCity: 'Testville', + shipmentAddressState: 'TX', + shipmentAddressZip: '11111', + mailingAddressLine1: '1 Test St', + mailingAddressCity: 'Testville', + mailingAddressState: 'TX', + mailingAddressZip: '11111', + allergenClients: 'none', + restrictions: ['none'], + refrigeratedDonation: RefrigeratedDonation.NO, + acceptFoodDeliveries: false, + reserveFoodForAllergic: ReserveFoodForAllergic.NO, + dedicatedAllergyFriendly: false, + activities: [Activity.CREATE_LABELED_SHELF], + itemsInStock: 'none', + needMoreOptions: 'none', + }; - expect(result).not.toContainEqual( - expect.objectContaining({ status: 'approved' }), - ); - expect(mockRepository.find).toHaveBeenCalledWith({ - where: { status: 'pending' }, + await service.addPantry(dto); + const saved = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryName: 'Test Minimal Pantry' }, + relations: ['pantryUser'], + }); + expect(saved).toBeDefined(); + expect(saved?.pantryUser?.email).toBe('jane.doe@example.com'); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + }); + + it('creates pantry with all optional fields included', async () => { + const dto: PantryApplicationDto = { + contactFirstName: 'John', + contactLastName: 'Smith', + contactEmail: 'john.smith@example.com', + contactPhone: '555-555-5556', + hasEmailContact: true, + emailContactOther: 'Use work phone', + secondaryContactFirstName: 'Sarah', + secondaryContactLastName: 'Johnson', + secondaryContactEmail: 'sarah.johnson@example.com', + secondaryContactPhone: '555-555-5557', + pantryName: 'Test Full Pantry', + shipmentAddressLine1: '100 Main St', + shipmentAddressLine2: 'Suite 200', + shipmentAddressCity: 'Springfield', + shipmentAddressState: 'IL', + shipmentAddressZip: '62701', + shipmentAddressCountry: 'USA', + mailingAddressLine1: '100 Main St', + mailingAddressLine2: 'Suite 200', + mailingAddressCity: 'Springfield', + mailingAddressState: 'IL', + mailingAddressZip: '62701', + mailingAddressCountry: 'USA', + allergenClients: '10 to 20', + restrictions: ['Peanut allergy', 'Tree nut allergy'], + refrigeratedDonation: RefrigeratedDonation.YES, + acceptFoodDeliveries: true, + deliveryWindowInstructions: 'Weekdays 9am-5pm', + reserveFoodForAllergic: ReserveFoodForAllergic.SOME, + reservationExplanation: 'We have a dedicated section', + dedicatedAllergyFriendly: true, + clientVisitFrequency: ClientVisitFrequency.DAILY, + identifyAllergensConfidence: AllergensConfidence.VERY_CONFIDENT, + serveAllergicChildren: ServeAllergicChildren.YES_MANY, + activities: [Activity.CREATE_LABELED_SHELF, Activity.COLLECT_FEEDBACK], + activitiesComments: 'We are committed to allergen management', + itemsInStock: 'Canned goods, pasta', + needMoreOptions: 'Fresh produce', + newsletterSubscription: true, + } as PantryApplicationDto; + + await service.addPantry(dto); + const saved = await testDataSource.getRepository(Pantry).findOne({ + where: { pantryName: 'Test Full Pantry' }, relations: ['pantryUser'], }); + expect(saved).toBeDefined(); + expect(saved?.pantryUser?.email).toBe('john.smith@example.com'); + expect(saved?.status).toBe(ApplicationStatus.PENDING); + expect(saved?.secondaryContactFirstName).toBe('Sarah'); + expect(saved?.shipmentAddressLine2).toBe('Suite 200'); + }); + }); + + describe('getPantryStats (single pantry)', () => { + it('throws NotFoundException for non-existent pantry names', async () => { + await expect( + service.getPantryStats(['Nonexistent Pantry']), + ).rejects.toThrow(NotFoundException); + }); + + it('throws NotFoundException when some provided pantry names do not exist', async () => { + await expect( + service.getPantryStats([ + 'Community Food Pantry Downtown', + 'Fake Pantry', + ]), + ).rejects.toThrow(NotFoundException); }); - it('should return an empty array if no pending pantries', async () => { - mockRepository.find.mockResolvedValueOnce([]); + it('error message includes the missing pantry name', async () => { + await expect( + service.getPantryStats([ + 'Community Food Pantry Downtown', + 'Fake Pantry', + ]), + ).rejects.toThrow('Pantries not found: Fake Pantry'); + }); - const result = await service.getPendingPantries(); + it('returns accurate aggregated stats for pantry with orders (Community Food Pantry Downtown)', async () => { + const stats = ( + await service.getPantryStats(['Community Food Pantry Downtown']) + )[0]; + + expect(stats.pantryId).toBe(1); + expect(stats.totalItems).toBe(125); + expect(stats.totalOz).toBeCloseTo(2320.05, 2); + expect(stats.totalLbs).toBeCloseTo(145.0, 2); + expect(stats.totalDonatedFoodValue).toBeCloseTo(625.0, 2); + expect(stats.totalValue).toBeCloseTo(645.0, 2); + expect(stats.percentageFoodRescueItems).toBe(0); + }); - expect(result).toEqual([]); + it('returns zeroed stats for a pantry with no orders (Riverside Food Assistance)', async () => { + const stats = ( + await service.getPantryStats(['Riverside Food Assistance']) + )[0]; + + expect(stats.pantryId).toBe(4); + expect(stats.totalItems).toBe(0); + expect(stats.totalOz).toBe(0); + expect(stats.totalLbs).toBe(0); + expect(stats.totalDonatedFoodValue).toBe(0); + expect(stats.totalShippingCost).toBe(0); + expect(stats.totalValue).toBe(0); + expect(stats.percentageFoodRescueItems).toBe(0); + }); + + it('respects year filter and returns zeros for a non-matching year', async () => { + const stats = ( + await service.getPantryStats(['Community Food Pantry Downtown'], [2030]) + )[0]; + + expect(stats.pantryId).toBe(1); + expect(stats.totalItems).toBe(0); + expect(stats.totalOz).toBe(0); + expect(stats.totalLbs).toBe(0); + expect(stats.totalDonatedFoodValue).toBe(0); + expect(stats.totalShippingCost).toBe(0); + expect(stats.totalValue).toBe(0); + expect(stats.percentageFoodRescueItems).toBe(0); }); }); - // Approve pantry by ID (status = approved) - describe('approve', () => { - it('should approve a pantry', async () => { - const mockPantryUser: Partial = { id: 1, email: 'test@test.com' }; - const mockCreatedUser: Partial = { id: 2, role: Role.PANTRY }; + describe('getPantryStats', () => { + it('filters by pantry name correctly and returns accurate sums', async () => { + const stats = await service.getPantryStats([ + 'Community Food Pantry Downtown', + 'Westside Community Kitchen', + ]); + expect(stats.length).toBe(2); - const mockPendingPantryWithUser: Partial = { - ...mockPendingPantry, - pantryUser: mockPantryUser as User, - }; + const community = stats.find((s) => s.pantryId === 1); + const westside = stats.find((s) => s.pantryId === 2); - mockRepository.findOne.mockResolvedValueOnce( - mockPendingPantryWithUser as Pantry, - ); - mockUsersService.create.mockResolvedValueOnce(mockCreatedUser as User); - mockRepository.update.mockResolvedValueOnce({} as UpdateResult); + expect(community).toBeDefined(); + expect(community?.totalItems).toBe(125); + expect(community?.totalOz).toBeCloseTo(2320.05, 2); - await service.approve(1); + expect(westside).toBeDefined(); + expect(westside?.totalItems).toBe(65); + expect(westside?.totalOz).toBeCloseTo(1195.0, 2); + }); - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - relations: ['pantryUser'], - }); - expect(mockUsersService.create).toHaveBeenCalledWith({ - ...mockPantryUser, - role: Role.PANTRY, - }); - expect(mockRepository.update).toHaveBeenCalledWith(1, { - status: ApplicationStatus.APPROVED, - pantryUser: mockCreatedUser, - }); + it('accepts single pantry name as string', async () => { + const stats = await service.getPantryStats([ + 'Community Food Pantry Downtown', + ]); + expect(stats.length).toBe(1); + expect(stats[0].pantryId).toBe(1); }); - it('should throw NotFoundException if pantry not found', async () => { - mockRepository.findOne.mockResolvedValueOnce(null); + it('pagination beyond range returns empty array', async () => { + const paged = await service.getPantryStats(undefined, undefined, 100); + expect(paged.length).toBe(0); + }); + + it('filters by year correctly (no results for future year)', async () => { + const yearFiltered = await service.getPantryStats(undefined, [2030]); + expect(yearFiltered.every((s) => s.totalItems === 0)).toBe(true); + }); - await expect(service.approve(999)).rejects.toThrow(NotFoundException); - await expect(service.approve(999)).rejects.toThrow( - 'Pantry 999 not found', + it('pagination page returns first 10 items', async () => { + for (let i = 0; i < 10; i++) { + await service.addPantry(makePantryDto(i)); + } + + const page1 = await service.getPantryStats(undefined, undefined, 1); + expect(page1.length).toBe(10); + }); + + it('year filter isolates orders by year (move one delivered order to 2025)', async () => { + await testDataSource.query(` + UPDATE public.orders + SET created_at = '2025-01-16 09:00:00' + WHERE order_id = ( + SELECT o.order_id FROM public.orders o + JOIN public.food_requests r ON o.request_id = r.request_id + WHERE r.pantry_id = ( + SELECT pantry_id FROM public.pantries WHERE pantry_name = 'Community Food Pantry Downtown' LIMIT 1 + ) AND o.status = 'delivered' + ORDER BY o.order_id DESC + LIMIT 1 + ) + `); + + const res = await service.getPantryStats(undefined, [2025]); + const community = res.find((s) => s.pantryId === 1); + expect(community).toBeDefined(); + expect(community?.totalItems).toBe(40); + expect(community?.totalDonatedFoodValue).toBeCloseTo(130.0, 2); + }); + + it('returns proper array for no pantryNames given', async () => { + const stats = await service.getPantryStats(); + expect(stats.length).toBe(6); + }); + + it('returns nothing for an invalid pantry name', async () => { + expect(service.getPantryStats(['Invalid Pantry Name'])).rejects.toThrow( + new NotFoundException(`Pantries not found: Invalid Pantry Name`), ); - expect(mockRepository.update).not.toHaveBeenCalled(); }); - }); - // Deny pantry by ID (status = denied) - describe('deny', () => { - it('should deny a pantry', async () => { - mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); - mockRepository.update.mockResolvedValueOnce({} as UpdateResult); + it('throws an error for a page less than 1', async () => { + await expect( + service.getPantryStats(undefined, undefined, 0), + ).rejects.toThrow( + new BadRequestException('Page number must be greater than 0'), + ); + }); - await service.deny(1); + it('validates all names before paginating — throws if any name is invalid regardless of page', async () => { + // Create 12 valid pantries so we have enough to paginate + for (let i = 0; i < 12; i++) { + await service.addPantry(makePantryDto(i)); + } + const validNames = Array.from( + { length: 12 }, + (_, i) => `BulkTest Pantry ${i}`, + ); - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - }); - expect(mockRepository.update).toHaveBeenCalledWith(1, { - status: 'denied', - }); + await expect( + service.getPantryStats([...validNames, 'Extra Pantry'], undefined, 1), + ).rejects.toThrow('Pantries not found: Extra Pantry'); + + await expect( + service.getPantryStats([...validNames, 'Extra Pantry'], undefined, 2), + ).rejects.toThrow('Pantries not found: Extra Pantry'); }); - it('should throw NotFoundException if pantry not found', async () => { - mockRepository.findOne.mockResolvedValueOnce(null); + it('with 12 valid names, page 1 returns the first 10 (ordered by pantryId ASC)', async () => { + for (let i = 0; i < 12; i++) { + await service.addPantry(makePantryDto(i)); + } + const names = Array.from( + { length: 12 }, + (_, i) => `BulkTest Pantry ${i}`, + ); - await expect(service.deny(999)).rejects.toThrow(NotFoundException); - await expect(service.deny(999)).rejects.toThrow('Pantry 999 not found'); - expect(mockRepository.update).not.toHaveBeenCalled(); + const page1 = await service.getPantryStats(names, undefined, 1); + expect(page1.length).toBe(10); }); - }); - // Add pantry - describe('addPantry', () => { - it('should add a new pantry application', async () => { - mockRepository.save.mockResolvedValueOnce(mockPendingPantry); - - await service.addPantry(mockPantryApplication); - - expect(mockRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - pantryName: mockPantryApplication.pantryName, - shipmentAddressLine1: mockPantryApplication.shipmentAddressLine1, - shipmentAddressLine2: mockPantryApplication.shipmentAddressLine2, - shipmentAddressCity: mockPantryApplication.shipmentAddressCity, - shipmentAddressState: mockPantryApplication.shipmentAddressState, - shipmentAddressZip: mockPantryApplication.shipmentAddressZip, - shipmentAddressCountry: mockPantryApplication.shipmentAddressCountry, - mailingAddressLine1: mockPantryApplication.mailingAddressLine1, - mailingAddressLine2: mockPantryApplication.mailingAddressLine2, - mailingAddressCity: mockPantryApplication.mailingAddressCity, - mailingAddressState: mockPantryApplication.mailingAddressState, - mailingAddressZip: mockPantryApplication.mailingAddressZip, - mailingAddressCountry: mockPantryApplication.mailingAddressCountry, - allergenClients: mockPantryApplication.allergenClients, - restrictions: mockPantryApplication.restrictions, - refrigeratedDonation: mockPantryApplication.refrigeratedDonation, - reserveFoodForAllergic: mockPantryApplication.reserveFoodForAllergic, - reservationExplanation: mockPantryApplication.reservationExplanation, - dedicatedAllergyFriendly: - mockPantryApplication.dedicatedAllergyFriendly, - clientVisitFrequency: mockPantryApplication.clientVisitFrequency, - identifyAllergensConfidence: - mockPantryApplication.identifyAllergensConfidence, - serveAllergicChildren: mockPantryApplication.serveAllergicChildren, - activities: mockPantryApplication.activities, - activitiesComments: mockPantryApplication.activitiesComments, - itemsInStock: mockPantryApplication.itemsInStock, - needMoreOptions: mockPantryApplication.needMoreOptions, - newsletterSubscription: true, - }), + it('with 12 valid names, page 2 returns the remaining 2', async () => { + for (let i = 0; i < 12; i++) { + await service.addPantry(makePantryDto(i)); + } + const names = Array.from( + { length: 12 }, + (_, i) => `BulkTest Pantry ${i}`, ); + + const page2 = await service.getPantryStats(names, undefined, 2); + expect(page2.length).toBe(2); }); - it('should create pantry representative from contact info', async () => { - mockRepository.save.mockResolvedValueOnce(mockPendingPantry); + it('with 12 valid names, page 3 returns empty array', async () => { + for (let i = 0; i < 12; i++) { + await service.addPantry(makePantryDto(i)); + } + const names = Array.from( + { length: 12 }, + (_, i) => `BulkTest Pantry ${i}`, + ); - await service.addPantry(mockPantryApplication); + const page3 = await service.getPantryStats(names, undefined, 3); + expect(page3.length).toBe(0); + }); - expect(mockRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - pantryUser: expect.objectContaining({ - firstName: mockPantryApplication.contactFirstName, - lastName: mockPantryApplication.contactLastName, - email: mockPantryApplication.contactEmail, - phone: mockPantryApplication.contactPhone, - role: 'pantry', - }), - }), + it('page 1 and page 2 results are disjoint (no overlapping pantryIds)', async () => { + for (let i = 0; i < 12; i++) { + await service.addPantry(makePantryDto(i)); + } + const names = Array.from( + { length: 12 }, + (_, i) => `BulkTest Pantry ${i}`, ); + + const [page1, page2] = await Promise.all([ + service.getPantryStats(names, undefined, 1), + service.getPantryStats(names, undefined, 2), + ]); + + const page1Ids = new Set(page1.map((s) => s.pantryId)); + const overlap = page2.filter((s) => page1Ids.has(s.pantryId)); + expect(overlap.length).toBe(0); + }); + }); + + describe('getTotalStats', () => { + it('aggregates stats across all pantries and matches migration sums', async () => { + const total = await service.getTotalStats(); + + expect(total.totalItems).toBe(220); + expect(total.totalOz).toBeCloseTo(4530.05, 2); + expect(total.totalLbs).toBeCloseTo(283.13, 2); + expect(total.totalDonatedFoodValue).toBeCloseTo(1087.5, 2); + expect(total.totalShippingCost).toBeCloseTo(60.0, 2); + expect(total.totalValue).toBeCloseTo(1147.5, 2); }); - it('should throw error if save fails', async () => { - mockRepository.save.mockRejectedValueOnce(new Error('Database error')); + it('respects year filter and returns zeros for non-matching years', async () => { + const totalEmpty = await service.getTotalStats([2030]); + expect(totalEmpty.totalItems).toBe(0); + expect(totalEmpty.totalOz).toBe(0); + expect(totalEmpty.totalLbs).toBe(0); + expect(totalEmpty.totalDonatedFoodValue).toBe(0); + expect(totalEmpty.totalShippingCost).toBe(0); + expect(totalEmpty.totalValue).toBe(0); + expect(totalEmpty.percentageFoodRescueItems).toBe(0); + }); + }); + + describe('findByIds', () => { + it('findByIds success', async () => { + const found = await service.findByIds([1, 2]); + expect(found.map((p) => p.pantryId)).toEqual([1, 2]); + }); - await expect(service.addPantry(mockPantryApplication)).rejects.toThrow( - 'Database error', + it('findByIds with some non-existent IDs throws NotFoundException', async () => { + await expect(service.findByIds([1, 9999])).rejects.toThrow( + new NotFoundException('Pantries not found: 9999'), ); - expect(mockRepository.save).toHaveBeenCalled(); }); }); - // TODO: once pantry service tests are fixed, uncomment this out - // describe('findByUserId', () => { - // it('should return a pantry by user id', async () => { - // const userId = 10; - // const pantry = await service.findByUserId(userId); - - // expect(pantry.pantryId).toBe(1); - // expect(pantry.pantryName).toBe('Community Food Pantry Downtown'); - // expect(mockRepository.findOne).toHaveBeenCalledWith({ - // where: { pantryUser: { id: userId } }, - // }); - // }); - - // it('should throw NotFoundException if pantry not found', async () => { - // await expect(service.findByUserId(999)).rejects.toThrow( - // new NotFoundException('Pantry for User 999 not found'), - // ); - // }); - // }); + describe('findByUserId', () => { + it('findByUserId success', async () => { + const pantry = await service.findOne(1); + const userId = pantry.pantryUser.id; + const result = await service.findByUserId(userId); + expect(result.pantryId).toBe(1); + }); + + it('findByUserId with non-existent user throws NotFoundException', async () => { + await expect(service.findByUserId(9999)).rejects.toThrow( + new NotFoundException('Pantry for User 9999 not found'), + ); + }); + }); }); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index d21cd516e..1cc836471 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, forwardRef, Inject, Injectable, @@ -7,11 +8,13 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { Pantry } from './pantries.entity'; +import { Order } from '../orders/order.entity'; import { User } from '../users/users.entity'; import { validateId } from '../utils/validation.utils'; import { ApplicationStatus } from '../shared/types'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; +import { PantryStats, TotalStats } from './types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; @@ -19,6 +22,7 @@ import { UsersService } from '../users/users.service'; export class PantriesService { constructor( @InjectRepository(Pantry) private repo: Repository, + @InjectRepository(Order) private orderRepo: Repository, @Inject(forwardRef(() => UsersService)) private usersService: UsersService, @@ -38,6 +42,199 @@ export class PantriesService { return pantry; } + private readonly EMPTY_STATS: Omit = { + totalItems: 0, + totalOz: 0, + totalLbs: 0, + totalDonatedFoodValue: 0, + totalShippingCost: 0, + totalValue: 0, + percentageFoodRescueItems: 0, + }; + + private async aggregateStats( + pantryIds?: number[], + years?: number[], + ): Promise { + // Make all the total calculations + // Coalesce to account for nulls when there are no orders or no items in an order + const ordersSubquery = years?.length + ? `COALESCE(( + SELECT SUM(o2.shipping_cost) + FROM orders o2 + JOIN food_requests r2 ON o2.request_id = r2.request_id + WHERE r2.pantry_id = request.pantry_id + AND EXTRACT(YEAR FROM o2.created_at) IN (:...years) + ), 0)` + : `COALESCE(( + SELECT SUM(o2.shipping_cost) + FROM orders o2 + JOIN food_requests r2 ON o2.request_id = r2.request_id + WHERE r2.pantry_id = request.pantry_id + ), 0)`; + + const qb = this.orderRepo + .createQueryBuilder('order') + .leftJoin('order.request', 'request') + .leftJoin('order.allocations', 'allocation') + .leftJoin('allocation.item', 'item') + .select('request.pantryId', 'pantryId') + .addSelect('COALESCE(SUM(allocation.allocatedQuantity), 0)', 'totalItems') + .addSelect( + 'COALESCE(SUM(COALESCE(item.ozPerItem, 0) * allocation.allocatedQuantity), 0)', + 'totalOz', + ) + .addSelect( + 'COALESCE(SUM(COALESCE(item.estimatedValue, 0) * allocation.allocatedQuantity), 0)', + 'totalDonatedFoodValue', + ) + .addSelect(ordersSubquery, 'totalShippingCost') + .addSelect( + `COALESCE(SUM(CASE WHEN item.foodRescue = true THEN allocation.allocatedQuantity ELSE 0 END), 0)`, + 'totalFoodRescueItems', + ) + .groupBy('request.pantryId'); + + if (pantryIds && pantryIds.length > 0) { + qb.andWhere('request.pantryId IN (:...pantryIds)', { pantryIds }); + } + if (years && years.length > 0) { + qb.andWhere('EXTRACT(YEAR FROM order.createdAt) IN (:...years)', { + years, + }); + } + + const rows = await qb.getRawMany(); + + return rows.map((row) => { + const totalItems = Number(row.totalItems); + const totalOz = Number(row.totalOz); + const totalDonatedFoodValue = Number(row.totalDonatedFoodValue); + const totalShippingCost = Number(row.totalShippingCost); + const totalFoodRescueItems = Number(row.totalFoodRescueItems); + + return { + pantryId: Number(row.pantryId), + totalItems, + totalOz, + totalLbs: parseFloat((totalOz / 16).toFixed(2)), + totalDonatedFoodValue, + totalShippingCost, + totalValue: totalDonatedFoodValue + totalShippingCost, + percentageFoodRescueItems: + totalItems > 0 + ? parseFloat(((totalFoodRescueItems / totalItems) * 100).toFixed(2)) + : 0, + } satisfies PantryStats; + }); + } + + async getPantryStats( + pantryNames?: string[], + years?: number[], + page = 1, + ): Promise { + const PAGE_SIZE = 10; + + if (page < 1) { + throw new BadRequestException('Page number must be greater than 0'); + } + + const nameArray = pantryNames + ? Array.isArray(pantryNames) + ? pantryNames + : [pantryNames] + : undefined; + + // If names were provided, validate ALL of them before paginating + if (nameArray?.length) { + const allMatched = await this.repo.find({ + select: ['pantryId', 'pantryName'], + where: { pantryName: In(nameArray) }, + order: { pantryId: 'ASC' }, + }); + + const missingNames = nameArray.filter( + (name) => !allMatched.some((p) => p.pantryName === name), + ); + if (missingNames.length > 0) { + throw new NotFoundException( + `Pantries not found: ${missingNames.join(', ')}`, + ); + } + + // Paginate the validated results in-memory + const paginated = allMatched.slice( + (page - 1) * PAGE_SIZE, + page * PAGE_SIZE, + ); + if (paginated.length === 0) return []; + + const pantryIds = paginated.map((p) => p.pantryId); + const yearsArray = years + ? (Array.isArray(years) ? years : [years]).map(Number) + : undefined; + + const stats = await this.aggregateStats(pantryIds, yearsArray); + const statsMap = new Map(stats.map((s) => [s.pantryId, s])); + return pantryIds.map( + (id) => statsMap.get(id) ?? { pantryId: id, ...this.EMPTY_STATS }, + ); + } + + // No names provided — paginate from the full table + const pantries = await this.repo.find({ + select: ['pantryId', 'pantryName'], + order: { pantryId: 'ASC' }, + skip: (page - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }); + + if (pantries.length === 0) return []; + + const pantryIds = pantries.map((p) => p.pantryId); + const yearsArray = years + ? (Array.isArray(years) ? years : [years]).map(Number) + : undefined; + + const stats = await this.aggregateStats(pantryIds, yearsArray); + const statsMap = new Map(stats.map((s) => [s.pantryId, s])); + return pantryIds.map( + (id) => statsMap.get(id) ?? { pantryId: id, ...this.EMPTY_STATS }, + ); + } + + async getTotalStats(years?: number[]): Promise { + const yearsArray = years + ? (Array.isArray(years) ? years : [years]).map(Number) + : undefined; + + const stats = await this.aggregateStats(undefined, yearsArray); + + const totalStats = { ...this.EMPTY_STATS }; + let totalFoodRescueItems = 0; + + for (const s of stats) { + totalStats.totalItems += s.totalItems; + totalStats.totalOz += s.totalOz; + totalStats.totalDonatedFoodValue += s.totalDonatedFoodValue; + totalStats.totalShippingCost += s.totalShippingCost; + totalStats.totalValue += s.totalValue; + totalFoodRescueItems += + (s.percentageFoodRescueItems / 100) * s.totalItems; + } + + totalStats.totalLbs = parseFloat((totalStats.totalOz / 16).toFixed(2)); + totalStats.percentageFoodRescueItems = + totalStats.totalItems > 0 + ? parseFloat( + ((totalFoodRescueItems / totalStats.totalItems) * 100).toFixed(2), + ) + : 0; + + return totalStats; + } + async getPendingPantries(): Promise { return await this.repo.find({ where: { status: ApplicationStatus.PENDING }, diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index dfc708d57..0fb3f495f 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -39,3 +39,17 @@ export enum ReserveFoodForAllergic { SOME = 'Some', NO = 'No', } + +export type PantryStats = { + pantryId: number; + totalItems: number; + totalOz: number; + totalLbs: number; + totalDonatedFoodValue: number; + totalShippingCost: number; + totalValue: number; + percentageFoodRescueItems: number; +}; + +// Make new type that is just a list of PantryStats with pantryId omitted +export type TotalStats = Omit; diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 8d4a17886..c25bc6766 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -1,7 +1,4 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import { VolunteersController } from './volunteers.controller'; -import { UsersController } from '../users/users.controller'; -import { UsersService } from '../users/users.service'; import { User } from '../users/users.entity'; import { Role } from '../users/types'; import { Test, TestingModule } from '@nestjs/testing'; @@ -156,8 +153,8 @@ describe('VolunteersController', () => { expect(result).toEqual(updatedUser); expect(result.pantries).toHaveLength(2); - expect(result.pantries![0].pantryId).toBe(1); - expect(result.pantries![1].pantryId).toBe(3); + expect(result.pantries?.[0].pantryId).toBe(1); + expect(result.pantries?.[1].pantryId).toBe(3); expect( mockVolunteersService.assignPantriesToVolunteer, ).toHaveBeenCalledWith(3, pantryIds); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 1bbcc7530..35e5a92c8 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -7,7 +7,17 @@ import { Pantry } from '../pantries/pantries.entity'; import { testDataSource } from '../config/typeormTestDataSource'; import { UsersService } from '../users/users.service'; import { PantriesService } from '../pantries/pantries.service'; +import { OrdersService } from '../orders/order.service'; +import { Order } from '../orders/order.entity'; +import { RequestsService } from '../foodRequests/request.service'; +import { FoodRequest } from '../foodRequests/request.entity'; import { AuthService } from '../auth/auth.service'; +import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; +import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service'; +import { DonationItem } from '../donationItems/donationItems.entity'; +import { DonationItemsService } from '../donationItems/donationItems.service'; +import { DonationService } from '../donations/donations.service'; +import { Donation } from '../donations/donations.entity'; jest.setTimeout(60000); @@ -25,9 +35,16 @@ describe('VolunteersService', () => { VolunteersService, UsersService, PantriesService, + OrdersService, + RequestsService, + FoodManufacturersService, + DonationItemsService, + DonationService, { provide: AuthService, - useValue: {}, + useValue: { + adminCreateUser: jest.fn().mockResolvedValue('test-sub'), + }, }, { provide: getRepositoryToken(User), @@ -37,6 +54,26 @@ describe('VolunteersService', () => { provide: getRepositoryToken(Pantry), useValue: testDataSource.getRepository(Pantry), }, + { + provide: getRepositoryToken(Order), + useValue: testDataSource.getRepository(Order), + }, + { + provide: getRepositoryToken(FoodRequest), + useValue: testDataSource.getRepository(FoodRequest), + }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, ], }).compile(); @@ -177,8 +214,8 @@ describe('VolunteersService', () => { expect(beforePantryIds).toEqual([2, 3]); const result = await service.assignPantriesToVolunteer(7, [1, 4]); - expect(result.pantries!).toHaveLength(4); - const afterPantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(4); + const afterPantryIds = result.pantries?.map((p) => p.pantryId); expect(afterPantryIds).toEqual([2, 3, 1, 4]); }); @@ -191,8 +228,8 @@ describe('VolunteersService', () => { expect(beforeAssignment).toEqual([]); const result = await service.assignPantriesToVolunteer(6, [2, 3]); - expect(result.pantries!).toHaveLength(2); - const pantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); @@ -203,8 +240,8 @@ describe('VolunteersService', () => { expect(beforePantryIds).toEqual([2, 3]); const result = await service.assignPantriesToVolunteer(7, [2, 3]); - expect(result.pantries!).toHaveLength(2); - const pantryIds = result.pantries!.map((p) => p.pantryId); + expect(result.pantries).toHaveLength(2); + const pantryIds = result.pantries?.map((p) => p.pantryId); expect(pantryIds).toEqual([2, 3]); }); }); diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 5d11c90c0..0e8a2cd42 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -25,6 +25,7 @@ import { } from '../../types/types'; import { Minus } from 'lucide-react'; import { generateNextDonationDate } from '@utils/utils'; +import { FloatingAlert } from '@components/floatingAlert'; interface NewDonationFormModalProps { onDonationSuccess: () => void; @@ -82,34 +83,13 @@ const NewDonationFormModal: React.FC = ({ Sunday: false, }); const [endsAfter, setEndsAfter] = useState('1'); - - const [totalItems, setTotalItems] = useState(0); - const [totalOz, setTotalOz] = useState(0); - const [totalValue, setTotalValue] = useState(0); + const [alertMessage, setAlertMessage] = useState(''); const handleChange = (id: number, field: string, value: string | boolean) => { const updatedRows = rows.map((row) => row.id === id ? { ...row, [field]: value } : row, ); setRows(updatedRows); - calculateTotals(updatedRows); - }; - - const calculateTotals = (updatedRows: DonationRow[]) => { - let totalItems = 0, - totalOz = 0, - totalValue = 0; - updatedRows.forEach((row) => { - if (row.numItems) { - const qty = parseInt(row.numItems); - totalItems += qty; - totalOz += parseFloat(row.ozPerItem) * qty; - totalValue += parseFloat(row.valuePerItem) * qty; - } - }); - setTotalItems(totalItems); - setTotalOz(parseFloat(totalOz.toFixed(2))); - setTotalValue(parseFloat(totalValue.toFixed(2))); }; const handleDayToggle = (day: DayOfWeek) => { @@ -135,7 +115,6 @@ const NewDonationFormModal: React.FC = ({ if (rows.length > 1) { const newRows = rows.filter((r) => r.id !== id); setRows(newRows); - calculateTotals(newRows); } }; @@ -169,7 +148,7 @@ const NewDonationFormModal: React.FC = ({ (row) => !row.foodItem || !row.foodType || !row.numItems, ); if (hasEmpty) { - alert('Please fill in all fields before submitting.'); + setAlertMessage('Please fill in all fields before submitting.'); return; } @@ -178,15 +157,12 @@ const NewDonationFormModal: React.FC = ({ repeatInterval === RecurrenceEnum.WEEKLY && !Object.values(repeatOn).some(Boolean) ) { - alert('Please select at least one day for weekly recurrence.'); + setAlertMessage('Please select at least one day for weekly recurrence.'); return; } const donation_body = { foodManufacturerId: 1, - totalItems, - totalOz: totalOz > 0 ? totalOz : undefined, - totalEstimatedValue: totalValue > 0 ? totalValue : undefined, recurrenceFreq: isRecurring ? parseInt(repeatEvery) : null, recurrence: isRecurring ? repeatInterval : RecurrenceEnum.NONE, repeatOnDays: @@ -226,17 +202,14 @@ const NewDonationFormModal: React.FC = ({ foodRescue: false, }, ]); - setTotalItems(0); - setTotalOz(0); - setTotalValue(0); setIsRecurring(false); setRepeatInterval(RecurrenceEnum.NONE); onClose(); } else { - alert('Failed to submit donation'); + setAlertMessage('Failed to submit donation'); } } catch (error) { - alert('Error submitting new donation: ' + error); + setAlertMessage('Error submitting new donation: ' + error); } }; @@ -258,6 +231,14 @@ const NewDonationFormModal: React.FC = ({ }} closeOnInteractOutside > + {alertMessage && ( + + )} diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 7f28b9d97..92de1a071 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -125,9 +125,6 @@ export interface Donation { donationId: number; dateDonated: string; status: DonationStatus; - totalItems: number; - totalOz: number; - totalEstimatedValue: number; foodManufacturer?: FoodManufacturer; recurrence: RecurrenceEnum; recurrenceFreq?: number; @@ -361,6 +358,20 @@ export type DayOfWeek = export type RepeatOnState = Record; +export interface PantryStats { + pantryId: number; + totalItems: number; + totalOz: number; + totalLbs: number; + totalDonatedFoodValue: number; + totalShippingCost: number; + totalValue: number; + percentageFoodRescueItems: number; +} + +// Make TotalStats interface just not include pantryId +export type TotalStats = Omit; + export type Assignments = Omit & { pantryIds: number[] }; export type GroupedByFoodType = Partial>;