diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index 0299b0e..30fe3da 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 b8a62f5..7ccc449 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 { @@ -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); @@ -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}`); @@ -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)); + } + + + } \ 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 b42d465..2de2730 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(); @@ -14,16 +15,19 @@ const mockPut = vi.fn().mockReturnThis(); const mockDelete = 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, delete : mockDelete, promise: mockPromise, - query : mockQuery }; + const mockSES = { send: mockSend, promise: mockPromise, @@ -332,6 +336,90 @@ 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)); + }); + + + + + describe('deleteNotification', () => { it('should successfully delete a notification given a valid id', async () => { mockPromise.mockResolvedValueOnce({}) diff --git a/backend/src/notifications/notifcation.service.ts b/backend/src/notifications/notifcation.service.ts index 9dede1e..c3330e3 100644 --- a/backend/src/notifications/notifcation.service.ts +++ b/backend/src/notifications/notifcation.service.ts @@ -132,6 +132,32 @@ 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}`) + } + } + + /** * Deletes the notification with the given id from the database and returns a success message if the deletion was successful * @param notificationId the id of the notification to delete diff --git a/backend/src/notifications/notification.controller.ts b/backend/src/notifications/notification.controller.ts index 7eaf3d8..bb1c054 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, Delete } from '@nestjs/common'; +import { Controller, Post, Body, Get, Query, Param, Patch, Put, Delete } from '@nestjs/common'; import { NotificationService } from './notifcation.service'; import { Notification } from '../../../middle-layer/types/Notification'; @@ -28,6 +28,14 @@ export class NotificationController { return await this.notificationService.getNotificationByUserId(userId); } + // updates notification by its id + @Put(':notificationId') + async updateNotification(@Param('notificationId') notificationId: string, + @Body() notification: Partial){ + return await this.notificationService.updateNotification(notificationId, notification); + } + + /** * Deletes the notification with the given id from the database, if it exists. *