From ab47c44c6a143ae27ac08209a7a753b8f55b94ad Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:54:52 -0400 Subject: [PATCH 1/4] add fm dashboard endpoint --- .../dtos/donation-details-dto.ts | 5 ++ .../manufacturers.controller.spec.ts | 42 +++++++++- .../manufacturers.controller.ts | 17 +++- .../manufacturers.service.spec.ts | 82 +++++++++++++++++++ .../manufacturers.service.ts | 45 ++++++++++ 5 files changed, 189 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts index c1f220e87..54546569c 100644 --- a/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts +++ b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts @@ -19,3 +19,8 @@ export class DonationDetailsDto { associatedPendingOrders!: DonationOrderDetailsDto[]; relevantDonationItems!: DonationItemWithAllocatedQuantityDto[]; } + +export class DonationReminderDto { + donation!: Donation; + reminderDate!: Date; +} diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index 204d32bd3..0bb5555d9 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -10,7 +10,10 @@ import { Donation } from '../donations/donations.entity'; import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto'; import { NotFoundException } from '@nestjs/common'; import { AuthenticatedRequest } from '../auth/authenticated-request'; -import { DonationDetailsDto } from './dtos/donation-details-dto'; +import { + DonationDetailsDto, + DonationReminderDto, +} from './dtos/donation-details-dto'; import { FoodType } from '../donationItems/types'; const mockManufacturersService = mock(); @@ -145,6 +148,43 @@ describe('FoodManufacturersController', () => { }); }); + describe('GET /:foodManufacturerId/next-two-donations', () => { + it('should return the next two upcoming donation reminders for a given food manufacturer', async () => { + const mockDonationReminders: DonationReminderDto[] = [ + { + donation: { + donationId: 1, + foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer, + } as Donation, + reminderDate: new Date('2024-07-01'), + }, + { + donation: { + donationId: 2, + foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer, + } as Donation, + reminderDate: new Date('2024-07-15'), + }, + ]; + + const req = { user: { id: 1 } }; + + mockManufacturersService.getUpcomingDonationReminders.mockResolvedValue( + mockDonationReminders, + ); + + const result = await controller.getNextTwoDonations( + req as AuthenticatedRequest, + 1, + ); + + expect(result).toBe(mockDonationReminders); + expect( + mockManufacturersService.getUpcomingDonationReminders, + ).toHaveBeenCalledWith(1, 1); + }); + }); + describe('POST /application', () => { it('should submit a food manufacturer application', async () => { const mockApplicationData: FoodManufacturerApplicationDto = { diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index 198fcf412..13bcd7997 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -19,7 +19,10 @@ import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer import { Roles } from '../auth/roles.decorator'; import { Role } from '../users/types'; import { AuthenticatedRequest } from '../auth/authenticated-request'; -import { DonationDetailsDto } from './dtos/donation-details-dto'; +import { + DonationDetailsDto, + DonationReminderDto, +} from './dtos/donation-details-dto'; @Controller('manufacturers') export class FoodManufacturersController { @@ -49,6 +52,18 @@ export class FoodManufacturersController { ); } + @Roles(Role.FOODMANUFACTURER) + @Get('/:foodManufacturerId/next-two-donations') + async getNextTwoDonations( + @Req() req: AuthenticatedRequest, + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + ): Promise { + return this.foodManufacturersService.getUpcomingDonationReminders( + foodManufacturerId, + req.user.id, + ); + } + @ApiBody({ description: 'Details for submitting a manufacturer application', schema: { diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index b972887ea..5124b1159 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -28,6 +28,7 @@ import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types'; import { DataSource } from 'typeorm'; import { FoodType } from '../donationItems/types'; import { Allocation } from '../allocations/allocations.entity'; +import { RecurrenceEnum } from '../donations/types'; jest.setTimeout(60000); @@ -558,4 +559,85 @@ describe('FoodManufacturersService', () => { ); }); }); + + describe('getUpcomingDonationReminders', () => { + it('returns upcoming donation reminders for food manufacturer', async () => { + const foodManufacturerId = 1; + const futureDate1 = new Date(); + futureDate1.setDate(futureDate1.getDate() + 7); + const futureDate2 = new Date(); + futureDate2.setDate(futureDate2.getDate() + 14); + + // FM 1 has donations 1 and 4 + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'monthly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 1`, + [futureDate1.toISOString()], + ); + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'monthly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 4`, + [futureDate2.toISOString()], + ); + + const result = await service.getUpcomingDonationReminders( + foodManufacturerId, + 3, + ); + + expect(result).toHaveLength(2); + expect(result[0].donation.donationId).toBe(1); + expect(result[0].reminderDate).toStrictEqual(futureDate1); + expect(result[1].donation.donationId).toBe(4); + expect(result[1].reminderDate).toStrictEqual(futureDate2); + }); + + it('returns empty array if no upcoming donation reminders exist', async () => { + const result = await service.getUpcomingDonationReminders(2, 4); + + expect(result).toEqual([]); + }); + + it('returns next two upcoming donation reminders from same donation', async () => { + const futureDate1 = new Date(); + futureDate1.setDate(futureDate1.getDate() + 30); + const futureDate2 = new Date(); + futureDate2.setDate(futureDate2.getDate() + 60); + + await testDataSource.query( + `INSERT INTO public.donations (food_manufacturer_id, recurrence, recurrence_freq, occurrences_remaining, next_donation_dates) + VALUES (1, $1, 1, 5, ARRAY[$2::timestamptz, $3::timestamptz])`, + [ + RecurrenceEnum.MONTHLY, + futureDate1.toISOString(), + futureDate2.toISOString(), + ], + ); + + const result = await service.getUpcomingDonationReminders(1, 3); + + expect(result).toHaveLength(2); + expect(result[0].donation.donationId).toBeDefined(); + expect(result[0].reminderDate).toStrictEqual(futureDate1); + expect(result[1].donation.donationId).toBeDefined(); + expect(result[1].reminderDate).toStrictEqual(futureDate2); + expect(result[0].donation.donationId).toBe(result[1].donation.donationId); + }); + + it('throws NotFoundException for non-existent manufacturer', async () => { + await expect( + service.getUpcomingDonationReminders(9999, 3), + ).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + + it('throws BadRequestException when user is not the representative of the food manufacturer', async () => { + await expect(service.getUpcomingDonationReminders(1, 4)).rejects.toThrow( + new BadRequestException( + 'User 4 is not allowed to access donations for Food Manufacturer 1', + ), + ); + }); + }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index ed93e6866..8125d6238 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -23,6 +23,7 @@ import { DonationDetailsDto, DonationItemWithAllocatedQuantityDto, DonationOrderDetailsDto, + DonationReminderDto, } from './dtos/donation-details-dto'; import { OrderStatus } from '../orders/types'; import { DonationStatus } from '../donations/types'; @@ -139,6 +140,50 @@ export class FoodManufacturersService { }); } + async getUpcomingDonationReminders( + foodManufacturerId: number, + currentUserId: number, + ): Promise { + validateId(foodManufacturerId, 'Food Manufacturer'); + validateId(currentUserId, 'User'); + + const manufacturer = await this.repo.findOne({ + where: { foodManufacturerId }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + + if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { + throw new BadRequestException( + `User ${currentUserId} is not allowed to access donations for Food Manufacturer ${foodManufacturerId}`, + ); + } + + const donations = await this.donationsRepo.find({ + where: { foodManufacturer: { foodManufacturerId } }, + relations: ['donationItems'], + }); + + const today = new Date(); + const donationReminders: DonationReminderDto[] = donations.flatMap( + (donation) => + (donation.nextDonationDates ?? []) + .filter((date) => date >= today) + .map((date) => ({ donation, reminderDate: date })), + ); + + donationReminders.sort( + (a, b) => a.reminderDate.getTime() - b.reminderDate.getTime(), + ); + + return donationReminders.slice(0, 2); + } + async getPendingManufacturers(): Promise { return await this.repo.find({ where: { status: ApplicationStatus.PENDING }, From d058bf50373ecab14ab77dfecf32fc3f392c1f91 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:36:34 -0400 Subject: [PATCH 2/4] clean up --- .../manufacturers.controller.spec.ts | 6 ++-- .../manufacturers.controller.ts | 4 +-- .../manufacturers.service.spec.ts | 34 ++++++++++++++++--- .../manufacturers.service.ts | 10 +++--- .../src/pantries/pantries.service.spec.ts | 5 +-- apps/backend/src/pantries/pantries.service.ts | 9 ++--- 6 files changed, 47 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index 0bb5555d9..374b94c5a 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -148,7 +148,7 @@ describe('FoodManufacturersController', () => { }); }); - describe('GET /:foodManufacturerId/next-two-donations', () => { + describe('GET /:foodManufacturerId/next-two-reminders', () => { it('should return the next two upcoming donation reminders for a given food manufacturer', async () => { const mockDonationReminders: DonationReminderDto[] = [ { @@ -173,12 +173,12 @@ describe('FoodManufacturersController', () => { mockDonationReminders, ); - const result = await controller.getNextTwoDonations( + const result = await controller.getNextTwoDonationReminders( req as AuthenticatedRequest, 1, ); - expect(result).toBe(mockDonationReminders); + expect(result).toEqual(mockDonationReminders); expect( mockManufacturersService.getUpcomingDonationReminders, ).toHaveBeenCalledWith(1, 1); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index 13bcd7997..7699e6f83 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -53,8 +53,8 @@ export class FoodManufacturersController { } @Roles(Role.FOODMANUFACTURER) - @Get('/:foodManufacturerId/next-two-donations') - async getNextTwoDonations( + @Get('/:foodManufacturerId/next-two-reminders') + async getNextTwoDonationReminders( @Req() req: AuthenticatedRequest, @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, ): Promise { diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 5124b1159..aef178963 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -3,8 +3,8 @@ import { FoodManufacturersService } from './manufacturers.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturers.entity'; import { - BadRequestException, ConflictException, + ForbiddenException, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; @@ -389,9 +389,9 @@ describe('FoodManufacturersService', () => { ); }); - it('throws BadRequestException when user is not the representative of the food manufacturer', async () => { + it('throws ForbiddenException when user is not the representative of the food manufacturer', async () => { await expect(service.getFMDonations(fmId1, fmRepId2)).rejects.toThrow( - new BadRequestException( + new ForbiddenException( `User ${fmRepId2} is not allowed to access donations for Food Manufacturer ${fmId1}`, ), ); @@ -624,6 +624,30 @@ describe('FoodManufacturersService', () => { expect(result[0].donation.donationId).toBe(result[1].donation.donationId); }); + it('only returns the next two reminders when more exist', async () => { + const futureDate1 = new Date(); + futureDate1.setDate(futureDate1.getDate() + 30); + const futureDate2 = new Date(); + futureDate2.setDate(futureDate2.getDate() + 60); + const futureDate3 = new Date(); + futureDate3.setDate(futureDate3.getDate() + 90); + + await testDataSource.query( + `INSERT INTO public.donations (food_manufacturer_id, recurrence, recurrence_freq, occurrences_remaining, next_donation_dates) + VALUES (1, $1, 1, 5, ARRAY[$2::timestamptz, $3::timestamptz, $4::timestamptz])`, + [ + RecurrenceEnum.MONTHLY, + futureDate1.toISOString(), + futureDate2.toISOString(), + futureDate3.toISOString(), + ], + ); + + const result = await service.getUpcomingDonationReminders(1, 3); + + expect(result).toHaveLength(2); + }); + it('throws NotFoundException for non-existent manufacturer', async () => { await expect( service.getUpcomingDonationReminders(9999, 3), @@ -632,9 +656,9 @@ describe('FoodManufacturersService', () => { ); }); - it('throws BadRequestException when user is not the representative of the food manufacturer', async () => { + it('throws ForbiddenException when user is not the representative of the food manufacturer', async () => { await expect(service.getUpcomingDonationReminders(1, 4)).rejects.toThrow( - new BadRequestException( + new ForbiddenException( 'User 4 is not allowed to access donations for Food Manufacturer 1', ), ); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 8125d6238..292de464a 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -1,9 +1,9 @@ import { - BadRequestException, Injectable, NotFoundException, ConflictException, InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturers.entity'; @@ -77,7 +77,7 @@ export class FoodManufacturersService { } if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { - throw new BadRequestException( + throw new ForbiddenException( `User ${currentUserId} is not allowed to access donations for Food Manufacturer ${foodManufacturerId}`, ); } @@ -159,17 +159,17 @@ export class FoodManufacturersService { } if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { - throw new BadRequestException( + throw new ForbiddenException( `User ${currentUserId} is not allowed to access donations for Food Manufacturer ${foodManufacturerId}`, ); } const donations = await this.donationsRepo.find({ where: { foodManufacturer: { foodManufacturerId } }, - relations: ['donationItems'], }); const today = new Date(); + today.setHours(0, 0, 0, 0); const donationReminders: DonationReminderDto[] = donations.flatMap( (donation) => (donation.nextDonationDates ?? []) @@ -293,7 +293,7 @@ export class FoodManufacturersService { } if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { - throw new BadRequestException( + throw new ForbiddenException( `User ${currentUserId} is not allowed to edit application for Food Manufacturer ${manufacturerId}`, ); } diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 47f0138ff..db8a86f10 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -5,6 +5,7 @@ import { Pantry } from './pantries.entity'; import { BadRequestException, ConflictException, + ForbiddenException, InternalServerErrorException, NotFoundException, } from '@nestjs/common'; @@ -460,7 +461,7 @@ describe('PantriesService', () => { ); }); - it('throws BadRequestException when user is not authorized to update pantry', async () => { + it('throws ForbiddenException when user is not authorized to update pantry', async () => { const dto: UpdatePantryApplicationDto = { itemsInStock: 'Rice and beans', }; @@ -470,7 +471,7 @@ describe('PantriesService', () => { await expect( service.updatePantryApplication(1, dto, invalidUserId), ).rejects.toThrow( - new BadRequestException( + new ForbiddenException( `User ${invalidUserId} is not allowed to edit application for Pantry 1`, ), ); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index b73041508..209571a47 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -6,6 +6,7 @@ import { NotFoundException, ConflictException, InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -332,7 +333,7 @@ export class PantriesService { pantryMessage.subject, pantryMessage.bodyHTML, ); - } catch (error) { + } catch { throw new InternalServerErrorException( 'Failed to send pantry application submitted confirmation email to representative', ); @@ -345,7 +346,7 @@ export class PantriesService { adminMessage.subject, adminMessage.bodyHTML, ); - } catch (error) { + } catch { throw new InternalServerErrorException( 'Failed to send new pantry application notification email to SSF', ); @@ -370,7 +371,7 @@ export class PantriesService { } if (pantry.pantryUser.id !== currentUserId) { - throw new BadRequestException( + throw new ForbiddenException( `User ${currentUserId} is not allowed to edit application for Pantry ${pantryId}`, ); } @@ -419,7 +420,7 @@ export class PantriesService { message.subject, message.bodyHTML, ); - } catch (error) { + } catch { throw new InternalServerErrorException( 'Failed to send pantry account approved notification email to representative', ); From 9f3784827afed19b271f5d6cecb98e1df1732b5e Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:31:29 -0400 Subject: [PATCH 3/4] add next weekly reminder logic --- .../manufacturers.controller.spec.ts | 9 +--- .../manufacturers.controller.ts | 13 ++++- .../manufacturers.service.spec.ts | 52 +++++++++++++------ .../manufacturers.service.ts | 38 +++++++++----- 4 files changed, 75 insertions(+), 37 deletions(-) diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts index 374b94c5a..e1d726463 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.spec.ts @@ -167,21 +167,16 @@ describe('FoodManufacturersController', () => { }, ]; - const req = { user: { id: 1 } }; - mockManufacturersService.getUpcomingDonationReminders.mockResolvedValue( mockDonationReminders, ); - const result = await controller.getNextTwoDonationReminders( - req as AuthenticatedRequest, - 1, - ); + const result = await controller.getNextTwoDonationReminders(1); expect(result).toEqual(mockDonationReminders); expect( mockManufacturersService.getUpcomingDonationReminders, - ).toHaveBeenCalledWith(1, 1); + ).toHaveBeenCalledWith(1); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.controller.ts b/apps/backend/src/foodManufacturers/manufacturers.controller.ts index 7699e6f83..a5312c604 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -23,6 +23,7 @@ import { DonationDetailsDto, DonationReminderDto, } from './dtos/donation-details-dto'; +import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; @Controller('manufacturers') export class FoodManufacturersController { @@ -52,15 +53,23 @@ export class FoodManufacturersController { ); } + @CheckOwnership({ + idParam: 'foodManufacturerId', + resolver: async ({ entityId, services }) => + pipeNullable( + () => services.get(FoodManufacturersService).findOne(entityId), + (manufacturer: FoodManufacturer) => [ + manufacturer.foodManufacturerRepresentative.id, + ], + ), + }) @Roles(Role.FOODMANUFACTURER) @Get('/:foodManufacturerId/next-two-reminders') async getNextTwoDonationReminders( - @Req() req: AuthenticatedRequest, @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, ): Promise { return this.foodManufacturersService.getUpcomingDonationReminders( foodManufacturerId, - req.user.id, ); } diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index aef178963..92ad60e31 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -582,7 +582,6 @@ describe('FoodManufacturersService', () => { const result = await service.getUpcomingDonationReminders( foodManufacturerId, - 3, ); expect(result).toHaveLength(2); @@ -593,7 +592,7 @@ describe('FoodManufacturersService', () => { }); it('returns empty array if no upcoming donation reminders exist', async () => { - const result = await service.getUpcomingDonationReminders(2, 4); + const result = await service.getUpcomingDonationReminders(2); expect(result).toEqual([]); }); @@ -614,7 +613,7 @@ describe('FoodManufacturersService', () => { ], ); - const result = await service.getUpcomingDonationReminders(1, 3); + const result = await service.getUpcomingDonationReminders(1); expect(result).toHaveLength(2); expect(result[0].donation.donationId).toBeDefined(); @@ -624,6 +623,39 @@ describe('FoodManufacturersService', () => { expect(result[0].donation.donationId).toBe(result[1].donation.donationId); }); + it('generates next weekly occurrence when a later donation would otherwise take its slot', async () => { + const foodManufacturerId = 1; + const weeklyDate = new Date(); + weeklyDate.setDate(weeklyDate.getDate() + 3); + const monthlyDate = new Date(); + monthlyDate.setDate(monthlyDate.getDate() + 30); + + // FM 1 has donations 1 and 4 + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'weekly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 1`, + [weeklyDate.toISOString()], + ); + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'monthly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 4`, + [monthlyDate.toISOString()], + ); + + const result = await service.getUpcomingDonationReminders( + foodManufacturerId, + ); + + const expectedSecondWeekly = new Date(weeklyDate); + expectedSecondWeekly.setDate(expectedSecondWeekly.getDate() + 7); + + expect(result).toHaveLength(2); + expect(result[0].donation.donationId).toBe(1); + expect(result[0].reminderDate).toStrictEqual(weeklyDate); + expect(result[1].donation.donationId).toBe(1); + expect(result[1].reminderDate).toStrictEqual(expectedSecondWeekly); + }); + it('only returns the next two reminders when more exist', async () => { const futureDate1 = new Date(); futureDate1.setDate(futureDate1.getDate() + 30); @@ -643,25 +675,15 @@ describe('FoodManufacturersService', () => { ], ); - const result = await service.getUpcomingDonationReminders(1, 3); + const result = await service.getUpcomingDonationReminders(1); expect(result).toHaveLength(2); }); it('throws NotFoundException for non-existent manufacturer', async () => { - await expect( - service.getUpcomingDonationReminders(9999, 3), - ).rejects.toThrow( + await expect(service.getUpcomingDonationReminders(9999)).rejects.toThrow( new NotFoundException('Food Manufacturer 9999 not found'), ); }); - - it('throws ForbiddenException when user is not the representative of the food manufacturer', async () => { - await expect(service.getUpcomingDonationReminders(1, 4)).rejects.toThrow( - new ForbiddenException( - 'User 4 is not allowed to access donations for Food Manufacturer 1', - ), - ); - }); }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 292de464a..bf16737aa 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -26,7 +26,7 @@ import { DonationReminderDto, } from './dtos/donation-details-dto'; import { OrderStatus } from '../orders/types'; -import { DonationStatus } from '../donations/types'; +import { DonationStatus, RecurrenceEnum } from '../donations/types'; import { ManufacturerStatsDto } from './dtos/manufacturer-stats.dto'; @Injectable() @@ -142,10 +142,8 @@ export class FoodManufacturersService { async getUpcomingDonationReminders( foodManufacturerId: number, - currentUserId: number, ): Promise { validateId(foodManufacturerId, 'Food Manufacturer'); - validateId(currentUserId, 'User'); const manufacturer = await this.repo.findOne({ where: { foodManufacturerId }, @@ -158,12 +156,6 @@ export class FoodManufacturersService { ); } - if (manufacturer.foodManufacturerRepresentative.id !== currentUserId) { - throw new ForbiddenException( - `User ${currentUserId} is not allowed to access donations for Food Manufacturer ${foodManufacturerId}`, - ); - } - const donations = await this.donationsRepo.find({ where: { foodManufacturer: { foodManufacturerId } }, }); @@ -171,10 +163,30 @@ export class FoodManufacturersService { const today = new Date(); today.setHours(0, 0, 0, 0); const donationReminders: DonationReminderDto[] = donations.flatMap( - (donation) => - (donation.nextDonationDates ?? []) - .filter((date) => date >= today) - .map((date) => ({ donation, reminderDate: date })), + (donation) => { + const allDates = donation.nextDonationDates ?? []; + const dates = allDates.filter((date) => date >= today); + const reminders: DonationReminderDto[] = dates.map((date) => ({ + donation, + reminderDate: date, + })); + + if ( + donation.recurrence === RecurrenceEnum.WEEKLY && + donation.recurrenceFreq && + allDates.length > 0 + ) { + for (const date of allDates) { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + 7 * donation.recurrenceFreq); + if (nextDate >= today) { + reminders.push({ donation, reminderDate: nextDate }); + } + } + } + + return reminders; + }, ); donationReminders.sort( From 6858606cd2dc01878277b25bea99624e9f299575 Mon Sep 17 00:00:00 2001 From: amywng <147568742+amywng@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:46:03 -0400 Subject: [PATCH 4/4] handle non-weekly cases and add tests --- .../src/donations/donations.service.ts | 45 +---- .../src/donations/recurrence.utils.spec.ts | 154 ++++++++++++++++++ .../backend/src/donations/recurrence.utils.ts | 39 +++++ .../manufacturers.service.spec.ts | 66 ++++++++ .../manufacturers.service.ts | 10 +- 5 files changed, 269 insertions(+), 45 deletions(-) create mode 100644 apps/backend/src/donations/recurrence.utils.spec.ts create mode 100644 apps/backend/src/donations/recurrence.utils.ts diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 10fc9b0eb..937d67545 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -9,6 +9,7 @@ import { DataSource, In, Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { validateId } from '../utils/validation.utils'; import { DayOfWeek, DonationStatus, RecurrenceEnum } from './types'; +import { calculateNextDonationDate } from './recurrence.utils'; import { CreateDonationDto, RepeatOnDaysDto } from './dtos/create-donation.dto'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto'; @@ -168,7 +169,7 @@ export class DonationService { occurrencesUpdated = true; if (occurrences > 0) { - let nextDate = this.calculateNextDate( + let nextDate = calculateNextDonationDate( currentDate, donation.recurrence, donation.recurrenceFreq, @@ -185,7 +186,7 @@ export class DonationService { occurrences -= 1; if (occurrences > 0) { - nextDate = this.calculateNextDate( + nextDate = calculateNextDonationDate( nextDate, donation.recurrence, donation.recurrenceFreq, @@ -215,46 +216,6 @@ export class DonationService { } } - /** - * Calculates next single donation date from a given currentDate during recurring donation processing - * - * used by handleRecurringDonations to determine the replacement date when an occurrence is processed - * unlike generateNextDonationDates, this always returns exactly one date and doesn't consider - * multiple selected days for weekly recurrence - * - * for MONTHLY/YEARLY recurrence, dates > 28 are clamped to 28 before adding the interval to - * prevent date rollover - * - * @param currentDate - date to calculate from (typically an expired donation date) - * @param recurrence - recurrence type (WEEKLY, MONTHLY, YEARLY, or NONE) - * @param recurrenceFreq - how many weeks/months/years to add (defaults to 1) - * @returns a new Date representing the next occurrence - */ - private calculateNextDate( - currentDate: Date, - recurrence: RecurrenceEnum, - recurrenceFreq: number | null = 1, - ): Date { - const freq = recurrenceFreq ?? 1; - const nextDate = new Date(currentDate); - switch (recurrence) { - case RecurrenceEnum.WEEKLY: - nextDate.setDate(nextDate.getDate() + 7 * freq); - break; - case RecurrenceEnum.MONTHLY: - if (nextDate.getDate() > 28) nextDate.setDate(28); - nextDate.setMonth(nextDate.getMonth() + freq); - break; - case RecurrenceEnum.YEARLY: - if (nextDate.getDate() > 28) nextDate.setDate(28); - nextDate.setFullYear(nextDate.getFullYear() + freq); - break; - default: - break; - } - return nextDate; - } - /** * Generates the initial set of next donation dates when creating a new recurring donation. * diff --git a/apps/backend/src/donations/recurrence.utils.spec.ts b/apps/backend/src/donations/recurrence.utils.spec.ts new file mode 100644 index 000000000..c4e944552 --- /dev/null +++ b/apps/backend/src/donations/recurrence.utils.spec.ts @@ -0,0 +1,154 @@ +import { calculateNextDonationDate } from './recurrence.utils'; +import { RecurrenceEnum } from './types'; + +describe('calculateNextDonationDate', () => { + describe('WEEKLY', () => { + it('advances by 7 days when freq is 1', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 1), + RecurrenceEnum.WEEKLY, + 1, + ); + expect(result).toEqual(new Date(2025, 0, 8)); + }); + + it('advances by 14 days when freq is 2', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 1), + RecurrenceEnum.WEEKLY, + 2, + ); + expect(result).toEqual(new Date(2025, 0, 15)); + }); + + it('advances by 21 days when freq is 3', () => { + const result = calculateNextDonationDate( + new Date(2025, 2, 10), + RecurrenceEnum.WEEKLY, + 3, + ); + expect(result).toEqual(new Date(2025, 2, 31)); + }); + }); + + describe('MONTHLY', () => { + it('advances by 1 month when freq is 1', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 15), + RecurrenceEnum.MONTHLY, + 1, + ); + expect(result).toEqual(new Date(2025, 1, 15)); + }); + + it('advances by 3 months when freq is 3', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 15), + RecurrenceEnum.MONTHLY, + 3, + ); + expect(result).toEqual(new Date(2025, 3, 15)); + }); + + it('crosses year boundary correctly', () => { + const result = calculateNextDonationDate( + new Date(2025, 10, 15), + RecurrenceEnum.MONTHLY, + 3, + ); + expect(result).toEqual(new Date(2026, 1, 15)); + }); + + it('clamps day to 28 before adding months when date is after the 28th', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 31), + RecurrenceEnum.MONTHLY, + 1, + ); + expect(result).toEqual(new Date(2025, 1, 28)); + }); + + it('does not clamp day when date is on the 28th', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 28), + RecurrenceEnum.MONTHLY, + 1, + ); + expect(result).toEqual(new Date(2025, 1, 28)); + }); + }); + + describe('YEARLY', () => { + it('advances by 1 year when freq is 1', () => { + const result = calculateNextDonationDate( + new Date(2025, 5, 15), + RecurrenceEnum.YEARLY, + 1, + ); + expect(result).toEqual(new Date(2026, 5, 15)); + }); + + it('advances by 3 years when freq is 3', () => { + const result = calculateNextDonationDate( + new Date(2025, 5, 15), + RecurrenceEnum.YEARLY, + 3, + ); + expect(result).toEqual(new Date(2028, 5, 15)); + }); + + it('clamps day to 28 before adding years when date is after the 28th', () => { + // Feb 29 doesn't exist in 2025, but JS parses it as Mar 1 — clamping still applies + const r = calculateNextDonationDate( + new Date(2024, 1, 29), + RecurrenceEnum.YEARLY, + 1, + ); + expect(r).toEqual(new Date(2025, 1, 28)); + }); + }); + + describe('null / default recurrenceFreq', () => { + it('defaults freq to 1 when null is passed for WEEKLY', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 1), + RecurrenceEnum.WEEKLY, + null, + ); + expect(result).toEqual(new Date(2025, 0, 8)); + }); + + it('defaults freq to 1 when null is passed for MONTHLY', () => { + const result = calculateNextDonationDate( + new Date(2025, 0, 15), + RecurrenceEnum.MONTHLY, + null, + ); + expect(result).toEqual(new Date(2025, 1, 15)); + }); + + it('defaults freq to 1 when null is passed for YEARLY', () => { + const result = calculateNextDonationDate( + new Date(2025, 5, 15), + RecurrenceEnum.YEARLY, + null, + ); + expect(result).toEqual(new Date(2026, 5, 15)); + }); + }); + + describe('NONE', () => { + it('returns the same date unchanged', () => { + const input = new Date(2025, 0, 15); + const result = calculateNextDonationDate(input, RecurrenceEnum.NONE, 1); + expect(result).toEqual(input); + }); + }); + + it('does not mutate the input date', () => { + const input = new Date(2025, 0, 1); + const original = input.getTime(); + calculateNextDonationDate(input, RecurrenceEnum.WEEKLY, 1); + expect(input.getTime()).toBe(original); + }); +}); diff --git a/apps/backend/src/donations/recurrence.utils.ts b/apps/backend/src/donations/recurrence.utils.ts new file mode 100644 index 000000000..0be53ad0a --- /dev/null +++ b/apps/backend/src/donations/recurrence.utils.ts @@ -0,0 +1,39 @@ +import { RecurrenceEnum } from './types'; + +/** + * Calculates next single donation date from a given currentDate during recurring donation processing + * + * used by handleRecurringDonations to determine the replacement date when an occurrence is processed + * unlike generateNextDonationDates, this always returns exactly one date and doesn't consider + * multiple selected days for weekly recurrence + * + * for MONTHLY/YEARLY recurrence, dates > 28 are clamped to 28 before adding the interval to + * prevent date rollover + * + * @param currentDate - date to calculate from (typically an expired donation date) + * @param recurrence - recurrence type (WEEKLY, MONTHLY, YEARLY, or NONE) + * @param recurrenceFreq - how many weeks/months/years to add (defaults to 1) + * @returns a new Date representing the next occurrence + */ +export function calculateNextDonationDate( + currentDate: Date, + recurrence: RecurrenceEnum, + recurrenceFreq: number | null = 1, +): Date { + const freq = recurrenceFreq ?? 1; + const nextDate = new Date(currentDate); + switch (recurrence) { + case RecurrenceEnum.WEEKLY: + nextDate.setDate(nextDate.getDate() + 7 * freq); + break; + case RecurrenceEnum.MONTHLY: + if (nextDate.getDate() > 28) nextDate.setDate(28); + nextDate.setMonth(nextDate.getMonth() + freq); + break; + case RecurrenceEnum.YEARLY: + if (nextDate.getDate() > 28) nextDate.setDate(28); + nextDate.setFullYear(nextDate.getFullYear() + freq); + break; + } + return nextDate; +} diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts index 92ad60e31..21b6e6b24 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.spec.ts @@ -623,6 +623,72 @@ describe('FoodManufacturersService', () => { expect(result[0].donation.donationId).toBe(result[1].donation.donationId); }); + it('monthly donation recurs twice before yearly donation', async () => { + const foodManufacturerId = 1; + const monthlyDate = new Date(); + monthlyDate.setDate(monthlyDate.getDate() + 60); + const yearlyDate = new Date(); + yearlyDate.setFullYear(yearlyDate.getFullYear() + 1); + + // FM 1 has donations 1 and 4 + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'monthly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 1`, + [monthlyDate.toISOString()], + ); + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'yearly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 4`, + [yearlyDate.toISOString()], + ); + + const result = await service.getUpcomingDonationReminders( + foodManufacturerId, + ); + + const expectedSecondMonthly = new Date(monthlyDate); + expectedSecondMonthly.setMonth(expectedSecondMonthly.getMonth() + 1); + + expect(result).toHaveLength(2); + expect(result[0].donation.donationId).toBe(1); + expect(result[0].reminderDate).toStrictEqual(monthlyDate); + expect(result[1].donation.donationId).toBe(1); + expect(result[1].reminderDate).toStrictEqual(expectedSecondMonthly); + }); + + it('yearly donation recurs twice before every-3-years donation', async () => { + const foodManufacturerId = 1; + const yearlyDate = new Date(); + yearlyDate.setDate(yearlyDate.getDate() + 30); + const threeYearlyDate = new Date(); + threeYearlyDate.setFullYear(threeYearlyDate.getFullYear() + 3); + + // FM 1 has donations 1 and 4 + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'yearly', recurrence_freq = 1, occurrences_remaining = 5 + WHERE donation_id = 1`, + [yearlyDate.toISOString()], + ); + await testDataSource.query( + `UPDATE public.donations SET next_donation_dates = ARRAY[$1::timestamptz], recurrence = 'yearly', recurrence_freq = 3, occurrences_remaining = 5 + WHERE donation_id = 4`, + [threeYearlyDate.toISOString()], + ); + + const result = await service.getUpcomingDonationReminders( + foodManufacturerId, + ); + + const expectedSecondYearly = new Date(yearlyDate); + expectedSecondYearly.setFullYear(expectedSecondYearly.getFullYear() + 1); + + expect(result).toHaveLength(2); + expect(result[0].donation.donationId).toBe(1); + expect(result[0].reminderDate).toStrictEqual(yearlyDate); + expect(result[1].donation.donationId).toBe(1); + expect(result[1].reminderDate).toStrictEqual(expectedSecondYearly); + }); + it('generates next weekly occurrence when a later donation would otherwise take its slot', async () => { const foodManufacturerId = 1; const weeklyDate = new Date(); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index bf16737aa..2240526ae 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -27,6 +27,7 @@ import { } from './dtos/donation-details-dto'; import { OrderStatus } from '../orders/types'; import { DonationStatus, RecurrenceEnum } from '../donations/types'; +import { calculateNextDonationDate } from '../donations/recurrence.utils'; import { ManufacturerStatsDto } from './dtos/manufacturer-stats.dto'; @Injectable() @@ -172,13 +173,16 @@ export class FoodManufacturersService { })); if ( - donation.recurrence === RecurrenceEnum.WEEKLY && + donation.recurrence !== RecurrenceEnum.NONE && donation.recurrenceFreq && allDates.length > 0 ) { for (const date of allDates) { - const nextDate = new Date(date); - nextDate.setDate(nextDate.getDate() + 7 * donation.recurrenceFreq); + const nextDate = calculateNextDonationDate( + date, + donation.recurrence, + donation.recurrenceFreq, + ); if (nextDate >= today) { reminders.push({ donation, reminderDate: nextDate }); }