From 2b2f026f54c3de6073103cf27ab356e7fb816ebd Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 02:00:22 -0400 Subject: [PATCH 01/15] fetch notifs on login --- frontend/src/context/auth/authContext.tsx | 2 ++ frontend/src/main-page/notifications/Bell.tsx | 11 +--------- .../notifications/processNotificationData.ts | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 frontend/src/main-page/notifications/processNotificationData.ts diff --git a/frontend/src/context/auth/authContext.tsx b/frontend/src/context/auth/authContext.tsx index 187badfa..ff1da795 100644 --- a/frontend/src/context/auth/authContext.tsx +++ b/frontend/src/context/auth/authContext.tsx @@ -13,6 +13,7 @@ import { } from '../../main-page/cash-flow/processCashflowData.ts'; import Button from '../../components/Button'; import { UserStatus } from '../../../../middle-layer/types/UserStatus.ts'; +import { fetchNotifications } from '../../main-page/notifications/processNotificationData.ts'; /** @@ -60,6 +61,7 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => fetchCosts(), fetchRevenues(), fetchCashflowSettings(), + fetchNotifications(), ]); }; diff --git a/frontend/src/main-page/notifications/Bell.tsx b/frontend/src/main-page/notifications/Bell.tsx index da4e5531..01afc1b8 100644 --- a/frontend/src/main-page/notifications/Bell.tsx +++ b/frontend/src/main-page/notifications/Bell.tsx @@ -6,6 +6,7 @@ import { setNotifications as setNotificationsAction } from "../../external/bcanS import { getAppStore } from "../../external/bcanSatchel/store"; import { observer } from "mobx-react-lite"; import { api } from "../../api"; +import { fetchNotifications } from "./processNotificationData"; // get current user id @@ -28,17 +29,7 @@ const BellButton: React.FC = observer(({ setOpenModal, openModa // function that handles when button is clicked and fetches notifications const handleClick = async () => { - const response = await api( - `/notifications/user/${store.user?.email}/current`, - { - method: "GET", - } - ); - console.log(response); - const currNotifications = await response.json(); - setNotificationsAction(currNotifications); setOpenModal(!openModal); - return notifications; }; return ( diff --git a/frontend/src/main-page/notifications/processNotificationData.ts b/frontend/src/main-page/notifications/processNotificationData.ts new file mode 100644 index 00000000..8ed6207f --- /dev/null +++ b/frontend/src/main-page/notifications/processNotificationData.ts @@ -0,0 +1,21 @@ +import { Notification } from "../../../../middle-layer/types/Notification"; +import { api } from "../../api"; +import { getAppStore } from "../../external/bcanSatchel/store"; +import { setNotifications } from "../../external/bcanSatchel/actions"; + +const store = getAppStore(); + +export const fetchNotifications = async ( + +) => { + try { + const response = await api(`/notifications/user/${store.user?.email}/current`); + if (!response.ok) { + throw new Error(`HTTP Error, Status: ${response.status}`); + } + const updatedNotifications: Notification[] = await response.json(); + setNotifications(updatedNotifications); + } catch (error) { + console.error("Error fetching notifications:", error); + } +}; \ No newline at end of file From 37fd009c2579f6edcae876aeb1c0ed7755f88e42 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 14:12:03 -0400 Subject: [PATCH 02/15] sync user changes w grant and notifications --- .../src/auth/__test__/auth.service.spec.ts | 9 +- backend/src/auth/auth.module.ts | 2 + backend/src/auth/auth.service.ts | 100 ++++++++++++++- .../src/grant/__test__/grant.service.spec.ts | 56 ++++++++- backend/src/grant/grant.service.ts | 17 ++- .../__test__/notification.service.test.ts | 117 ++++++++++++++---- .../src/notifications/notification.service.ts | 37 ++++++ .../notifications/types/notification.types.ts | 1 + .../notifications/processNotificationData.ts | 14 ++- middle-layer/types/Notification.ts | 1 + 10 files changed, 315 insertions(+), 39 deletions(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 71f46329..48e182cc 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from '../auth.service'; +import { NotificationService } from '../../notifications/notification.service'; import { User } from '../../../../middle-layer/types/User'; import { UserStatus } from '../../../../middle-layer/types/UserStatus'; import { @@ -106,7 +107,13 @@ describe('AuthService', () => { mockDynamoPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + providers: [ + AuthService, + { + provide: NotificationService, + useValue: { updateNotificationsUserEmailByGrantId: vi.fn().mockResolvedValue(undefined) }, + }, + ], }).compile(); service = module.get(AuthService); diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 236c1913..40781d77 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { NotificationsModule } from '../notifications/notification.module'; @Module({ imports: [ @@ -9,6 +10,7 @@ import { AuthService } from './auth.service'; secret: process.env.JWT_SECRET, signOptions: { expiresIn: '1h' }, }), + NotificationsModule, ], controllers: [AuthController], providers: [ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c13507e0..6d09c09f 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -9,6 +9,7 @@ import { group, table } from "console"; import * as crypto from "crypto"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; +import { NotificationService } from "../notifications/notification.service"; import { HttpException, HttpStatus, @@ -37,7 +38,7 @@ export class AuthService { .digest("base64"); } - constructor() { + constructor(private readonly notificationService: NotificationService) { try { this.logger.log("Starting AuthService constructor..."); this.logger.log("AWS module:", typeof AWS); @@ -881,6 +882,55 @@ async updateProfile( } throw new InternalServerErrorException("Failed to update user data in database"); + } + // ── Step 3: Update grants where user is BCAN POC ────────────────────── + try { + await this.grantService.updateGrantsByPOC( + currentEmail, + newEmail, + `${firstName} ${lastName}`, + ); + this.logger.log(`Grants updated for new POC info`); + } catch (grantError: any) { + this.logger.error(`Failed to update grants, rolling back profile changes`, grantError); + + // Rollback DynamoDB + await this.dynamoDb.update({ + TableName: tableName, + Key: { email: currentEmail }, + UpdateExpression: "SET firstName = :firstName, lastName = :lastName, email = :email", + ExpressionAttributeValues: { + ":firstName": existingUser.firstName, + ":lastName": existingUser.lastName, + ":email": currentEmail, + }, + ReturnValues: "NONE", + }).promise(); + this.logger.log(`DynamoDB rolled back to original values`); + + // Rollback Cognito if email changed + if (isEmailChanging) { + await this.cognito.updateUserAttributes({ + AccessToken: accessToken, + UserAttributes: [{ Name: "email", Value: currentEmail }], + }).promise(); + this.logger.log(`Cognito rolled back to ${currentEmail}`); + } + + throw new InternalServerErrorException("Failed to update grants. All changes have been rolled back."); + } + + + // Step 3: Sync bcan_poc on affected grants and notification userEmails + const grantTableName = process.env.DYNAMODB_GRANT_TABLE_NAME; + if (grantTableName) { + await this.syncGrantsAndNotifications( + currentEmail, + newEmail, + firstName, + lastName, + grantTableName, + ); } } catch (error) { if (error instanceof HttpException) { @@ -898,7 +948,53 @@ async updateProfile( } } - // Add this to auth.service.ts + // Syncs bcan_poc on all grants where the user is the POC, then updates notification userEmails + private async syncGrantsAndNotifications( + currentEmail: string, + newEmail: string, + firstName: string, + lastName: string, + grantTableName: string, + ): Promise { + let grants: any[] = []; + try { + const result = await this.dynamoDb.scan({ + TableName: grantTableName, + FilterExpression: 'bcan_poc.POC_email = :email', + ExpressionAttributeValues: { ':email': currentEmail }, + }).promise(); + grants = result.Items || []; + } catch (error) { + this.logger.error(`Failed to scan grants for POC email ${currentEmail}:`, error); + return; + } + + const newPocName = `${firstName} ${lastName}`; + const isEmailChanging = currentEmail.toLowerCase() !== newEmail.toLowerCase(); + + for (const grant of grants) { + try { + if (isEmailChanging) { + await this.dynamoDb.update({ + TableName: grantTableName, + Key: { grantId: grant.grantId }, + UpdateExpression: 'SET bcan_poc.POC_name = :name, bcan_poc.POC_email = :email', + ExpressionAttributeValues: { ':name': newPocName, ':email': newEmail }, + }).promise(); + } else { + await this.dynamoDb.update({ + TableName: grantTableName, + Key: { grantId: grant.grantId }, + UpdateExpression: 'SET bcan_poc.POC_name = :name', + ExpressionAttributeValues: { ':name': newPocName }, + }).promise(); + } + await this.notificationService.updateNotificationsUserEmailByGrantId(grant.grantId, newEmail); + } catch (error) { + this.logger.error(`Failed to sync grant ${grant.grantId} for user ${currentEmail}:`, error); + } + } + } // purpose statement: validates a user's session token via cognito and retrieves user data from dynamodb // use case: employee is accessing the app with an existing session token diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index c90990aa..11ef869e 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -673,6 +673,7 @@ describe('Notification helpers', () => { notificationServiceMock = { createNotification: vi.fn().mockResolvedValue(undefined), updateNotification: vi.fn().mockResolvedValue(undefined), + updateNotificationsUserEmailByGrantId: vi.fn().mockResolvedValue(undefined), }; grantServiceWithMockNotif = new GrantService(notificationServiceMock); @@ -721,15 +722,17 @@ describe('Notification helpers', () => { expect(notificationServiceMock.createNotification).toHaveBeenCalledTimes(6); expect(notificationServiceMock.createNotification).toHaveBeenCalledWith( expect.objectContaining({ - notificationId: expect.stringContaining('-app'), + notificationId: expect.stringContaining('-app-'), message: expect.stringContaining('Application due in'), alertTime: expect.any(String), + grantId: 100, }) ); expect(notificationServiceMock.createNotification).toHaveBeenCalledWith( expect.objectContaining({ - notificationId: expect.stringContaining('-report'), + notificationId: expect.stringContaining('-report-'), message: expect.stringContaining('Report due in'), + grantId: 100, }) ); }); @@ -782,14 +785,14 @@ describe('Notification helpers', () => { // Expect 6 updateNotification calls (3 per deadline) expect(notificationServiceMock.updateNotification).toHaveBeenCalledTimes(6); expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith( - expect.stringContaining('-app'), + expect.stringContaining('-app-'), expect.objectContaining({ message: expect.stringContaining('Application due in'), alertTime: expect.any(String), }) ); expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith( - expect.stringContaining('-report'), + expect.stringContaining('-report-'), expect.objectContaining({ message: expect.stringContaining('Report due in'), }) @@ -819,5 +822,50 @@ describe('Notification helpers', () => { expect(notificationServiceMock.updateNotification).not.toHaveBeenCalled(); }); }); + + describe('updateGrant bcan_poc notification sync', () => { + it('should call updateNotificationsUserEmailByGrantId when bcan_poc is updated', async () => { + const mockUpdatedGrant: 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: [], + description: '', + timeline: 12, + estimated_completion_time: 365, + grantmaker_poc: { POC_name: 'Sarah', POC_email: 'sarah@test.com' }, + bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' }, + attachments: [], + isRestricted: false, + }; + + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); + + await grantServiceWithMockNotif.updateGrant(mockUpdatedGrant); + + expect(notificationServiceMock.updateNotificationsUserEmailByGrantId).toHaveBeenCalledWith( + 100, + 'newpoc@test.com', + ); + }); + + it('should not call updateNotificationsUserEmailByGrantId when bcan_poc is not in the update', async () => { + const mockUpdatedGrant = { + grantId: 100, + organization: 'Boston Cares Updated', + amount: 15000, + } as unknown as Grant; + + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); + + await grantServiceWithMockNotif.updateGrant(mockUpdatedGrant); + + expect(notificationServiceMock.updateNotificationsUserEmailByGrantId).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 9d75a119..43190b36 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -331,6 +331,13 @@ export class GrantService { const result = await this.dynamoDb.update(params).promise(); this.logger.log(`Successfully updated grant ${grantData.grantId} in database`); //await this.updateGrantNotifications(grantData); + if (updateKeys.includes('bcan_poc') && grantData.bcan_poc?.POC_email) { + this.logger.debug(`bcan_poc changed for grant ${grantData.grantId}; syncing notification userEmails`); + await this.notificationService.updateNotificationsUserEmailByGrantId( + grantData.grantId, + grantData.bcan_poc.POC_email, + ); + } return JSON.stringify(result); } catch(error: unknown) { // Re-throw NestJS exceptions @@ -513,11 +520,12 @@ export class GrantService { ); const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`; const notification: Notification = { - notificationId: `${grantId}-app`, + notificationId: `${grantId}-app-${alertTime}`, userEmail: email, message, alertTime: alertTime as TDateISO, sent: false, + grantId, }; await this.notificationService.createNotification(notification); } @@ -540,11 +548,12 @@ export class GrantService { ); const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`; const notification: Notification = { - notificationId: `${grantId}-report`, + notificationId: `${grantId}-report-${alertTime}`, userEmail: email, message, alertTime: alertTime as TDateISO, sent: false, + grantId, }; await this.notificationService.createNotification(notification); } @@ -574,7 +583,7 @@ export class GrantService { ); const alertTimes = this.getNotificationTimes(application_deadline); for (const alertTime of alertTimes) { - const notificationId = `${grantId}-app`; + const notificationId = `${grantId}-app-${alertTime}`; const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`; this.logger.debug( @@ -599,7 +608,7 @@ export class GrantService { for (const reportDeadline of report_deadlines) { const alertTimes = this.getNotificationTimes(reportDeadline); for (const alertTime of alertTimes) { - const notificationId = `${grantId}-report`; + const notificationId = `${grantId}-report-${alertTime}`; const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`; this.logger.debug( diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 7fa88164..8453edc4 100644 --- a/backend/src/notifications/__test__/notification.service.test.ts +++ b/backend/src/notifications/__test__/notification.service.test.ts @@ -95,7 +95,8 @@ describe('NotificationController', () => { userEmail: 'user1@example.com', message: 'New Grant Created 🎉 ', alertTime: '2024-01-15T10:30:00.000Z', - sent: false + sent: false, + grantId: 100, } as Notification; mockNotification_id1_user2 = { @@ -103,7 +104,8 @@ describe('NotificationController', () => { userEmail: 'user2@example.com', message: 'New Grant Created', alertTime: '2025-01-15T10:30:00.000Z', - sent: false + sent: false, + grantId: 100, } as Notification; mockNotification_id2_user1 = { @@ -111,7 +113,8 @@ describe('NotificationController', () => { userEmail: 'user1@example.com', message: 'New Grant Created', alertTime: '2025-01-15T10:30:00.000Z', - sent: false + sent: false, + grantId: 200, } as Notification; mockNotification_id2_user2 = { @@ -119,7 +122,8 @@ describe('NotificationController', () => { userEmail: 'user2@example.com', message: 'New Grant Created', alertTime: '2025-01-15T10:30:00.000Z', - sent: false + sent: false, + grantId: 200, } as Notification; mockPut.mockReturnValue({ promise: mockPromise }); @@ -268,13 +272,14 @@ describe('NotificationController', () => { }); it('should create notification with valid data in the set table', async () => { - const mockNotification = { + const mockNotification: Notification = { notificationId: '123', userEmail: 'user@example.com', message: 'Test notification', alertTime: '2024-01-15T10:30:00.000Z', - sent: false - } as Notification; + sent: false, + grantId: 42, + }; const result = await notificationService.createNotification(mockNotification); @@ -286,7 +291,8 @@ describe('NotificationController', () => { userEmail: 'user@example.com', message: 'Test notification', alertTime: '2024-01-15T10:30:00.000Z', - sent: false + sent: false, + grantId: 42, }, }); expect(result).toEqual(mockNotification); @@ -295,13 +301,14 @@ describe('NotificationController', () => { it('should create notification with fallback table name when environment variable is not set', async () => { delete process.env.DYNAMODB_NOTIFICATION_TABLE_NAME; - const mockNotification = { + const mockNotification: Notification = { notificationId: '123', userEmail: 'user@example.com', message: 'Test notification', alertTime: '2024-01-15T10:30:00.000Z', - sent: false - } as Notification; + sent: false, + grantId: 42, + }; const result = await notificationService.createNotification(mockNotification); expect(result).toEqual(mockNotification); @@ -313,55 +320,60 @@ describe('NotificationController', () => { userEmail: 'user@example.com', message: 'Test notification', alertTime: '2024-01-15T10:30:00.000Z', - sent: false + sent: false, + grantId: 42, }, }); }); it('should throw BadRequestException when userEmail is missing', async () => { - const invalidNotification = { + const invalidNotification: Notification = { notificationId: '123', userEmail: '', message: 'Test', alertTime: '2024-01-15T10:30:00.000Z', - sent: false - } as Notification; + sent: false, + grantId: 1, + }; await expect(notificationService.createNotification(invalidNotification)).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException when notificationId is missing', async () => { - const invalidNotification = { + const invalidNotification: Notification = { notificationId: '', userEmail: 'user@example.com', message: 'Test', alertTime: '2024-01-15T10:30:00.000Z', - sent: false - } as Notification; + sent: false, + grantId: 1, + }; await expect(notificationService.createNotification(invalidNotification)).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException for invalid alertTime', async () => { - const invalidNotification = { + const invalidNotification: Notification = { notificationId: '123', userEmail: 'user@example.com', message: 'Test', alertTime: 'not-a-valid-date' as any, - sent: false - } as Notification; + sent: false, + grantId: 1, + }; await expect(notificationService.createNotification(invalidNotification)).rejects.toThrow(BadRequestException); }); it('should throw InternalServerErrorException when DynamoDB fails on create', async () => { - const validNotification = { + const validNotification: Notification = { notificationId: '123', userEmail: 'user@example.com', message: 'Test', alertTime: '2024-01-15T10:30:00.000Z', - sent: false - } as Notification; + sent: false, + grantId: 1, + }; mockPromise.mockRejectedValueOnce(new Error('DynamoDB service unavailable')); @@ -473,4 +485,61 @@ describe('NotificationController', () => { await expect(notificationService.deleteNotification('123')).rejects.toThrow(InternalServerErrorException); }); }); + + describe('getNotificationsByGrantId', () => { + it('should return notifications matching the given grantId', async () => { + const matchingNotifications = [mockNotification_id1_user1, mockNotification_id1_user2]; + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: matchingNotifications }) }); + + const result = await notificationService.getNotificationsByGrantId(100); + + expect(mockScan).toHaveBeenCalledWith({ + TableName: 'BCANNotifications', + FilterExpression: 'grantId = :grantId', + ExpressionAttributeValues: { ':grantId': 100 }, + }); + expect(result).toEqual(matchingNotifications); + }); + + it('should return empty array when no notifications match', async () => { + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: [] }) }); + + const result = await notificationService.getNotificationsByGrantId(999); + + expect(result).toEqual([]); + }); + + it('should throw InternalServerErrorException when DynamoDB scan fails', async () => { + mockScan.mockReturnValueOnce({ promise: vi.fn().mockRejectedValue(new Error('scan failed')) }); + + await expect(notificationService.getNotificationsByGrantId(100)).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('updateNotificationsUserEmailByGrantId', () => { + it('should update userEmail on all notifications for the given grantId', async () => { + const matchingNotifications = [mockNotification_id1_user1, mockNotification_id2_user1]; + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: matchingNotifications }) }); + const mockUpdateResponse = { Attributes: {} }; + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue(mockUpdateResponse) }); + + await notificationService.updateNotificationsUserEmailByGrantId(100, 'newemail@example.com'); + + expect(mockUpdate).toHaveBeenCalledTimes(2); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { notificationId: '1' }, + ExpressionAttributeValues: expect.objectContaining({ ':userEmail': 'newemail@example.com' }), + }) + ); + }); + + it('should do nothing when no notifications exist for the grantId', async () => { + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: [] }) }); + + await notificationService.updateNotificationsUserEmailByGrantId(999, 'newemail@example.com'); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/backend/src/notifications/notification.service.ts b/backend/src/notifications/notification.service.ts index e28fa545..aaba9e08 100644 --- a/backend/src/notifications/notification.service.ts +++ b/backend/src/notifications/notification.service.ts @@ -100,6 +100,7 @@ export class NotificationService { } this.logger.log(`Retrieved ${data.Items.length} notifications for user ${email}`); + console.log("Notifications retrieved: ", data.Items); return data.Items as Notification[]; } catch (error) { this.logger.error(`Error retrieving notifications for user : ${email}`, error as string); @@ -217,6 +218,42 @@ export class NotificationService { } + // Function to get all notifications belonging to a given grant + async getNotificationsByGrantId(grantId: number): Promise { + this.logger.log(`Fetching notifications for grantId: ${grantId}`); + const tableName = process.env.DYNAMODB_NOTIFICATION_TABLE_NAME || 'TABLE_FAILURE'; + + const params = { + TableName: tableName, + FilterExpression: 'grantId = :grantId', + ExpressionAttributeValues: { + ':grantId': grantId, + }, + }; + + try { + const data = await this.dynamoDb.scan(params).promise(); + this.logger.log(`Found ${data.Items?.length ?? 0} notifications for grantId: ${grantId}`); + return (data.Items || []) as Notification[]; + } catch (error) { + this.logger.error(`Failed to retrieve notifications for grantId: ${grantId}`, error); + throw new InternalServerErrorException('Failed to retrieve notifications by grant'); + } + } + + // Updates the userEmail on all notifications belonging to a grant + async updateNotificationsUserEmailByGrantId(grantId: number, newEmail: string): Promise { + this.logger.log(`Updating userEmail to ${newEmail} for all notifications of grantId: ${grantId}`); + + const notifications = await this.getNotificationsByGrantId(grantId); + + for (const notification of notifications) { + await this.updateNotification(notification.notificationId, { userEmail: newEmail }); + } + + this.logger.log(`Updated ${notifications.length} notifications for grantId: ${grantId}`); + } + /** * 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/types/notification.types.ts b/backend/src/notifications/types/notification.types.ts index 86aa185b..7e0210e3 100644 --- a/backend/src/notifications/types/notification.types.ts +++ b/backend/src/notifications/types/notification.types.ts @@ -6,4 +6,5 @@ export class NotificationBody { message!: string; alertTime!: TDateISO; sent!: boolean; + grantId!: number; } \ No newline at end of file diff --git a/frontend/src/main-page/notifications/processNotificationData.ts b/frontend/src/main-page/notifications/processNotificationData.ts index 8ed6207f..5f5ac7e9 100644 --- a/frontend/src/main-page/notifications/processNotificationData.ts +++ b/frontend/src/main-page/notifications/processNotificationData.ts @@ -5,15 +5,21 @@ import { setNotifications } from "../../external/bcanSatchel/actions"; const store = getAppStore(); -export const fetchNotifications = async ( - -) => { +export const fetchNotifications = async () => { try { const response = await api(`/notifications/user/${store.user?.email}/current`); if (!response.ok) { throw new Error(`HTTP Error, Status: ${response.status}`); } - const updatedNotifications: Notification[] = await response.json(); + + const payload = (await response.json()) as unknown; + const updatedNotifications: Notification[] = Array.isArray(payload) + ? (payload as Notification[]) + : Array.isArray((payload as { Items?: unknown[] })?.Items) + ? ((payload as { Items: Notification[] }).Items) + : []; + + console.log("Fetched notifications: ", updatedNotifications); setNotifications(updatedNotifications); } catch (error) { console.error("Error fetching notifications:", error); diff --git a/middle-layer/types/Notification.ts b/middle-layer/types/Notification.ts index 8aebf6c3..2b144ddd 100644 --- a/middle-layer/types/Notification.ts +++ b/middle-layer/types/Notification.ts @@ -9,4 +9,5 @@ export interface Notification { message: string; alertTime: TDateISO; // Sort sent: boolean; // email has been sent for this notification + grantId: number; // the grant this notification belongs to } \ No newline at end of file From d53c4d9845c860115db65b87b0b78e37b2104399 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 14:49:41 -0400 Subject: [PATCH 03/15] logs to show filtered notif count --- backend/src/notifications/notification.service.ts | 5 ++++- .../main-page/notifications/processNotificationData.ts | 9 +-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/src/notifications/notification.service.ts b/backend/src/notifications/notification.service.ts index aaba9e08..353d64b9 100644 --- a/backend/src/notifications/notification.service.ts +++ b/backend/src/notifications/notification.service.ts @@ -56,7 +56,10 @@ export class NotificationService { const currentTime = new Date(); this.logger.log(`Found current notifications for userEmail ${userEmail}`); - return notifactions.filter(notification => new Date(notification.alertTime) <= currentTime); + + const currentNotifications = notifactions.filter(notification => new Date(notification.alertTime) <= currentTime); + this.logger.log(`Filtered current notifications for userEmail ${userEmail}, count: ${currentNotifications.length}`); + return currentNotifications; } catch (error) { this.logger.error("Failed to notifications by user id error: " + error) throw error; diff --git a/frontend/src/main-page/notifications/processNotificationData.ts b/frontend/src/main-page/notifications/processNotificationData.ts index 5f5ac7e9..be17c63c 100644 --- a/frontend/src/main-page/notifications/processNotificationData.ts +++ b/frontend/src/main-page/notifications/processNotificationData.ts @@ -11,14 +11,7 @@ export const fetchNotifications = async () => { if (!response.ok) { throw new Error(`HTTP Error, Status: ${response.status}`); } - - const payload = (await response.json()) as unknown; - const updatedNotifications: Notification[] = Array.isArray(payload) - ? (payload as Notification[]) - : Array.isArray((payload as { Items?: unknown[] })?.Items) - ? ((payload as { Items: Notification[] }).Items) - : []; - + const updatedNotifications: Notification[] = await response.json(); console.log("Fetched notifications: ", updatedNotifications); setNotifications(updatedNotifications); } catch (error) { From 8f83f6b6aa376f77f025a81f680d2d8a3b80eeb2 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 15:17:45 -0400 Subject: [PATCH 04/15] update notifs if app deadline or report deadlines are edited for grant& added fetch notifs on grant creation/save --- backend/src/grant/grant.service.ts | 70 ++++++++++++++++++- .../edit-grant/processGrantDataEditSave.ts | 3 + 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index d022abf4..73a8ce0b 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -330,7 +330,6 @@ export class GrantService { this.logger.debug(`Executing DynamoDB update for grant ${grantData.grantId}`); const result = await this.dynamoDb.update(params).promise(); this.logger.log(`Successfully updated grant ${grantData.grantId} in database`); - //await this.updateGrantNotifications(grantData); // IF BCAN POC IS UPDATED, UPDATE ALL NOTIFICATIONS LINKED TO THAT GRANT TO THE NEW POC EMAIL if (updateKeys.includes('bcan_poc') && grantData.bcan_poc?.POC_email) { @@ -340,6 +339,75 @@ export class GrantService { grantData.bcan_poc.POC_email, ); } + + const needsAppRebuild = updateKeys.includes('application_deadline'); + const needsReportRebuild = updateKeys.includes('report_deadlines'); + + if (needsAppRebuild || needsReportRebuild) { + const email = grantData.bcan_poc?.POC_email || undefined; + + if (!email) { + this.logger.warn(`No POC email on grant ${grantData.grantId}; skipping notification rebuild`); + } else { + const existingNotifs = await this.notificationService.getNotificationsByGrantId(grantData.grantId); + + if (needsAppRebuild) { + this.logger.debug(`Rebuilding application notifications for grant ${grantData.grantId}`); + const appNotifs = existingNotifs.filter(n => n.notificationId.includes('-app-')); + for (const n of appNotifs) { + try { + await this.notificationService.deleteNotification(n.notificationId); + } catch (err) { + this.logger.warn(`Failed to delete app notification ${n.notificationId}: ${err instanceof Error ? err.message : err}`); + } + } + + if (grantData.application_deadline) { + const alertTimes = this.getNotificationTimes(grantData.application_deadline); + for (const alertTime of alertTimes) { + const message = `Application due in ${this.daysUntil(alertTime, grantData.application_deadline)} days for ${grantData.organization}`; + await this.notificationService.createNotification({ + notificationId: `${grantData.grantId}-app-${alertTime}`, + userEmail: email, + message, + alertTime: alertTime as TDateISO, + sent: false, + grantId: grantData.grantId, + }); + } + } + } + + if (needsReportRebuild) { + this.logger.debug(`Rebuilding report notifications for grant ${grantData.grantId}`); + const reportNotifs = existingNotifs.filter(n => n.notificationId.includes('-report-')); + for (const n of reportNotifs) { + try { + await this.notificationService.deleteNotification(n.notificationId); + } catch (err) { + this.logger.warn(`Failed to delete report notification ${n.notificationId}: ${err instanceof Error ? err.message : err}`); + } + } + if (grantData.report_deadlines && Array.isArray(grantData.report_deadlines)) { + for (const reportDeadline of grantData.report_deadlines) { + const alertTimes = this.getNotificationTimes(reportDeadline); + for (const alertTime of alertTimes) { + const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${updatedGrant.organization}`; + await this.notificationService.createNotification({ + notificationId: `${grantData.grantId}-report-${alertTime}`, + userEmail: email, + message, + alertTime: alertTime as TDateISO, + sent: false, + grantId: grantData.grantId, + }); + } + } + } + } + } + } + return JSON.stringify(result); } catch(error: unknown) { // Re-throw NestJS exceptions diff --git a/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts b/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts index 1a57637a..83c6e325 100644 --- a/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts +++ b/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts @@ -3,6 +3,7 @@ import { Grant } from "../../../../../middle-layer/types/Grant.ts"; import { api } from "../../../api.ts"; import { GrantFormState } from "./EditGrant.tsx"; import { fetchGrants } from "../filter-bar/processGrantData.ts"; +import { fetchNotifications } from "../../../main-page/notifications/processNotificationData.ts"; export type GrantMutationResult = | { success: true; grantId?: number } @@ -22,6 +23,7 @@ export const createNewGrant = async ( if (response.ok) { const createdGrantId = (await response.json()) as number; await fetchGrants(); + await fetchNotifications(); return { success: true, grantId: createdGrantId }; } else { const errorData = await response.json(); @@ -53,6 +55,7 @@ export const saveGrantEdits = async ( if (response.ok) { await fetchGrants(); + await fetchNotifications(); return { success: true }; } else { const errorData = await response.json(); From 45bb1b3a6405862cab9dec46f72f48311b4cff4d Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 15:49:56 -0400 Subject: [PATCH 05/15] only useful notifs created, fetch notifs on delete, fixed display for grant time, made saving profile button disabled while loading --- backend/src/grant/grant.service.ts | 27 ++++++++++++++++--- .../edit-grant/processGrantDataEditSave.ts | 3 ++- .../notifications/GrantNotification.tsx | 11 +++++++- frontend/src/main-page/settings/Settings.tsx | 6 +++++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 73a8ce0b..f47df1dd 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -392,7 +392,7 @@ export class GrantService { for (const reportDeadline of grantData.report_deadlines) { const alertTimes = this.getNotificationTimes(reportDeadline); for (const alertTime of alertTimes) { - const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${updatedGrant.organization}`; + const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${grantData.organization}`; await this.notificationService.createNotification({ notificationId: `${grantData.grantId}-report-${alertTime}`, userEmail: email, @@ -537,7 +537,21 @@ export class GrantService { this.logger.debug(`Executing DynamoDB delete for grant ${grantId}`); await this.dynamoDb.delete(params).promise(); this.logger.log(`Successfully deleted grant ${grantId} from database`); - return `Grant ${grantId} deleted successfully`; + + // Delete associated notifications + this.logger.debug(`Deleting notifications associated with grant ${grantId}`); + const notifications = await this.notificationService.getNotificationsByGrantId(grantId); + for (const n of notifications) { + try { + await this.notificationService.deleteNotification(n.notificationId); + this.logger.debug(`Deleted notification ${n.notificationId} associated with grant ${grantId}`); + } catch (err) { + this.logger.warn(`Failed to delete notification ${n.notificationId} associated with grant ${grantId}: ${err instanceof Error ? err.message : err}`); + } + } + + return `Grant ${grantId} and notifications deleted successfully`; + } catch (error: unknown) { // Re-throw NestJS exceptions if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { @@ -561,14 +575,19 @@ export class GrantService { } // Calculates notification times for a deadline (14, 7, and 3 days before) + // update: returns only relevant times (ex: if a deadline is 10 days away, only return 7 and 3 day notifications) private getNotificationTimes(deadlineISO: string): string[] { const deadline = new Date(deadlineISO); const daysBefore = [14, 7, 3]; - return daysBefore.map(days => { + const allNotificationTimes = daysBefore.map(days => { const d = new Date(deadline); d.setDate(deadline.getDate() - days); - return d.toISOString(); + return d; }); + + return allNotificationTimes + .filter(d => d > new Date()) // only return future notification times + .map(d => d.toISOString()); } // Creates notifications for a grant's application and report deadlines diff --git a/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts b/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts index 83c6e325..ebf95eda 100644 --- a/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts +++ b/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts @@ -88,8 +88,9 @@ export const deleteGrant = async (grantId: any) => { if (response.ok) { console.log("✅ Grant deleted successfully"); - // Refetch grants to update UI + // Refetch grants and notifications to update UI await fetchGrants(); + await fetchNotifications(); } else { // Get error details const errorText = await response.text(); diff --git a/frontend/src/main-page/notifications/GrantNotification.tsx b/frontend/src/main-page/notifications/GrantNotification.tsx index a17bb2a6..f69ed950 100644 --- a/frontend/src/main-page/notifications/GrantNotification.tsx +++ b/frontend/src/main-page/notifications/GrantNotification.tsx @@ -11,7 +11,16 @@ interface GrantNotificationProps { } function formatAlertTime(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { + const date = new Date(dateStr); + const diffDays = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24); + if (diffDays > 6) { + const m = date.getMonth() + 1; + const d = date.getDate(); + const y = date.getFullYear(); + const time = date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + return `${m}/${d}/${y} | ${time}`; + } + return date.toLocaleString('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit', diff --git a/frontend/src/main-page/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index ad90040f..4099a3cd 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -33,6 +33,7 @@ function Settings() { const [changePasswordError, setChangePasswordError] = useState(null); const [isProfilePictureModalOpen, setIsProfilePictureModalOpen] = useState(false); const [profilePictureMessage, setProfilePictureMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (user) { @@ -74,6 +75,7 @@ function Settings() { return; } + setIsSaving(true); try { const response = await api("/auth/update-profile", { method: "POST", @@ -91,6 +93,7 @@ function Settings() { (errorBody && (errorBody.message as string)) || "Failed to update profile. Please try again."; setPersonalInfoError(message); + setIsSaving(false); return; } const updatedUser = { @@ -112,6 +115,8 @@ function Settings() { } catch (error) { console.error("Error updating profile:", error); setPersonalInfoError("An unexpected error occurred. Please try again."); + } finally { + setIsSaving(false); } }; @@ -275,6 +280,7 @@ function Settings() { text="Save" onClick={handleSaveEdit} className="bg-primary-900 text-white" + disabled={isSaving} /> From 5095861c5b097b6babbc2e24fe898839d1d0d3b8 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 16:10:34 -0400 Subject: [PATCH 06/15] small comment --- backend/src/grant/grant.service.ts | 1 + frontend/src/context/auth/authContext.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index f47df1dd..34b87405 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -340,6 +340,7 @@ export class GrantService { ); } + // IF APPLICATION DEADLINE OR REPORT DEADLINES ARE UPDATED, RECALCULATE NOTIFICATIONS FOR THAT GRANT const needsAppRebuild = updateKeys.includes('application_deadline'); const needsReportRebuild = updateKeys.includes('report_deadlines'); diff --git a/frontend/src/context/auth/authContext.tsx b/frontend/src/context/auth/authContext.tsx index ff1da795..860a159c 100644 --- a/frontend/src/context/auth/authContext.tsx +++ b/frontend/src/context/auth/authContext.tsx @@ -61,13 +61,13 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => fetchCosts(), fetchRevenues(), fetchCashflowSettings(), - fetchNotifications(), ]); }; await Promise.all([ fetchUsers(), fetchGrants(), + fetchNotifications(), ]); }; From 6b2c64fe187e7eca19b8dcf3d3feb24f0d437607 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 17:51:03 -0400 Subject: [PATCH 07/15] fixed deleting issue and made it so editing grant only edits notifs if it needs to happen --- .../src/grant/__test__/grant.service.spec.ts | 89 ++++++++++++------- backend/src/grant/grant.controller.ts | 4 +- backend/src/grant/grant.service.ts | 59 +++++++----- .../__test__/notification.service.test.ts | 69 +++++++++++--- .../src/notifications/notification.service.ts | 25 ++++-- 5 files changed, 175 insertions(+), 71 deletions(-) diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index 11ef869e..b85a2103 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -673,7 +673,9 @@ describe('Notification helpers', () => { notificationServiceMock = { createNotification: vi.fn().mockResolvedValue(undefined), updateNotification: vi.fn().mockResolvedValue(undefined), - updateNotificationsUserEmailByGrantId: vi.fn().mockResolvedValue(undefined), + updateNotificationsEmailAndOrgByGrantId: vi.fn().mockResolvedValue(undefined), + getNotificationsByGrantId: vi.fn().mockResolvedValue([]), + deleteNotification: vi.fn().mockResolvedValue(undefined), }; grantServiceWithMockNotif = new GrantService(notificationServiceMock); @@ -823,48 +825,75 @@ describe('Notification helpers', () => { }); }); - describe('updateGrant bcan_poc notification sync', () => { - it('should call updateNotificationsUserEmailByGrantId when bcan_poc is updated', async () => { - const mockUpdatedGrant: 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: [], - description: '', - timeline: 12, - estimated_completion_time: 365, - grantmaker_poc: { POC_name: 'Sarah', POC_email: 'sarah@test.com' }, - bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' }, - attachments: [], - isRestricted: false, - }; + describe('updateGrant notification sync', () => { + const baseGrant: 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: [], + description: '', + 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, + }; + it('should call updateNotificationsEmailAndOrgByGrantId when bcan_poc email changes', async () => { + mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) }); mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); - await grantServiceWithMockNotif.updateGrant(mockUpdatedGrant); + const updatedGrant = { ...baseGrant, bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' } }; + await grantServiceWithMockNotif.updateGrant(updatedGrant); - expect(notificationServiceMock.updateNotificationsUserEmailByGrantId).toHaveBeenCalledWith( + expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).toHaveBeenCalledWith( 100, 'newpoc@test.com', + 'Boston Cares', ); }); - it('should not call updateNotificationsUserEmailByGrantId when bcan_poc is not in the update', async () => { - const mockUpdatedGrant = { - grantId: 100, - organization: 'Boston Cares Updated', - amount: 15000, - } as unknown as Grant; + it('should call updateNotificationsEmailAndOrgByGrantId when organization changes', async () => { + mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) }); + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); + + const updatedGrant = { ...baseGrant, organization: 'Boston Cares Updated' }; + await grantServiceWithMockNotif.updateGrant(updatedGrant); + + expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).toHaveBeenCalledWith( + 100, + 'tom@test.com', + 'Boston Cares Updated', + ); + }); + + it('should not call updateNotificationsEmailAndOrgByGrantId when neither email nor org changed', async () => { + mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) }); + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); + + const updatedGrant = { ...baseGrant, amount: 99999 }; + await grantServiceWithMockNotif.updateGrant(updatedGrant); + expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).not.toHaveBeenCalled(); + }); + + it('should not call updateNotificationsEmailAndOrgByGrantId when deadlines change (rebuild takes priority)', async () => { + mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) }); mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); - await grantServiceWithMockNotif.updateGrant(mockUpdatedGrant); + const updatedGrant = { + ...baseGrant, + bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' }, + application_deadline: '2026-06-01T00:00:00.000Z' as TDateISO, + }; + await grantServiceWithMockNotif.updateGrant(updatedGrant); - expect(notificationServiceMock.updateNotificationsUserEmailByGrantId).not.toHaveBeenCalled(); + expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).not.toHaveBeenCalled(); }); }); }); diff --git a/backend/src/grant/grant.controller.ts b/backend/src/grant/grant.controller.ts index 7996f3f6..9a70d30d 100644 --- a/backend/src/grant/grant.controller.ts +++ b/backend/src/grant/grant.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Put, Body, Patch, Post, Delete, ValidationPipe, Logger, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Put, Body, Patch, Post, Delete, ValidationPipe, Logger, UseGuards, ParseIntPipe } from '@nestjs/common'; import { GrantService } from './grant.service'; import { Grant } from '../../../middle-layer/types/Grant'; import { VerifyUserGuard } from '../guards/auth.guard'; @@ -100,7 +100,7 @@ export class GrantController { @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) @ApiResponse({ status: 500, description: 'Internal Server Error - AWS error or server configuration issue', example: '{Error occurred}' }) - async deleteGrant(@Param('grantId') grantId: number): Promise { + async deleteGrant(@Param('grantId', ParseIntPipe) grantId: number): Promise { this.logger.log(`DELETE /grant/${grantId} - Deleting grant`); const result = await this.grantService.deleteGrantById(grantId); this.logger.log(`DELETE /grant/${grantId} - Successfully deleted grant`); diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 34b87405..2dd62fd8 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -327,25 +327,24 @@ export class GrantService { } try { + const oldGrant = await this.getGrantById(grantData.grantId); + this.logger.debug(`Executing DynamoDB update for grant ${grantData.grantId}`); const result = await this.dynamoDb.update(params).promise(); this.logger.log(`Successfully updated grant ${grantData.grantId} in database`); - // IF BCAN POC IS UPDATED, UPDATE ALL NOTIFICATIONS LINKED TO THAT GRANT TO THE NEW POC EMAIL - if (updateKeys.includes('bcan_poc') && grantData.bcan_poc?.POC_email) { - this.logger.debug(`bcan_poc changed for grant ${grantData.grantId}; syncing notification userEmails`); - await this.notificationService.updateNotificationsUserEmailByGrantId( - grantData.grantId, - grantData.bcan_poc.POC_email, - ); - } + // compare old to new fields to see if application deadline or report deadlines changed + const needsAppRebuild = + oldGrant.application_deadline !== grantData.application_deadline; + const needsReportRebuild = + JSON.stringify(oldGrant.report_deadlines) !== JSON.stringify(grantData.report_deadlines); - // IF APPLICATION DEADLINE OR REPORT DEADLINES ARE UPDATED, RECALCULATE NOTIFICATIONS FOR THAT GRANT - const needsAppRebuild = updateKeys.includes('application_deadline'); - const needsReportRebuild = updateKeys.includes('report_deadlines'); + const needsBCANPocEmailUpdate = oldGrant.bcan_poc?.POC_email !== grantData.bcan_poc?.POC_email; + const needsOrganizationUpdate = oldGrant.organization !== grantData.organization; + // IF APPLICATION DEADLINE OR REPORT DEADLINES ACTUALLY CHANGED, REBUILD NOTIFICATIONS BY DELETING OLD ONES AND CREATING NEW ONES if (needsAppRebuild || needsReportRebuild) { - const email = grantData.bcan_poc?.POC_email || undefined; + const email = grantData.bcan_poc?.POC_email || oldGrant.bcan_poc?.POC_email; if (!email) { this.logger.warn(`No POC email on grant ${grantData.grantId}; skipping notification rebuild`); @@ -362,7 +361,7 @@ export class GrantService { this.logger.warn(`Failed to delete app notification ${n.notificationId}: ${err instanceof Error ? err.message : err}`); } } - + if (grantData.application_deadline) { const alertTimes = this.getNotificationTimes(grantData.application_deadline); for (const alertTime of alertTimes) { @@ -408,6 +407,15 @@ export class GrantService { } } } + // OTHERWISE, UPDATE EXISTING NOTIFS IF BCAN POC EMAIL/ORGANIZATION CHANGED, UPDATE ALL NOTIFICATIONS TO THE NEW EMAIL/ORG + else if (needsBCANPocEmailUpdate || needsOrganizationUpdate) { + this.logger.debug(`bcan_poc/organization changed for grant ${grantData.grantId}; syncing notification userEmails and messages`); + await this.notificationService.updateNotificationsEmailAndOrgByGrantId( + grantData.grantId, + grantData.bcan_poc.POC_email, + grantData.organization + ); + } return JSON.stringify(result); } catch(error: unknown) { @@ -539,19 +547,24 @@ export class GrantService { await this.dynamoDb.delete(params).promise(); this.logger.log(`Successfully deleted grant ${grantId} from database`); - // Delete associated notifications - this.logger.debug(`Deleting notifications associated with grant ${grantId}`); - const notifications = await this.notificationService.getNotificationsByGrantId(grantId); - for (const n of notifications) { - try { - await this.notificationService.deleteNotification(n.notificationId); - this.logger.debug(`Deleted notification ${n.notificationId} associated with grant ${grantId}`); - } catch (err) { - this.logger.warn(`Failed to delete notification ${n.notificationId} associated with grant ${grantId}: ${err instanceof Error ? err.message : err}`); + // Delete associated notifications — isolated so failures don't affect the grant deletion response + try { + this.logger.debug(`Deleting notifications associated with grant ${grantId}`); + const notifications = await this.notificationService.getNotificationsByGrantId(grantId); + for (const n of notifications) { + try { + await this.notificationService.deleteNotification(n.notificationId); + this.logger.debug(`Deleted notification ${n.notificationId} for grant ${grantId}`); + } catch (err) { + this.logger.warn(`Failed to delete notification ${n.notificationId} for grant ${grantId}: ${err instanceof Error ? err.message : err}`); + } } + this.logger.log(`Deleted ${notifications.length} notifications for grant ${grantId}`); + } catch (err) { + this.logger.error(`Failed to fetch notifications for grant ${grantId} during cleanup: ${err instanceof Error ? err.message : err}`); } - return `Grant ${grantId} and notifications deleted successfully`; + return `Grant ${grantId} deleted successfully`; } catch (error: unknown) { // Re-throw NestJS exceptions diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 8453edc4..6324b385 100644 --- a/backend/src/notifications/__test__/notification.service.test.ts +++ b/backend/src/notifications/__test__/notification.service.test.ts @@ -516,28 +516,77 @@ describe('NotificationController', () => { }); }); - describe('updateNotificationsUserEmailByGrantId', () => { - it('should update userEmail on all notifications for the given grantId', async () => { - const matchingNotifications = [mockNotification_id1_user1, mockNotification_id2_user1]; - mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: matchingNotifications }) }); - const mockUpdateResponse = { Attributes: {} }; - mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue(mockUpdateResponse) }); + describe('updateNotificationsEmailAndOrgByGrantId', () => { + const notifWithOrg = (overrides: Partial) => ({ + ...mockNotification_id1_user1, + message: 'Application due in 30 days for OldOrg', + ...overrides, + }); + + it('should update userEmail when it differs from new email', async () => { + const notifications = [notifWithOrg({ userEmail: 'old@example.com', notificationId: '1' })]; + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); + + await notificationService.updateNotificationsEmailAndOrgByGrantId(100, 'new@example.com', 'OldOrg'); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { notificationId: '1' }, + ExpressionAttributeValues: expect.objectContaining({ ':userEmail': 'new@example.com' }), + }) + ); + }); + + it('should update message when org differs', async () => { + const notifications = [notifWithOrg({ userEmail: 'same@example.com', notificationId: '1' })]; + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); + + await notificationService.updateNotificationsEmailAndOrgByGrantId(100, 'same@example.com', 'NewOrg'); - await notificationService.updateNotificationsUserEmailByGrantId(100, 'newemail@example.com'); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { notificationId: '1' }, + ExpressionAttributeValues: expect.objectContaining({ ':message': 'Application due in 30 days for NewOrg' }), + }) + ); + }); + + it('should update both fields when both differ', async () => { + const notifications = [notifWithOrg({ userEmail: 'old@example.com', notificationId: '1' })]; + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) }); - expect(mockUpdate).toHaveBeenCalledTimes(2); + await notificationService.updateNotificationsEmailAndOrgByGrantId(100, 'new@example.com', 'NewOrg'); + + expect(mockUpdate).toHaveBeenCalledTimes(1); expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ Key: { notificationId: '1' }, - ExpressionAttributeValues: expect.objectContaining({ ':userEmail': 'newemail@example.com' }), + ExpressionAttributeValues: expect.objectContaining({ + ':userEmail': 'new@example.com', + ':message': 'Application due in 30 days for NewOrg', + }), }) ); }); + it('should skip update when neither email nor org changed', async () => { + const notifications = [notifWithOrg({ userEmail: 'same@example.com', notificationId: '1' })]; + mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + + await notificationService.updateNotificationsEmailAndOrgByGrantId(100, 'same@example.com', 'OldOrg'); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + it('should do nothing when no notifications exist for the grantId', async () => { mockScan.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: [] }) }); - await notificationService.updateNotificationsUserEmailByGrantId(999, 'newemail@example.com'); + await notificationService.updateNotificationsEmailAndOrgByGrantId(999, 'new@example.com', 'NewOrg'); expect(mockUpdate).not.toHaveBeenCalled(); }); diff --git a/backend/src/notifications/notification.service.ts b/backend/src/notifications/notification.service.ts index 353d64b9..f0180e7e 100644 --- a/backend/src/notifications/notification.service.ts +++ b/backend/src/notifications/notification.service.ts @@ -103,7 +103,7 @@ export class NotificationService { } this.logger.log(`Retrieved ${data.Items.length} notifications for user ${email}`); - console.log("Notifications retrieved: ", data.Items); + console.log("Notifications retrieved: ", data.Items.map(item => item.message)); return data.Items as Notification[]; } catch (error) { this.logger.error(`Error retrieving notifications for user : ${email}`, error as string); @@ -244,14 +244,27 @@ export class NotificationService { } } - // Updates the userEmail on all notifications belonging to a grant - async updateNotificationsUserEmailByGrantId(grantId: number, newEmail: string): Promise { - this.logger.log(`Updating userEmail to ${newEmail} for all notifications of grantId: ${grantId}`); + // Updates the userEmail and organization on all notifications belonging to a grant + async updateNotificationsEmailAndOrgByGrantId(grantId: number, newEmail: string, newOrg: string): Promise { + this.logger.log(`Updating userEmail to ${newEmail} and organization to ${newOrg} for all notifications of grantId: ${grantId}`); const notifications = await this.getNotificationsByGrantId(grantId); - for (const notification of notifications) { - await this.updateNotification(notification.notificationId, { userEmail: newEmail }); + for (const n of notifications) { + const updates: Partial = {}; + + if (n.userEmail !== newEmail) { + updates.userEmail = newEmail; + } + + const updatedMessage = n.message.replace(/ for .+$/, ` for ${newOrg}`); + if (updatedMessage !== n.message) { + updates.message = updatedMessage; + } + + if (Object.keys(updates).length > 0) { + await this.updateNotification(n.notificationId, updates); + } } this.logger.log(`Updated ${notifications.length} notifications for grantId: ${grantId}`); From f84016951721a116b0ddf25a44ee282d7bc256f1 Mon Sep 17 00:00:00 2001 From: Lyanne Xu Date: Wed, 15 Apr 2026 20:39:40 -0400 Subject: [PATCH 08/15] have updategrantsbyPOC fetch grants with pagination Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/src/grant/grant.service.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 2dd62fd8..773d0e92 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -753,20 +753,31 @@ export class GrantService { throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); } - const data = await this.dynamoDb.scan({ TableName: tableName }).promise(); - const grants = (data.Items as Grant[]) || []; + const normalizedCurrentEmail = currentEmail.toLowerCase(); + const grants: Grant[] = []; + let lastEvaluatedKey: AWS.DynamoDB.DocumentClient.Key | undefined; + + do { + const data = await this.dynamoDb.scan({ + TableName: tableName, + ExclusiveStartKey: lastEvaluatedKey, + }).promise(); + + grants.push(...(((data.Items as Grant[]) || []))); + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); const affectedGrants = grants.filter( - (g) => g.bcan_poc?.POC_email?.toLowerCase() === currentEmail.toLowerCase() + (g) => g.bcan_poc?.POC_email?.toLowerCase() === normalizedCurrentEmail ); this.logger.log(`Found ${affectedGrants.length} grants to update`); for (const grant of affectedGrants) { - await this.updateGrant({ + await this.updateGrant({ ...grant, bcan_poc: { POC_name: newName, POC_email: newEmail }, - }); + }); } this.logger.log(`Successfully updated ${affectedGrants.length} grants for new POC info`); From 39db0a5521cc2fc5c3d87c3baf9b423d51d8ea0a Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 20:50:33 -0400 Subject: [PATCH 09/15] resolved copilot comments --- .../src/auth/__test__/auth.service.spec.ts | 8 +- backend/src/auth/auth.module.ts | 1 - backend/src/auth/auth.service.ts | 1 - .../__test__/notification.service.test.ts | 79 +++++++++++++++++++ .../src/notifications/notification.service.ts | 51 +++++++++--- backend/src/user/user.module.ts | 2 + backend/src/user/user.service.ts | 10 +++ frontend/src/main-page/notifications/Bell.tsx | 3 - .../notifications/processNotificationData.ts | 7 +- frontend/src/main-page/settings/Settings.tsx | 2 + 10 files changed, 142 insertions(+), 22 deletions(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 596a6031..491b1eba 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -10,6 +10,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; +import { GrantService } from '../../grant/grant.service'; // ─── Cognito mocks ──────────────────────────────────────────────────────────── const mockCognitoPromise = vi.fn(); @@ -110,16 +111,13 @@ describe('AuthService', () => { providers: [ AuthService, { - provide: NotificationService, - useValue: { updateNotificationsUserEmailByGrantId: vi.fn().mockResolvedValue(undefined) }, + provide: GrantService, + useValue: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) }, }, ], }).compile(); service = module.get(AuthService); - (service as any).grantService = { - updateGrantsByPOC: vi.fn().mockResolvedValue(undefined), - }; }); // ── register ──────────────────────────────────────────────────────────────── diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index d5cb4766..d4343607 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -11,7 +11,6 @@ import { GrantModule } from '../grant/grant.module'; secret: process.env.JWT_SECRET, signOptions: { expiresIn: '1h' }, }), - NotificationsModule, GrantModule, ], controllers: [AuthController], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index ac7fab88..028f8ae8 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -9,7 +9,6 @@ import { group, table } from "console"; import * as crypto from "crypto"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; -import { NotificationService } from "../notifications/notification.service"; import { GrantService } from '../grant/grant.service'; import { HttpException, diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 6324b385..8edfd815 100644 --- a/backend/src/notifications/__test__/notification.service.test.ts +++ b/backend/src/notifications/__test__/notification.service.test.ts @@ -24,6 +24,7 @@ const mockDelete = vi.fn(); const mockQuery = vi.fn(); const mockSendEmail = vi.fn(); const mockUpdate = vi.fn(); +const mockBatchWrite = vi.fn(); const mockDocumentClient = { scan: mockScan, @@ -32,6 +33,7 @@ const mockDocumentClient = { query: mockQuery, update: mockUpdate, delete: mockDelete, + batchWrite: mockBatchWrite, }; const mockSES = { @@ -69,6 +71,7 @@ describe('NotificationController', () => { mockQuery.mockReturnValue({ promise: mockPromise }); mockSendEmail.mockReturnValue({ promise: mockPromise }); mockSend.mockReturnValue({ promise: mockPromise }); + mockBatchWrite.mockReturnValue({ promise: mockPromise }); const originalEnv = process.env; process.env = { ...originalEnv }; @@ -514,6 +517,23 @@ describe('NotificationController', () => { await expect(notificationService.getNotificationsByGrantId(100)).rejects.toThrow(InternalServerErrorException); }); + + it('should accumulate results across multiple pages using LastEvaluatedKey', async () => { + const page1 = [mockNotification_id1_user1]; + const page2 = [mockNotification_id1_user2]; + mockScan + .mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: page1, LastEvaluatedKey: { notificationId: '1' } }) }) + .mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: page2, LastEvaluatedKey: undefined }) }); + + const result = await notificationService.getNotificationsByGrantId(100); + + expect(mockScan).toHaveBeenCalledTimes(2); + // second call must pass ExclusiveStartKey from first page's LastEvaluatedKey + expect(mockScan.mock.calls[1][0]).toMatchObject({ + ExclusiveStartKey: { notificationId: '1' }, + }); + expect(result).toEqual([...page1, ...page2]); + }); }); describe('updateNotificationsEmailAndOrgByGrantId', () => { @@ -591,4 +611,63 @@ describe('NotificationController', () => { expect(mockUpdate).not.toHaveBeenCalled(); }); }); + + describe('deleteNotificationsByUserEmail', () => { + it('should do nothing when user has no notifications', async () => { + mockQuery.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: [] }) }); + + await notificationService.deleteNotificationsByUserEmail('user1@example.com'); + + expect(mockBatchWrite).not.toHaveBeenCalled(); + }); + + it('should batch-delete all notifications for a user in a single call when <= 25', async () => { + const notifications = [mockNotification_id1_user1, mockNotification_id2_user1]; + mockQuery.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + mockBatchWrite.mockReturnValue({ promise: vi.fn().mockResolvedValue({}) }); + + await notificationService.deleteNotificationsByUserEmail('user1@example.com'); + + expect(mockBatchWrite).toHaveBeenCalledTimes(1); + expect(mockBatchWrite).toHaveBeenCalledWith({ + RequestItems: { + BCANNotifications: [ + { DeleteRequest: { Key: { notificationId: '1' } } }, + { DeleteRequest: { Key: { notificationId: '2' } } }, + ], + }, + }); + }); + + it('should split into multiple batchWrite calls when > 25 notifications', async () => { + const notifications = Array.from({ length: 30 }, (_, i) => ({ + notificationId: String(i), + userEmail: 'user1@example.com', + message: 'msg', + alertTime: '2024-01-15T10:30:00.000Z' as TDateISO, + sent: false, + grantId: i, + })); + mockQuery.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + mockBatchWrite.mockReturnValue({ promise: vi.fn().mockResolvedValue({}) }); + + await notificationService.deleteNotificationsByUserEmail('user1@example.com'); + + expect(mockBatchWrite).toHaveBeenCalledTimes(2); + // first call has 25 items, second has 5 + const firstCall = mockBatchWrite.mock.calls[0][0]; + const secondCall = mockBatchWrite.mock.calls[1][0]; + expect(firstCall.RequestItems['BCANNotifications']).toHaveLength(25); + expect(secondCall.RequestItems['BCANNotifications']).toHaveLength(5); + }); + + it('should throw when batchWrite fails', async () => { + const notifications = [mockNotification_id1_user1]; + mockQuery.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Items: notifications }) }); + mockBatchWrite.mockReturnValue({ promise: vi.fn().mockRejectedValue(new Error('DynamoDB error')) }); + + await expect(notificationService.deleteNotificationsByUserEmail('user1@example.com')) + .rejects.toThrow(); + }); + }); }); \ No newline at end of file diff --git a/backend/src/notifications/notification.service.ts b/backend/src/notifications/notification.service.ts index f0180e7e..46cfa486 100644 --- a/backend/src/notifications/notification.service.ts +++ b/backend/src/notifications/notification.service.ts @@ -103,7 +103,7 @@ export class NotificationService { } this.logger.log(`Retrieved ${data.Items.length} notifications for user ${email}`); - console.log("Notifications retrieved: ", data.Items.map(item => item.message)); + this.logger.debug("Notifications retrieved: ", data.Items.map(item => item.message)); return data.Items as Notification[]; } catch (error) { this.logger.error(`Error retrieving notifications for user : ${email}`, error as string); @@ -226,18 +226,25 @@ export class NotificationService { this.logger.log(`Fetching notifications for grantId: ${grantId}`); const tableName = process.env.DYNAMODB_NOTIFICATION_TABLE_NAME || 'TABLE_FAILURE'; - const params = { - TableName: tableName, - FilterExpression: 'grantId = :grantId', - ExpressionAttributeValues: { - ':grantId': grantId, - }, - }; + const results: Notification[] = []; + let lastEvaluatedKey: AWS.DynamoDB.DocumentClient.Key | undefined = undefined; try { - const data = await this.dynamoDb.scan(params).promise(); - this.logger.log(`Found ${data.Items?.length ?? 0} notifications for grantId: ${grantId}`); - return (data.Items || []) as Notification[]; + do { + const params: AWS.DynamoDB.DocumentClient.ScanInput = { + TableName: tableName, + FilterExpression: 'grantId = :grantId', + ExpressionAttributeValues: { ':grantId': grantId }, + ...(lastEvaluatedKey && { ExclusiveStartKey: lastEvaluatedKey }), + }; + + const data = await this.dynamoDb.scan(params).promise(); + results.push(...((data.Items || []) as Notification[])); + lastEvaluatedKey = data.LastEvaluatedKey; + } while (lastEvaluatedKey); + + this.logger.log(`Found ${results.length} notifications for grantId: ${grantId}`); + return results; } catch (error) { this.logger.error(`Failed to retrieve notifications for grantId: ${grantId}`, error); throw new InternalServerErrorException('Failed to retrieve notifications by grant'); @@ -270,6 +277,28 @@ export class NotificationService { this.logger.log(`Updated ${notifications.length} notifications for grantId: ${grantId}`); } + // Deletes all notifications for a given user email + async deleteNotificationsByUserEmail(email: string): Promise { + const notifications = await this.getNotificationByUserEmail(email); + if (notifications.length === 0) { + this.logger.log(`No notifications to delete for user ${email}`); + return; + } + + const tableName = process.env.DYNAMODB_NOTIFICATION_TABLE_NAME || 'TABLE_FAILURE'; + for (let i = 0; i < notifications.length; i += 25) { + const chunk = notifications.slice(i, i + 25); + const deleteRequests = chunk.map(n => ({ + DeleteRequest: { Key: { notificationId: n.notificationId } }, + })); + await this.dynamoDb.batchWrite({ + RequestItems: { [tableName]: deleteRequests }, + }).promise(); + } + + this.logger.log(`Deleted ${notifications.length} notifications for user ${email}`); + } + /** * 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/user/user.module.ts b/backend/src/user/user.module.ts index 4ca01654..e0298983 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { UserController } from './user.controller'; +import { NotificationsModule } from '../notifications/notification.module'; @Module({ + imports: [NotificationsModule], controllers: [UserController], providers: [UserService], }) diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 388b1d48..bb100347 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -11,6 +11,7 @@ import { import * as AWS from "aws-sdk"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; +import { NotificationService } from "../notifications/notification.service"; /** * File could use safer 'User' typing after grabbing users, verifying type after the scan. @@ -25,6 +26,8 @@ export class UserService { private s3 = new AWS.S3(); private profilePicBucket : string = process.env.PROFILE_PICTURE_BUCKET!; + constructor(private readonly notificationService: NotificationService) {} + async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { const tableName = process.env.DYNAMODB_USER_TABLE_NAME; @@ -310,6 +313,13 @@ private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: st `✅ User ${email} deleted successfully by ${requestedBy.email}` ); + // Delete any associated notifications + try { + await this.notificationService.deleteNotificationsByUserEmail(email); + } catch (notifError) { + this.logger.error(`Failed to delete notifications for ${email}:`, notifError); + } + return userToDelete; } diff --git a/frontend/src/main-page/notifications/Bell.tsx b/frontend/src/main-page/notifications/Bell.tsx index 01afc1b8..56ab4383 100644 --- a/frontend/src/main-page/notifications/Bell.tsx +++ b/frontend/src/main-page/notifications/Bell.tsx @@ -2,11 +2,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBell } from "@fortawesome/free-solid-svg-icons"; import { useEffect } from "react"; import NotificationPopup from "./NotificationPopup"; -import { setNotifications as setNotificationsAction } from "../../external/bcanSatchel/actions"; import { getAppStore } from "../../external/bcanSatchel/store"; import { observer } from "mobx-react-lite"; -import { api } from "../../api"; -import { fetchNotifications } from "./processNotificationData"; // get current user id diff --git a/frontend/src/main-page/notifications/processNotificationData.ts b/frontend/src/main-page/notifications/processNotificationData.ts index be17c63c..c9560b0a 100644 --- a/frontend/src/main-page/notifications/processNotificationData.ts +++ b/frontend/src/main-page/notifications/processNotificationData.ts @@ -7,7 +7,12 @@ const store = getAppStore(); export const fetchNotifications = async () => { try { - const response = await api(`/notifications/user/${store.user?.email}/current`); + const userEmail = store.user?.email; + if (!userEmail) { + return; + } + + const response = await api(`/notifications/user/${userEmail}/current`); if (!response.ok) { throw new Error(`HTTP Error, Status: ${response.status}`); } diff --git a/frontend/src/main-page/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index 4099a3cd..015f78b3 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -15,6 +15,7 @@ import { setActiveUsers, updateUserProfile } from "../../external/bcanSatchel/ac import { User } from "../../../../middle-layer/types/User"; import { fetchGrants } from "../grants/filter-bar/processGrantData"; import { InputField } from "../../sign-up"; +import { fetchNotifications } from "../notifications/processNotificationData"; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -109,6 +110,7 @@ function Settings() { updateUserProfile(updatedUser); setPersonalInfo(editForm); await fetchGrants(); + await fetchNotifications(); setIsEditingPersonalInfo(false); setPersonalInfoError(null); From ccd1318f238a5ae2bc61f1f2af46b604bff1191a Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 21:06:18 -0400 Subject: [PATCH 10/15] build errors fixed --- backend/src/grant/grant.service.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index c2554047..b4a0ef02 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -365,12 +365,12 @@ export class GrantService { if (grantData.application_deadline) { const alertTimes = this.getNotificationTimes(grantData.application_deadline); for (const alertTime of alertTimes) { - const message = `Application due in ${this.daysUntil(alertTime, grantData.application_deadline)} days for ${grantData.organization}`; + const message = `Application due in ${alertTime.days} days for ${grantData.organization}`; await this.notificationService.createNotification({ notificationId: `${grantData.grantId}-app-${alertTime}`, userEmail: email, message, - alertTime: alertTime as TDateISO, + alertTime: alertTime.alertTime as TDateISO, sent: false, grantId: grantData.grantId, }); @@ -392,12 +392,12 @@ export class GrantService { for (const reportDeadline of grantData.report_deadlines) { const alertTimes = this.getNotificationTimes(reportDeadline); for (const alertTime of alertTimes) { - const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${grantData.organization}`; + const message = `Report due in ${alertTime.days} days for ${grantData.organization}`; await this.notificationService.createNotification({ notificationId: `${grantData.grantId}-report-${alertTime}`, userEmail: email, message, - alertTime: alertTime as TDateISO, + alertTime: alertTime.alertTime as TDateISO, sent: false, grantId: grantData.grantId, }); @@ -613,8 +613,7 @@ export class GrantService { }); return allNotificationTimes - .filter(d => d > new Date()) // only return future notification times - .map(d => d.toISOString()); + .filter(d => new Date(d.alertTime) > new Date()); // only return future notification times } // Creates notifications for a grant's application and report deadlines From b9876a910447e183136b3b6bcecd3c00d4fcfe04 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 21:21:54 -0400 Subject: [PATCH 11/15] test fixes for build --- .../src/auth/__test__/auth.service.spec.ts | 34 +++++++++++++------ .../src/grant/__test__/grant.service.spec.ts | 24 ++++++++----- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 491b1eba..018bd7ac 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -196,22 +196,36 @@ describe('AuthService', () => { const original = process.env.COGNITO_USER_POOL_ID; delete process.env.COGNITO_USER_POOL_ID; - const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); - const svc = module.get(AuthService); - - await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); - process.env.COGNITO_USER_POOL_ID = original; + try { + const module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: GrantService, useValue: { updateGrantsByPOC: vi.fn() } }, + ], + }).compile(); + const svc = module.get(AuthService); + await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); + } finally { + process.env.COGNITO_USER_POOL_ID = original; + } }); it('should throw InternalServerErrorException when DYNAMODB_USER_TABLE_NAME is missing', async () => { const original = process.env.DYNAMODB_USER_TABLE_NAME; delete process.env.DYNAMODB_USER_TABLE_NAME; - const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); - const svc = module.get(AuthService); - - await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); - process.env.DYNAMODB_USER_TABLE_NAME = original; + try { + const module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: GrantService, useValue: { updateGrantsByPOC: vi.fn() } }, + ], + }).compile(); + const svc = module.get(AuthService); + await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); + } finally { + process.env.DYNAMODB_USER_TABLE_NAME = original; + } }); it('should rollback Cognito user if adminSetUserPassword fails', async () => { diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index 0269e34b..5b966312 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -169,11 +169,13 @@ describe("GrantService", () => { }).compile(); grantService = Object.assign(module.get(GrantService), { - notificationService: { - createNotification: vi.fn(), - updateNotification: vi.fn(), + notificationService: { + createNotification: vi.fn().mockResolvedValue(undefined), + updateNotification: vi.fn().mockResolvedValue(undefined), getNotificationByUserEmail: vi.fn().mockResolvedValue([]), - deleteNotification: vi.fn().mockResolvedValue('deleted') + getNotificationsByGrantId: vi.fn().mockResolvedValue([]), + updateNotificationsEmailAndOrgByGrantId: vi.fn().mockResolvedValue(undefined), + deleteNotification: vi.fn().mockResolvedValue('deleted'), } }); @@ -397,6 +399,10 @@ describe("GrantService", () => { estimated_completion_time: mockUpdatedGrant.estimated_completion_time, }; + mockGet.mockReturnValueOnce({ + promise: vi.fn().mockResolvedValue({ Item: mockGrants[1] }), + }); + mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: updatedAttributes }), }); @@ -725,7 +731,7 @@ describe('Notification helpers', () => { describe('getNotificationTimes', () => { it('should return ISO strings for 14, 7, and 3 days before deadline', () => { - const deadline = '2025-12-25T00:00:00.000Z'; + const deadline = '2027-12-25T00:00:00.000Z'; const result = (grantServiceWithMockNotif as any).getNotificationTimes(deadline); expect(result).toHaveLength(3); @@ -752,8 +758,8 @@ describe('Notification helpers', () => { 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'], + application_deadline: '2027-12-31T00:00:00.000Z', + report_deadlines: ['2027-01-31T00:00:00.000Z'], description: 'Helping local communities', timeline: 12, estimated_completion_time: 365, @@ -817,8 +823,8 @@ describe('Notification helpers', () => { 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'], + application_deadline: '2027-06-30T00:00:00.000Z', + report_deadlines: ['2027-07-15T00:00:00.000Z'], description: 'Test desc', timeline: 1, estimated_completion_time: 100, From db5141d64292a49f5c96fd8c1f5559667ad3c1a2 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 21:42:05 -0400 Subject: [PATCH 12/15] test fix --- backend/src/auth/__test__/auth.service.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 018bd7ac..b2de2afe 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -117,7 +117,9 @@ describe('AuthService', () => { ], }).compile(); - service = module.get(AuthService); + Object.assign(module.get(AuthService), { + grantService: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) }, + }); }); // ── register ──────────────────────────────────────────────────────────────── From c1696bb99d4afd984c096b22ffd22d0d43efb99f Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 15 Apr 2026 21:49:40 -0400 Subject: [PATCH 13/15] fixed test frfr --- backend/src/auth/__test__/auth.service.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index b2de2afe..0df2e854 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -117,6 +117,9 @@ describe('AuthService', () => { ], }).compile(); + service = module.get(AuthService); + // Vitest uses esbuild which doesn't emit decorator metadata, so NestJS can't + // resolve constructor injection. Patch directly like the grant service spec does. Object.assign(module.get(AuthService), { grantService: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) }, }); From 83e77819a1df25ac1c28babdb468cfb9530e136a Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 16 Apr 2026 14:43:33 -0400 Subject: [PATCH 14/15] one line change to streamline test --- backend/src/auth/__test__/auth.service.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 0df2e854..9a01963d 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -117,10 +117,7 @@ describe('AuthService', () => { ], }).compile(); - service = module.get(AuthService); - // Vitest uses esbuild which doesn't emit decorator metadata, so NestJS can't - // resolve constructor injection. Patch directly like the grant service spec does. - Object.assign(module.get(AuthService), { + service = Object.assign(module.get(AuthService), { grantService: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) }, }); }); From adddc954274dafc44edbc9d08600d200aa48abba Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Thu, 16 Apr 2026 23:26:29 -0400 Subject: [PATCH 15/15] Styling refinement --- frontend/src/main-page/notifications/GrantNotification.tsx | 4 ++-- frontend/src/main-page/notifications/NotificationPopup.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/main-page/notifications/GrantNotification.tsx b/frontend/src/main-page/notifications/GrantNotification.tsx index f69ed950..6ba88511 100644 --- a/frontend/src/main-page/notifications/GrantNotification.tsx +++ b/frontend/src/main-page/notifications/GrantNotification.tsx @@ -41,7 +41,7 @@ const GrantNotification: React.FC = ({ return ( -
+
{avatarUrl ? ( {`${firstName} @@ -51,7 +51,7 @@ const GrantNotification: React.FC = ({
-
{message}
+
{message}
{formatAlertTime(alertTime)}
diff --git a/frontend/src/main-page/notifications/NotificationPopup.tsx b/frontend/src/main-page/notifications/NotificationPopup.tsx index 85957248..ced7b4c6 100644 --- a/frontend/src/main-page/notifications/NotificationPopup.tsx +++ b/frontend/src/main-page/notifications/NotificationPopup.tsx @@ -80,7 +80,7 @@ const NotificationPopup: React.FC = observer(({
-
+
{liveNotifications && liveNotifications.length > 0 ? ( liveNotifications.map((n) => (