Skip to content
Merged
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
165 changes: 165 additions & 0 deletions backend/src/grant/__test__/grant.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,16 @@ describe("GrantService", () => {
providers: [GrantService],
}).compile();

grantService = Object.assign(module.get<GrantService>(GrantService), {
notificationService: {
createNotification: vi.fn(),
updateNotification: vi.fn()
}
});

controller = module.get<GrantController>(GrantController);
grantService = module.get<GrantService>(GrantService);

});

it("should be defined", () => {
Expand Down Expand Up @@ -392,4 +400,161 @@ describe('deleteGrantById', () => {
.rejects.toThrow(/Failed to delete/);
});
});
describe('Notification helpers', () => {
let notificationServiceMock: any;
let grantServiceWithMockNotif: GrantService;

beforeEach(() => {
// mock notification service with spy functions
notificationServiceMock = {
createNotification: vi.fn().mockResolvedValue(undefined),
updateNotification: vi.fn().mockResolvedValue(undefined),
};

grantServiceWithMockNotif = new GrantService(notificationServiceMock);
});

describe('getNotificationTimes', () => {
it('should return ISO strings for 14, 7, and 3 days before deadline', () => {
const deadline = '2025-12-25T00:00:00.000Z';
const result = (grantServiceWithMockNotif as any).getNotificationTimes(deadline);

expect(result).toHaveLength(3);
result.forEach((date: any) => expect(date).toMatch(/^\d{4}-\d{2}-\d{2}T/));

const parsed = result.map((r: string | number | Date) => new Date(r));
const main = new Date(deadline);
const diffs = parsed.map((d: string | number) => Math.round((+main - +d) / (1000 * 60 * 60 * 24)));

expect(diffs).toEqual([14, 7, 3]);
});
});

describe('createGrantNotifications', () => {
it('should create notifications for application and report deadlines', async () => {
const mockGrant: Grant = {
grantId: 100,
organization: 'Boston Cares',
does_bcan_qualify: true,
status: Status.Active,
amount: 10000,
grant_start_date: '2025-01-01',
application_deadline: '2025-12-31T00:00:00.000Z',
report_deadlines: ['2026-01-31T00:00:00.000Z'],
description: 'Helping local communities',
timeline: 12,
estimated_completion_time: 365,
grantmaker_poc: { POC_name: 'Sarah', POC_email: '[email protected]' },
bcan_poc: { POC_name: 'Tom', POC_email: '[email protected]' },
attachments: [],
isRestricted: false,
};

await (grantServiceWithMockNotif as any).createGrantNotifications(mockGrant, 'user123');

// application_deadline => 3 notifications (14,7,3 days)
// one report_deadline => 3 more
expect(notificationServiceMock.createNotification).toHaveBeenCalledTimes(6);
expect(notificationServiceMock.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user123',
notificationId: expect.stringContaining('-app'),
message: expect.stringContaining('Application due in'),
alertTime: expect.any(String),
})
);
expect(notificationServiceMock.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
notificationId: expect.stringContaining('-report'),
message: expect.stringContaining('Report due in'),
})
);
});

it('should handle missing deadlines gracefully', async () => {
const mockGrant = {
grantId: 55,
organization: 'No Deadline Org',
does_bcan_qualify: true,
status: Status.Active,
amount: 5000,
grant_start_date: '2025-01-01',
description: '',
timeline: 1,
estimated_completion_time: 10,
grantmaker_poc: { POC_name: 'A', POC_email: '[email protected]' },
bcan_poc: { POC_name: 'B', POC_email: '[email protected]' },
attachments: [],
isRestricted: false,
} as unknown as Grant;

await (grantServiceWithMockNotif as any).createGrantNotifications(mockGrant, 'userX');

expect(notificationServiceMock.createNotification).not.toHaveBeenCalled();
});
});

describe('updateGrantNotifications', () => {
it('should call updateNotification for all alert times', async () => {
const mockGrant: Grant = {
grantId: 123,
organization: 'Grant Org',
does_bcan_qualify: true,
status: Status.Pending,
amount: 5000,
grant_start_date: '2025-01-01',
application_deadline: '2025-06-30T00:00:00.000Z',
report_deadlines: ['2025-07-15T00:00:00.000Z'],
description: 'Test desc',
timeline: 1,
estimated_completion_time: 100,
grantmaker_poc: { POC_name: 'Alice', POC_email: '[email protected]' },
bcan_poc: { POC_name: 'Bob', POC_email: '[email protected]' },
attachments: [],
isRestricted: false,
};

await (grantServiceWithMockNotif as any).updateGrantNotifications(mockGrant);

// Expect 6 updateNotification calls (3 per deadline)
expect(notificationServiceMock.updateNotification).toHaveBeenCalledTimes(6);
expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith(
expect.stringContaining('-app'),
expect.objectContaining({
message: expect.stringContaining('Application due in'),
alertTime: expect.any(String),
})
);
expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith(
expect.stringContaining('-report'),
expect.objectContaining({
message: expect.stringContaining('Report due in'),
})
);
});

it('should not crash when no deadlines exist', async () => {
const mockGrant = {
grantId: 321,
organization: 'No deadlines',
does_bcan_qualify: false,
status: Status.Inactive,
amount: 0,
grant_start_date: '2025-01-01',
report_deadlines: [],
description: '',
timeline: 0,
estimated_completion_time: 0,
grantmaker_poc: { POC_name: 'X', POC_email: '[email protected]' },
bcan_poc: { POC_name: 'Y', POC_email: '[email protected]' },
attachments: [],
isRestricted: false,
} as unknown as Grant;

await (grantServiceWithMockNotif as any).updateGrantNotifications(mockGrant);

expect(notificationServiceMock.updateNotification).not.toHaveBeenCalled();
});
});
});
});
115 changes: 113 additions & 2 deletions backend/src/grant/grant.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import AWS from 'aws-sdk';
import { Grant } from '../../../middle-layer/types/Grant';

