Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 3 additions & 42 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -168,7 +169,7 @@ export class DonationService {
occurrencesUpdated = true;

if (occurrences > 0) {
let nextDate = this.calculateNextDate(
let nextDate = calculateNextDonationDate(
currentDate,
donation.recurrence,
donation.recurrenceFreq,
Expand All @@ -185,7 +186,7 @@ export class DonationService {
occurrences -= 1;

if (occurrences > 0) {
nextDate = this.calculateNextDate(
nextDate = calculateNextDonationDate(
nextDate,
donation.recurrence,
donation.recurrenceFreq,
Expand Down Expand Up @@ -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.
*
Expand Down
154 changes: 154 additions & 0 deletions apps/backend/src/donations/recurrence.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 39 additions & 0 deletions apps/backend/src/donations/recurrence.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ export class DonationDetailsDto {
associatedPendingOrders!: DonationOrderDetailsDto[];
relevantDonationItems!: DonationItemWithAllocatedQuantityDto[];
}

export class DonationReminderDto {
donation!: Donation;
reminderDate!: Date;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<FoodManufacturersService>();
Expand Down Expand Up @@ -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 = {
Expand Down
26 changes: 25 additions & 1 deletion apps/backend/src/foodManufacturers/manufacturers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<DonationReminderDto[]> {
return this.foodManufacturersService.getUpcomingDonationReminders(
foodManufacturerId,
);
}

@ApiBody({
description: 'Details for submitting a manufacturer application',
schema: {
Expand Down
Loading
Loading