From 3872a5a030286a92086612307a543c551d508e1d Mon Sep 17 00:00:00 2001 From: adityapat24 Date: Mon, 3 Nov 2025 15:31:22 -0500 Subject: [PATCH 1/3] add helper functions --- backend/src/grant/grant.service.ts | 60 +++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index b8a62f50..ed7b6c96 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -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 { @@ -178,4 +182,56 @@ export class GrantService { } } + + 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(); + }); + } + + 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-${alertTime}`, + 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-${alertTime}`, + userId, + message, + alertTime: alertTime as TDateISO, + }; + await this.notificationService.createNotification(notification); + } + } + } + } + + private daysUntil(alertTime: string, deadline: string): number { + const diffMs = +new Date(deadline) - +new Date(alertTime); + return Math.round(diffMs / (1000 * 60 * 60 * 24)); + } + } \ No newline at end of file From 73524dea20c8a5e5978a3452b5e68ce3fe88c42b Mon Sep 17 00:00:00 2001 From: adityapat24 Date: Wed, 5 Nov 2025 17:31:22 -0500 Subject: [PATCH 2/3] edit notifications and tests for helper functions --- .../src/grant/__test__/grant.service.spec.ts | 165 ++++++++++++++++++ backend/src/grant/grant.service.ts | 59 ++++++- .../__test__/notification.service.test.ts | 92 +++++++++- .../src/notifications/notifcation.service.ts | 26 +++ .../notifications/notification.controller.ts | 9 +- 5 files changed, 346 insertions(+), 5 deletions(-) diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index 0299b0e1..30fe3da9 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -93,8 +93,16 @@ describe("GrantService", () => { providers: [GrantService], }).compile(); + grantService = Object.assign(module.get(GrantService), { + notificationService: { + createNotification: vi.fn(), + updateNotification: vi.fn() + } + }); + controller = module.get(GrantController); grantService = module.get(GrantService); + }); it("should be defined", () => { @@ -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: 'sarah@test.com' }, + bcan_poc: { POC_name: 'Tom', POC_email: 'tom@test.com' }, + 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: 'a@a.com' }, + bcan_poc: { POC_name: 'B', POC_email: 'b@b.com' }, + 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: 'alice@test.com' }, + bcan_poc: { POC_name: 'Bob', POC_email: 'bob@test.com' }, + 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: 'x@test.com' }, + bcan_poc: { POC_name: 'Y', POC_email: 'y@test.com' }, + attachments: [], + isRestricted: false, + } as unknown as Grant; + + await (grantServiceWithMockNotif as any).updateGrantNotifications(mockGrant); + + expect(notificationServiceMock.updateNotification).not.toHaveBeenCalled(); + }); + }); +}); }); diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index ed7b6c96..7ccc4492 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -115,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); @@ -151,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}`); @@ -183,6 +186,10 @@ 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]; @@ -193,6 +200,11 @@ export class GrantService { }); } + /** + * 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; @@ -202,7 +214,7 @@ export class GrantService { for (const alertTime of alertTimes) { const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`; const notification: Notification = { - notificationId: `${grantId}-app-${alertTime}`, + notificationId: `${grantId}-app`, userId, message, alertTime: alertTime as TDateISO, @@ -218,7 +230,7 @@ export class GrantService { for (const alertTime of alertTimes) { const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`; const notification: Notification = { - notificationId: `${grantId}-report-${alertTime}`, + notificationId: `${grantId}-report`, userId, message, alertTime: alertTime as TDateISO, @@ -228,10 +240,53 @@ export class GrantService { } } } + + /** + * 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)); } + + } \ No newline at end of file diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 1f32e0ed..3bc18323 100644 --- a/backend/src/notifications/__test__/notification.service.test.ts +++ b/backend/src/notifications/__test__/notification.service.test.ts @@ -4,6 +4,7 @@ import { NotificationController } from '../notification.controller'; import { NotificationService } from '../notifcation.service'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { servicesVersion } from 'typescript'; +import { TDateISO } from '../../utils/date'; // Create mock functions that we can reference const mockPromise = vi.fn(); @@ -13,15 +14,18 @@ const mockSend = vi.fn().mockReturnThis(); // for SES const mockPut = vi.fn().mockReturnThis(); const mockQuery = vi.fn().mockReturnThis(); const mockSendEmail = vi.fn().mockReturnThis(); +const mockUpdate = vi.fn().mockReturnThis(); const mockDocumentClient = { scan: mockScan, get: mockGet, - put : mockPut, + put: mockPut, + query: mockQuery, + update: mockUpdate, promise: mockPromise, - query : mockQuery }; + const mockSES = { send: mockSend, promise: mockPromise, @@ -330,5 +334,89 @@ describe('NotificationController', () => { }); }); + it('should update a notification successfully with multiple fields', async () => { + // Arrange + const notificationId = 'notif-123'; + const updates = { + message: 'Updated message', + alertTime: '2025-01-01T00:00:00.000Z' as unknown as TDateISO + }; + + + const mockUpdateResponse = { + Attributes: { + message: 'Updated message', + alertTime: '2025-01-01T00:00:00.000Z', + }, + }; + + mockUpdate.mockReturnValue({ promise: mockPromise }); + mockPromise.mockResolvedValue(mockUpdateResponse); + + const result = await notificationService.updateNotification(notificationId, updates); + + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'BCANNotifications', + Key: { notificationId }, + UpdateExpression: 'SET #message = :message, #alertTime = :alertTime', + ExpressionAttributeNames: { + '#message': 'message', + '#alertTime': 'alertTime', + }, + ExpressionAttributeValues: { + ':message': 'Updated message', + ':alertTime': '2025-01-01T00:00:00.000Z', + }, + ReturnValues: 'UPDATED_NEW', + }); + + expect(result).toEqual(JSON.stringify(mockUpdateResponse)); + }); + + it('should throw error when DynamoDB update fails', async () => { + // Arrange + const notificationId = 'notif-fail'; + const updates = { message: 'Failure test' }; + const mockError = new Error('DynamoDB update failed'); + + mockDocumentClient.update = vi.fn().mockReturnThis(); + mockPromise.mockRejectedValue(mockError); + + // Act & Assert + await expect(notificationService.updateNotification(notificationId, updates)) + .rejects.toThrow('Failed to update Notification notif-fail'); + + expect(mockDocumentClient.update).toHaveBeenCalled(); + }); + + it('should correctly update a single field', async () => { + // Arrange + const notificationId = 'notif-single'; + const updates = { message: 'Single field update' }; + const mockUpdateResponse = { Attributes: { message: 'Single field update' } }; + + mockDocumentClient.update = vi.fn().mockReturnThis(); + mockPromise.mockResolvedValue(mockUpdateResponse); + + // Act + const result = await notificationService.updateNotification(notificationId, updates); + + // Assert + expect(mockDocumentClient.update).toHaveBeenCalledWith({ + TableName: 'BCANNotifications', + Key: { notificationId }, + UpdateExpression: 'SET #message = :message', + ExpressionAttributeNames: { '#message': 'message' }, + ExpressionAttributeValues: { ':message': 'Single field update' }, + ReturnValues: 'UPDATED_NEW', + }); + + expect(result).toEqual(JSON.stringify(mockUpdateResponse)); + }); + + + + + }); \ No newline at end of file diff --git a/backend/src/notifications/notifcation.service.ts b/backend/src/notifications/notifcation.service.ts index 3fcc0e07..f78d7ea2 100644 --- a/backend/src/notifications/notifcation.service.ts +++ b/backend/src/notifications/notifcation.service.ts @@ -132,4 +132,30 @@ export class NotificationService { } } + // function to update notification by its id + async updateNotification(notificationId: string, updates: Partial): Promise { + const updateKeys = Object.keys(updates); + const UpdateExpression = "SET " + updateKeys.map(k => `#${k} = :${k}`).join(", "); + const ExpressionAttributeNames = updateKeys.reduce((acc, key) => ({ ...acc, [`#${key}`]: key }), {}); + const ExpressionAttributeValues = updateKeys.reduce((acc, key) => ({ ...acc, [`:${key}`]: updates[key as keyof Notification] }), {}); + + const params = { + TableName: process.env.DYNAMODB_NOTIFICATION_TABLE_NAME!, + Key: { notificationId }, + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues, + ReturnValues: "UPDATED_NEW", + }; + + try { + const result = await this.dynamoDb.update(params).promise(); + return JSON.stringify(result); + } catch(err) { + console.log(err); + throw new Error(`Failed to update Notification ${notificationId}`) + } + } + + } \ No newline at end of file diff --git a/backend/src/notifications/notification.controller.ts b/backend/src/notifications/notification.controller.ts index 3f589a7f..76f4fb2a 100644 --- a/backend/src/notifications/notification.controller.ts +++ b/backend/src/notifications/notification.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, Get, Query, Param } from '@nestjs/common'; +import { Controller, Post, Body, Get, Query, Param, Patch } from '@nestjs/common'; import { NotificationService } from './notifcation.service'; import { Notification } from '../../../middle-layer/types/Notification'; @@ -28,5 +28,12 @@ export class NotificationController { return await this.notificationService.getNotificationByUserId(userId); } + // updates notification by its id + @Patch(':notificationId') + async updateNotification(@Param('notificationId') notificationId: string, + @Body() notification: Partial){ + return await this.notificationService.updateNotification(notificationId, notification); + } + } \ No newline at end of file From 55b6c5728e67b256eba9033c2ca0a1b078ed4b4a Mon Sep 17 00:00:00 2001 From: adityapat24 Date: Wed, 5 Nov 2025 18:23:57 -0500 Subject: [PATCH 3/3] changed patch to put --- backend/src/notifications/notification.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/notifications/notification.controller.ts b/backend/src/notifications/notification.controller.ts index 76f4fb2a..f20d2c98 100644 --- a/backend/src/notifications/notification.controller.ts +++ b/backend/src/notifications/notification.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, Get, Query, Param, Patch } from '@nestjs/common'; +import { Controller, Post, Body, Get, Query, Param, Patch, Put } from '@nestjs/common'; import { NotificationService } from './notifcation.service'; import { Notification } from '../../../middle-layer/types/Notification'; @@ -29,7 +29,7 @@ export class NotificationController { } // updates notification by its id - @Patch(':notificationId') + @Put(':notificationId') async updateNotification(@Param('notificationId') notificationId: string, @Body() notification: Partial){ return await this.notificationService.updateNotification(notificationId, notification);