From 95476a4f9dfe08e7d1ebdf290152e5a012042b3a Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 28 Feb 2026 16:44:58 -0500 Subject: [PATCH 01/13] Initial implementation. Still need to cleanup tests --- apps/backend/src/config/migrations.ts | 2 + .../src/donations/donations.controller.ts | 3 - .../backend/src/donations/donations.entity.ts | 9 - .../src/donations/donations.service.ts | 3 - .../src/donations/dtos/create-donation.dto.ts | 15 - .../1772241115031-DropDonationTotalColumns.ts | 25 + apps/backend/src/orders/order.entity.ts | 4 + apps/backend/src/orders/order.service.spec.ts | 31 +- apps/backend/src/orders/order.service.ts | 23 +- .../src/pantries/pantries.controller.spec.ts | 64 +++ .../src/pantries/pantries.controller.ts | 16 + .../src/pantries/pantries.service.spec.ts | 442 +++++++++--------- apps/backend/src/pantries/pantries.service.ts | 129 ++++- apps/backend/src/pantries/types.ts | 13 + .../components/forms/newDonationFormModal.tsx | 47 +- apps/frontend/src/types/types.ts | 3 - package.json | 2 +- 17 files changed, 526 insertions(+), 305 deletions(-) create mode 100644 apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index 85684cd70..b8394231a 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'; const schemaMigrations = [ User1725726359198, @@ -70,6 +71,7 @@ const schemaMigrations = [ DonationItemFoodTypeNotNull1771524930613, MoveRequestFieldsToOrders1770571145350, RenameDonationMatchingStatus1771260403657, + DropDonationTotalColumns1772241115031, ]; export default schemaMigrations; 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 a4fa539f6..167d4add3 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -74,9 +74,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..688e68e75 --- /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(10,2), + ADD COLUMN total_estimated_value NUMERIC(10,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 33eff085c..502913e97 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -277,6 +277,33 @@ 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).not.toContain(2026); + // Remaining orders may still be 2024; none should be 2026 + expect(years.every((y) => y === 2024 || y === 2025)).toBe(true); + }); + it('throws NotFoundException for non-existent pantry', async () => { const pantryId = 9999; @@ -327,7 +354,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', async () => { @@ -340,7 +367,7 @@ describe('OrdersService', () => { const order = await service.findOne(3); expect(order.trackingLink).toEqual('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 17aeffd8b..8ac427e2e 100644 --- a/apps/backend/src/orders/order.service.ts +++ b/apps/backend/src/orders/order.service.ts @@ -195,7 +195,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 }); @@ -203,12 +206,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 5e1f3bc9f..c895661ab 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -10,6 +10,7 @@ import { Activity, AllergensConfidence, ClientVisitFrequency, + PantryStats, RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, @@ -282,4 +283,67 @@ describe('PantriesController', () => { expect(mockPantriesService.findByUserId).toHaveBeenCalledWith(999); }); }); + + 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: PantryStats = { + 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(); + }); + }); }); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 746e67aa7..be7457b07 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,6 +20,7 @@ import { Activity, AllergensConfidence, ClientVisitFrequency, + PantryStats, RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, @@ -38,6 +40,20 @@ export class PantriesController { private emailsService: EmailsService, ) {} + @Get('/stats-by-pantry') + async getPantryStats( + @Query('pantryNames') pantryNames?: string[], + @Query('years') years?: number[], + @Query('page') page = 1, + ): Promise { + return this.pantriesService.getPantryStats(pantryNames, years, page); + } + + @Get('/total-stats') + async getTotalStats(): Promise { + return this.pantriesService.getTotalStats(); + } + @Roles(Role.PANTRY) @Get('/my-id') async getCurrentUserPantryId( diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 8163c1ebc..1acf2ee79 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -2,9 +2,8 @@ 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 { User } from '../users/user.entity'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ClientVisitFrequency, @@ -16,289 +15,274 @@ import { } from './types'; import { ApplicationStatus } from '../shared/types'; -const mockRepository = mock>(); +// database helpers +import { testDataSource } from '../config/typeormTestDataSource'; +import { Order } from '../orders/order.entity'; +import { OrdersService } from '../orders/order.service'; +import { FoodRequest } from '../foodRequests/request.entity'; +import { RequestsService } from '../foodRequests/request.service'; -describe('PantriesService', () => { - let service: PantriesService; +// This spec uses the migration-populated dummy data to exercise +// PantriesService against a real database. Each test resets the +// schema to guarantee isolation. - // Mock Pantry - const mockPendingPantry = { - pantryId: 1, - pantryName: 'Test Pantry', - status: ApplicationStatus.PENDING, - } as Pantry; +describe('PantriesService (integration using dummy data)', () => { + let service: PantriesService; + let ordersService: OrdersService; - // 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, { 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), }, ], }).compile(); service = module.get(PantriesService); + ordersService = module.get(OrdersService); }); - afterEach(() => { - jest.clearAllMocks(); + beforeEach(async () => { + await testDataSource.runMigrations(); }); - it('should be defined', () => { - expect(service).toBeDefined(); + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); }); - // Find pantry by ID - describe('findOne', () => { - it('should return a pantry by id', async () => { - mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } + }); - const result = await service.findOne(1); + it('service should be defined', () => { + expect(service).toBeDefined(); + }); - expect(result).toBe(mockPendingPantry); - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - relations: ['pantryUser'], - }); + describe('findOne', () => { + it('returns pantry by existing ID', async () => { + const pantry = await service.findOne(1); + expect(pantry).toBeDefined(); + expect(pantry.pantryId).toBe(1); }); - it('should throw NotFoundException if pantry not found', async () => { - mockRepository.findOne.mockResolvedValueOnce(null); - - await expect(service.findOne(999)).rejects.toThrow(NotFoundException); - await expect(service.findOne(999)).rejects.toThrow( - 'Pantry 999 not found', + it('throws NotFoundException for missing ID', async () => { + await expect(service.findOne(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'], - }); - }); - - it('should not return approved pantries', async () => { - mockRepository.find.mockResolvedValueOnce([mockPendingPantry]); - - const result = await service.getPendingPantries(); - - expect(result).not.toContainEqual( - expect.objectContaining({ status: 'approved' }), + 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, ); - expect(mockRepository.find).toHaveBeenCalledWith({ - where: { status: 'pending' }, - relations: ['pantryUser'], - }); - }); - - it('should return an empty array if no pending pantries', async () => { - mockRepository.find.mockResolvedValueOnce([]); - - const result = await service.getPendingPantries(); - - expect(result).toEqual([]); }); }); - // Approve pantry by ID (status = approved) - describe('approve', () => { - it('should approve a pantry', async () => { - mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); - mockRepository.update.mockResolvedValueOnce({} as UpdateResult); - - await service.approve(1); + describe('approve/deny', () => { + it('approves a pending pantry', async () => { + await service.approve(5); + const p = await service.findOne(5); + expect(p.status).toBe('approved'); + }); - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - }); - expect(mockRepository.update).toHaveBeenCalledWith(1, { - status: 'approved', - }); + it('denies a pending pantry', async () => { + await service.deny(6); + const p = await service.findOne(6); + expect(p.status).toBe('denied'); }); - it('should throw NotFoundException if pantry not found', async () => { - mockRepository.findOne.mockResolvedValueOnce(null); + it('throws when approving non-existent', async () => { + await expect(service.approve(9999)).rejects.toThrow( + new NotFoundException('Pantry 9999 not found'), + ); + }); - await expect(service.approve(999)).rejects.toThrow(NotFoundException); - await expect(service.approve(999)).rejects.toThrow( - 'Pantry 999 not found', + it('throws when denying non-existent', async () => { + await expect(service.deny(9999)).rejects.toThrow( + new NotFoundException('Pantry 9999 not found'), ); - 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); - - await service.deny(1); - - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - }); - expect(mockRepository.update).toHaveBeenCalledWith(1, { - status: 'denied', + 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', + } as PantryApplicationDto; + + 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('should throw NotFoundException if pantry not found', async () => { - mockRepository.findOne.mockResolvedValueOnce(null); - - await expect(service.deny(999)).rejects.toThrow(NotFoundException); - await expect(service.deny(999)).rejects.toThrow('Pantry 999 not found'); - expect(mockRepository.update).not.toHaveBeenCalled(); + 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'); }); }); - // 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, - }), - ); + describe('getStatsForPantry', () => { + it('returns meaningful stats for pantry with orders', async () => { + const pantry = await service.findOne(1); + const stats = await service.getStatsForPantry(pantry); + expect(stats.totalItems).toBeGreaterThan(0); + expect(stats.totalOz).toBeGreaterThan(0); + expect(stats.pantryId).toBe(1); }); + }); - it('should create pantry representative from contact info', async () => { - mockRepository.save.mockResolvedValueOnce(mockPendingPantry); + describe('getPantryStats', () => { + it('filters by pantry name correctly', async () => { + const stats = await service.getPantryStats([ + 'Community Food Pantry Downtown', + ]); + expect(stats.length).toBe(1); + expect(stats[0].pantryId).toBe(1); + }); - await service.addPantry(mockPantryApplication); + it('handles pagination', async () => { + const paged = await service.getPantryStats(undefined, undefined, 1); + expect(paged.length).toBeGreaterThanOrEqual(1); + }); - expect(mockRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - pantryUser: expect.objectContaining({ - firstName: mockPantryApplication.contactFirstName, - lastName: mockPantryApplication.contactLastName, - email: mockPantryApplication.contactEmail, - phone: mockPantryApplication.contactPhone, - role: 'pantry', - }), - }), - ); + it('filters by year correctly', async () => { + const yearFiltered = await service.getPantryStats(undefined, [2030]); + expect(yearFiltered.every((s) => s.totalItems === 0)).toBe(true); }); + }); - it('should throw error if save fails', async () => { - mockRepository.save.mockRejectedValueOnce(new Error('Database error')); + describe('getTotalStats', () => { + it('aggregates stats across all pantries', async () => { + const total = await service.getTotalStats(); + expect(total.totalItems).toBeGreaterThan(0); + }); - await expect(service.addPantry(mockPantryApplication)).rejects.toThrow( - 'Database error', - ); - expect(mockRepository.save).toHaveBeenCalled(); + it('respects year filter', async () => { + const totalEmpty = await service.getTotalStats([2030]); + expect(totalEmpty.totalItems).toBe(0); }); }); - // 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('findByIds success and failure', async () => { + const found = await service.findByIds([1, 2]); + expect(found.map((p) => p.pantryId)).toEqual([1, 2]); + await expect(service.findByIds([1, 9999])).rejects.toThrow(); + }); - // it('should throw NotFoundException if pantry not found', async () => { - // await expect(service.findByUserId(999)).rejects.toThrow( - // new NotFoundException('Pantry for User 999 not found'), - // ); - // }); - // }); + it('findByUserId success and failure', async () => { + const res: any[] = await testDataSource.query( + `SELECT user_id FROM public.users WHERE email='pantry1@ssf.org' LIMIT 1`, + ); + const userId = res[0].user_id; + const pantry = await service.findByUserId(userId); + expect(pantry.pantryId).toBe(1); + await expect(service.findByUserId(999999)).rejects.toThrow(); + }); }); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 8755c4199..058e53413 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -7,10 +7,20 @@ 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 } from './types'; +import { Order } from '../orders/order.entity'; +import { OrdersService } from '../orders/order.service'; @Injectable() export class PantriesService { - constructor(@InjectRepository(Pantry) private repo: Repository) {} + constructor( + @InjectRepository(Pantry) private repo: Repository, + private ordersService: OrdersService, + ) {} + + async getAll(): Promise { + return this.repo.find({ relations: ['pantryUser'] }); + } async findOne(pantryId: number): Promise { validateId(pantryId, 'Pantry'); @@ -26,6 +36,123 @@ export class PantriesService { return pantry; } + // Get all order stats for a pantry, with optional filtering by year + async getStatsForPantry( + pantry: Pantry, + years?: number[], + ): Promise { + const orders: Order[] = await this.ordersService.getOrdersByPantry( + pantry.pantryId, + years, + ); + const stats: PantryStats = { + pantryId: pantry.pantryId, + totalItems: 0, + totalOz: 0, + totalLbs: 0, + totalDonatedFoodValue: 0, + totalShippingCost: 0, + totalValue: 0, + percentageFoodRescueItems: 0, + }; + let totalFoodRescueItems = 0; + orders.forEach((order) => { + const allocations = order.allocations; + allocations.forEach((allocation) => { + const item = allocation.item; + stats.totalItems += allocation.allocatedQuantity; + stats.totalOz += (item.ozPerItem ?? 0) * allocation.allocatedQuantity; + stats.totalDonatedFoodValue += + (item.estimatedValue ?? 0) * allocation.allocatedQuantity; + if (item.foodRescue) { + totalFoodRescueItems += allocation.allocatedQuantity; + } + }); + stats.totalLbs = parseFloat((stats.totalOz / 16).toFixed(2)); + stats.totalShippingCost += order.shippingCost ?? 0; + stats.totalValue = stats.totalDonatedFoodValue + stats.totalShippingCost; + }); + stats.percentageFoodRescueItems = + stats.totalItems > 0 + ? parseFloat( + ((totalFoodRescueItems / stats.totalItems) * 100).toFixed(2), + ) + : 0; + return stats; + } + + // Get stats for multiple pantries, with optional filtering by pantry name, year, and pagination + async getPantryStats( + pantryNames?: string[], + years?: number[], + page = 1, + ): Promise { + // Determines how many pantry stats are returned per page + const PAGE_SIZE = 10; + + // Convert pantryNames to array if its just a single string, and handle case where it's undefined + const nameArray = pantryNames + ? Array.isArray(pantryNames) + ? pantryNames + : [pantryNames] + : undefined; + + const nameFilter = + nameArray && nameArray.length > 0 ? { pantryName: In(nameArray) } : {}; + + const pantries = await this.repo.find({ + where: nameFilter, + order: { pantryId: 'ASC' }, + skip: (page - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }); + + // Convert years to array if its just a single number, and handle case where it's undefined + const yearsArray = years + ? (Array.isArray(years) ? years : [years]).map(Number) + : undefined; + + const pantryStats: PantryStats[] = []; + for (const pantry of pantries) { + const stats = await this.getStatsForPantry(pantry, yearsArray); + pantryStats.push(stats); + } + return pantryStats; + } + + // Get total stats across all pantries, with optional filtering by year + async getTotalStats(years?: number[]): Promise { + const pantries = await this.repo.find(); + const totalStats: PantryStats = { + totalItems: 0, + totalOz: 0, + totalLbs: 0, + totalDonatedFoodValue: 0, + totalShippingCost: 0, + totalValue: 0, + percentageFoodRescueItems: 0, + }; + let totalFoodRescueItems = 0; + for (const pantry of pantries) { + const stats = await this.getStatsForPantry(pantry, years); + totalStats.totalItems += stats.totalItems; + totalStats.totalOz += stats.totalOz; + totalStats.totalLbs += stats.totalLbs; + totalStats.totalDonatedFoodValue += stats.totalDonatedFoodValue; + totalStats.totalShippingCost += stats.totalShippingCost; + totalStats.totalValue += stats.totalValue; + totalFoodRescueItems += + (stats.percentageFoodRescueItems / 100) * stats.totalItems; + } + 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..781cd041f 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -1,3 +1,5 @@ +import { NumericType } from 'typeorm'; + export enum RefrigeratedDonation { YES = 'Yes, always', NO = 'No', @@ -39,3 +41,14 @@ 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; +}; diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index 91ad1472f..924f2606a 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -26,6 +26,7 @@ import { } from '../../types/types'; import { Minus } from 'lucide-react'; import { generateNextDonationDate } from '@utils/utils'; +import { FloatingAlert } from '@components/floatingAlert'; interface NewDonationFormModalProps { onDonationSuccess: () => void; @@ -83,34 +84,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) => { @@ -136,7 +116,6 @@ const NewDonationFormModal: React.FC = ({ if (rows.length > 1) { const newRows = rows.filter((r) => r.id !== id); setRows(newRows); - calculateTotals(newRows); } }; @@ -170,7 +149,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; } @@ -179,15 +158,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: @@ -227,17 +203,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); } }; @@ -259,6 +232,14 @@ const NewDonationFormModal: React.FC = ({ }} closeOnInteractOutside > + {alertMessage && ( + + )} diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 7bceeff4d..d57e4179e 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; diff --git a/package.json b/package.json index 2bc442e8c..4f5f2efd1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "format": "prettier --no-error-on-unmatched-pattern --write apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx", "lint": "eslint apps/frontend --ext .ts,.tsx --fix && eslint apps/backend --ext .ts,.tsx --fix", - "test": "jest", + "test": "jest --runInBand", "prepush": "yarn run format:check && yarn run lint:check", "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install", From b233d843b96053698d8f90e6f190a43451be67e7 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 28 Feb 2026 17:29:31 -0500 Subject: [PATCH 02/13] Final commit --- .github/workflows/backend-tests.yml | 2 +- .../src/pantries/pantries.service.spec.ts | 213 +++++++++++++++--- apps/backend/src/pantries/pantries.service.ts | 2 +- 3 files changed, 184 insertions(+), 33 deletions(-) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 98c5a73e3..26b4e0f44 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -42,4 +42,4 @@ jobs: node-version: 20 - run: yarn install --frozen-lockfile - run: yarn list strip-ansi string-width string-length - - run: npx jest \ No newline at end of file + - run: yarn test \ No newline at end of file diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 1acf2ee79..f158a6b4e 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -21,6 +21,7 @@ import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; +import { stat } from 'fs'; // This spec uses the migration-populated dummy data to exercise // PantriesService against a real database. Each test resets the @@ -108,17 +109,11 @@ describe('PantriesService (integration using dummy data)', () => { }); }); - describe('approve/deny', () => { + describe('approve', () => { it('approves a pending pantry', async () => { await service.approve(5); const p = await service.findOne(5); - expect(p.status).toBe('approved'); - }); - - it('denies a pending pantry', async () => { - await service.deny(6); - const p = await service.findOne(6); - expect(p.status).toBe('denied'); + expect(p.status).toBe(ApplicationStatus.APPROVED); }); it('throws when approving non-existent', async () => { @@ -126,6 +121,14 @@ describe('PantriesService (integration using dummy data)', () => { new NotFoundException('Pantry 9999 not found'), ); }); + }); + + describe('deny', () => { + it('denies a pending pantry', async () => { + await service.deny(6); + const p = await service.findOne(6); + expect(p.status).toBe(ApplicationStatus.DENIED); + }); it('throws when denying non-existent', async () => { await expect(service.deny(9999)).rejects.toThrow( @@ -229,17 +232,73 @@ describe('PantriesService (integration using dummy data)', () => { }); describe('getStatsForPantry', () => { - it('returns meaningful stats for pantry with orders', async () => { + it('returns accurate aggregated stats for pantry with orders (Community Food Pantry Downtown)', async () => { const pantry = await service.findOne(1); const stats = await service.getStatsForPantry(pantry); - expect(stats.totalItems).toBeGreaterThan(0); - expect(stats.totalOz).toBeGreaterThan(0); + + // From the dummy data migration: pantry 1 allocations total + // delivered allocations: 10 + 5 + 25 = 40 + // pending allocations: 75 + 10 = 85 + // totalItems = 125 expect(stats.pantryId).toBe(1); + expect(stats.totalItems).toBe(125); + + // totalOz: delivered (10*16 + 5*8.01 + 25*24) = 800.05 + // pending (75*16 + 10*32) = 1520 + // total = 2320.05 + expect(stats.totalOz).toBeCloseTo(2320.05, 2); + + // totalLbs is rounded to 2 decimals inside the service + expect(stats.totalLbs).toBeCloseTo(145.0, 2); + + // total donated value: delivered (10*4.5 + 5*2 + 25*3) = 130 + // pending (75*6 + 10*4.5) = 495 -> total = 625 + expect(stats.totalDonatedFoodValue).toBeCloseTo(625.0, 2); + + // Migration sets a default shipping cost (20.00) for delivered/shipped orders + // Community has one delivered order -> shippingCost = 20 -> totalValue = 625 + 20 + expect(stats.totalValue).toBeCloseTo(645.0, 2); + + // Dummy data contains no explicit foodRescue flags -> percentage 0 + expect(stats.percentageFoodRescueItems).toBe(0); + }); + + it('returns zeroed stats for a pantry with no orders (Riverside Food Assistance)', async () => { + const pantry = await service.findOne(4); + const stats = await service.getStatsForPantry(pantry); + 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); }); }); describe('getPantryStats', () => { - it('filters by pantry name correctly', async () => { + it('filters by pantry name correctly and returns accurate sums', async () => { + const stats = await service.getPantryStats([ + 'Community Food Pantry Downtown', + 'Westside Community Kitchen', + ]); + // Expect two pantries + expect(stats.length).toBe(2); + + const community = stats.find((s) => s.pantryId === 1)!; + const westside = stats.find((s) => s.pantryId === 2)!; + + expect(community).toBeDefined(); + expect(community.totalItems).toBe(125); + expect(community.totalOz).toBeCloseTo(2320.05, 2); + + expect(westside).toBeDefined(); + expect(westside.totalItems).toBe(65); + expect(westside.totalOz).toBeCloseTo(1195.0, 2); + }); + + it('accepts single pantry name as string', async () => { const stats = await service.getPantryStats([ 'Community Food Pantry Downtown', ]); @@ -247,42 +306,134 @@ describe('PantriesService (integration using dummy data)', () => { expect(stats[0].pantryId).toBe(1); }); - it('handles pagination', async () => { - const paged = await service.getPantryStats(undefined, undefined, 1); - expect(paged.length).toBeGreaterThanOrEqual(1); + 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', async () => { + 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); }); + + it('pagination page returns first 10 items', async () => { + // Get over 10 pantries in the system to verify pagination + for (let i = 0; i < 10; i++) { + await service.addPantry({ + 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); + } + + 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 () => { + // Find the delivered order for Community Food Pantry Downtown and set its created_at to 2025 + 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); + }); }); describe('getTotalStats', () => { - it('aggregates stats across all pantries', async () => { + it('aggregates stats across all pantries and matches migration sums', async () => { const total = await service.getTotalStats(); - expect(total.totalItems).toBeGreaterThan(0); + + // totalItems: 125 + 65 + 30 = 220 + expect(total.totalItems).toBe(220); + + // totalOz: 2320.05 + 1195 + 1015 = 4530.05 + expect(total.totalOz).toBeCloseTo(4530.05, 2); + + // totalLbs: 145.00 + 74.69 + 63.44 = 283.13 + expect(total.totalLbs).toBeCloseTo(283.13, 2); + + // total donated value: 625 + 292.5 + 170 = 1087.5 + expect(total.totalDonatedFoodValue).toBeCloseTo(1087.5, 2); + + // shipping costs were applied to delivered/shipped orders + expect(total.totalShippingCost).toBeCloseTo(60.0, 2); + + expect(total.totalValue).toBeCloseTo(1147.5, 2); }); - it('respects year filter', async () => { + it('respects year filter and returns zeros for non-matching years', async () => { const totalEmpty = await service.getTotalStats([2030]); + expect(totalEmpty).toBeDefined(); 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); }); }); - it('findByIds success and failure', async () => { - const found = await service.findByIds([1, 2]); - expect(found.map((p) => p.pantryId)).toEqual([1, 2]); - await expect(service.findByIds([1, 9999])).rejects.toThrow(); + describe('findByIds', () => { + it('findByIds success', async () => { + const found = await service.findByIds([1, 2]); + expect(found.map((p) => p.pantryId)).toEqual([1, 2]); + }); + + it('findByIds with some non-existent IDs throws NotFoundException', async () => { + await expect(service.findByIds([1, 9999])).rejects.toThrow( + new NotFoundException('Pantries not found: 9999'), + ); + }); }); - it('findByUserId success and failure', async () => { - const res: any[] = await testDataSource.query( - `SELECT user_id FROM public.users WHERE email='pantry1@ssf.org' LIMIT 1`, - ); - const userId = res[0].user_id; - const pantry = await service.findByUserId(userId); - expect(pantry.pantryId).toBe(1); - await expect(service.findByUserId(999999)).rejects.toThrow(); + 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 058e53413..76425f907 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -36,7 +36,7 @@ export class PantriesService { return pantry; } - // Get all order stats for a pantry, with optional filtering by year + // Helper to get all order stats for a pantry, with optional filtering by year async getStatsForPantry( pantry: Pantry, years?: number[], From 91fafc8d164e9316c81e8b9402b8356ebcd4251a Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 1 Mar 2026 15:57:37 -0500 Subject: [PATCH 03/13] Final commit --- apps/backend/src/orders/order.service.spec.ts | 1 - apps/backend/src/pantries/pantries.controller.spec.ts | 2 +- apps/backend/src/pantries/pantries.controller.ts | 2 ++ apps/backend/src/pantries/pantries.service.spec.ts | 8 -------- apps/frontend/src/types/types.ts | 11 +++++++++++ 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index 502913e97..9ff76d31f 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -300,7 +300,6 @@ describe('OrdersService', () => { const years = orders.map((o) => new Date(o.createdAt).getFullYear()); expect(years).toContain(2025); expect(years).not.toContain(2026); - // Remaining orders may still be 2024; none should be 2026 expect(years.every((y) => y === 2024 || y === 2025)).toBe(true); }); diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index c895661ab..bd9c3bfb1 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -17,7 +17,7 @@ import { } from './types'; import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; -import { NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { User } from '../users/user.entity'; import { AuthenticatedRequest } from '../auth/authenticated-request'; diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index be7457b07..93a510790 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -40,6 +40,7 @@ export class PantriesController { private emailsService: EmailsService, ) {} + @Roles(Role.ADMIN) @Get('/stats-by-pantry') async getPantryStats( @Query('pantryNames') pantryNames?: string[], @@ -49,6 +50,7 @@ export class PantriesController { return this.pantriesService.getPantryStats(pantryNames, years, page); } + @Roles(Role.ADMIN) @Get('/total-stats') async getTotalStats(): Promise { return this.pantriesService.getTotalStats(); diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index f158a6b4e..1e22b1938 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -14,18 +14,11 @@ import { AllergensConfidence, } from './types'; import { ApplicationStatus } from '../shared/types'; - -// database helpers import { testDataSource } from '../config/typeormTestDataSource'; import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; -import { stat } from 'fs'; - -// This spec uses the migration-populated dummy data to exercise -// PantriesService against a real database. Each test resets the -// schema to guarantee isolation. describe('PantriesService (integration using dummy data)', () => { let service: PantriesService; @@ -283,7 +276,6 @@ describe('PantriesService (integration using dummy data)', () => { 'Community Food Pantry Downtown', 'Westside Community Kitchen', ]); - // Expect two pantries expect(stats.length).toBe(2); const community = stats.find((s) => s.pantryId === 1)!; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index d57e4179e..ef2eb979e 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -363,3 +363,14 @@ export type DayOfWeek = | 'Sunday'; export type RepeatOnState = Record; + +export interface PantryStats { + pantryId?: number; + totalItems: number; + totalOz: number; + totalLbs: number; + totalDonatedFoodValue: number; + totalShippingCost: number; + totalValue: number; + percentageFoodRescueItems: number; +} From bea87afb1db0e60bc4edf0412275344e7f532fa7 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 1 Mar 2026 16:01:08 -0500 Subject: [PATCH 04/13] Final commit --- .../donationItems.controller.spec.ts | 23 ------------------- .../manufacturers.controller.spec.ts | 1 - .../foodRequests/request.controller.spec.ts | 1 - .../src/foodRequests/request.controller.ts | 4 ---- apps/backend/src/orders/order.controller.ts | 1 - .../src/pantries/pantries.service.spec.ts | 4 ---- apps/backend/src/pantries/types.ts | 2 -- 7 files changed, 36 deletions(-) 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/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index 3739aede0..f80216e0c 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -7,7 +7,6 @@ import { Allergen, DonateWastedFood } from './types'; import { ApplicationStatus } from '../shared/types'; import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto'; import { Donation } from '../donations/donations.entity'; -import { DonationService } from '../donations/donations.service'; const mockManufacturersService = mock(); diff --git a/apps/backend/src/foodRequests/request.controller.spec.ts b/apps/backend/src/foodRequests/request.controller.spec.ts index ada54836a..66f2c1cd1 100644 --- a/apps/backend/src/foodRequests/request.controller.spec.ts +++ b/apps/backend/src/foodRequests/request.controller.spec.ts @@ -8,7 +8,6 @@ import { OrderStatus } from '../orders/types'; import { FoodType } from '../donationItems/types'; import { OrderDetailsDto } from './dtos/order-details.dto'; import { CreateRequestDto } from './dtos/create-request.dto'; -import { Order } from '../orders/order.entity'; const mockRequestsService = mock(); diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 9d2bcd014..c1035a497 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -5,11 +5,7 @@ import { ParseIntPipe, Post, Body, - UploadedFiles, - UseInterceptors, - NotFoundException, ValidationPipe, - BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { RequestsService } from './request.service'; diff --git a/apps/backend/src/orders/order.controller.ts b/apps/backend/src/orders/order.controller.ts index e075e9857..4adb1c3e0 100644 --- a/apps/backend/src/orders/order.controller.ts +++ b/apps/backend/src/orders/order.controller.ts @@ -1,7 +1,6 @@ import { Controller, Get, - Post, Patch, Param, ParseIntPipe, diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 1e22b1938..f9b2b6ef4 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -16,13 +16,11 @@ import { import { ApplicationStatus } from '../shared/types'; import { testDataSource } from '../config/typeormTestDataSource'; import { Order } from '../orders/order.entity'; -import { OrdersService } from '../orders/order.service'; import { FoodRequest } from '../foodRequests/request.entity'; import { RequestsService } from '../foodRequests/request.service'; describe('PantriesService (integration using dummy data)', () => { let service: PantriesService; - let ordersService: OrdersService; beforeAll(async () => { if (!testDataSource.isInitialized) { @@ -34,7 +32,6 @@ describe('PantriesService (integration using dummy data)', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PantriesService, - OrdersService, RequestsService, { provide: getRepositoryToken(Pantry), @@ -56,7 +53,6 @@ describe('PantriesService (integration using dummy data)', () => { }).compile(); service = module.get(PantriesService); - ordersService = module.get(OrdersService); }); beforeEach(async () => { diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index 781cd041f..bee9fbbe5 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -1,5 +1,3 @@ -import { NumericType } from 'typeorm'; - export enum RefrigeratedDonation { YES = 'Yes, always', NO = 'No', From b373939fce3ba296d080bc1794656f54edd54f86 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 1 Mar 2026 16:07:22 -0500 Subject: [PATCH 05/13] Final commit --- apps/backend/src/pantries/pantries.service.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index f9b2b6ef4..bcfd480d1 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -18,6 +18,7 @@ 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'; describe('PantriesService (integration using dummy data)', () => { let service: PantriesService; @@ -32,6 +33,7 @@ describe('PantriesService (integration using dummy data)', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PantriesService, + OrdersService, RequestsService, { provide: getRepositoryToken(Pantry), From 7dd2243a94808cc3c8e8cba86e2e8f5a9a4f7852 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 1 Mar 2026 17:42:52 -0500 Subject: [PATCH 06/13] Final commit --- .../src/pantries/pantries.controller.spec.ts | 20 +++++++++++++++++++ .../src/pantries/pantries.controller.ts | 4 ++-- apps/backend/src/pantries/pantries.service.ts | 10 +++++----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index bd9c3bfb1..e5970c600 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -345,5 +345,25 @@ describe('PantriesController', () => { expect(result).toEqual(mockTotalStats); expect(mockPantriesService.getTotalStats).toHaveBeenCalled(); }); + + it('should forward years query parameter to service', async () => { + const mockTotalStats: PantryStats = { + 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 93a510790..68c722ad2 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -52,8 +52,8 @@ export class PantriesController { @Roles(Role.ADMIN) @Get('/total-stats') - async getTotalStats(): Promise { - return this.pantriesService.getTotalStats(); + async getTotalStats(@Query('years') years?: number[]): Promise { + return this.pantriesService.getTotalStats(years); } @Roles(Role.PANTRY) diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 76425f907..abcd76634 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -18,10 +18,6 @@ export class PantriesService { private ordersService: OrdersService, ) {} - async getAll(): Promise { - return this.repo.find({ relations: ['pantryUser'] }); - } - async findOne(pantryId: number): Promise { validateId(pantryId, 'Pantry'); @@ -122,6 +118,10 @@ export class PantriesService { // Get total stats across all pantries, with optional filtering by year async getTotalStats(years?: number[]): Promise { + // Ensure years is an array + const yearsArray = years + ? (Array.isArray(years) ? years : [years]).map(Number) + : undefined; const pantries = await this.repo.find(); const totalStats: PantryStats = { totalItems: 0, @@ -134,7 +134,7 @@ export class PantriesService { }; let totalFoodRescueItems = 0; for (const pantry of pantries) { - const stats = await this.getStatsForPantry(pantry, years); + const stats = await this.getStatsForPantry(pantry, yearsArray); totalStats.totalItems += stats.totalItems; totalStats.totalOz += stats.totalOz; totalStats.totalLbs += stats.totalLbs; From 524434c8c59196abc58a715164c2b0bc56a9b7bb Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sun, 1 Mar 2026 18:58:33 -0500 Subject: [PATCH 07/13] Removed unused imports --- apps/backend/src/volunteers/volunteers.controller.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 5a50f637d..74b4ce964 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/user.entity'; import { Role } from '../users/types'; import { Test, TestingModule } from '@nestjs/testing'; From 670fbd091ff913a662794ce5629066f68be64261 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 6 Mar 2026 02:38:28 -0500 Subject: [PATCH 08/13] Final commit!!! --- .../1772241115031-DropDonationTotalColumns.ts | 4 +- .../src/pantries/pantries.controller.spec.ts | 5 +- .../src/pantries/pantries.controller.ts | 5 +- apps/backend/src/pantries/pantries.module.ts | 3 +- .../src/pantries/pantries.service.spec.ts | 105 +++++---- apps/backend/src/pantries/pantries.service.ts | 211 +++++++++++------- apps/backend/src/pantries/types.ts | 5 +- 7 files changed, 202 insertions(+), 136 deletions(-) diff --git a/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts b/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts index 688e68e75..89e80c439 100644 --- a/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts +++ b/apps/backend/src/migrations/1772241115031-DropDonationTotalColumns.ts @@ -18,8 +18,8 @@ export class DropDonationTotalColumns1772241115031 ALTER TABLE "donations" ADD COLUMN total_items INTEGER, - ADD COLUMN total_oz NUMERIC(10,2), - ADD COLUMN total_estimated_value NUMERIC(10,2); + ADD COLUMN total_oz NUMERIC(20,2), + ADD COLUMN total_estimated_value NUMERIC(20,2); `); } } diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index e5970c600..a7fad2e5b 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -14,6 +14,7 @@ import { RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, + TotalStats, } from './types'; import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; @@ -328,7 +329,7 @@ describe('PantriesController', () => { describe('getTotalStats', () => { it('should return total stats across all pantries', async () => { - const mockTotalStats: PantryStats = { + const mockTotalStats: TotalStats = { totalItems: 500, totalOz: 8000, totalLbs: 500, @@ -347,7 +348,7 @@ describe('PantriesController', () => { }); it('should forward years query parameter to service', async () => { - const mockTotalStats: PantryStats = { + const mockTotalStats: TotalStats = { totalItems: 500, totalOz: 8000, totalLbs: 500, diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 68c722ad2..a297488cc 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -24,6 +24,7 @@ import { RefrigeratedDonation, ReserveFoodForAllergic, ServeAllergicChildren, + TotalStats, } from './types'; import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; @@ -45,14 +46,14 @@ export class PantriesController { async getPantryStats( @Query('pantryNames') pantryNames?: string[], @Query('years') years?: number[], - @Query('page') page = 1, + @Query('page', ParseIntPipe) page = 1, ): Promise { return this.pantriesService.getPantryStats(pantryNames, years, page); } @Roles(Role.ADMIN) @Get('/total-stats') - async getTotalStats(@Query('years') years?: number[]): Promise { + async getTotalStats(@Query('years') years?: number[]): Promise { return this.pantriesService.getTotalStats(years); } diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 76a192fe0..d1e2ef77a 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/user.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 56dc3950b..d276f3118 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -111,9 +111,11 @@ describe('PantriesService (integration using dummy data)', () => { 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 p = await service.findOne(5); - expect(p.status).toBe(ApplicationStatus.APPROVED); + const pantryAfter = await service.findOne(5); + expect(pantryAfter.status).toBe(ApplicationStatus.APPROVED); }); it('throws when approving non-existent', async () => { @@ -125,9 +127,11 @@ describe('PantriesService (integration using dummy data)', () => { 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 p = await service.findOne(6); - expect(p.status).toBe(ApplicationStatus.DENIED); + const pantryAfter = await service.findOne(6); + expect(pantryAfter.status).toBe(ApplicationStatus.DENIED); }); it('throws when denying non-existent', async () => { @@ -163,7 +167,7 @@ describe('PantriesService (integration using dummy data)', () => { activities: [Activity.CREATE_LABELED_SHELF], itemsInStock: 'none', needMoreOptions: 'none', - } as PantryApplicationDto; + }; await service.addPantry(dto); const saved = await testDataSource.getRepository(Pantry).findOne({ @@ -231,41 +235,50 @@ describe('PantriesService (integration using dummy data)', () => { }); }); - describe('getStatsForPantry', () => { + 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('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'); + }); + it('returns accurate aggregated stats for pantry with orders (Community Food Pantry Downtown)', async () => { - const pantry = await service.findOne(1); - const stats = await service.getStatsForPantry(pantry); + const stats = ( + await service.getPantryStats(['Community Food Pantry Downtown']) + )[0]; - // From the dummy data migration: pantry 1 allocations total - // delivered allocations: 10 + 5 + 25 = 40 - // pending allocations: 75 + 10 = 85 - // totalItems = 125 expect(stats.pantryId).toBe(1); expect(stats.totalItems).toBe(125); - - // totalOz: delivered (10*16 + 5*8.01 + 25*24) = 800.05 - // pending (75*16 + 10*32) = 1520 - // total = 2320.05 expect(stats.totalOz).toBeCloseTo(2320.05, 2); - - // totalLbs is rounded to 2 decimals inside the service expect(stats.totalLbs).toBeCloseTo(145.0, 2); - - // total donated value: delivered (10*4.5 + 5*2 + 25*3) = 130 - // pending (75*6 + 10*4.5) = 495 -> total = 625 expect(stats.totalDonatedFoodValue).toBeCloseTo(625.0, 2); - - // Migration sets a default shipping cost (20.00) for delivered/shipped orders - // Community has one delivered order -> shippingCost = 20 -> totalValue = 625 + 20 expect(stats.totalValue).toBeCloseTo(645.0, 2); - - // Dummy data contains no explicit foodRescue flags -> percentage 0 expect(stats.percentageFoodRescueItems).toBe(0); }); it('returns zeroed stats for a pantry with no orders (Riverside Food Assistance)', async () => { - const pantry = await service.findOne(4); - const stats = await service.getStatsForPantry(pantry); + const stats = ( + await service.getPantryStats(['Riverside Food Assistance']) + )[0]; + expect(stats.pantryId).toBe(4); expect(stats.totalItems).toBe(0); expect(stats.totalOz).toBe(0); @@ -275,6 +288,17 @@ describe('PantriesService (integration using dummy data)', () => { 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.totalDonatedFoodValue).toBe(0); + }); }); describe('getPantryStats', () => { @@ -285,16 +309,16 @@ describe('PantriesService (integration using dummy data)', () => { ]); expect(stats.length).toBe(2); - const community = stats.find((s) => s.pantryId === 1)!; - const westside = stats.find((s) => s.pantryId === 2)!; + const community = stats.find((s) => s.pantryId === 1); + const westside = stats.find((s) => s.pantryId === 2); expect(community).toBeDefined(); - expect(community.totalItems).toBe(125); - expect(community.totalOz).toBeCloseTo(2320.05, 2); + expect(community?.totalItems).toBe(125); + expect(community?.totalOz).toBeCloseTo(2320.05, 2); expect(westside).toBeDefined(); - expect(westside.totalItems).toBe(65); - expect(westside.totalOz).toBeCloseTo(1195.0, 2); + expect(westside?.totalItems).toBe(65); + expect(westside?.totalOz).toBeCloseTo(1195.0, 2); }); it('accepts single pantry name as string', async () => { @@ -316,7 +340,6 @@ describe('PantriesService (integration using dummy data)', () => { }); it('pagination page returns first 10 items', async () => { - // Get over 10 pantries in the system to verify pagination for (let i = 0; i < 10; i++) { await service.addPantry({ contactFirstName: `Bulk${i}`, @@ -350,7 +373,6 @@ describe('PantriesService (integration using dummy data)', () => { }); it('year filter isolates orders by year (move one delivered order to 2025)', async () => { - // Find the delivered order for Community Food Pantry Downtown and set its created_at to 2025 await testDataSource.query(` UPDATE public.orders SET created_at = '2025-01-16 09:00:00' @@ -377,27 +399,16 @@ describe('PantriesService (integration using dummy data)', () => { it('aggregates stats across all pantries and matches migration sums', async () => { const total = await service.getTotalStats(); - // totalItems: 125 + 65 + 30 = 220 expect(total.totalItems).toBe(220); - - // totalOz: 2320.05 + 1195 + 1015 = 4530.05 expect(total.totalOz).toBeCloseTo(4530.05, 2); - - // totalLbs: 145.00 + 74.69 + 63.44 = 283.13 expect(total.totalLbs).toBeCloseTo(283.13, 2); - - // total donated value: 625 + 292.5 + 170 = 1087.5 expect(total.totalDonatedFoodValue).toBeCloseTo(1087.5, 2); - - // shipping costs were applied to delivered/shipped orders expect(total.totalShippingCost).toBeCloseTo(60.0, 2); - expect(total.totalValue).toBeCloseTo(1147.5, 2); }); it('respects year filter and returns zeros for non-matching years', async () => { const totalEmpty = await service.getTotalStats([2030]); - expect(totalEmpty).toBeDefined(); expect(totalEmpty.totalItems).toBe(0); expect(totalEmpty.totalOz).toBe(0); expect(totalEmpty.totalLbs).toBe(0); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 01da26ff2..79c918d2a 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -7,14 +7,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/user.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 } from './types'; -import { Order } from '../orders/order.entity'; -import { OrdersService } from '../orders/order.service'; +import { PantryStats, TotalStats } from './types'; import { userSchemaDto } from '../users/dtos/userSchema.dto'; import { UsersService } from '../users/users.service'; @@ -22,7 +21,7 @@ import { UsersService } from '../users/users.service'; export class PantriesService { constructor( @InjectRepository(Pantry) private repo: Repository, - private ordersService: OrdersService, + @InjectRepository(Order) private orderRepo: Repository, @Inject(forwardRef(() => UsersService)) private usersService: UsersService, @@ -42,124 +41,174 @@ export class PantriesService { return pantry; } - // Helper to get all order stats for a pantry, with optional filtering by year - async getStatsForPantry( - pantry: 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 { - const orders: Order[] = await this.ordersService.getOrdersByPantry( - pantry.pantryId, - years, - ); - const stats: PantryStats = { - pantryId: pantry.pantryId, - totalItems: 0, - totalOz: 0, - totalLbs: 0, - totalDonatedFoodValue: 0, - totalShippingCost: 0, - totalValue: 0, - percentageFoodRescueItems: 0, - }; - let totalFoodRescueItems = 0; - orders.forEach((order) => { - const allocations = order.allocations; - allocations.forEach((allocation) => { - const item = allocation.item; - stats.totalItems += allocation.allocatedQuantity; - stats.totalOz += (item.ozPerItem ?? 0) * allocation.allocatedQuantity; - stats.totalDonatedFoodValue += - (item.estimatedValue ?? 0) * allocation.allocatedQuantity; - if (item.foodRescue) { - totalFoodRescueItems += allocation.allocatedQuantity; - } + ): Promise { + 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( + `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)`, + '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, }); - stats.totalLbs = parseFloat((stats.totalOz / 16).toFixed(2)); - stats.totalShippingCost += order.shippingCost ?? 0; - stats.totalValue = stats.totalDonatedFoodValue + stats.totalShippingCost; + // Replace the shipping cost select with a year-filtered version + qb.addSelect( + `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)`, + 'totalShippingCost', + ); + } + + 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; }); - stats.percentageFoodRescueItems = - stats.totalItems > 0 - ? parseFloat( - ((totalFoodRescueItems / stats.totalItems) * 100).toFixed(2), - ) - : 0; - return stats; } - // Get stats for multiple pantries, with optional filtering by pantry name, year, and pagination async getPantryStats( pantryNames?: string[], years?: number[], page = 1, ): Promise { - // Determines how many pantry stats are returned per page const PAGE_SIZE = 10; - - // Convert pantryNames to array if its just a single string, and handle case where it's undefined const nameArray = pantryNames ? Array.isArray(pantryNames) ? pantryNames : [pantryNames] : undefined; - const nameFilter = - nameArray && nameArray.length > 0 ? { pantryName: In(nameArray) } : {}; - + const pantryFilter = nameArray?.length ? { pantryName: In(nameArray) } : {}; const pantries = await this.repo.find({ - where: nameFilter, + select: ['pantryId', 'pantryName'], + where: pantryFilter, order: { pantryId: 'ASC' }, skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE, }); - // Convert years to array if its just a single number, and handle case where it's undefined + if (pantries.length === 0 && !nameArray?.length) return []; + + // If pantry names were provided but no pantries were found, throw an error + if (nameArray?.length) { + const missingNames = nameArray.filter( + (name) => !pantries.some((p) => p.pantryName === name), + ); + if (missingNames.length > 0) { + throw new NotFoundException( + `Pantries not found: ${missingNames.join(', ')}`, + ); + } + } + + const pantryIds = pantries.map((p) => p.pantryId); const yearsArray = years ? (Array.isArray(years) ? years : [years]).map(Number) : undefined; - const pantryStats: PantryStats[] = []; - for (const pantry of pantries) { - const stats = await this.getStatsForPantry(pantry, yearsArray); - pantryStats.push(stats); - } - return pantryStats; + const stats = await this.aggregateStats(pantryIds, yearsArray); + + // Fill zeros for any pantries that had no orders + const statsMap = new Map(stats.map((s) => [s.pantryId, s])); + return pantryIds.map( + (id) => statsMap.get(id) ?? { pantryId: id, ...this.EMPTY_STATS }, + ); } - // Get total stats across all pantries, with optional filtering by year - async getTotalStats(years?: number[]): Promise { - // Ensure years is an array + async getTotalStats( + years?: number[], + ): Promise> { const yearsArray = years ? (Array.isArray(years) ? years : [years]).map(Number) : undefined; - const pantries = await this.repo.find(); - const totalStats: PantryStats = { - totalItems: 0, - totalOz: 0, - totalLbs: 0, - totalDonatedFoodValue: 0, - totalShippingCost: 0, - totalValue: 0, - percentageFoodRescueItems: 0, - }; + + const stats = await this.aggregateStats(undefined, yearsArray); + + const totalStats = { ...this.EMPTY_STATS }; let totalFoodRescueItems = 0; - for (const pantry of pantries) { - const stats = await this.getStatsForPantry(pantry, yearsArray); - totalStats.totalItems += stats.totalItems; - totalStats.totalOz += stats.totalOz; - totalStats.totalLbs += stats.totalLbs; - totalStats.totalDonatedFoodValue += stats.totalDonatedFoodValue; - totalStats.totalShippingCost += stats.totalShippingCost; - totalStats.totalValue += stats.totalValue; + + 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 += - (stats.percentageFoodRescueItems / 100) * stats.totalItems; + (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; } diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index bee9fbbe5..0fb3f495f 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -41,7 +41,7 @@ export enum ReserveFoodForAllergic { } export type PantryStats = { - pantryId?: number; + pantryId: number; totalItems: number; totalOz: number; totalLbs: number; @@ -50,3 +50,6 @@ export type PantryStats = { totalValue: number; percentageFoodRescueItems: number; }; + +// Make new type that is just a list of PantryStats with pantryId omitted +export type TotalStats = Omit; From 24cdc9f01911fe6b40d4fded2bb99dab47156617 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 6 Mar 2026 02:43:12 -0500 Subject: [PATCH 09/13] Linter fixes --- apps/backend/src/pantries/pantries.service.spec.ts | 4 ++-- apps/backend/src/pantries/pantries.service.ts | 5 +---- .../src/volunteers/volunteers.controller.spec.ts | 4 ++-- .../src/volunteers/volunteers.service.spec.ts | 12 ++++++------ 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index d276f3118..26d5b39e4 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -390,8 +390,8 @@ describe('PantriesService (integration using dummy data)', () => { 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); + expect(community?.totalItems).toBe(40); + expect(community?.totalDonatedFoodValue).toBeCloseTo(130.0, 2); }); }); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 79c918d2a..33dc7a627 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -92,7 +92,6 @@ export class PantriesService { qb.andWhere('EXTRACT(YEAR FROM order.createdAt) IN (:...years)', { years, }); - // Replace the shipping cost select with a year-filtered version qb.addSelect( `COALESCE(( SELECT SUM(o2.shipping_cost) @@ -179,9 +178,7 @@ export class PantriesService { ); } - async getTotalStats( - years?: number[], - ): Promise> { + async getTotalStats(years?: number[]): Promise { const yearsArray = years ? (Array.isArray(years) ? years : [years]).map(Number) : undefined; diff --git a/apps/backend/src/volunteers/volunteers.controller.spec.ts b/apps/backend/src/volunteers/volunteers.controller.spec.ts index 74b4ce964..bef0da50e 100644 --- a/apps/backend/src/volunteers/volunteers.controller.spec.ts +++ b/apps/backend/src/volunteers/volunteers.controller.spec.ts @@ -153,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 90eb4c6a3..594691735 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -193,8 +193,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]); }); @@ -207,8 +207,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]); }); @@ -219,8 +219,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]); }); }); From 4b52324e747e5d4472bb0727de75098a7c6011e7 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Fri, 6 Mar 2026 11:16:48 -0500 Subject: [PATCH 10/13] Added in comments --- .../src/pantries/pantries.service.spec.ts | 42 ++++++++++++++++++- apps/backend/src/pantries/pantries.service.ts | 2 + 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 26d5b39e4..a366a4bb8 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -22,7 +22,9 @@ import { OrdersService } from '../orders/order.service'; import { UsersService } from '../users/users.service'; import { AuthService } from '../auth/auth.service'; -describe('PantriesService (integration using dummy data)', () => { +jest.setTimeout(60000); + +describe('PantriesService', () => { let service: PantriesService; beforeAll(async () => { @@ -265,12 +267,26 @@ describe('PantriesService (integration using dummy data)', () => { await service.getPantryStats(['Community Food Pantry Downtown']) )[0]; + // From the dummy data migration: pantry 1 allocations total + // delivered allocations: 10 + 5 + 25 = 40 + // pending allocations: 75 + 10 = 85 + // totalItems = 125 expect(stats.pantryId).toBe(1); expect(stats.totalItems).toBe(125); + // totalOz: delivered (10*16 + 5*8.01 + 25*24) = 800.05 + // pending (75*16 + 10*32) = 1520 + // total = 2320.05 expect(stats.totalOz).toBeCloseTo(2320.05, 2); + // totalLbs = 2320.05 / 16 = 145.003... -> rounded to 145.0 expect(stats.totalLbs).toBeCloseTo(145.0, 2); + // totalDonatedFoodValue: delivered (10*4.5 + 5*2 + 25*3) = 130 + // pending (75*6 + 10*4.5) = 495 + // total = 625 expect(stats.totalDonatedFoodValue).toBeCloseTo(625.0, 2); + // Migration sets a default shipping cost (20.00) for delivered/shipped orders + // Community has one delivered order -> shippingCost = 20 -> totalValue = 625 + 20 expect(stats.totalValue).toBeCloseTo(645.0, 2); + // Dummy data contains no explicit foodRescue flags -> percentage 0 expect(stats.percentageFoodRescueItems).toBe(0); }); @@ -279,6 +295,7 @@ describe('PantriesService (integration using dummy data)', () => { await service.getPantryStats(['Riverside Food Assistance']) )[0]; + // Pantry 4 has no orders in the migration seed data, so all aggregates are 0 expect(stats.pantryId).toBe(4); expect(stats.totalItems).toBe(0); expect(stats.totalOz).toBe(0); @@ -294,10 +311,15 @@ describe('PantriesService (integration using dummy data)', () => { await service.getPantryStats(['Community Food Pantry Downtown'], [2030]) )[0]; + // No orders exist with created_at in 2030, so all aggregates collapse to 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); }); }); @@ -313,10 +335,14 @@ describe('PantriesService (integration using dummy data)', () => { const westside = stats.find((s) => s.pantryId === 2); expect(community).toBeDefined(); + // pantry 1: see single-pantry test above for full breakdown expect(community?.totalItems).toBe(125); expect(community?.totalOz).toBeCloseTo(2320.05, 2); expect(westside).toBeDefined(); + // pantry 2 (Westside Community Kitchen) allocations: + // order allocations: 30 + 35 = 65 + // totalOz: (30*25 + 35*20) = 750 + 450 = 1195 (using item ozPerItem from migration) expect(westside?.totalItems).toBe(65); expect(westside?.totalOz).toBeCloseTo(1195.0, 2); }); @@ -336,6 +362,7 @@ describe('PantriesService (integration using dummy data)', () => { it('filters by year correctly (no results for future year)', async () => { const yearFiltered = await service.getPantryStats(undefined, [2030]); + // No orders were created in 2030 in the migration seed, so every pantry returns 0 items expect(yearFiltered.every((s) => s.totalItems === 0)).toBe(true); }); @@ -368,6 +395,7 @@ describe('PantriesService (integration using dummy data)', () => { } as PantryApplicationDto); } + // PAGE_SIZE = 10, so page 1 should return exactly 10 pantries const page1 = await service.getPantryStats(undefined, undefined, 1); expect(page1.length).toBe(10); }); @@ -387,6 +415,10 @@ describe('PantriesService (integration using dummy data)', () => { ) `); + // After moving pantry 1's most recent delivered order into 2025, + // a [2025] year filter should return only that order's allocations + // delivered allocations from that order: 10 + 5 + 25 = 40 + // totalDonatedFoodValue: 10*4.5 + 5*2 + 25*3 = 45 + 10 + 75 = 130 const res = await service.getPantryStats(undefined, [2025]); const community = res.find((s) => s.pantryId === 1); expect(community).toBeDefined(); @@ -399,15 +431,23 @@ describe('PantriesService (integration using dummy data)', () => { it('aggregates stats across all pantries and matches migration sums', async () => { const total = await service.getTotalStats(); + // totalItems: pantry 1 (125) + pantry 2 (65) + pantry 3 (30) + pantry 4 (0) = 220 expect(total.totalItems).toBe(220); + // totalOz: pantry 1 (2320.05) + pantry 2 (1195) + pantry 3 (1015) + pantry 4 (0) = 4530.05 expect(total.totalOz).toBeCloseTo(4530.05, 2); + // totalLbs = 4530.05 / 16 = 283.128... -> rounded to 283.13 expect(total.totalLbs).toBeCloseTo(283.13, 2); + // totalDonatedFoodValue: pantry 1 (625) + pantry 2 (325) + pantry 3 (137.5) + pantry 4 (0) = 1087.5 expect(total.totalDonatedFoodValue).toBeCloseTo(1087.5, 2); + // totalShippingCost: migration seeds one delivered/shipped order per active pantry at $20 each + // pantry 1 (20) + pantry 2 (20) + pantry 3 (20) + pantry 4 (0) = 60 expect(total.totalShippingCost).toBeCloseTo(60.0, 2); + // totalValue = totalDonatedFoodValue + totalShippingCost = 1087.5 + 60 = 1147.5 expect(total.totalValue).toBeCloseTo(1147.5, 2); }); it('respects year filter and returns zeros for non-matching years', async () => { + // No orders exist with created_at in 2030, so all totals are 0 const totalEmpty = await service.getTotalStats([2030]); expect(totalEmpty.totalItems).toBe(0); expect(totalEmpty.totalOz).toBe(0); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 33dc7a627..1944b351b 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -55,6 +55,8 @@ export class PantriesService { pantryIds?: number[], years?: number[], ): Promise { + // Make all the total calulcations + // Coalesce to account fo nulls when there are no orders or no items in an order const qb = this.orderRepo .createQueryBuilder('order') .leftJoin('order.request', 'request') From 5e87294134a999d128044c83ede36e56c76bc06c Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 7 Mar 2026 03:10:30 -0500 Subject: [PATCH 11/13] more amy comments yayyy --- apps/backend/src/orders/order.service.spec.ts | 1 - .../src/pantries/pantries.controller.ts | 2 +- .../src/pantries/pantries.service.spec.ts | 17 ++++++++ apps/backend/src/pantries/pantries.service.ts | 40 ++++++++++--------- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index af6d63522..70bb75395 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -300,7 +300,6 @@ describe('OrdersService', () => { const years = orders.map((o) => new Date(o.createdAt).getFullYear()); expect(years).toContain(2025); - expect(years).not.toContain(2026); expect(years.every((y) => y === 2024 || y === 2025)).toBe(true); }); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index a297488cc..2486737b2 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -46,7 +46,7 @@ export class PantriesController { async getPantryStats( @Query('pantryNames') pantryNames?: string[], @Query('years') years?: number[], - @Query('page', ParseIntPipe) page = 1, + @Query('page', new ParseIntPipe({ optional: true })) page = 1, ): Promise { return this.pantriesService.getPantryStats(pantryNames, years, page); } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index a366a4bb8..f80919aaa 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -425,6 +425,23 @@ describe('PantriesService', () => { 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`), + ); + }); + + it('throws an error for a page less than 1', async () => { + await expect( + service.getPantryStats(undefined, undefined, 0), + ).rejects.toThrow(new Error('Page number must be greater than 0')); + }); }); describe('getTotalStats', () => { diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 1944b351b..48f768876 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -57,6 +57,21 @@ export class PantriesService { ): Promise { // Make all the total calulcations // Coalesce to account fo 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') @@ -72,15 +87,7 @@ export class PantriesService { 'COALESCE(SUM(COALESCE(item.estimatedValue, 0) * allocation.allocatedQuantity), 0)', 'totalDonatedFoodValue', ) - .addSelect( - `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)`, - 'totalShippingCost', - ) + .addSelect(ordersSubquery, 'totalShippingCost') .addSelect( `COALESCE(SUM(CASE WHEN item.foodRescue = true THEN allocation.allocatedQuantity ELSE 0 END), 0)`, 'totalFoodRescueItems', @@ -94,16 +101,6 @@ export class PantriesService { qb.andWhere('EXTRACT(YEAR FROM order.createdAt) IN (:...years)', { years, }); - qb.addSelect( - `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)`, - 'totalShippingCost', - ); } const rows = await qb.getRawMany(); @@ -137,12 +134,17 @@ export class PantriesService { page = 1, ): Promise { const PAGE_SIZE = 10; + // Throw an error if page is less than 1 + if (page < 1) { + throw new NotFoundException('Page number must be greater than 0'); + } const nameArray = pantryNames ? Array.isArray(pantryNames) ? pantryNames : [pantryNames] : undefined; + // Verify the nameArray exists and is greater than 0 const pantryFilter = nameArray?.length ? { pantryName: In(nameArray) } : {}; const pantries = await this.repo.find({ select: ['pantryId', 'pantryName'], From cbc7b6439e102b9dab8828bd18ea82e2aae63239 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Sat, 7 Mar 2026 03:16:19 -0500 Subject: [PATCH 12/13] Retrigger CI From 9b89b972f4f0bdbda04cf1e1f9af460cece95236 Mon Sep 17 00:00:00 2001 From: Dalton Burkhart Date: Mon, 9 Mar 2026 04:01:58 -0400 Subject: [PATCH 13/13] Fixed tests --- .../src/pantries/pantries.service.spec.ts | 21 +++++++++++++++++++ .../src/volunteers/volunteers.service.spec.ts | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index c31bde0a4..75cd66929 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -21,6 +21,12 @@ 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'; jest.setTimeout(60000); @@ -40,6 +46,9 @@ describe('PantriesService', () => { OrdersService, RequestsService, UsersService, + DonationItemsService, + DonationService, + FoodManufacturersService, { provide: AuthService, useValue: { @@ -62,6 +71,18 @@ describe('PantriesService', () => { provide: getRepositoryToken(FoodRequest), useValue: testDataSource.getRepository(FoodRequest), }, + { + provide: getRepositoryToken(DonationItem), + useValue: testDataSource.getRepository(DonationItem), + }, + { + provide: getRepositoryToken(Donation), + useValue: testDataSource.getRepository(Donation), + }, + { + provide: getRepositoryToken(FoodManufacturer), + useValue: testDataSource.getRepository(FoodManufacturer), + }, ], }).compile(); diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 594691735..f517b4c5d 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -12,6 +12,12 @@ 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); @@ -31,6 +37,9 @@ describe('VolunteersService', () => { PantriesService, OrdersService, RequestsService, + FoodManufacturersService, + DonationItemsService, + DonationService, { provide: AuthService, useValue: { @@ -53,6 +62,18 @@ describe('VolunteersService', () => { 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();