Skip to content

Commit a67f112

Browse files
authored
Merge pull request #208 from Code-4-Community/AP/createGrantNotifications
Ap/create grant notifications
2 parents 7047520 + 2f0684d commit a67f112

File tree

5 files changed

+403
-5
lines changed

5 files changed

+403
-5
lines changed

backend/src/grant/__test__/grant.service.spec.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,16 @@ describe("GrantService", () => {
9393
providers: [GrantService],
9494
}).compile();
9595

96+
grantService = Object.assign(module.get<GrantService>(GrantService), {
97+
notificationService: {
98+
createNotification: vi.fn(),
99+
updateNotification: vi.fn()
100+
}
101+
});
102+
96103
controller = module.get<GrantController>(GrantController);
97104
grantService = module.get<GrantService>(GrantService);
105+
98106
});
99107

100108
it("should be defined", () => {
@@ -392,4 +400,161 @@ describe('deleteGrantById', () => {
392400
.rejects.toThrow(/Failed to delete/);
393401
});
394402
});
403+
describe('Notification helpers', () => {
404+
let notificationServiceMock: any;
405+
let grantServiceWithMockNotif: GrantService;
406+
407+
beforeEach(() => {
408+
// mock notification service with spy functions
409+
notificationServiceMock = {
410+
createNotification: vi.fn().mockResolvedValue(undefined),
411+
updateNotification: vi.fn().mockResolvedValue(undefined),
412+
};
413+
414+
grantServiceWithMockNotif = new GrantService(notificationServiceMock);
415+
});
416+
417+
describe('getNotificationTimes', () => {
418+
it('should return ISO strings for 14, 7, and 3 days before deadline', () => {
419+
const deadline = '2025-12-25T00:00:00.000Z';
420+
const result = (grantServiceWithMockNotif as any).getNotificationTimes(deadline);
421+
422+
expect(result).toHaveLength(3);
423+
result.forEach((date: any) => expect(date).toMatch(/^\d{4}-\d{2}-\d{2}T/));
424+
425+
const parsed = result.map((r: string | number | Date) => new Date(r));
426+
const main = new Date(deadline);
427+
const diffs = parsed.map((d: string | number) => Math.round((+main - +d) / (1000 * 60 * 60 * 24)));
428+
429+
expect(diffs).toEqual([14, 7, 3]);
430+
});
431+
});
432+
433+
describe('createGrantNotifications', () => {
434+
it('should create notifications for application and report deadlines', async () => {
435+
const mockGrant: Grant = {
436+
grantId: 100,
437+
organization: 'Boston Cares',
438+
does_bcan_qualify: true,
439+
status: Status.Active,
440+
amount: 10000,
441+
grant_start_date: '2025-01-01',
442+
application_deadline: '2025-12-31T00:00:00.000Z',
443+
report_deadlines: ['2026-01-31T00:00:00.000Z'],
444+
description: 'Helping local communities',
445+
timeline: 12,
446+
estimated_completion_time: 365,
447+
grantmaker_poc: { POC_name: 'Sarah', POC_email: '[email protected]' },
448+
bcan_poc: { POC_name: 'Tom', POC_email: '[email protected]' },
449+
attachments: [],
450+
isRestricted: false,
451+
};
452+
453+
await (grantServiceWithMockNotif as any).createGrantNotifications(mockGrant, 'user123');
454+
455+
// application_deadline => 3 notifications (14,7,3 days)
456+
// one report_deadline => 3 more
457+
expect(notificationServiceMock.createNotification).toHaveBeenCalledTimes(6);
458+
expect(notificationServiceMock.createNotification).toHaveBeenCalledWith(
459+
expect.objectContaining({
460+
userId: 'user123',
461+
notificationId: expect.stringContaining('-app'),
462+
message: expect.stringContaining('Application due in'),
463+
alertTime: expect.any(String),
464+
})
465+
);
466+
expect(notificationServiceMock.createNotification).toHaveBeenCalledWith(
467+
expect.objectContaining({
468+
notificationId: expect.stringContaining('-report'),
469+
message: expect.stringContaining('Report due in'),
470+
})
471+
);
472+
});
473+
474+
it('should handle missing deadlines gracefully', async () => {
475+
const mockGrant = {
476+
grantId: 55,
477+
organization: 'No Deadline Org',
478+
does_bcan_qualify: true,
479+
status: Status.Active,
480+
amount: 5000,
481+
grant_start_date: '2025-01-01',
482+
description: '',
483+
timeline: 1,
484+
estimated_completion_time: 10,
485+
grantmaker_poc: { POC_name: 'A', POC_email: '[email protected]' },
486+
bcan_poc: { POC_name: 'B', POC_email: '[email protected]' },
487+
attachments: [],
488+
isRestricted: false,
489+
} as unknown as Grant;
490+
491+
await (grantServiceWithMockNotif as any).createGrantNotifications(mockGrant, 'userX');
492+
493+
expect(notificationServiceMock.createNotification).not.toHaveBeenCalled();
494+
});
495+
});
496+
497+
describe('updateGrantNotifications', () => {
498+
it('should call updateNotification for all alert times', async () => {
499+
const mockGrant: Grant = {
500+
grantId: 123,
501+
organization: 'Grant Org',
502+
does_bcan_qualify: true,
503+
status: Status.Pending,
504+
amount: 5000,
505+
grant_start_date: '2025-01-01',
506+
application_deadline: '2025-06-30T00:00:00.000Z',
507+
report_deadlines: ['2025-07-15T00:00:00.000Z'],
508+
description: 'Test desc',
509+
timeline: 1,
510+
estimated_completion_time: 100,
511+
grantmaker_poc: { POC_name: 'Alice', POC_email: '[email protected]' },
512+
bcan_poc: { POC_name: 'Bob', POC_email: '[email protected]' },
513+
attachments: [],
514+
isRestricted: false,
515+
};
516+
517+
await (grantServiceWithMockNotif as any).updateGrantNotifications(mockGrant);
518+
519+
// Expect 6 updateNotification calls (3 per deadline)
520+
expect(notificationServiceMock.updateNotification).toHaveBeenCalledTimes(6);
521+
expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith(
522+
expect.stringContaining('-app'),
523+
expect.objectContaining({
524+
message: expect.stringContaining('Application due in'),
525+
alertTime: expect.any(String),
526+
})
527+
);
528+
expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith(
529+
expect.stringContaining('-report'),
530+
expect.objectContaining({
531+
message: expect.stringContaining('Report due in'),
532+
})
533+
);
534+
});
535+
536+
it('should not crash when no deadlines exist', async () => {
537+
const mockGrant = {
538+
grantId: 321,
539+
organization: 'No deadlines',
540+
does_bcan_qualify: false,
541+
status: Status.Inactive,
542+
amount: 0,
543+
grant_start_date: '2025-01-01',
544+
report_deadlines: [],
545+
description: '',
546+
timeline: 0,
547+
estimated_completion_time: 0,
548+
grantmaker_poc: { POC_name: 'X', POC_email: '[email protected]' },
549+
bcan_poc: { POC_name: 'Y', POC_email: '[email protected]' },
550+
attachments: [],
551+
isRestricted: false,
552+
} as unknown as Grant;
553+
554+
await (grantServiceWithMockNotif as any).updateGrantNotifications(mockGrant);
555+
556+
expect(notificationServiceMock.updateNotification).not.toHaveBeenCalled();
557+
});
558+
});
559+
});
395560
});