import { NotificationService } from '.././notifications/notifcation.service';
import { Notification } from '../../../middle-layer/types/Notification';
import { TDateISO } from '../utils/date';
@Injectable()
export class GrantService {
private readonly logger = new Logger(GrantService.name);
private dynamoDb = new AWS.DynamoDB.DocumentClient();
private dynamoDb = new AWS.DynamoDB.DocumentClient();

constructor(private readonly notificationService: NotificationService) {}

// function to retrieve all grants in our database
async getAllGrants(): Promise<Grant[]> {
Expand Down Expand Up @@ -111,6 +115,7 @@ export class GrantService {

try {
const result = await this.dynamoDb.update(params).promise();
await this.updateGrantNotifications(grantData);
return JSON.stringify(result); // returns the changed attributes stored in db
} catch(err) {
console.log(err);
Expand Down Expand Up @@ -147,6 +152,8 @@ export class GrantService {
try {
await this.dynamoDb.put(params).promise();
this.logger.log(`Uploaded grant from ${grant.organization}`);
const userId = grant.bcan_poc.POC_email;
await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId);
} catch (error: any) {
this.logger.error(`Failed to upload new grant from ${grant.organization}`, error.stack);
throw new Error(`Failed to upload new grant from ${grant.organization}`);
Expand Down Expand Up @@ -178,4 +185,108 @@ export class GrantService {
}

}

/*
Helper method that takes in a deadline in ISO format and returns an array of ISO strings representing the notification times
for 14 days, 7 days, and 3 days before the deadline.
*/
private getNotificationTimes(deadlineISO: string): string[] {
const deadline = new Date(deadlineISO);
const daysBefore = [14, 7, 3];
return daysBefore.map(days => {
const d = new Date(deadline);
d.setDate(deadline.getDate() - days);
return d.toISOString();
});
}

/**
* Helper method that creates notifications for a grant's application and report deadlines
* @param grant represents the grant of which we want to create a notification for
* @param userId represents the user to whom we want to send the notification
*/
private async createGrantNotifications(grant: Grant, userId: string) {
const { grantId, organization, application_deadline, report_deadlines } = grant;

// Application deadline notifications
if (application_deadline) {
const alertTimes = this.getNotificationTimes(application_deadline);
for (const alertTime of alertTimes) {
const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`;
const notification: Notification = {
notificationId: `${grantId}-app`,
userId,
message,
alertTime: alertTime as TDateISO,
};
await this.notificationService.createNotification(notification);
}
}

// Report deadlines notifications
if (report_deadlines && Array.isArray(report_deadlines)) {
for (const reportDeadline of report_deadlines) {
const alertTimes = this.getNotificationTimes(reportDeadline);
for (const alertTime of alertTimes) {
const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`;
const notification: Notification = {
notificationId: `${grantId}-report`,
userId,
message,
alertTime: alertTime as TDateISO,
};
await this.notificationService.createNotification(notification);
}
}
}
}

/**
* Helper method to update notifications for a grant's application and report deadlines
* @param grant represents the grant of which we want to update notifications for
*/
private async updateGrantNotifications(grant: Grant) {
const { grantId, organization, application_deadline, report_deadlines } = grant;

// Application notifications
if (application_deadline) {
const alertTimes = this.getNotificationTimes(application_deadline);
for (const alertTime of alertTimes) {
const notificationId = `${grantId}-app`;
const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`;

await this.notificationService.updateNotification(notificationId, {
message,
alertTime: alertTime as TDateISO,
});
}
}

// Report notifications
if (report_deadlines && Array.isArray(report_deadlines)) {
for (const reportDeadline of report_deadlines) {
const alertTimes = this.getNotificationTimes(reportDeadline);
for (const alertTime of alertTimes) {
const notificationId = `${grantId}-report`;
const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`;

await this.notificationService.updateNotification(notificationId, {
message,
alertTime: alertTime as TDateISO,
});
}
}
}
}

/*
Helper method that calculates the number of days between alert time and deadline
*/
private daysUntil(alertTime: string, deadline: string): number {
const diffMs = +new Date(deadline) - +new Date(alertTime);
return Math.round(diffMs / (1000 * 60 * 60 * 24));
}



}
Loading