diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index a9ca4258..9a01963d 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 { @@ -9,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(); @@ -106,13 +108,18 @@ describe('AuthService', () => { mockDynamoPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + providers: [ + AuthService, + { + provide: GrantService, + useValue: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) }, + }, + ], }).compile(); - service = module.get(AuthService); - (service as any).grantService = { - updateGrantsByPOC: vi.fn().mockResolvedValue(undefined), - }; + service = Object.assign(module.get(AuthService), { + grantService: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) }, + }); }); // ── register ──────────────────────────────────────────────────────────────── @@ -191,22 +198,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/auth/auth.module.ts b/backend/src/auth/auth.module.ts index a1d1a2ac..d4343607 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'; import { GrantModule } from '../grant/grant.module'; @Module({ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 45fee677..028f8ae8 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -883,42 +883,45 @@ 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}`); - } + // ── 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); - throw new InternalServerErrorException("Failed to update grants. All changes have been rolled back."); + // 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."); + } + + } catch (error) { if (error instanceof HttpException) { throw error; @@ -935,8 +938,6 @@ async updateProfile( } } - // Add this to auth.service.ts - // 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 async validateSession(accessToken: string): Promise { diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index 3252bdda..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 }), }); @@ -715,6 +721,9 @@ describe('Notification helpers', () => { notificationServiceMock = { createNotification: vi.fn().mockResolvedValue(undefined), updateNotification: vi.fn().mockResolvedValue(undefined), + updateNotificationsEmailAndOrgByGrantId: vi.fn().mockResolvedValue(undefined), + getNotificationsByGrantId: vi.fn().mockResolvedValue([]), + deleteNotification: vi.fn().mockResolvedValue(undefined), }; grantServiceWithMockNotif = new GrantService(notificationServiceMock); @@ -722,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); @@ -749,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, @@ -767,15 +776,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, }) ); }); @@ -812,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, @@ -828,14 +839,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'), }) @@ -865,5 +876,77 @@ describe('Notification helpers', () => { expect(notificationServiceMock.updateNotification).not.toHaveBeenCalled(); }); }); + + 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: {} }) }); + + const updatedGrant = { ...baseGrant, bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' } }; + await grantServiceWithMockNotif.updateGrant(updatedGrant); + + expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).toHaveBeenCalledWith( + 100, + 'newpoc@test.com', + 'Boston Cares', + ); + }); + + 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: {} }) }); + + 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.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 4cd5f151..b4a0ef02 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -327,10 +327,96 @@ 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`); - //await this.updateGrantNotifications(grantData); + + // 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); + + 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 || oldGrant.bcan_poc?.POC_email; + + 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 ${alertTime.days} days for ${grantData.organization}`; + await this.notificationService.createNotification({ + notificationId: `${grantData.grantId}-app-${alertTime}`, + userEmail: email, + message, + alertTime: 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 ${alertTime.days} days for ${grantData.organization}`; + await this.notificationService.createNotification({ + notificationId: `${grantData.grantId}-report-${alertTime}`, + userEmail: email, + message, + alertTime: alertTime.alertTime as TDateISO, + sent: false, + grantId: grantData.grantId, + }); + } + } + } + } + } + } + // 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) { // Re-throw NestJS exceptions @@ -461,7 +547,26 @@ 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`); + + // 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} deleted successfully`; + } catch (error: unknown) { // Re-throw NestJS exceptions if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { @@ -501,11 +606,14 @@ export class GrantService { private getNotificationTimes(deadlineISO: string): { alertTime: string, days: number }[] { 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 { alertTime: d.toISOString(), days }; }); + + return allNotificationTimes + .filter(d => new Date(d.alertTime) > new Date()); // only return future notification times } // Creates notifications for a grant's application and report deadlines @@ -532,6 +640,7 @@ export class GrantService { message, alertTime: alertTime as TDateISO, sent: false, + grantId, }; await this.notificationService.createNotification(notification); } @@ -559,6 +668,7 @@ export class GrantService { message, alertTime: alertTime as TDateISO, sent: false, + grantId, }; await this.notificationService.createNotification(notification); } @@ -655,20 +765,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`); diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 7fa88164..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 }; @@ -95,7 +98,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 +107,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 +116,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 +125,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 +275,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 +294,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 +304,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 +323,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 +488,186 @@ 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); + }); + + 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', () => { + 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'); + + 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: {} }) }); + + await notificationService.updateNotificationsEmailAndOrgByGrantId(100, 'new@example.com', 'NewOrg'); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + Key: { notificationId: '1' }, + 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.updateNotificationsEmailAndOrgByGrantId(999, 'new@example.com', 'NewOrg'); + + 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 e28fa545..46cfa486 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; @@ -100,6 +103,7 @@ export class NotificationService { } this.logger.log(`Retrieved ${data.Items.length} notifications for user ${email}`); + 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); @@ -217,6 +221,84 @@ 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 results: Notification[] = []; + let lastEvaluatedKey: AWS.DynamoDB.DocumentClient.Key | undefined = undefined; + + try { + 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'); + } + } + + // 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 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}`); + } + + // 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/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/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/context/auth/authContext.tsx b/frontend/src/context/auth/authContext.tsx index 187badfa..860a159c 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'; /** @@ -66,6 +67,7 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => await Promise.all([ fetchUsers(), fetchGrants(), + fetchNotifications(), ]); }; diff --git a/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts b/frontend/src/main-page/grants/edit-grant/processGrantDataEditSave.ts index 1a57637a..ebf95eda 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(); @@ -85,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/Bell.tsx b/frontend/src/main-page/notifications/Bell.tsx index da4e5531..56ab4383 100644 --- a/frontend/src/main-page/notifications/Bell.tsx +++ b/frontend/src/main-page/notifications/Bell.tsx @@ -2,10 +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"; // get current user id @@ -28,17 +26,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/GrantNotification.tsx b/frontend/src/main-page/notifications/GrantNotification.tsx index 6ff6f18a..04cd635d 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', @@ -32,7 +41,7 @@ const GrantNotification: React.FC = ({ return ( -
+
{avatarUrl ? ( {`${firstName} @@ -42,7 +51,7 @@ const GrantNotification: React.FC = ({
-
{message}
+
{message}
{formatAlertTime(alertTime)}
diff --git a/frontend/src/main-page/notifications/processNotificationData.ts b/frontend/src/main-page/notifications/processNotificationData.ts new file mode 100644 index 00000000..c9560b0a --- /dev/null +++ b/frontend/src/main-page/notifications/processNotificationData.ts @@ -0,0 +1,25 @@ +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 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}`); + } + const updatedNotifications: Notification[] = await response.json(); + console.log("Fetched notifications: ", updatedNotifications); + setNotifications(updatedNotifications); + } catch (error) { + console.error("Error fetching notifications:", error); + } +}; \ No newline at end of file diff --git a/frontend/src/main-page/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index cdbd1df4..5c8c5a2e 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -16,6 +16,7 @@ import { User } from "../../../../middle-layer/types/User"; import ActionConfirmation from "../../components/ActionConfirmation"; import { fetchGrants } from "../grants/filter-bar/processGrantData"; import { InputField } from "../../sign-up"; +import { fetchNotifications } from "../notifications/processNotificationData"; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -38,6 +39,7 @@ function Settings() { const [profilePictureMessage, setProfilePictureMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const isEmailChanged = editForm.email.trim().toLowerCase() !== (store.user?.email ?? "").trim().toLowerCase(); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (user) { @@ -79,6 +81,7 @@ function Settings() { return; } + setIsSaving(true); try { const response = await api("/auth/update-profile", { method: "POST", @@ -96,6 +99,7 @@ function Settings() { (errorBody && (errorBody.message as string)) || "Failed to update profile. Please try again."; setPersonalInfoError(message); + setIsSaving(false); return; } const updatedUser = { @@ -111,12 +115,15 @@ function Settings() { updateUserProfile(updatedUser); setPersonalInfo(editForm); await fetchGrants(); + await fetchNotifications(); setIsEditingPersonalInfo(false); setPersonalInfoError(null); } catch (error) { console.error("Error updating profile:", error); setPersonalInfoError("An unexpected error occurred. Please try again."); + } finally { + setIsSaving(false); } }; @@ -308,6 +315,7 @@ function Settings() { text="Save" onClick={() => setIsSaveProfileModalOpen(true)} className="bg-primary-900 text-white" + disabled={isSaving} />
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