diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 10fc9b0e..937d6754 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 00000000..c4e94455 --- /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 00000000..0be53ad0 --- /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/dtos/donation-details-dto.ts b/apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts index c1f220e8..54546569 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 204d32bd..e1d72646 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,38 @@ describe('FoodManufacturersController', () => { }); }); + describe('GET /:foodManufacturerId/next-two-reminders', () => { + 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'), + }, + ]; + + mockManufacturersService.getUpcomingDonationReminders.mockResolvedValue( + mockDonationReminders, + ); + + const result = await controller.getNextTwoDonationReminders(1); + + expect(result).toEqual(mockDonationReminders); + expect( + mockManufacturersService.getUpcomingDonationReminders, + ).toHaveBeenCalledWith(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 198fcf41..a5312c60 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.controller.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.controller.ts @@ -19,7 +19,11 @@ 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'; +import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator'; @Controller('manufacturers') export class FoodManufacturersController { @@ -49,6 +53,26 @@ 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( + @Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number, + ): Promise { + return this.foodManufacturersService.getUpcomingDonationReminders( + foodManufacturerId, + ); + } + @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 b972887e..21b6e6b2 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'; @@ -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); @@ -388,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}`, ), ); @@ -558,4 +559,197 @@ 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, + ); + + 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); + + 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); + + 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('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(); + 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); + 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); + + expect(result).toHaveLength(2); + }); + + it('throws NotFoundException for non-existent manufacturer', async () => { + await expect(service.getUpcomingDonationReminders(9999)).rejects.toThrow( + new NotFoundException('Food Manufacturer 9999 not found'), + ); + }); + }); }); diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index ed93e686..2240526a 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'; @@ -23,9 +23,11 @@ import { DonationDetailsDto, DonationItemWithAllocatedQuantityDto, DonationOrderDetailsDto, + DonationReminderDto, } from './dtos/donation-details-dto'; import { OrderStatus } from '../orders/types'; -import { DonationStatus } from '../donations/types'; +import { DonationStatus, RecurrenceEnum } from '../donations/types'; +import { calculateNextDonationDate } from '../donations/recurrence.utils'; import { ManufacturerStatsDto } from './dtos/manufacturer-stats.dto'; @Injectable() @@ -76,7 +78,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}`, ); } @@ -139,6 +141,65 @@ export class FoodManufacturersService { }); } + async getUpcomingDonationReminders( + foodManufacturerId: number, + ): Promise { + validateId(foodManufacturerId, 'Food Manufacturer'); + + const manufacturer = await this.repo.findOne({ + where: { foodManufacturerId }, + relations: ['foodManufacturerRepresentative'], + }); + + if (!manufacturer) { + throw new NotFoundException( + `Food Manufacturer ${foodManufacturerId} not found`, + ); + } + + const donations = await this.donationsRepo.find({ + where: { foodManufacturer: { foodManufacturerId } }, + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const donationReminders: DonationReminderDto[] = donations.flatMap( + (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.NONE && + donation.recurrenceFreq && + allDates.length > 0 + ) { + for (const date of allDates) { + const nextDate = calculateNextDonationDate( + date, + donation.recurrence, + donation.recurrenceFreq, + ); + if (nextDate >= today) { + reminders.push({ donation, reminderDate: nextDate }); + } + } + } + + return reminders; + }, + ); + + 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 }, @@ -248,7 +309,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 47f0138f..db8a86f1 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 b7304150..209571a4 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', );