diff --git a/sources/packages/backend/apps/api/src/route-controllers/application-change-request/_tests_/e2e/application-change-request.aest.controller.assessApplicationChangeRequest.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/application-change-request/_tests_/e2e/application-change-request.aest.controller.assessApplicationChangeRequest.e2e-spec.ts index 75459de953..7fd49ee45f 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/application-change-request/_tests_/e2e/application-change-request.aest.controller.assessApplicationChangeRequest.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/application-change-request/_tests_/e2e/application-change-request.aest.controller.assessApplicationChangeRequest.e2e-spec.ts @@ -1,6 +1,6 @@ import { HttpStatus, INestApplication } from "@nestjs/common"; import * as request from "supertest"; -import { DataSource } from "typeorm"; +import { DataSource, IsNull } from "typeorm"; import { AESTGroups, BEARER_AUTH_TYPE, @@ -8,7 +8,12 @@ import { getAESTToken, getAESTUser, } from "../../../../testHelpers"; -import { ApplicationEditStatus, ApplicationStatus, User } from "@sims/sims-db"; +import { + ApplicationEditStatus, + ApplicationStatus, + NotificationMessageType, + User, +} from "@sims/sims-db"; import { faker } from "@faker-js/faker"; import { createE2EDataSources, @@ -19,6 +24,8 @@ import { import { ZeebeGrpcClient } from "@camunda8/sdk/dist/zeebe"; import MockDate from "mockdate"; import { INVALID_APPLICATION_EDIT_STATUS } from "@sims/services/constants"; +import { GC_NOTIFY_TEMPLATE_IDS } from "@sims/test-utils/constants"; +import { getPSTPDTDateTime } from "@sims/utilities"; describe("ApplicationChangeRequestAESTController(e2e)-assessApplicationChangeRequest", () => { let app: INestApplication; @@ -42,6 +49,15 @@ describe("ApplicationChangeRequestAESTController(e2e)-assessApplicationChangeReq beforeEach(async () => { MockDate.reset(); + // Mark all existing change request review completed notifications as sent to isolate test assertions. + await db.notification.update( + { + notificationMessage: { + id: NotificationMessageType.StudentChangeRequestReviewCompleted, + }, + }, + { dateSent: new Date() }, + ); }); it("Should approve a change request and copy the offering and appeal when the application change request is waiting for approval.", async () => { @@ -218,6 +234,25 @@ describe("ApplicationChangeRequestAESTController(e2e)-assessApplicationChangeReq creator: ministryUser, }, ]); + // Validate notification. + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.StudentChangeRequestReviewCompleted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.StudentChangeRequestReviewCompleted, + email_address: changeRequest.student.user.email, + personalisation: { + givenNames: changeRequest.student.user.firstName ?? "", + lastName: changeRequest.student.user.lastName, + date: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); }); it("Should approve a change request and copy the offering and no appeals when the application change request is waiting for approval, and no appeals are present.", async () => { @@ -320,6 +355,25 @@ describe("ApplicationChangeRequestAESTController(e2e)-assessApplicationChangeReq }, }, }); + // Validate notification. + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.StudentChangeRequestReviewCompleted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.StudentChangeRequestReviewCompleted, + email_address: changeRequest.student.user.email, + personalisation: { + givenNames: changeRequest.student.user.firstName ?? "", + lastName: changeRequest.student.user.lastName, + date: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); }); it("Should be able to decline a change request and create a student note when the application change request is waiting for approval.", async () => { @@ -402,6 +456,25 @@ describe("ApplicationChangeRequestAESTController(e2e)-assessApplicationChangeReq creator: ministryUser, }, ]); + // Validate notification. + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.StudentChangeRequestReviewCompleted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.StudentChangeRequestReviewCompleted, + email_address: changeRequest.student.user.email, + personalisation: { + givenNames: changeRequest.student.user.firstName ?? "", + lastName: changeRequest.student.user.lastName, + date: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); }); it("Should throw a BadRequestException when the application change request approval has an invalid status.", async () => { diff --git a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.aest.controller.approveStudentAppealRequests.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.aest.controller.approveStudentAppealRequests.e2e-spec.ts index ac8c5e1d85..5809470092 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.aest.controller.approveStudentAppealRequests.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.aest.controller.approveStudentAppealRequests.e2e-spec.ts @@ -1,3 +1,4 @@ +import { In, IsNull } from "typeorm"; import { HttpStatus, INestApplication } from "@nestjs/common"; import * as request from "supertest"; import { @@ -19,12 +20,15 @@ import { ApplicationStatus, AssessmentTriggerType, ModifiedIndependentStatus, + NotificationMessageType, NoteType, StudentAppealActionType, StudentAppealStatus, } from "@sims/sims-db"; import { StudentAppealApprovalAPIInDTO } from "../../../../route-controllers"; import MockDate from "mockdate"; +import { getPSTPDTDateTime } from "@sims/utilities"; +import { GC_NOTIFY_TEMPLATE_IDS } from "@sims/test-utils/constants"; describe("StudentAppealAESTController(e2e)-approveStudentAppealRequests", () => { let app: INestApplication; @@ -39,6 +43,18 @@ describe("StudentAppealAESTController(e2e)-approveStudentAppealRequests", () => beforeEach(async () => { MockDate.reset(); + // Mark all existing student appeal notifications as sent to isolate test assertions. + await db.notification.update( + { + notificationMessage: { + id: In([ + NotificationMessageType.StudentChangeRequestReviewCompleted, + NotificationMessageType.MinistryAppealCompleted, + ]), + }, + }, + { dateSent: new Date() }, + ); }); it("Should approve student appeal requests and add note when the appeal with appeal requests submitted for approval are in pending status.", async () => { @@ -121,6 +137,25 @@ describe("StudentAppealAESTController(e2e)-approveStudentAppealRequests", () => }, ], }); + // Validate notification. + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.StudentChangeRequestReviewCompleted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.StudentChangeRequestReviewCompleted, + email_address: application.student.user.email, + personalisation: { + givenNames: application.student.user.firstName ?? "", + lastName: application.student.user.lastName, + date: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); }); it("Should throw an unprocessable entity error when the application associated with the appeal is not in completed status.", async () => { @@ -291,6 +326,25 @@ describe("StudentAppealAESTController(e2e)-approveStudentAppealRequests", () => }, ], }); + // Validate notification. + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.MinistryAppealCompleted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.MinistryAppealCompleted, + email_address: student.user.email, + personalisation: { + givenNames: student.user.firstName ?? "", + lastName: student.user.lastName, + date: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); }); } }); diff --git a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitApplicationAppeal.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitApplicationAppeal.e2e-spec.ts index 7484908126..ef6fe0e9be 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitApplicationAppeal.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitApplicationAppeal.e2e-spec.ts @@ -1,6 +1,6 @@ import { HttpStatus, INestApplication } from "@nestjs/common"; import * as request from "supertest"; -import { DataSource, Repository } from "typeorm"; +import { DataSource, In, IsNull, Repository } from "typeorm"; import { BEARER_AUTH_TYPE, createTestingAppModule, @@ -27,6 +27,7 @@ import { Application, ApplicationStatus, FileOriginType, + NotificationMessageType, OfferingIntensity, ProgramYear, StudentAppealRequest, @@ -37,11 +38,14 @@ import { import { StudentApplicationAppealAPIInDTO } from "../../models/student-appeal.dto"; import { AppStudentsModule } from "../../../../app.students.module"; import { FormNames, FormService } from "../../../../services"; +import MockDate from "mockdate"; +import { getDateOnlyFormat, getPSTPDTDateTime } from "@sims/utilities"; import { APPLICATION_CHANGE_NOT_ELIGIBLE, APPLICATION_HAS_PENDING_APPEAL, APPLICATION_IS_NOT_ELIGIBLE_FOR_AN_APPEAL, } from "../../../../constants"; +import { GC_NOTIFY_TEMPLATE_IDS } from "@sims/test-utils/constants"; describe("StudentAppealStudentsController(e2e)-submitApplicationAppeal", () => { let app: INestApplication; @@ -55,6 +59,7 @@ describe("StudentAppealStudentsController(e2e)-submitApplicationAppeal", () => { const DEPENDANT_INFORMATION_FORM_NAME = "studentdependantsappeal"; const PARTNER_INFORMATION_FORM_NAME = "partnerinformationandincomeappeal"; const ROOM_AND_BOARD_COSTS_FORM_NAME = "roomandboardcostsappeal"; + const MINISTRY_EMAIL_ADDRESS = "dummy@some.domain"; let recentActiveProgramYear: ProgramYear; beforeAll(async () => { @@ -68,10 +73,33 @@ describe("StudentAppealStudentsController(e2e)-submitApplicationAppeal", () => { studentAppealRequestRepo = dataSource.getRepository(StudentAppealRequest); studentFileRepo = dataSource.getRepository(StudentFile); recentActiveProgramYear = await getRecentActiveProgramYear(db); + // Update fake email contacts to send ministry notifications. + await db.notificationMessage.update( + { + id: In([ + NotificationMessageType.MinistryChangeRequestSubmitted, + NotificationMessageType.StudentAppealSubmitted, + ]), + }, + { emailContacts: [MINISTRY_EMAIL_ADDRESS] }, + ); }); beforeEach(async () => { + MockDate.reset(); await resetMockJWTUserInfo(appModule); + // Mark all existing ministry notifications as sent to isolate test assertions. + await db.notification.update( + { + notificationMessage: { + id: In([ + NotificationMessageType.MinistryChangeRequestSubmitted, + NotificationMessageType.StudentAppealSubmitted, + ]), + }, + }, + { dateSent: new Date() }, + ); }); it( @@ -128,6 +156,8 @@ describe("StudentAppealStudentsController(e2e)-submitApplicationAppeal", () => { const endpoint = `/students/appeal/application/${application.id}`; await mockJWTUserInfo(appModule, student.user); + const now = new Date(); + MockDate.set(now); // Act/Assert let createdAppealId: number; @@ -168,6 +198,28 @@ describe("StudentAppealStudentsController(e2e)-submitApplicationAppeal", () => { programYear: application.programYear.programYear, }, ); + // Validate notification for legacy change request (pre-2025-26 program year). + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.MinistryChangeRequestSubmitted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.MinistryChangeRequestSubmitted, + email_address: MINISTRY_EMAIL_ADDRESS, + personalisation: { + givenNames: student.user.firstName, + lastName: student.user.lastName, + birthDate: getDateOnlyFormat(student.birthDate), + studentEmail: student.user.email, + applicationNumber: application.applicationNumber, + dateTime: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); }, ); @@ -617,125 +669,145 @@ describe("StudentAppealStudentsController(e2e)-submitApplicationAppeal", () => { ); }); - it( - "Should create room and board costs appeal " + - "when student submit an appeal for a program year which is eligible for appeal process.", - async () => { - // Arrange - // Create student to submit application. - const student = await saveFakeStudent(appDataSource); - // Create application submit appeal with eligible program year. - const application = await saveFakeApplicationDisbursements( - db.dataSource, - { - student, - programYear: recentActiveProgramYear, + it("Should create room and board costs appeal when student submit an appeal for a program year which is eligible for appeal process.", async () => { + // Arrange + // Create student to submit application. + const student = await saveFakeStudent(appDataSource); + // Create application submit appeal with eligible program year. + const application = await saveFakeApplicationDisbursements( + db.dataSource, + { + student, + programYear: recentActiveProgramYear, + }, + { + offeringIntensity: OfferingIntensity.fullTime, + applicationStatus: ApplicationStatus.Completed, + currentAssessmentInitialValues: { + eligibleApplicationAppeals: [FormNames.RoomAndBoardCostsAppeal], }, + }, + ); + // Create a temporary file for room and board costs appeal. + const roomAndBoardFile = await saveFakeStudentFileUpload( + appDataSource, + { + student, + creator: student.user, + }, + { fileOrigin: FileOriginType.Temporary }, + ); + // Prepare the data to request a change of financial information. + const roomAndBoardAppealData = { + roomAndBoardAmount: 561, + roomAndBoardSituations: { + parentUnEmployed: false, + parentEarnLowIncome: false, + parentReceiveIncomeAssistance: false, + livingAtHomePayingRoomAndBoard: true, + parentReceiveCanadaPensionOrOldAgeSupplement: false, + }, + roomAndBoardSupportingDocuments: [ { - offeringIntensity: OfferingIntensity.fullTime, - applicationStatus: ApplicationStatus.Completed, - currentAssessmentInitialValues: { - eligibleApplicationAppeals: [FormNames.RoomAndBoardCostsAppeal], - }, + url: `student/files/${roomAndBoardFile.uniqueFileName}`, + hash: "", + name: roomAndBoardFile.uniqueFileName, + size: 4, + type: "text/plain", + storage: "url", + originalName: roomAndBoardFile.fileName, }, - ); - // Create a temporary file for room and board costs appeal. - const roomAndBoardFile = await saveFakeStudentFileUpload( - appDataSource, + ], + }; + const payload: StudentApplicationAppealAPIInDTO = { + studentAppealRequests: [ { - student, - creator: student.user, - }, - { fileOrigin: FileOriginType.Temporary }, - ); - // Prepare the data to request a change of financial information. - const roomAndBoardAppealData = { - roomAndBoardAmount: 561, - roomAndBoardSituations: { - parentUnEmployed: false, - parentEarnLowIncome: false, - parentReceiveIncomeAssistance: false, - livingAtHomePayingRoomAndBoard: true, - parentReceiveCanadaPensionOrOldAgeSupplement: false, + formName: ROOM_AND_BOARD_COSTS_FORM_NAME, + formData: roomAndBoardAppealData, + files: [roomAndBoardFile.uniqueFileName], }, - roomAndBoardSupportingDocuments: [ - { - url: `student/files/${roomAndBoardFile.uniqueFileName}`, - hash: "", - name: roomAndBoardFile.uniqueFileName, - size: 4, - type: "text/plain", - storage: "url", - originalName: roomAndBoardFile.fileName, - }, - ], - }; - const payload: StudentApplicationAppealAPIInDTO = { - studentAppealRequests: [ - { - formName: ROOM_AND_BOARD_COSTS_FORM_NAME, - formData: roomAndBoardAppealData, - files: [roomAndBoardFile.uniqueFileName], - }, - ], - }; - // Mock JWT user to return the saved student from token. - await mockJWTUserInfo(appModule, student.user); - // Get any student user token. - const studentToken = await getStudentToken( - FakeStudentUsersTypes.FakeStudentUserType1, - ); - // Mock the form service to validate the dry-run submission result. - // and this mock must be removed. - const formService = await getProviderInstanceForModule( - appModule, - AppStudentsModule, - FormService, - ); - const dryRunSubmissionMock = jest.fn().mockResolvedValue({ - valid: true, - formName: ROOM_AND_BOARD_COSTS_FORM_NAME, - data: { data: roomAndBoardAppealData }, - }); - formService.dryRunSubmission = dryRunSubmissionMock; - const endpoint = `/students/appeal/application/${application.id}`; + ], + }; + // Mock JWT user to return the saved student from token. + await mockJWTUserInfo(appModule, student.user); + // Get any student user token. + const studentToken = await getStudentToken( + FakeStudentUsersTypes.FakeStudentUserType1, + ); + // Mock the form service to validate the dry-run submission result. + // and this mock must be removed. + const formService = await getProviderInstanceForModule( + appModule, + AppStudentsModule, + FormService, + ); + const dryRunSubmissionMock = jest.fn().mockResolvedValue({ + valid: true, + formName: ROOM_AND_BOARD_COSTS_FORM_NAME, + data: { data: roomAndBoardAppealData }, + }); + formService.dryRunSubmission = dryRunSubmissionMock; + const endpoint = `/students/appeal/application/${application.id}`; + const now = new Date(); + MockDate.set(now); - // Act/Assert - let createdAppealId: number; - await request(app.getHttpServer()) - .post(endpoint) - .send(payload) - .auth(studentToken, BEARER_AUTH_TYPE) - .expect(HttpStatus.CREATED) - .then((response) => { - expect(response.body.id).toBeGreaterThan(0); - createdAppealId = +response.body.id; - }); - const studentAppeal = await db.studentAppeal.findOne({ - select: { + // Act/Assert + let createdAppealId: number; + await request(app.getHttpServer()) + .post(endpoint) + .send(payload) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.CREATED) + .then((response) => { + expect(response.body.id).toBeGreaterThan(0); + createdAppealId = +response.body.id; + }); + const studentAppeal = await db.studentAppeal.findOne({ + select: { + id: true, + appealRequests: { id: true, - appealRequests: { - id: true, - submittedFormName: true, - submittedData: true, - }, + submittedFormName: true, + submittedData: true, }, - relations: { appealRequests: true }, - where: { application: { id: application.id } }, - }); - const [appealRequest] = studentAppeal.appealRequests; - expect(studentAppeal.id).toBe(createdAppealId); - expect(appealRequest.submittedFormName).toBe( - ROOM_AND_BOARD_COSTS_FORM_NAME, - ); - expect(appealRequest.submittedData).toStrictEqual(roomAndBoardAppealData); - // Expect to call the dry run submission. - expect(dryRunSubmissionMock).toHaveBeenCalledWith( - ROOM_AND_BOARD_COSTS_FORM_NAME, - roomAndBoardAppealData, - ); - }, - ); + }, + relations: { appealRequests: true }, + where: { application: { id: application.id } }, + }); + const [appealRequest] = studentAppeal.appealRequests; + expect(studentAppeal.id).toBe(createdAppealId); + expect(appealRequest.submittedFormName).toBe( + ROOM_AND_BOARD_COSTS_FORM_NAME, + ); + expect(appealRequest.submittedData).toStrictEqual(roomAndBoardAppealData); + // Expect to call the dry run submission. + expect(dryRunSubmissionMock).toHaveBeenCalledWith( + ROOM_AND_BOARD_COSTS_FORM_NAME, + roomAndBoardAppealData, + ); + // Validate notification for new appeal (2025-26+ program year). + const createdNotification = await db.notification.findOne({ + select: { id: true, messagePayload: true }, + where: { + notificationMessage: { + id: NotificationMessageType.StudentAppealSubmitted, + }, + dateSent: IsNull(), + }, + }); + expect(createdNotification.messagePayload).toStrictEqual({ + template_id: GC_NOTIFY_TEMPLATE_IDS.StudentAppealSubmitted, + email_address: MINISTRY_EMAIL_ADDRESS, + personalisation: { + givenNames: student.user.firstName, + lastName: student.user.lastName, + birthDate: getDateOnlyFormat(student.birthDate), + studentEmail: student.user.email, + applicationNumber: application.applicationNumber, + dateTime: `${getPSTPDTDateTime(now)} PST/PDT`, + }, + }); + }); it( "Should create step-parent waiver appeal for an application" + diff --git a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitStudentAppeal.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitStudentAppeal.e2e-spec.ts index a3a4861e08..65f855773b 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitStudentAppeal.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/_tests_/e2e/student-appeal.students.controller.submitStudentAppeal.e2e-spec.ts @@ -31,6 +31,7 @@ import { getPSTPDTDateTime, } from "@sims/utilities/date-utils"; import { STUDENT_HAS_PENDING_APPEAL } from "../../../../constants"; +import { GC_NOTIFY_TEMPLATE_IDS } from "@sims/test-utils/constants"; describe("StudentAppealStudentsController(e2e)-submitStudentAppeal", () => { let app: INestApplication; @@ -59,7 +60,7 @@ describe("StudentAppealStudentsController(e2e)-submitStudentAppeal", () => { // Update fake email contact to send ministry email. await db.notificationMessage.update( { - id: NotificationMessageType.StudentSubmittedChangeRequestNotification, + id: NotificationMessageType.StudentAppealSubmitted, }, { emailContacts: [MINISTRY_EMAIL_ADDRESS] }, ); @@ -73,12 +74,12 @@ describe("StudentAppealStudentsController(e2e)-submitStudentAppeal", () => { beforeEach(async () => { MockDate.reset(); await resetMockJWTUserInfo(appModule); - // Mark all existing appeals(change request) notifications as sent + // Mark all existing appeals notifications as sent // to allow it to asserted when a new appeal is submitted. await db.notification.update( { notificationMessage: { - id: NotificationMessageType.StudentSubmittedChangeRequestNotification, + id: NotificationMessageType.StudentAppealSubmitted, }, }, { dateSent: new Date() }, @@ -171,20 +172,20 @@ describe("StudentAppealStudentsController(e2e)-submitStudentAppeal", () => { select: { id: true, messagePayload: true }, where: { notificationMessage: { - id: NotificationMessageType.StudentSubmittedChangeRequestNotification, + id: NotificationMessageType.StudentAppealSubmitted, }, dateSent: IsNull(), }, }); expect(createdNotification.messagePayload).toStrictEqual({ - template_id: "241a360a-07d6-486f-9aa4-fae6903e1cff", + template_id: GC_NOTIFY_TEMPLATE_IDS.StudentAppealSubmitted, email_address: MINISTRY_EMAIL_ADDRESS, personalisation: { givenNames: student.user.firstName, lastName: student.user.lastName, birthDate: getDateOnlyFormat(student.birthDate), studentEmail: student.user.email, - applicationNumber: "not applicable", + applicationNumber: "N/A", dateTime: `${getPSTPDTDateTime(now)} PST/PDT`, }, }); diff --git a/sources/packages/backend/apps/api/src/services/application-change-request/application-change-request.service.ts b/sources/packages/backend/apps/api/src/services/application-change-request/application-change-request.service.ts index 3915d7b6c3..97a2031f78 100644 --- a/sources/packages/backend/apps/api/src/services/application-change-request/application-change-request.service.ts +++ b/sources/packages/backend/apps/api/src/services/application-change-request/application-change-request.service.ts @@ -7,11 +7,19 @@ import { FormSubmission, getUserFullNameLikeSearch, NoteType, + Student, StudentAppeal, User, } from "@sims/sims-db"; -import { Brackets, DataSource, Repository, SelectQueryBuilder } from "typeorm"; +import { + Brackets, + DataSource, + EntityManager, + Repository, + SelectQueryBuilder, +} from "typeorm"; import { NoteSharedService, WorkflowClientService } from "@sims/services"; +import { NotificationActionsService } from "@sims/services/notifications"; import { ApplicationService } from "../application/application.service"; import { APPLICATION_NOT_FOUND, @@ -31,6 +39,7 @@ export class ApplicationChangeRequestService { private readonly noteSharedService: NoteSharedService, private readonly workflowClientService: WorkflowClientService, private readonly applicationService: ApplicationService, + private readonly notificationActionsService: NotificationActionsService, @InjectRepository(Application) private readonly applicationRepo: Repository, ) {} @@ -98,7 +107,14 @@ export class ApplicationChangeRequestService { changeRequestApplication.updatedAt = currentDate; changeRequestApplication.applicationEditStatusUpdatedBy = auditUser; changeRequestApplication.applicationEditStatusUpdatedOn = currentDate; - await applicationRepo.save(changeRequestApplication); + await Promise.all([ + applicationRepo.save(changeRequestApplication), + this.saveChangeRequestReviewCompletedNotification( + changeRequestApplication.student.id, + auditUserId, + transactionalEntityManager, + ), + ]); return; } // Previously completed application that will be replaced by the newly approved application change request. @@ -136,7 +152,14 @@ export class ApplicationChangeRequestService { id: copyFromAssessment.formSubmission.id, } as FormSubmission; } - await applicationRepo.save(changeRequestApplication); + await Promise.all([ + applicationRepo.save(changeRequestApplication), + this.saveChangeRequestReviewCompletedNotification( + changeRequestApplication.student.id, + auditUserId, + transactionalEntityManager, + ), + ]); }); // Send a message to the workflow to proceed. await this.workflowClientService.sendApplicationChangeRequestStatusMessage( @@ -145,6 +168,37 @@ export class ApplicationChangeRequestService { ); } + /** + * Creates a student notification when a change request review is completed by the ministry. + * @param studentId student ID to load the notification data. + * @param auditUserId user who completed the change request review. + * @param entityManager entity manager for the current transaction. + */ + private async saveChangeRequestReviewCompletedNotification( + studentId: number, + auditUserId: number, + entityManager: EntityManager, + ): Promise { + const student = await entityManager.getRepository(Student).findOneOrFail({ + select: { + id: true, + user: { id: true, firstName: true, lastName: true, email: true }, + }, + relations: { user: true }, + where: { id: studentId }, + }); + await this.notificationActionsService.saveStudentChangeRequestReviewCompletedNotification( + { + givenNames: student.user.firstName, + lastName: student.user.lastName, + toAddress: student.user.email, + userId: student.user.id, + }, + auditUserId, + entityManager, + ); + } + /** * Gets applications based purely on their edit status. * @param applicationEditStatus The application edit status to filter. diff --git a/sources/packages/backend/apps/api/src/services/application/application.service.ts b/sources/packages/backend/apps/api/src/services/application/application.service.ts index 0a706cb999..7fe1598bd3 100644 --- a/sources/packages/backend/apps/api/src/services/application/application.service.ts +++ b/sources/packages/backend/apps/api/src/services/application/application.service.ts @@ -34,7 +34,7 @@ import { } from "@sims/sims-db"; import { StudentFileService } from "../student-file/student-file.service"; import { - ApplicationScholasticStandingStatus as ApplicationScholasticStandingStatus, + ApplicationScholasticStandingStatus, ApplicationSubmissionResult, } from "./application.models"; import { @@ -77,6 +77,7 @@ import { ApplicationEditedTooManyTimesNotification, NotificationActionsService, NotificationService, + StudentSubmittedChangeRequestNotification, } from "@sims/services/notifications"; import { InstitutionLocationService } from "../institution-location/institution-location.service"; import { StudentService } from ".."; @@ -507,20 +508,27 @@ export class ApplicationService extends RecordDataModelService { transactionalEntityManager.getRepository(Application); await applicationRepository.save(newApplication); - // Check if the application requires E2 restriction check. - await this.saveApplicationRestrictions( - newApplication.data, - studentId, - newApplication.id, - auditUserId, - transactionalEntityManager, - ); - newApplication.modifier = auditUser; newApplication.updatedAt = now; newApplication.studentAssessments = [originalAssessment]; newApplication.currentAssessment = originalAssessment; - await applicationRepository.save(newApplication); + // Check if the application requires E2 restriction check, save the updated + // application, and notify the ministry, all in parallel. + await Promise.all([ + this.saveApplicationRestrictions( + newApplication.data, + studentId, + newApplication.id, + auditUserId, + transactionalEntityManager, + ), + applicationRepository.save(newApplication), + this.saveMinistryChangeRequestSubmittedNotification( + studentId, + application.applicationNumber, + transactionalEntityManager, + ), + ]); }); return { application: newApplication, @@ -586,6 +594,41 @@ export class ApplicationService extends RecordDataModelService { }); } + /** + * Sends a ministry notification when a student submits a change request (edit post-COE). + * Loads the required student and application number within the provided + * transaction to ensure data consistency. + * @param studentId ID of the student who submitted the change request. + * @param applicationNumber application number of the application being changed. + * @param entityManager entity manager for the current transaction. + */ + private async saveMinistryChangeRequestSubmittedNotification( + studentId: number, + applicationNumber: string, + entityManager: EntityManager, + ): Promise { + const student = await entityManager.getRepository(Student).findOneOrFail({ + select: { + id: true, + birthDate: true, + user: { id: true, firstName: true, lastName: true, email: true }, + }, + relations: { user: true }, + where: { id: studentId }, + }); + const ministryNotification: StudentSubmittedChangeRequestNotification = { + givenNames: student.user.firstName, + lastName: student.user.lastName, + email: student.user.email, + birthDate: student.birthDate, + applicationNumber, + }; + await this.notificationActionsService.saveMinistryChangeRequestSubmittedNotification( + ministryNotification, + entityManager, + ); + } + /** * Saves a notification when the application is edited too many times * governed by {@link APPLICATION_EDIT_COUNT_TO_SEND_NOTIFICATION}. @@ -642,7 +685,7 @@ export class ApplicationService extends RecordDataModelService { const sequenceNumberSize = MAX_APPLICATION_NUMBER_LENGTH - sequenceName.length; - let nextApplicationSequence = NaN; + let nextApplicationSequence = Number.NaN; await this.sequenceService.consumeNextSequence( sequenceName, async (nextSequenceNumber: number) => { diff --git a/sources/packages/backend/apps/api/src/services/form-submission/form-submission-approval.service.ts b/sources/packages/backend/apps/api/src/services/form-submission/form-submission-approval.service.ts index e2911c0565..ac2f078b6d 100644 --- a/sources/packages/backend/apps/api/src/services/form-submission/form-submission-approval.service.ts +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission-approval.service.ts @@ -31,6 +31,7 @@ import { } from "./form-submission.models"; import { NoteSharedService } from "@sims/services"; import { FormSubmissionActionProcessor } from "./form-submission-actions/form-submission-action-processor"; +import { NotificationActionsService } from "@sims/services/notifications"; @Injectable() export class FormSubmissionApprovalService { @@ -38,6 +39,7 @@ export class FormSubmissionApprovalService { private readonly dataSource: DataSource, private readonly noteSharedService: NoteSharedService, private readonly formSubmissionActionProcessor: FormSubmissionActionProcessor, + private readonly notificationActionsService: NotificationActionsService, ) {} /** @@ -180,7 +182,10 @@ export class FormSubmissionApprovalService { const formSubmission = await formSubmissionRepo.findOne({ select: { id: true, - student: { id: true }, + student: { + id: true, + user: { id: true, firstName: true, lastName: true, email: true }, + }, submissionStatus: true, formCategory: true, application: { id: true, applicationStatus: true }, @@ -195,7 +200,7 @@ export class FormSubmissionApprovalService { }, }, relations: { - student: true, + student: { user: true }, application: true, formSubmissionItems: { currentDecision: { decisionNote: true } }, }, @@ -260,6 +265,18 @@ export class FormSubmissionApprovalService { now, entityManager, ); + // Send student notification that the form or appeal adjudication is complete. + const studentUser = formSubmission.student.user; + await this.notificationActionsService.saveStudentFormCompletedNotification( + { + givenNames: studentUser.firstName, + lastName: studentUser.lastName, + toAddress: studentUser.email, + userId: studentUser.id, + }, + auditUserId, + entityManager, + ); return formSubmission; }); } diff --git a/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts b/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts index 04c1a5331b..ed67ca9f2f 100644 --- a/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; import { Application, User, @@ -26,6 +26,7 @@ import { CustomNamedError, processInParallel } from "@sims/utilities"; import { DryRunSubmissionResult } from "../../types"; import { FormSubmissionValidator } from "./form-submission-validator"; import { SupplementaryDataLoader } from "./form-supplementary-data"; +import { NotificationActionsService } from "@sims/services/notifications"; /** * Manages how the form submissions are submitted, including the validations, @@ -41,6 +42,7 @@ export class FormSubmissionSubmitService { private readonly formService: FormService, private readonly formSubmissionValidator: FormSubmissionValidator, private readonly supplementaryDataLoader: SupplementaryDataLoader, + private readonly notificationActionsService: NotificationActionsService, ) {} /** @@ -116,11 +118,72 @@ export class FormSubmissionSubmitService { { entityManager: entityManager }, ); } - // TODO: send notification. + // Send a ministry notification when a new form submission has been created. + await this.saveFormSubmissionNotification( + studentId, + applicationId, + submissionConfigs, + referenceSubmissionConfig.formCategory, + entityManager, + ); return entityManager.getRepository(FormSubmission).save(formSubmission); }); } + /** + * Sends a ministry notification when a new form submission has been created. + * Loads the required student and application data within the provided + * transaction to ensure data consistency. + * @param studentId ID of the student who submitted the form. + * @param applicationId ID of the application linked to the submission, if applicable. + * @param submissionConfigs form submission configurations used to determine + * the form names included in the notification. + * @param formCategory category of the submitted form, used to derive the notification form type. + * @param entityManager entity manager for the current transaction. + */ + private async saveFormSubmissionNotification( + studentId: number, + applicationId: number | undefined, + submissionConfigs: FormSubmissionConfig[], + formCategory: FormCategory, + entityManager: EntityManager, + ): Promise { + // Load student and application data in a single query. + const student = await entityManager.getRepository(Student).findOneOrFail({ + select: { + id: true, + birthDate: true, + user: { id: true, firstName: true, lastName: true, email: true }, + applications: { id: true, applicationNumber: true }, + }, + relations: { user: true, applications: true }, + where: { + id: studentId, + applications: { id: applicationId }, + }, + }); + if (!student) { + throw new Error("Student not found while sending notification."); + } + if (applicationId && !student.applications.length) { + throw new Error( + "Application not found found while sending notification.", + ); + } + await this.notificationActionsService.saveMinistryFormSubmittedNotification( + { + givenNames: student.user.firstName, + lastName: student.user.lastName, + email: student.user.email, + birthDate: student.birthDate, + formCategory: formCategory, + formNames: submissionConfigs.map((config) => config.formType), + applicationNumber: student.applications?.[0]?.applicationNumber, + }, + entityManager, + ); + } + /** * Converts the form submission models to form submission configurations, * making the association of the form submission items with the related form diff --git a/sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts index 79481426a3..3ef9f255c4 100644 --- a/sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts @@ -40,6 +40,7 @@ export type FormSubmissionConfig = FormSubmissionModel & Pick< DynamicFormConfiguration, | "formDefinitionName" + | "formType" | "formCategory" | "hasApplicationScope" | "allowBundledSubmission" diff --git a/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal-assessment/student-appeal-assessment.service.ts b/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal-assessment/student-appeal-assessment.service.ts index 94c8ce4b01..c0cf5416f2 100644 --- a/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal-assessment/student-appeal-assessment.service.ts +++ b/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal-assessment/student-appeal-assessment.service.ts @@ -17,6 +17,7 @@ import { import { NotificationActionsService } from "@sims/services/notifications"; import { NoteSharedService } from "@sims/services"; import { StudentAppealActionsProcessor } from "."; +import { allowApplicationChangeRequest } from "../../../utilities"; /** * Service layer for Student appeals. @@ -93,20 +94,53 @@ export class StudentAppealAssessmentService { entityManager, ); // Create student notification when ministry completes student appeal. - const studentUser = appealToUpdate.student.user; - await this.notificationActionsService.saveChangeRequestCompleteNotification( - { - givenNames: studentUser.firstName, - lastName: studentUser.lastName, - toAddress: studentUser.email, - userId: studentUser.id, - }, + await this.saveAssessmentCompletedNotification( + appealToUpdate, auditUserId, entityManager, ); }); } + /** + * Creates a student notification when the ministry completes a student appeal assessment. + * Determines whether to send a change request review completed notification (for legacy + * change requests) or a form completed notification (for new appeals), based on the + * associated application program year. + * @param appeal student appeal that was assessed, including student user and application data. + * @param auditUserId ID of the user performing the operation, used for auditing purposes. + * @param entityManager entity manager for the current transaction. + */ + private async saveAssessmentCompletedNotification( + appeal: StudentAppeal, + auditUserId: number, + entityManager: EntityManager, + ): Promise { + const studentUser = appeal.student.user; + const isLegacyChangeRequest = + appeal.application !== null && + !allowApplicationChangeRequest(appeal.application.programYear); + const studentNotification = { + givenNames: studentUser.firstName, + lastName: studentUser.lastName, + toAddress: studentUser.email, + userId: studentUser.id, + }; + if (isLegacyChangeRequest) { + await this.notificationActionsService.saveStudentChangeRequestReviewCompletedNotification( + studentNotification, + auditUserId, + entityManager, + ); + } else { + await this.notificationActionsService.saveStudentAppealCompletedNotification( + studentNotification, + auditUserId, + entityManager, + ); + } + } + /** * Get the student appeal information required to process their approval or decline. * @param appealId appeal ID to be retrieved. @@ -134,6 +168,8 @@ export class StudentAppealAssessmentService { "appealRequest.submittedData", "application.id", "application.applicationStatus", + "programYear.id", + "programYear.programYear", "student.id", "user.id", "user.firstName", @@ -144,6 +180,7 @@ export class StudentAppealAssessmentService { .innerJoin("studentAppeal.student", "student") .innerJoin("student.user", "user") .leftJoin("studentAppeal.application", "application") + .leftJoin("application.programYear", "programYear") .leftJoin("application.currentAssessment", "currentAssessment") .leftJoin("currentAssessment.offering", "offering") .leftJoin("studentAppeal.studentAssessment", "studentAssessment") diff --git a/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts b/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts index 158effc23e..06c3611718 100644 --- a/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts +++ b/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts @@ -33,6 +33,7 @@ import { SortPriority, OrderByCondition, StudentAppealPaginationOptions, + allowApplicationChangeRequest, } from "../../utilities"; import { FieldSortOrder } from "@sims/utilities"; import { PROGRAM_YEAR_2025_26_START_DATE } from "./constants"; @@ -115,7 +116,10 @@ export class StudentAppealService extends RecordDataModelService } /** - * Create a notification for the student appeal. + * Creates a ministry notification for the student appeal submission. + * Determines whether to send a change request submitted notification (for legacy change requests) + * or a form submitted notification (for new appeals), based on the associated application + * program year. * @param appealId appeal ID to send the notification. * @param entityManager entity manager to keep DB operations in the same transaction. */ @@ -138,26 +142,79 @@ export class StudentAppealService extends RecordDataModelService }, birthDate: true, }, - application: { id: true, applicationNumber: true }, + application: { + id: true, + applicationNumber: true, + programYear: { id: true, programYear: true }, + }, + }, + relations: { + student: { user: true }, + application: { programYear: true }, }, - relations: { student: { user: true }, application: true }, where: { id: appealId }, loadEagerRelations: false, }); + + // Check if the submission is for new appeal process (appeal process is for submissions from 2025-26 program year). + const isLegacyChangeRequest = + studentAppeal.application !== null && + !allowApplicationChangeRequest(studentAppeal.application.programYear); + if (isLegacyChangeRequest) { + // For legacy change requests, send a change request submitted notification. + return this.saveMinistryChangeRequestNotification( + studentAppeal, + entityManager, + ); + } + // Not a legacy change request, so save as a new appeal submission. + return this.saveMinistryAppealNotification(studentAppeal, entityManager); + } + + /** + * Sends a ministry notification for a legacy change request submission. + * @param studentAppeal student appeal with student, user, and application data. + * @param entityManager entity manager for the current transaction. + */ + private async saveMinistryChangeRequestNotification( + studentAppeal: StudentAppeal, + entityManager: EntityManager, + ): Promise { const ministryNotification: StudentSubmittedChangeRequestNotification = { givenNames: studentAppeal.student.user.firstName, lastName: studentAppeal.student.user.lastName, email: studentAppeal.student.user.email, birthDate: studentAppeal.student.birthDate, - applicationNumber: - studentAppeal.application?.applicationNumber ?? "not applicable", + applicationNumber: studentAppeal.application.applicationNumber, }; - return this.notificationActionsService.saveStudentSubmittedChangeRequestNotification( + await this.notificationActionsService.saveMinistryChangeRequestSubmittedNotification( ministryNotification, entityManager, ); } + /** + * Sends a ministry notification for a new appeal (application appeal or other appeal) submission. + * Classifies the appeal type and maps technical form names to human-readable friendly names. + * @param studentAppeal student appeal with student, user, application, and appeal request data. + * @param entityManager entity manager for the current transaction. + */ + private async saveMinistryAppealNotification( + studentAppeal: StudentAppeal, + entityManager: EntityManager, + ): Promise { + await this.notificationActionsService.saveMinistryStudentSubmittedAppealNotification( + { + givenNames: studentAppeal.student.user.firstName, + lastName: studentAppeal.student.user.lastName, + email: studentAppeal.student.user.email, + birthDate: studentAppeal.student.birthDate, + applicationNumber: studentAppeal.application?.applicationNumber, + }, + entityManager, + ); + } + /** * Checks if a student appeal exists. * @param studentId student ID related to the appeal. diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1773355579843-InsertAppealChangeRequestNotificationMessages.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1773355579843-InsertAppealChangeRequestNotificationMessages.ts new file mode 100644 index 0000000000..12bf4db84e --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1773355579843-InsertAppealChangeRequestNotificationMessages.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class InsertAppealChangeRequestNotificationMessages1773355579843 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Insert-appeal-change-request-notification-messages.sql", + "NotificationMessages", + ), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-insert-appeal-change-request-notification-messages.sql", + "NotificationMessages", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/NotificationMessages/Insert-appeal-change-request-notification-messages.sql b/sources/packages/backend/apps/db-migrations/src/sql/NotificationMessages/Insert-appeal-change-request-notification-messages.sql new file mode 100644 index 0000000000..200a294892 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/NotificationMessages/Insert-appeal-change-request-notification-messages.sql @@ -0,0 +1,23 @@ +INSERT INTO + sims.notification_messages(id, description, template_id) +VALUES + ( + 36, + 'Ministry notification for student submits change request.', + 'fad81016-0bed-4d4e-ad48-f70cc943399c' + ), + ( + 37, + 'Student notification for change request review completed.', + '9a4855d1-4f9a-4293-9868-cd853a8e4061' + ), + ( + 38, + 'Ministry notification for student form submission.', + '296aa2ea-dfa7-4285-9d5b-315b2a4911d6' + ), + ( + 39, + 'Student notification for form submission completed.', + 'fed6b26e-d1f2-4a8c-bfe5-5cb66c00458b' + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/NotificationMessages/Rollback-insert-appeal-change-request-notification-messages.sql b/sources/packages/backend/apps/db-migrations/src/sql/NotificationMessages/Rollback-insert-appeal-change-request-notification-messages.sql new file mode 100644 index 0000000000..2173ad62f4 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/NotificationMessages/Rollback-insert-appeal-change-request-notification-messages.sql @@ -0,0 +1,4 @@ +DELETE FROM + sims.notification_messages +WHERE + id IN (36, 37, 38, 39); \ No newline at end of file diff --git a/sources/packages/backend/libs/services/src/notifications/notification/notification-actions.service.ts b/sources/packages/backend/libs/services/src/notifications/notification/notification-actions.service.ts index dd9c6e8436..339a59a548 100644 --- a/sources/packages/backend/libs/services/src/notifications/notification/notification-actions.service.ts +++ b/sources/packages/backend/libs/services/src/notifications/notification/notification-actions.service.ts @@ -37,6 +37,8 @@ import { ParentInformationRequiredFromStudentNotification, ScholasticStandingReversalNotification, StudentCOERequiredNearEndDateNotification, + MinistryFormSubmittedNotification, + MinistryStudentAppealNotification, } from ".."; import { NotificationService } from "./notification.service"; import { LoggerService } from "@sims/utilities/logger"; @@ -419,44 +421,6 @@ export class NotificationActionsService { ); } - /** - * Create change request complete notification to notify student - * when a change request is completed by ministry. - * @param notification notification details. - * @param auditUserId user who completes the change request. - * @param entityManager entity manager to execute in transaction. - */ - async saveChangeRequestCompleteNotification( - notification: StudentNotification, - auditUserId: number, - entityManager: EntityManager, - ): Promise { - const { templateId } = - await this.notificationMessageService.getNotificationMessageDetails( - NotificationMessageType.MinistryCompletesChange, - ); - - const changeRequestCompleteNotification = { - userId: notification.userId, - messageType: NotificationMessageType.MinistryCompletesChange, - messagePayload: { - email_address: notification.toAddress, - template_id: templateId, - personalisation: { - givenNames: notification.givenNames ?? "", - lastName: notification.lastName, - date: this.getDateTimeOnPSTTimeZone(), - }, - }, - }; - - await this.notificationService.saveNotifications( - [changeRequestCompleteNotification], - auditUserId, - { entityManager }, - ); - } - /** * Create institution report change notification to notify student * when institution reports a change to their application. @@ -878,48 +842,6 @@ export class NotificationActionsService { ); } - /** - * Creates student submitted change request after COE notification for ministry. - * @param notification notification details. - * @param entityManager entity manager to execute in transaction. - */ - async saveStudentSubmittedChangeRequestNotification( - notification: StudentSubmittedChangeRequestNotification, - entityManager: EntityManager, - ): Promise { - const auditUser = this.systemUsersService.systemUser; - const { templateId, emailContacts } = - await this.assertNotificationMessageDetails( - NotificationMessageType.StudentSubmittedChangeRequestNotification, - ); - if (!emailContacts?.length) { - return; - } - const ministryNotificationsToSend = emailContacts.map((emailContact) => ({ - userId: auditUser.id, - messageType: - NotificationMessageType.StudentSubmittedChangeRequestNotification, - messagePayload: { - email_address: emailContact, - template_id: templateId, - personalisation: { - givenNames: notification.givenNames ?? "", - lastName: notification.lastName, - birthDate: getDateOnlyFormat(notification.birthDate), - studentEmail: notification.email, - applicationNumber: notification.applicationNumber, - dateTime: this.getDateTimeOnPSTTimeZone(), - }, - }, - })); - // Save notifications to be sent to the ministry into the notification table. - await this.notificationService.saveNotifications( - ministryNotificationsToSend, - auditUser.id, - { entityManager }, - ); - } - /** * Creates student requests basic bceid account notification for ministry. * @param notification notification details. @@ -1486,4 +1408,242 @@ export class NotificationActionsService { { entityManager }, ); } + + /** + * Creates a ministry notification when a student submits an appeal, + * using the existing production appeal submitted template. + * @param notification notification details. + * @param entityManager entity manager to execute in transaction. + */ + async saveMinistryStudentSubmittedAppealNotification( + notification: MinistryStudentAppealNotification, + entityManager: EntityManager, + ): Promise { + const auditUser = this.systemUsersService.systemUser; + const { templateId, emailContacts } = + await this.assertNotificationMessageDetails( + NotificationMessageType.StudentAppealSubmitted, + ); + if (!emailContacts?.length) { + return; + } + const ministryNotificationsToSend = emailContacts.map((emailContact) => ({ + userId: auditUser.id, + messageType: NotificationMessageType.StudentAppealSubmitted, + messagePayload: { + email_address: emailContact, + template_id: templateId, + personalisation: { + givenNames: notification.givenNames ?? "", + lastName: notification.lastName, + birthDate: getDateOnlyFormat(notification.birthDate), + studentEmail: notification.email, + applicationNumber: notification.applicationNumber ?? "N/A", + dateTime: this.getDateTimeOnPSTTimeZone(), + }, + }, + })); + // Save notifications to be sent to the ministry into the notification table. + await this.notificationService.saveNotifications( + ministryNotificationsToSend, + auditUser.id, + { entityManager }, + ); + } + + /** + * Creates a student notification when the ministry completes reviewing an appeal, + * using the existing production ministry-completes-appeal template. + * @param notification notification details. + * @param auditUserId user who completed the appeal review. + * @param entityManager entity manager to execute in transaction. + */ + async saveStudentAppealCompletedNotification( + notification: StudentNotification, + auditUserId: number, + entityManager: EntityManager, + ): Promise { + const { templateId } = + await this.notificationMessageService.getNotificationMessageDetails( + NotificationMessageType.MinistryAppealCompleted, + ); + const appealCompletedNotification = { + userId: notification.userId, + messageType: NotificationMessageType.MinistryAppealCompleted, + messagePayload: { + email_address: notification.toAddress, + template_id: templateId, + personalisation: { + givenNames: notification.givenNames ?? "", + lastName: notification.lastName, + date: this.getDateTimeOnPSTTimeZone(), + }, + }, + }; + await this.notificationService.saveNotifications( + [appealCompletedNotification], + auditUserId, + { entityManager }, + ); + } + + /** + * Creates a ministry notification when a student submits a change request, + * using the updated change request submitted template. + * @param notification notification details. + * @param entityManager entity manager to execute in transaction. + */ + async saveMinistryChangeRequestSubmittedNotification( + notification: StudentSubmittedChangeRequestNotification, + entityManager: EntityManager, + ): Promise { + const auditUser = this.systemUsersService.systemUser; + const { templateId, emailContacts } = + await this.assertNotificationMessageDetails( + NotificationMessageType.MinistryChangeRequestSubmitted, + ); + if (!emailContacts?.length) { + return; + } + const ministryNotificationsToSend = emailContacts.map((emailContact) => ({ + userId: auditUser.id, + messageType: NotificationMessageType.MinistryChangeRequestSubmitted, + messagePayload: { + email_address: emailContact, + template_id: templateId, + personalisation: { + givenNames: notification.givenNames ?? "", + lastName: notification.lastName, + birthDate: getDateOnlyFormat(notification.birthDate), + studentEmail: notification.email, + applicationNumber: notification.applicationNumber, + dateTime: this.getDateTimeOnPSTTimeZone(), + }, + }, + })); + // Save notifications to be sent to the ministry into the notification table. + await this.notificationService.saveNotifications( + ministryNotificationsToSend, + auditUser.id, + { entityManager }, + ); + } + + /** + * Creates a student notification when a change request review is completed by the ministry, + * using the updated change request review completed template. + * @param notification notification details. + * @param auditUserId user who completes the change request review. + * @param entityManager entity manager to execute in transaction. + */ + async saveStudentChangeRequestReviewCompletedNotification( + notification: StudentNotification, + auditUserId: number, + entityManager: EntityManager, + ): Promise { + const { templateId } = + await this.notificationMessageService.getNotificationMessageDetails( + NotificationMessageType.StudentChangeRequestReviewCompleted, + ); + const changeRequestReviewCompletedNotification = { + userId: notification.userId, + messageType: NotificationMessageType.StudentChangeRequestReviewCompleted, + messagePayload: { + email_address: notification.toAddress, + template_id: templateId, + personalisation: { + givenNames: notification.givenNames ?? "", + lastName: notification.lastName, + date: this.getDateTimeOnPSTTimeZone(), + }, + }, + }; + await this.notificationService.saveNotifications( + [changeRequestReviewCompletedNotification], + auditUserId, + { entityManager }, + ); + } + + /** + * Creates a ministry notification when a student submits a form submission, + * using the form category directly from the dynamic form configuration. + * @param notification notification details. + * @param entityManager entity manager to execute in transaction. + */ + async saveMinistryFormSubmittedNotification( + notification: MinistryFormSubmittedNotification, + entityManager: EntityManager, + ): Promise { + const auditUser = this.systemUsersService.systemUser; + const { templateId, emailContacts } = + await this.assertNotificationMessageDetails( + NotificationMessageType.MinistryFormSubmitted, + ); + if (!emailContacts?.length) { + return; + } + + const ministryNotificationsToSend = emailContacts.map((emailContact) => ({ + userId: auditUser.id, + messageType: NotificationMessageType.MinistryFormSubmitted, + messagePayload: { + email_address: emailContact, + template_id: templateId, + personalisation: { + givenNames: notification.givenNames ?? "", + lastName: notification.lastName, + birthDate: getDateOnlyFormat(notification.birthDate), + studentEmail: notification.email, + formCategory: notification.formCategory, + formName: notification.formNames + .map((name) => `
  • ${name}
  • `) + .join(""), + applicationNumber: notification.applicationNumber ?? "N/A", + dateTime: this.getDateTimeOnPSTTimeZone(), + }, + }, + })); + // Save notifications to be sent to the ministry into the notification table. + await this.notificationService.saveNotifications( + ministryNotificationsToSend, + auditUser.id, + { entityManager }, + ); + } + + /** + * Creates a student notification when a form submission is completed. + * @param notification notification details. + * @param auditUserId user who completed the form submission. + * @param entityManager entity manager to execute in transaction. + */ + async saveStudentFormCompletedNotification( + notification: StudentNotification, + auditUserId: number, + entityManager: EntityManager, + ): Promise { + const { templateId } = + await this.notificationMessageService.getNotificationMessageDetails( + NotificationMessageType.StudentFormCompleted, + ); + const formCompletedNotification = { + userId: notification.userId, + messageType: NotificationMessageType.StudentFormCompleted, + messagePayload: { + email_address: notification.toAddress, + template_id: templateId, + personalisation: { + givenNames: notification.givenNames ?? "", + lastName: notification.lastName, + date: this.getDateTimeOnPSTTimeZone(), + }, + }, + }; + await this.notificationService.saveNotifications( + [formCompletedNotification], + auditUserId, + { entityManager }, + ); + } } diff --git a/sources/packages/backend/libs/services/src/notifications/notification/notification.model.ts b/sources/packages/backend/libs/services/src/notifications/notification/notification.model.ts index c87df72950..91bd43e128 100644 --- a/sources/packages/backend/libs/services/src/notifications/notification/notification.model.ts +++ b/sources/packages/backend/libs/services/src/notifications/notification/notification.model.ts @@ -1,4 +1,4 @@ -import { NotificationMessageType } from "@sims/sims-db"; +import { FormCategory, NotificationMessageType } from "@sims/sims-db"; import { NotificationEmailMessage } from "./gc-notify.model"; import { NotificationMetadata } from "@sims/sims-db/entities/notification-metadata.type"; @@ -111,6 +111,18 @@ export interface StudentSubmittedChangeRequestNotification { applicationNumber: string; } +/** + * Ministry notification data when a student submits an appeal using the + * existing (pre-form-submissions-framework) appeal notification template. + */ +export interface MinistryStudentAppealNotification { + givenNames: string; + lastName: string; + email: string; + birthDate: string; + applicationNumber?: string; +} + export interface StudentRequestsBasicBCeIDAccountNotification { givenNames: string; lastName: string; @@ -245,3 +257,17 @@ export interface StudentCOERequiredNearEndDateNotification { email: string; applicationNumber: string; } + +/** + * Ministry notification data when a student submits a form submission, + * including the form category, human-readable form names, and the related application number if available. + */ +export interface MinistryFormSubmittedNotification { + givenNames: string; + lastName: string; + email: string; + birthDate: string; + formCategory: FormCategory; + formNames: string[]; + applicationNumber?: string; +} diff --git a/sources/packages/backend/libs/sims-db/src/entities/notification.model.ts b/sources/packages/backend/libs/sims-db/src/entities/notification.model.ts index 33164d921d..da102b890f 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/notification.model.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/notification.model.ts @@ -113,9 +113,9 @@ export enum NotificationMessageType { */ MinistryCompletesException = 4, /** - * Ministry completes updating a change requested by student. + * Ministry completes updating an appeal requested by student. */ - MinistryCompletesChange = 5, + MinistryAppealCompleted = 5, /** * Institution reporting a change on application. */ @@ -166,9 +166,9 @@ export enum NotificationMessageType { */ MinistryNotificationDisbursementBlocked = 17, /** - * Student submitted change request after COE. + * Student submitted appeal. */ - StudentSubmittedChangeRequestNotification = 18, + StudentAppealSubmitted = 18, /** * Student submits application with exception request. */ @@ -237,4 +237,20 @@ export enum NotificationMessageType { * Student COE required near study end date. */ StudentCOERequiredNearEndDateNotification = 35, + /** + * Ministry notification when a student submits a change request. + */ + MinistryChangeRequestSubmitted = 36, + /** + * Student notification when a change request review is completed. + */ + StudentChangeRequestReviewCompleted = 37, + /** + * Ministry notification when a student submits a form submission. + */ + MinistryFormSubmitted = 38, + /** + * Student notification when a form submission is completed. + */ + StudentFormCompleted = 39, } diff --git a/sources/packages/backend/libs/test-utils/src/constants/index.ts b/sources/packages/backend/libs/test-utils/src/constants/index.ts index 2ffc896660..3e09b8b37c 100644 --- a/sources/packages/backend/libs/test-utils/src/constants/index.ts +++ b/sources/packages/backend/libs/test-utils/src/constants/index.ts @@ -1 +1,2 @@ export * from "./institution.constants"; +export * from "./notification.constants"; diff --git a/sources/packages/backend/libs/test-utils/src/constants/notification.constants.ts b/sources/packages/backend/libs/test-utils/src/constants/notification.constants.ts new file mode 100644 index 0000000000..2d7ac443b8 --- /dev/null +++ b/sources/packages/backend/libs/test-utils/src/constants/notification.constants.ts @@ -0,0 +1,13 @@ +/** + * GC Notify template IDs for the notification message types related to + * appeals and change requests, seeded in the database during migrations. + * These constants are intended for use in E2E tests only. + */ +export const GC_NOTIFY_TEMPLATE_IDS = { + StudentAppealSubmitted: "241a360a-07d6-486f-9aa4-fae6903e1cff", + MinistryAppealCompleted: "d78624da-c0f3-4bf7-8508-e311a50cfead", + MinistryChangeRequestSubmitted: "fad81016-0bed-4d4e-ad48-f70cc943399c", + StudentChangeRequestReviewCompleted: "9a4855d1-4f9a-4293-9868-cd853a8e4061", + MinistryFormSubmitted: "296aa2ea-dfa7-4285-9d5b-315b2a4911d6", + StudentFormCompleted: "fed6b26e-d1f2-4a8c-bfe5-5cb66c00458b", +} as const;