backend/src/grant/grant.service.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
22
import AWS from 'aws-sdk';
33
import { Grant } from '../../../middle-layer/types/Grant';
4-
4+
import { NotificationService } from '.././notifications/notifcation.service';
5+
import { Notification } from '../../../middle-layer/types/Notification';
6+
import { TDateISO } from '../utils/date';
57
@Injectable()
68
export class GrantService {
79
private readonly logger = new Logger(GrantService.name);
8-
private dynamoDb = new AWS.DynamoDB.DocumentClient();
10+
private dynamoDb = new AWS.DynamoDB.DocumentClient();
11+
12+
constructor(private readonly notificationService: NotificationService) {}
913

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

112116
try {
113117
const result = await this.dynamoDb.update(params).promise();
118+
await this.updateGrantNotifications(grantData);
114119
return JSON.stringify(result); // returns the changed attributes stored in db
115120
} catch(err) {
116121
console.log(err);
@@ -147,6 +152,8 @@ export class GrantService {
147152
try {
148153
await this.dynamoDb.put(params).promise();
149154
this.logger.log(`Uploaded grant from ${grant.organization}`);
155+
const userId = grant.bcan_poc.POC_email;
156+
await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId);
150157
} catch (error: any) {
151158
this.logger.error(`Failed to upload new grant from ${grant.organization}`, error.stack);
152159
throw new Error(`Failed to upload new grant from ${grant.organization}`);
@@ -178,4 +185,108 @@ export class GrantService {
178185
}
179186

180187
}
188+
189+
/*
190+
Helper method that takes in a deadline in ISO format and returns an array of ISO strings representing the notification times
191+
for 14 days, 7 days, and 3 days before the deadline.
192+
*/
193+
private getNotificationTimes(deadlineISO: string): string[] {
194+
const deadline = new Date(deadlineISO);
195+
const daysBefore = [14, 7, 3];
196+
return daysBefore.map(days => {
197+
const d = new Date(deadline);
198+
d.setDate(deadline.getDate() - days);
199+
return d.toISOString();
200+
});
201+
}
202+
203+
/**
204+
* Helper method that creates notifications for a grant's application and report deadlines
205+
* @param grant represents the grant of which we want to create a notification for
206+
* @param userId represents the user to whom we want to send the notification
207+
*/
208+
private async createGrantNotifications(grant: Grant, userId: string) {
209+
const { grantId, organization, application_deadline, report_deadlines } = grant;
210+
211+
// Application deadline notifications
212+
if (application_deadline) {
213+
const alertTimes = this.getNotificationTimes(application_deadline);
214+
for (const alertTime of alertTimes) {
215+
const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`;
216+
const notification: Notification = {
217+
notificationId: `${grantId}-app`,
218+
userId,
219+
message,
220+
alertTime: alertTime as TDateISO,
221+
};
222+
await this.notificationService.createNotification(notification);
223+
}
224+
}
225+
226+
// Report deadlines notifications
227+
if (report_deadlines && Array.isArray(report_deadlines)) {
228+
for (const reportDeadline of report_deadlines) {
229+
const alertTimes = this.getNotificationTimes(reportDeadline);
230+
for (const alertTime of alertTimes) {
231+
const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`;
232+
const notification: Notification = {
233+
notificationId: `${grantId}-report`,
234+
userId,
235+
message,
236+
alertTime: alertTime as TDateISO,
237+
};
238+
await this.notificationService.createNotification(notification);
239+
}
240+
}
241+
}
242+
}
243+
244+
/**
245+
* Helper method to update notifications for a grant's application and report deadlines
246+
* @param grant represents the grant of which we want to update notifications for
247+
*/
248+
private async updateGrantNotifications(grant: Grant) {
249+
const { grantId, organization, application_deadline, report_deadlines } = grant;
250+
251+
// Application notifications
252+
if (application_deadline) {
253+
const alertTimes = this.getNotificationTimes(application_deadline);
254+
for (const alertTime of alertTimes) {
255+
const notificationId = `${grantId}-app`;
256+
const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`;
257+
258+
await this.notificationService.updateNotification(notificationId, {
259+
message,
260+
alertTime: alertTime as TDateISO,
261+
});
262+
}
263+
}
264+
265+
// Report notifications
266+
if (report_deadlines && Array.isArray(report_deadlines)) {
267+
for (const reportDeadline of report_deadlines) {
268+
const alertTimes = this.getNotificationTimes(reportDeadline);
269+
for (const alertTime of alertTimes) {
270+
const notificationId = `${grantId}-report`;
271+
const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`;
272+
273+
await this.notificationService.updateNotification(notificationId, {
274+
message,
275+
alertTime: alertTime as TDateISO,
276+
});
277+
}
278+
}
279+
}
280+
}
281+
282+
/*
283+
Helper method that calculates the number of days between alert time and deadline
284+
*/
285+
private daysUntil(alertTime: string, deadline: string): number {
286+
const diffMs = +new Date(deadline) - +new Date(alertTime);
287+
return Math.round(diffMs / (1000 * 60 * 60 * 24));
288+
}
289+
290+
291+
181292
}

0 commit comments

Comments
 (0)