From 9a460ee797130977352b48f98b530d3e81fe4fe8 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Tue, 17 Mar 2026 10:59:06 -0700 Subject: [PATCH 1/9] Starting E2E for getFormSubmission --- ...s.controller.getFormSubmission.e2e-spec.ts | 189 ++++++++++++++++++ .../factories/dynamic-form-configuration.ts | 17 +- .../src/factories/form-submission-item.ts | 4 + .../src/factories/form-submission.ts | 78 ++++++++ 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts new file mode 100644 index 0000000000..6e529539a0 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts @@ -0,0 +1,189 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + AESTGroups, + BEARER_AUTH_TYPE, + createTestingAppModule, + FakeStudentUsersTypes, getAESTUser, getStudentToken, + mockJWTUserInfo, + resetMockJWTUserInfo +} from "../../../../testHelpers"; +import { + createE2EDataSources, + createFakeDynamicFormConfiguration, + E2EDataSources, saveFakeFormSubmissionFromInputTestData +} from "@sims/test-utils"; +import { TestingModule } from "@nestjs/testing"; +import { DynamicFormConfiguration, FormCategory, FormSubmissionDecisionStatus, FormSubmissionStatus, User } from "@sims/sims-db"; + +describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { + let app: INestApplication; + let appModule: TestingModule; + let db: E2EDataSources; + let ministryUser: User; + let studentAppealApplicationA: DynamicFormConfiguration, studentAppealApplicationB: DynamicFormConfiguration; + + beforeAll(async () => { + const { nestApplication, dataSource, module } = + await createTestingAppModule(); + app = nestApplication; + appModule = module; + db = createE2EDataSources(dataSource); + ministryUser = await getAESTUser(db.dataSource, AESTGroups.BusinessAdministrators); + + [studentAppealApplicationA, studentAppealApplicationB] = await db.dynamicFormConfiguration.save([ + createFakeDynamicFormConfiguration("Student application appeal A", null, { + initialValues: { formCategory: FormCategory.StudentAppeal, hasApplicationScope: true }, + }), + createFakeDynamicFormConfiguration("Student application appeal B", null, { + initialValues: { formCategory: FormCategory.StudentAppeal, hasApplicationScope: true }, + }), + ]); + + }); + + beforeEach(async () => { + await resetMockJWTUserInfo(appModule); + }); + + it("Should get a form submission as pending and its decisions as pending when the final decision is not yet made and there is an approved and a pending decision (not decision set).", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Pending, + auditUser: ministryUser, + formSubmissionItems: [ + { + // Should be Pending as the final decision was not yet made. + dynamicFormConfiguration: studentAppealApplicationA, + setFirstDecisionAsCurrent: true, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + ], + }, + { + // Should be pending as it has no decision. + dynamicFormConfiguration: studentAppealApplicationB, + decisions: [], + } + ] + }); + const [formSubmissionItemA, formSubmissionItemB] = formSubmission.formSubmissionItems; + const endpoint = `/students/form-submission/${formSubmission.id}`; + const studentToken = await getStudentToken( + FakeStudentUsersTypes.FakeStudentUserType1, + ); + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + //.then((response) => { console.log(inspect(response.body, { depth: null })) }) + .expect({ + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Pending, + submittedDate: formSubmission.submittedDate.toISOString(), + assessedDate: null, + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Pending } + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Pending } + } + ] + }); + }); + + it("Should get a form submission as approved and its decisions statuses when form submission is completed.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + setFirstDecisionAsCurrent: true, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + ], + }, + { + dynamicFormConfiguration: studentAppealApplicationB, + setFirstDecisionAsCurrent: true, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Declined, + }, + ], + }, + ] + }); + const [formSubmissionItemA, formSubmissionItemB] = formSubmission.formSubmissionItems; + const endpoint = `/students/form-submission/${formSubmission.id}`; + const studentToken = await getStudentToken( + FakeStudentUsersTypes.FakeStudentUserType1, + ); + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + //.then((response) => { console.log(inspect(response.body, { depth: null })) }) + .expect({ + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Completed, + submittedDate: formSubmission.submittedDate.toISOString(), + assessedDate: formSubmission.assessedDate?.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Approved } + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Declined } + } + ] + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts b/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts index 915a0b27dd..a677f66d0f 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts @@ -18,22 +18,23 @@ import { faker } from "@faker-js/faker"; * @returns fake dynamic form configuration. */ export function createFakeDynamicFormConfiguration( - formType: DynamicFormType, + formType: DynamicFormType | string, relations?: { programYear?: ProgramYear }, options?: { offeringIntensity?: OfferingIntensity; formDefinitionName?: string; + initialValues?: Partial; }, ): DynamicFormConfiguration { const dynamicFormConfiguration = new DynamicFormConfiguration(); - dynamicFormConfiguration.formType = formType; + dynamicFormConfiguration.formType = formType as DynamicFormType; dynamicFormConfiguration.programYear = relations?.programYear; dynamicFormConfiguration.offeringIntensity = options?.offeringIntensity; dynamicFormConfiguration.formDefinitionName = options?.formDefinitionName ?? faker.string.alphanumeric({ length: 50 }); - dynamicFormConfiguration.formCategory = FormCategory.System; - dynamicFormConfiguration.hasApplicationScope = false; - dynamicFormConfiguration.allowBundledSubmission = false; + dynamicFormConfiguration.formCategory = options?.initialValues?.formCategory ?? FormCategory.System; + dynamicFormConfiguration.hasApplicationScope = options?.initialValues?.hasApplicationScope ?? false; + dynamicFormConfiguration.allowBundledSubmission = options?.initialValues?.allowBundledSubmission ?? false; return dynamicFormConfiguration; } @@ -48,7 +49,7 @@ export function createFakeDynamicFormConfiguration( */ export async function ensureDynamicFormConfigurationExists( db: E2EDataSources, - formType: DynamicFormType, + formType: DynamicFormType | string, options?: { programYear?: ProgramYear; offeringIntensity?: OfferingIntensity; @@ -64,7 +65,7 @@ export async function ensureDynamicFormConfigurationExists( }, relations: { programYear: true }, where: { - formType, + formType: formType as DynamicFormType, programYear: { id: options?.programYear.id }, offeringIntensity: options?.offeringIntensity, }, @@ -73,7 +74,7 @@ export async function ensureDynamicFormConfigurationExists( return existingDynamicFormConfiguration; } const dynamicFormConfiguration = createFakeDynamicFormConfiguration( - formType, + formType as DynamicFormType, { programYear: options?.programYear }, { offeringIntensity: options?.offeringIntensity }, ); diff --git a/sources/packages/backend/libs/test-utils/src/factories/form-submission-item.ts b/sources/packages/backend/libs/test-utils/src/factories/form-submission-item.ts index 557f5ebb49..9b4fecec2d 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/form-submission-item.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/form-submission-item.ts @@ -2,6 +2,7 @@ import { DynamicFormConfiguration, FormSubmissionItem, FormSubmissionItemDecision, + User, } from "@sims/sims-db"; import { faker } from "@faker-js/faker"; @@ -15,10 +16,13 @@ import { faker } from "@faker-js/faker"; export function createFakeFormSubmissionItem(relations: { dynamicFormConfiguration: DynamicFormConfiguration; currentDecision?: FormSubmissionItemDecision; + auditUser?: User; }): FormSubmissionItem { const item = new FormSubmissionItem(); item.dynamicFormConfiguration = relations.dynamicFormConfiguration; item.submittedData = { someField: faker.lorem.word() }; item.currentDecision = relations.currentDecision; + item.decisions = relations.currentDecision ? [relations.currentDecision] : []; + item.creator = relations.auditUser ?? null; return item; } diff --git a/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts b/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts index d6b0ae471a..f7ff13660b 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts @@ -3,12 +3,90 @@ import { DynamicFormConfiguration, FormCategory, FormSubmission, + FormSubmissionDecisionStatus, + FormSubmissionItemDecision, FormSubmissionStatus, + NoteType, Student, + User, } from "@sims/sims-db"; import { E2EDataSources } from "../data-source/e2e-data-source"; import { createFakeFormSubmissionItem } from "./form-submission-item"; import { saveFakeStudent } from "./student"; +import { createFakeNote } from "@sims/test-utils/factories/note"; + +export interface FormSubmissionDecisionTestInputData { + decisionStatus: FormSubmissionDecisionStatus; +} + +export interface FormSubmissionItemTestInputData { + dynamicFormConfiguration?: DynamicFormConfiguration; + setFirstDecisionAsCurrent?: boolean; + decisions: FormSubmissionDecisionTestInputData[]; +} + +export interface FormSubmissionTestInputData { + student?: Student; + application?: Application; + formCategory: FormCategory; + submissionStatus: FormSubmissionStatus; + auditUser: User; + formSubmissionItems: FormSubmissionItemTestInputData[]; +} + +export async function saveFakeFormSubmissionFromInputTestData( + db: E2EDataSources, + inputData: FormSubmissionTestInputData, +): Promise { + const now = new Date(); + const student = inputData.student ?? inputData.application?.student ?? (await saveFakeStudent(db.dataSource)); + const formSubmission = new FormSubmission(); + formSubmission.student = student; + formSubmission.creator = student.user; + formSubmission.submittedDate = now; + formSubmission.formCategory = inputData.formCategory; + formSubmission.submissionStatus = inputData.submissionStatus; + if (inputData.submissionStatus !== FormSubmissionStatus.Pending) { + formSubmission.assessedDate = now; + formSubmission.assessedBy = inputData.auditUser; + } + formSubmission.formSubmissionItems = []; + for (const itemInputData of inputData.formSubmissionItems) { + const submissionItem = createFakeFormSubmissionItem({ + dynamicFormConfiguration: itemInputData.dynamicFormConfiguration, + auditUser: inputData.auditUser, + }); + submissionItem.decisions = []; + for (const decisionData of itemInputData.decisions) { + const decision = new FormSubmissionItemDecision(); + decision.decisionStatus = decisionData.decisionStatus; + decision.creator = inputData.auditUser; + decision.createdAt = now; + decision.decisionBy = inputData.auditUser; + decision.decisionDate = now; + decision.modifier = inputData.auditUser; + decision.updatedAt = now; + const note = createFakeNote(); + note.creator = inputData.auditUser; + note.description = `Note for decision with status ${decisionData.decisionStatus}`; + note.noteType = inputData.formCategory === FormCategory.StudentAppeal ? NoteType.StudentAppeal : NoteType.StudentForm; + await db.note.save(note); + decision.decisionNote = note; + submissionItem.decisions.push(decision); + } + formSubmission.formSubmissionItems.push(submissionItem); + } + await db.formSubmission.save(formSubmission); + for (let i = 0; i < formSubmission.formSubmissionItems.length; i++) { + const itemTestInput = inputData.formSubmissionItems[i]; + const formSubmissionItem = formSubmission.formSubmissionItems[i]; + if (itemTestInput.setFirstDecisionAsCurrent) { + formSubmissionItem.currentDecision = formSubmissionItem.decisions[0]; + } + } + await db.formSubmission.save(formSubmission); + return formSubmission; +} /** * Saves a fake form submission with one or more items to the database. From 9a46c83a2bc01024774b665402326a5eee93f0ac Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Tue, 17 Mar 2026 11:28:08 -0700 Subject: [PATCH 2/9] getFormSubmission updates --- ...s.controller.getFormSubmission.e2e-spec.ts | 144 ++++++++++++++---- 1 file changed, 115 insertions(+), 29 deletions(-) diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts index 6e529539a0..b816f53f10 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts @@ -4,24 +4,34 @@ import { AESTGroups, BEARER_AUTH_TYPE, createTestingAppModule, - FakeStudentUsersTypes, getAESTUser, getStudentToken, + FakeStudentUsersTypes, + getAESTUser, + getStudentToken, mockJWTUserInfo, - resetMockJWTUserInfo + resetMockJWTUserInfo, } from "../../../../testHelpers"; import { createE2EDataSources, createFakeDynamicFormConfiguration, - E2EDataSources, saveFakeFormSubmissionFromInputTestData + E2EDataSources, + saveFakeFormSubmissionFromInputTestData, } from "@sims/test-utils"; import { TestingModule } from "@nestjs/testing"; -import { DynamicFormConfiguration, FormCategory, FormSubmissionDecisionStatus, FormSubmissionStatus, User } from "@sims/sims-db"; +import { + DynamicFormConfiguration, + FormCategory, + FormSubmissionDecisionStatus, + FormSubmissionStatus, + User, +} from "@sims/sims-db"; describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { let app: INestApplication; let appModule: TestingModule; let db: E2EDataSources; let ministryUser: User; - let studentAppealApplicationA: DynamicFormConfiguration, studentAppealApplicationB: DynamicFormConfiguration; + let studentAppealApplicationA: DynamicFormConfiguration, + studentAppealApplicationB: DynamicFormConfiguration; beforeAll(async () => { const { nestApplication, dataSource, module } = @@ -29,17 +39,34 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { app = nestApplication; appModule = module; db = createE2EDataSources(dataSource); - ministryUser = await getAESTUser(db.dataSource, AESTGroups.BusinessAdministrators); - - [studentAppealApplicationA, studentAppealApplicationB] = await db.dynamicFormConfiguration.save([ - createFakeDynamicFormConfiguration("Student application appeal A", null, { - initialValues: { formCategory: FormCategory.StudentAppeal, hasApplicationScope: true }, - }), - createFakeDynamicFormConfiguration("Student application appeal B", null, { - initialValues: { formCategory: FormCategory.StudentAppeal, hasApplicationScope: true }, - }), - ]); + ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + [studentAppealApplicationA, studentAppealApplicationB] = + await db.dynamicFormConfiguration.save([ + createFakeDynamicFormConfiguration( + "Student application appeal A", + null, + { + initialValues: { + formCategory: FormCategory.StudentAppeal, + hasApplicationScope: true, + }, + }, + ), + createFakeDynamicFormConfiguration( + "Student application appeal B", + null, + { + initialValues: { + formCategory: FormCategory.StudentAppeal, + hasApplicationScope: true, + }, + }, + ), + ]); }); beforeEach(async () => { @@ -67,10 +94,11 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { // Should be pending as it has no decision. dynamicFormConfiguration: studentAppealApplicationB, decisions: [], - } - ] + }, + ], }); - const [formSubmissionItemA, formSubmissionItemB] = formSubmission.formSubmissionItems; + const [formSubmissionItemA, formSubmissionItemB] = + formSubmission.formSubmissionItems; const endpoint = `/students/form-submission/${formSubmission.id}`; const studentToken = await getStudentToken( FakeStudentUsersTypes.FakeStudentUserType1, @@ -98,7 +126,9 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { dynamicFormConfigurationId: studentAppealApplicationA.id, submissionData: formSubmissionItemA.submittedData, formDefinitionName: studentAppealApplicationA.formDefinitionName, - currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Pending } + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, }, { id: formSubmissionItemB.id, @@ -107,13 +137,15 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { dynamicFormConfigurationId: studentAppealApplicationB.id, submissionData: formSubmissionItemB.submittedData, formDefinitionName: studentAppealApplicationB.formDefinitionName, - currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Pending } - } - ] + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + }, + ], }); }); - it("Should get a form submission as approved and its decisions statuses when form submission is completed.", async () => { + it("Should get a form submission as completed and its decisions statuses when form submission is completed.", async () => { // Arrange const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { formCategory: FormCategory.StudentAppeal, @@ -138,9 +170,10 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { }, ], }, - ] + ], }); - const [formSubmissionItemA, formSubmissionItemB] = formSubmission.formSubmissionItems; + const [formSubmissionItemA, formSubmissionItemB] = + formSubmission.formSubmissionItems; const endpoint = `/students/form-submission/${formSubmission.id}`; const studentToken = await getStudentToken( FakeStudentUsersTypes.FakeStudentUserType1, @@ -168,7 +201,9 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { dynamicFormConfigurationId: studentAppealApplicationA.id, submissionData: formSubmissionItemA.submittedData, formDefinitionName: studentAppealApplicationA.formDefinitionName, - currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Approved } + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, }, { id: formSubmissionItemB.id, @@ -177,9 +212,60 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { dynamicFormConfigurationId: studentAppealApplicationB.id, submissionData: formSubmissionItemB.submittedData, formDefinitionName: studentAppealApplicationB.formDefinitionName, - currentDecision: { decisionStatus: FormSubmissionDecisionStatus.Declined } - } - ] + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Declined, + }, + }, + ], + }); + }); + + it("Should throw a not found exception when the form submission ID belongs to another student.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Pending, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [], + }, + ], + }); + const endpoint = `/students/form-submission/${formSubmission.id}`; + const studentToken = await getStudentToken( + FakeStudentUsersTypes.FakeStudentUserType1, + ); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.NOT_FOUND) + .expect({ + message: `Form submission with ID ${formSubmission.id} not found.`, + error: "Not Found", + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + it("Should throw a not found exception when the form submission ID does not exist.", async () => { + // Arrange + const endpoint = "/students/form-submission/9999999"; + const studentToken = await getStudentToken( + FakeStudentUsersTypes.FakeStudentUserType1, + ); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.NOT_FOUND) + .expect({ + message: "Form submission with ID 9999999 not found.", + error: "Not Found", + statusCode: HttpStatus.NOT_FOUND, }); }); From 19ad67de8d213b26217764dc9a46185504a437e5 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Wed, 18 Mar 2026 14:00:01 -0700 Subject: [PATCH 3/9] Added getFormSubmission for FormSubmissionAESTController --- ...t.controller.getFormSubmission.e2e-spec.ts | 481 ++++++++++++++++++ ...s.controller.getFormSubmission.e2e-spec.ts | 6 +- .../form-submission.aest.controller.ts | 82 +-- .../form-submission.controller.service.ts | 4 +- .../src/factories/form-submission.ts | 87 +++- 5 files changed, 593 insertions(+), 67 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts new file mode 100644 index 0000000000..68bbcf9585 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts @@ -0,0 +1,481 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + AESTGroups, + BEARER_AUTH_TYPE, + createTestingAppModule, + getAESTToken, + getAESTUser, + mockJWTUserInfo, + resetMockJWTUserInfo, +} from "../../../../testHelpers"; +import { + createE2EDataSources, + createFakeDynamicFormConfiguration, + E2EDataSources, + saveFakeFormSubmissionFromInputTestData, +} from "@sims/test-utils"; +import { TestingModule } from "@nestjs/testing"; +import { + DynamicFormConfiguration, + FormCategory, + FormSubmissionDecisionStatus, + FormSubmissionStatus, + User, +} from "@sims/sims-db"; +import { inspect } from "util"; + +describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { + let app: INestApplication; + let appModule: TestingModule; + let db: E2EDataSources; + let ministryUser: User; + let studentAppealApplicationA: DynamicFormConfiguration; + let studentAppealApplicationB: DynamicFormConfiguration; + let studentFormApplication: DynamicFormConfiguration; + + beforeAll(async () => { + const { nestApplication, dataSource, module } = + await createTestingAppModule(); + app = nestApplication; + appModule = module; + db = createE2EDataSources(dataSource); + ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + + [ + studentAppealApplicationA, + studentAppealApplicationB, + studentFormApplication, + ] = await db.dynamicFormConfiguration.save([ + createFakeDynamicFormConfiguration("Student application appeal A", null, { + initialValues: { + formCategory: FormCategory.StudentAppeal, + hasApplicationScope: true, + }, + }), + createFakeDynamicFormConfiguration("Student application appeal B", null, { + initialValues: { + formCategory: FormCategory.StudentAppeal, + hasApplicationScope: true, + }, + }), + createFakeDynamicFormConfiguration("Student form application", null, { + initialValues: { + formCategory: FormCategory.StudentForm, + hasApplicationScope: false, + }, + }), + ]); + }); + + beforeEach(async () => { + await resetMockJWTUserInfo(appModule); + }); + + it("Should get a form submission as pending, its decisions and history when the form has multiple decisions and the user has approval authorization.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Pending, + auditUser: ministryUser, + formSubmissionItems: [ + { + // Should be Pending as the final decision was not yet made. + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + ], + }, + { + // Should be pending as it has no decision. + dynamicFormConfiguration: studentAppealApplicationB, + decisions: [], + }, + ], + }); + const [formSubmissionItemA, formSubmissionItemB] = + formSubmission.formSubmissionItems; + const [itemADecision1, itemADecision2] = formSubmissionItemA.decisions; + const endpoint = `/aest/form-submission/${formSubmission.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + //.expect(({ body }) => console.log(inspect(body, { depth: null }))) + .expect({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: studentAppealApplicationA.formCategory, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + id: itemADecision1.id, + decisionStatus: itemADecision1.decisionStatus, + decisionDate: itemADecision1.decisionDate.toISOString(), + decisionBy: `${itemADecision1.decisionBy.firstName} ${itemADecision1.decisionBy.lastName}`, + decisionNoteDescription: itemADecision1.decisionNote.description, + }, + previousDecisions: [ + { + id: itemADecision2.id, + decisionStatus: itemADecision2.decisionStatus, + decisionDate: itemADecision2.decisionDate.toISOString(), + decisionBy: `${itemADecision2.decisionBy.firstName} ${itemADecision2.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision2.decisionNote.description, + }, + ], + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: studentAppealApplicationB.formCategory, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + updatedAt: formSubmissionItemB.updatedAt.toISOString(), + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + previousDecisions: [], + }, + ], + }); + }); + + it("Should get a form submission as pending, and its decisions as pending without history when the form has multiple decisions, including an approved decision, and the user does not have approval authorization.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Pending, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + ], + }, + ], + }); + const [formSubmissionItemA] = formSubmission.formSubmissionItems; + const endpoint = `/aest/form-submission/${formSubmission.id}`; + const token = await getAESTToken(AESTGroups.Operations); + + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => console.log(inspect(body, { depth: null }))) + .expect({ + hasApprovalAuthorization: false, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: studentAppealApplicationA.formCategory, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + }, + ], + }); + }); + + it("Should get a form submission as completed, and its decisions statuses, including current notes when the user does not have approval authorization.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + ], + }, + ], + }); + const [formSubmissionItemA] = formSubmission.formSubmissionItems; + const [itemADecision1] = formSubmissionItemA.decisions; + const endpoint = `/aest/form-submission/${formSubmission.id}`; + const token = await getAESTToken(AESTGroups.Operations); + + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => console.log(inspect(body, { depth: null }))) + .expect(({ body }) => { + expect(body).toStrictEqual({ + hasApprovalAuthorization: false, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: studentAppealApplicationA.formCategory, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + decisionStatus: itemADecision1.decisionStatus, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, + }, + ], + }); + }); + }); + + it("Should get a form submission as completed, and its decisions statuses, including current notes and audit when the user has approval authorization.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentForm, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentFormApplication, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + ], + }, + ], + }); + const [formSubmissionItemA] = formSubmission.formSubmissionItems; + const [itemADecision1, itemADecision2] = formSubmissionItemA.decisions; + const endpoint = `/aest/form-submission/${formSubmission.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => console.log(inspect(body, { depth: null }))) + .expect(({ body }) => { + expect(body).toStrictEqual({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentFormApplication.formType, + formCategory: studentFormApplication.formCategory, + dynamicFormConfigurationId: studentFormApplication.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentFormApplication.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + id: itemADecision1.id, + decisionStatus: itemADecision1.decisionStatus, + decisionDate: itemADecision1.decisionDate.toISOString(), + decisionBy: `${itemADecision1.decisionBy.firstName} ${itemADecision1.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, + previousDecisions: [ + { + id: itemADecision2.id, + decisionStatus: itemADecision2.decisionStatus, + decisionDate: itemADecision2.decisionDate.toISOString(), + decisionBy: `${itemADecision2.decisionBy.firstName} ${itemADecision2.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision2.decisionNote.description, + }, + ], + }, + ], + }); + }); + }); + + it("Should get a form submission item, and its decisions statuses, including current notes and audit when the user has approval authorization and an item ID was provided.", async () => { + // Arrange + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + formCategory: FormCategory.StudentForm, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + ], + }, + { + // This will be the item returned in the response, as its ID will be provided in the query parameter. + dynamicFormConfiguration: studentAppealApplicationB, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Declined, + }, + { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + ], + }, + ], + }); + const [, formSubmissionItemB] = formSubmission.formSubmissionItems; + const [itemBDecision1, itemBDecision2] = formSubmissionItemB.decisions; + const endpoint = `/aest/form-submission/${formSubmission.id}?itemId=${formSubmissionItemB.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Mock the user received in the token. + await mockJWTUserInfo(appModule, formSubmission.student.user); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => console.log(inspect(body, { depth: null }))) + .expect(({ body }) => { + expect(body).toStrictEqual({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: studentAppealApplicationB.formCategory, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + updatedAt: formSubmissionItemB.updatedAt.toISOString(), + currentDecision: { + id: itemBDecision1.id, + decisionStatus: itemBDecision1.decisionStatus, + decisionDate: itemBDecision1.decisionDate.toISOString(), + decisionBy: `${itemBDecision1.decisionBy.firstName} ${itemBDecision1.decisionBy.lastName}`, + decisionNoteDescription: + itemBDecision1.decisionNote.description, + }, + previousDecisions: [ + { + id: itemBDecision2.id, + decisionStatus: itemBDecision2.decisionStatus, + decisionDate: itemBDecision2.decisionDate.toISOString(), + decisionBy: `${itemBDecision2.decisionBy.firstName} ${itemBDecision2.decisionBy.lastName}`, + decisionNoteDescription: + itemBDecision2.decisionNote.description, + }, + ], + }, + ], + }); + }); + }); + + it("Should throw a not found exception when the form submission ID does not exist.", async () => { + // Arrange + const endpoint = "/aest/form-submission/9999999"; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.NOT_FOUND) + .expect({ + message: "Form submission with ID 9999999 not found.", + error: "Not Found", + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + it("Should throw a not found exception when the form submission ID or the item ID does not exist.", async () => { + // Arrange + const endpoint = "/aest/form-submission/9999999?itemId=8888888"; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.NOT_FOUND) + .expect({ + message: + "Form submission with ID 9999999 and form submission item ID 8888888 not found.", + error: "Not Found", + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts index b816f53f10..fd6140944a 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts @@ -30,8 +30,8 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { let appModule: TestingModule; let db: E2EDataSources; let ministryUser: User; - let studentAppealApplicationA: DynamicFormConfiguration, - studentAppealApplicationB: DynamicFormConfiguration; + let studentAppealApplicationA: DynamicFormConfiguration; + let studentAppealApplicationB: DynamicFormConfiguration; beforeAll(async () => { const { nestApplication, dataSource, module } = @@ -73,7 +73,7 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { await resetMockJWTUserInfo(appModule); }); - it("Should get a form submission as pending and its decisions as pending when the final decision is not yet made and there is an approved and a pending decision (not decision set).", async () => { + it("Should get a form submission as pending and its decisions as pending when the final decision is not yet made and there is an approved and a pending decision (no decision set).", async () => { // Arrange const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { formCategory: FormCategory.StudentAppeal, diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts index 2e98dec20e..511958bc8f 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts @@ -36,6 +36,7 @@ import { AuthorizedParties, IUserToken, Role, UserGroups } from "../../auth"; import { FormSubmissionCompletionAPIInDTO, FormSubmissionItemDecisionAPIInDTO, + FormSubmissionItemMinistryAPIOutDTO, FormSubmissionMinistryAPIOutDTO, FormSubmissionPendingSummaryAPIOutDTO, FormSubmissionsAPIOutDTO, @@ -160,43 +161,50 @@ export class FormSubmissionAESTController extends BaseController { applicationId: submission.application?.id, applicationNumber: submission.application?.applicationNumber, submittedDate: submission.submittedDate, - submissionItems: submission.formSubmissionItems.map((item) => ({ - id: item.id, - formType: item.dynamicFormConfiguration.formType, - formCategory: item.dynamicFormConfiguration.formCategory, - dynamicFormConfigurationId: item.dynamicFormConfiguration.id, - submissionData: item.submittedData, - formDefinitionName: item.dynamicFormConfiguration.formDefinitionName, - updatedAt: item.updatedAt, - currentDecision: - hasApprovalAuthorization && item.currentDecision - ? { - id: item.currentDecision.id, - decisionStatus: - item.currentDecision?.decisionStatus ?? - FormSubmissionDecisionStatus.Pending, - decisionDate: item.currentDecision.decisionDate, - decisionBy: getUserFullName(item.currentDecision.decisionBy), - decisionNoteDescription: - item.currentDecision.decisionNote.description, - } - : { - decisionStatus: - item.currentDecision?.decisionStatus ?? - FormSubmissionDecisionStatus.Pending, - }, - previousDecisions: hasApprovalAuthorization - ? item.decisions - .filter((decision) => decision.id !== item.currentDecision.id) - .map((decision) => ({ - id: decision.id, - decisionStatus: decision.decisionStatus, - decisionDate: decision.decisionDate, - decisionBy: getUserFullName(decision.decisionBy), - decisionNoteDescription: decision.decisionNote.description, - })) - : undefined, - })), + submissionItems: + submission.formSubmissionItems.map( + (item) => ({ + id: item.id, + formType: item.dynamicFormConfiguration.formType, + formCategory: item.dynamicFormConfiguration.formCategory, + dynamicFormConfigurationId: item.dynamicFormConfiguration.id, + submissionData: item.submittedData, + formDefinitionName: + item.dynamicFormConfiguration.formDefinitionName, + updatedAt: item.updatedAt, + currentDecision: + item.currentDecision && hasApprovalAuthorization + ? { + id: item.currentDecision.id, + decisionStatus: + item.currentDecision?.decisionStatus ?? + FormSubmissionDecisionStatus.Pending, + decisionDate: item.currentDecision.decisionDate, + decisionBy: getUserFullName( + item.currentDecision.decisionBy, + ), + decisionNoteDescription: + item.currentDecision.decisionNote.description, + } + : this.formSubmissionControllerService.mapCurrentDecision( + submission.submissionStatus, + item, + true, + true, + ), + previousDecisions: hasApprovalAuthorization + ? item.decisions + .filter((decision) => decision.id !== item.currentDecision.id) + .map((decision) => ({ + id: decision.id, + decisionStatus: decision.decisionStatus, + decisionDate: decision.decisionDate, + decisionBy: getUserFullName(decision.decisionBy), + decisionNoteDescription: decision.decisionNote.description, + })) + : undefined, + }), + ), }; } diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts index b080698ef5..fd4fb72186 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts @@ -118,13 +118,15 @@ export class FormSubmissionControllerService { * Define the decision to be returned. * The decision and its details are determined based on the form submission status * and the access to the decision details that the consumer has. + * Used for students and institutions that have different access to the decision details, + * and for Ministry users with limited access to the decision details. * @param submissionStatus form submission status. * @param submissionItem form submission to determine the decision details to be returned. * @param includeBasicDecisionDetails flag to indicate if the basic decision details should be included in the response, * besides the status that is always included. * @returns the decision that must be exposed the consumer. */ - private mapCurrentDecision( + mapCurrentDecision( submissionStatus: FormSubmissionStatus, submissionItem: FormSubmissionItem, includeBasicDecisionDetails: boolean, diff --git a/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts b/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts index f7ff13660b..8a668442c3 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/form-submission.ts @@ -20,11 +20,21 @@ export interface FormSubmissionDecisionTestInputData { } export interface FormSubmissionItemTestInputData { - dynamicFormConfiguration?: DynamicFormConfiguration; - setFirstDecisionAsCurrent?: boolean; + /** + * Dynamic form configuration to be associated with the form submission item. + */ + dynamicFormConfiguration: DynamicFormConfiguration; + /** + * Decisions to be created for the form submission item. + * If provided, the first decision will be considered the current decision for the item, + * and the rest will be considered previous decisions. + */ decisions: FormSubmissionDecisionTestInputData[]; } +/** + * Test data to create form submissions, its items and decisions. + */ export interface FormSubmissionTestInputData { student?: Student; application?: Application; @@ -34,56 +44,81 @@ export interface FormSubmissionTestInputData { formSubmissionItems: FormSubmissionItemTestInputData[]; } +/** + * Creates and saves a fake form submission with its items and decisions based on the provided test input data. + * @param db E2E data sources. + * @param testInputData test input data for creating the form submission and related data. + * @returns the created form submission. + */ export async function saveFakeFormSubmissionFromInputTestData( db: E2EDataSources, - inputData: FormSubmissionTestInputData, + testInputData: FormSubmissionTestInputData, ): Promise { const now = new Date(); - const student = inputData.student ?? inputData.application?.student ?? (await saveFakeStudent(db.dataSource)); + const student = + testInputData.student ?? + testInputData.application?.student ?? + (await saveFakeStudent(db.dataSource)); const formSubmission = new FormSubmission(); formSubmission.student = student; + formSubmission.application = testInputData.application; formSubmission.creator = student.user; formSubmission.submittedDate = now; - formSubmission.formCategory = inputData.formCategory; - formSubmission.submissionStatus = inputData.submissionStatus; - if (inputData.submissionStatus !== FormSubmissionStatus.Pending) { + formSubmission.formCategory = testInputData.formCategory; + formSubmission.submissionStatus = testInputData.submissionStatus; + if (testInputData.submissionStatus !== FormSubmissionStatus.Pending) { formSubmission.assessedDate = now; - formSubmission.assessedBy = inputData.auditUser; + formSubmission.assessedBy = testInputData.auditUser; } formSubmission.formSubmissionItems = []; - for (const itemInputData of inputData.formSubmissionItems) { + await db.formSubmission.save(formSubmission); + for (const itemInputData of testInputData.formSubmissionItems) { const submissionItem = createFakeFormSubmissionItem({ dynamicFormConfiguration: itemInputData.dynamicFormConfiguration, - auditUser: inputData.auditUser, + auditUser: testInputData.auditUser, }); + submissionItem.formSubmission = formSubmission; + // Update the array to avoid reloading the data and allowing a + // method consumer to have access to the data. + formSubmission.formSubmissionItems.push(submissionItem); + // Save form submission item to generate the ID for the item, which is required for the decision relation. + await db.formSubmissionItem.save(submissionItem); submissionItem.decisions = []; - for (const decisionData of itemInputData.decisions) { + for (const decisionTestInputData of itemInputData.decisions) { const decision = new FormSubmissionItemDecision(); - decision.decisionStatus = decisionData.decisionStatus; - decision.creator = inputData.auditUser; + decision.formSubmissionItem = submissionItem; + decision.decisionStatus = decisionTestInputData.decisionStatus; + decision.creator = testInputData.auditUser; decision.createdAt = now; - decision.decisionBy = inputData.auditUser; + decision.decisionBy = testInputData.auditUser; decision.decisionDate = now; - decision.modifier = inputData.auditUser; + decision.modifier = testInputData.auditUser; decision.updatedAt = now; - const note = createFakeNote(); - note.creator = inputData.auditUser; - note.description = `Note for decision with status ${decisionData.decisionStatus}`; - note.noteType = inputData.formCategory === FormCategory.StudentAppeal ? NoteType.StudentAppeal : NoteType.StudentForm; + // Note creation. + const noteType = + testInputData.formCategory === FormCategory.StudentAppeal + ? NoteType.StudentAppeal + : NoteType.StudentForm; + const note = createFakeNote(noteType, { + creator: testInputData.auditUser, + }); await db.note.save(note); decision.decisionNote = note; + // Update the array to avoid reloading the data and allowing a + // method consumer to have access to the data. submissionItem.decisions.push(decision); } - formSubmission.formSubmissionItems.push(submissionItem); + await db.formSubmissionItemDecision.save(submissionItem.decisions); } - await db.formSubmission.save(formSubmission); - for (let i = 0; i < formSubmission.formSubmissionItems.length; i++) { - const itemTestInput = inputData.formSubmissionItems[i]; - const formSubmissionItem = formSubmission.formSubmissionItems[i]; - if (itemTestInput.setFirstDecisionAsCurrent) { - formSubmissionItem.currentDecision = formSubmissionItem.decisions[0]; + // Associate the current decision for each item as the first decision in + // the decisions array, if there are any decisions. + for (const formSubmissionItem of formSubmission.formSubmissionItems) { + if (formSubmissionItem.decisions.length > 0) { + const [currentDecision] = formSubmissionItem.decisions; + formSubmissionItem.currentDecision = currentDecision; } } + // Save the form submission again to update the relation with the current decision. await db.formSubmission.save(formSubmission); return formSubmission; } From b1fe08c560d61b61e175112e3e5ddf14d6ff5e45 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Wed, 18 Mar 2026 16:23:58 -0700 Subject: [PATCH 4/9] Institutions getFormSubmission --- ...s.controller.getFormSubmission.e2e-spec.ts | 295 ++++++++++++++++++ ...s.controller.getFormSubmission.e2e-spec.ts | 70 +++-- 2 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts new file mode 100644 index 0000000000..d391777cd3 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts @@ -0,0 +1,295 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + AESTGroups, + authorizeUserTokenForLocation, + BEARER_AUTH_TYPE, + createTestingAppModule, + getAESTUser, + getAuthRelatedEntities, + getInstitutionToken, + InstitutionTokenTypes, +} from "../../../../testHelpers"; +import { + createE2EDataSources, + createFakeDynamicFormConfiguration, + createFakeInstitutionLocation, + E2EDataSources, + saveFakeApplication, + saveFakeFormSubmissionFromInputTestData, +} from "@sims/test-utils"; +import { + DynamicFormConfiguration, + FormCategory, + FormSubmissionDecisionStatus, + FormSubmissionStatus, + Institution, + InstitutionLocation, + User, +} from "@sims/sims-db"; + +describe("FormSubmissionInstitutionsController(e2e)-getFormSubmission", () => { + let app: INestApplication; + let db: E2EDataSources; + let collegeF: Institution; + let collegeFLocation: InstitutionLocation; + let ministryUser: User; + let studentAppealApplicationA: DynamicFormConfiguration; + let studentAppealApplicationB: DynamicFormConfiguration; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + db = createE2EDataSources(dataSource); + ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + // College F. + const { institution } = await getAuthRelatedEntities( + db.dataSource, + InstitutionTokenTypes.CollegeFUser, + ); + collegeF = institution; + collegeFLocation = createFakeInstitutionLocation({ institution: collegeF }); + await authorizeUserTokenForLocation( + db.dataSource, + InstitutionTokenTypes.CollegeFUser, + collegeFLocation, + ); + [ + studentAppealApplicationA, + studentAppealApplicationB, + //studentFormApplication, + ] = await db.dynamicFormConfiguration.save([ + createFakeDynamicFormConfiguration("Student application appeal A", null, { + initialValues: { + formCategory: FormCategory.StudentAppeal, + hasApplicationScope: true, + }, + }), + createFakeDynamicFormConfiguration("Student application appeal B", null, { + initialValues: { + formCategory: FormCategory.StudentAppeal, + hasApplicationScope: true, + }, + }), + ]); + }); + + it("Should get a form submission as pending and its decisions as pending when the final decision is not yet made and there is an approved and a pending decision (no decision set).", async () => { + // Arrange + const application = await saveFakeApplication(db.dataSource, { + institutionLocation: collegeFLocation, + }); + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + application, + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Pending, + auditUser: ministryUser, + formSubmissionItems: [ + { + // Should be Pending as the final decision was not yet made. + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + ], + }, + { + // Should be pending as it has no decision. + dynamicFormConfiguration: studentAppealApplicationB, + decisions: [], + }, + ], + }); + const [formSubmissionItemA, formSubmissionItemB] = + formSubmission.formSubmissionItems; + const endpoint = `/institutions/form-submission/student/${formSubmission.student.id}/form-submission/${formSubmission.id}`; + const studentToken = await getInstitutionToken( + InstitutionTokenTypes.CollegeFUser, + ); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + //.then((response) => { console.log(inspect(response.body, { depth: null })) }) + .expect(({ body }) => + expect(body).toEqual({ + id: formSubmission.id, + applicationId: application.id, + applicationNumber: application.applicationNumber, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Pending, + submittedDate: formSubmission.submittedDate.toISOString(), + assessedDate: null, + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + }, + ], + }), + ); + }); + + it("Should get a form submission as completed and its decisions statuses, including the decision notes, when form submission is completed.", async () => { + // Arrange + const application = await saveFakeApplication(db.dataSource, { + institutionLocation: collegeFLocation, + }); + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + application, + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + ], + }, + { + dynamicFormConfiguration: studentAppealApplicationB, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Declined, + }, + ], + }, + ], + }); + const [formSubmissionItemA, formSubmissionItemB] = + formSubmission.formSubmissionItems; + const [itemADecision1] = formSubmissionItemA.decisions; + const [itemBDecision1] = formSubmissionItemB.decisions; + const endpoint = `/institutions/form-submission/student/${formSubmission.student.id}/form-submission/${formSubmission.id}`; + const studentToken = await getInstitutionToken( + InstitutionTokenTypes.CollegeFUser, + ); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(studentToken, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + //.then((response) => { console.log(inspect(response.body, { depth: null })) }) + .expect(({ body }) => + expect(body).toEqual({ + id: formSubmission.id, + applicationId: application.id, + applicationNumber: application.applicationNumber, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Pending, + submittedDate: formSubmission.submittedDate.toISOString(), + assessedDate: null, + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + decisionNoteDescription: + itemBDecision1.decisionNote.description, + }, + }, + ], + }), + ); + }); + + it("Should throw a not found exception when the form submission ID exists for the student but the user does not have access to the location.", async () => { + // Arrange + const collegeFAlternativeLocation = createFakeInstitutionLocation({ + institution: collegeF, + }); + const application = await saveFakeApplication(db.dataSource, { + institutionLocation: collegeFAlternativeLocation, + }); + const formSubmission = await saveFakeFormSubmissionFromInputTestData(db, { + application, + formCategory: FormCategory.StudentAppeal, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + ], + }, + { + dynamicFormConfiguration: studentAppealApplicationB, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Declined, + }, + ], + }, + ], + }); + const endpoint = `/institutions/form-submission/student/${formSubmission.student.id}/form-submission/${formSubmission.id}`; + const token = await getInstitutionToken(InstitutionTokenTypes.CollegeFUser); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.NOT_FOUND) + .expect({ + message: `Form submission with ID ${formSubmission.id} not found.`, + error: "Not Found", + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts index fd6140944a..3f48916678 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts @@ -83,7 +83,6 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { { // Should be Pending as the final decision was not yet made. dynamicFormConfiguration: studentAppealApplicationA, - setFirstDecisionAsCurrent: true, decisions: [ { decisionStatus: FormSubmissionDecisionStatus.Approved, @@ -154,7 +153,6 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { formSubmissionItems: [ { dynamicFormConfiguration: studentAppealApplicationA, - setFirstDecisionAsCurrent: true, decisions: [ { decisionStatus: FormSubmissionDecisionStatus.Approved, @@ -163,7 +161,6 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { }, { dynamicFormConfiguration: studentAppealApplicationB, - setFirstDecisionAsCurrent: true, decisions: [ { decisionStatus: FormSubmissionDecisionStatus.Declined, @@ -174,6 +171,8 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { }); const [formSubmissionItemA, formSubmissionItemB] = formSubmission.formSubmissionItems; + const [itemADecision1] = formSubmissionItemA.decisions; + const [itemBDecision1] = formSubmissionItemB.decisions; const endpoint = `/students/form-submission/${formSubmission.id}`; const studentToken = await getStudentToken( FakeStudentUsersTypes.FakeStudentUserType1, @@ -186,38 +185,43 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(studentToken, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - //.then((response) => { console.log(inspect(response.body, { depth: null })) }) - .expect({ - id: formSubmission.id, - formCategory: FormCategory.StudentAppeal, - status: FormSubmissionStatus.Completed, - submittedDate: formSubmission.submittedDate.toISOString(), - assessedDate: formSubmission.assessedDate?.toISOString(), - submissionItems: [ - { - id: formSubmissionItemA.id, - formType: studentAppealApplicationA.formType, - formCategory: FormCategory.StudentAppeal, - dynamicFormConfigurationId: studentAppealApplicationA.id, - submissionData: formSubmissionItemA.submittedData, - formDefinitionName: studentAppealApplicationA.formDefinitionName, - currentDecision: { - decisionStatus: FormSubmissionDecisionStatus.Approved, + .expect(({ body }) => + expect(body).toEqual({ + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Completed, + submittedDate: formSubmission.submittedDate.toISOString(), + assessedDate: formSubmission.assessedDate?.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Approved, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, }, - }, - { - id: formSubmissionItemB.id, - formType: studentAppealApplicationB.formType, - formCategory: FormCategory.StudentAppeal, - dynamicFormConfigurationId: studentAppealApplicationB.id, - submissionData: formSubmissionItemB.submittedData, - formDefinitionName: studentAppealApplicationB.formDefinitionName, - currentDecision: { - decisionStatus: FormSubmissionDecisionStatus.Declined, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Declined, + decisionNoteDescription: + itemBDecision1.decisionNote.description, + }, }, - }, - ], - }); + ], + }), + ); }); it("Should throw a not found exception when the form submission ID belongs to another student.", async () => { From a44b9e0a54d6fecfcf90600287c144dc849bdd94 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Wed, 18 Mar 2026 16:53:24 -0700 Subject: [PATCH 5/9] Pre review changes --- ...t.controller.getFormSubmission.e2e-spec.ts | 174 +++++++++--------- ...s.controller.getFormSubmission.e2e-spec.ts | 25 +-- ...s.controller.getFormSubmission.e2e-spec.ts | 97 +++++----- ....controller.getSubmissionForms.e2e-spec.ts | 8 +- .../factories/dynamic-form-configuration.ts | 34 ++-- 5 files changed, 156 insertions(+), 182 deletions(-) diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts index 68bbcf9585..75df7bc2e9 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.aest.controller.getFormSubmission.e2e-spec.ts @@ -11,8 +11,8 @@ import { } from "../../../../testHelpers"; import { createE2EDataSources, - createFakeDynamicFormConfiguration, E2EDataSources, + ensureDynamicFormConfigurationExists, saveFakeFormSubmissionFromInputTestData, } from "@sims/test-utils"; import { TestingModule } from "@nestjs/testing"; @@ -23,7 +23,6 @@ import { FormSubmissionStatus, User, } from "@sims/sims-db"; -import { inspect } from "util"; describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { let app: INestApplication; @@ -44,29 +43,20 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { db.dataSource, AESTGroups.BusinessAdministrators, ); - + // Create the form configurations to be used along the tests. [ studentAppealApplicationA, studentAppealApplicationB, studentFormApplication, - ] = await db.dynamicFormConfiguration.save([ - createFakeDynamicFormConfiguration("Student application appeal A", null, { - initialValues: { - formCategory: FormCategory.StudentAppeal, - hasApplicationScope: true, - }, + ] = await Promise.all([ + ensureDynamicFormConfigurationExists(db, "Student application appeal A", { + formCategory: FormCategory.StudentAppeal, }), - createFakeDynamicFormConfiguration("Student application appeal B", null, { - initialValues: { - formCategory: FormCategory.StudentAppeal, - hasApplicationScope: true, - }, + ensureDynamicFormConfigurationExists(db, "Student application appeal B", { + formCategory: FormCategory.StudentAppeal, }), - createFakeDynamicFormConfiguration("Student form application", null, { - initialValues: { - formCategory: FormCategory.StudentForm, - hasApplicationScope: false, - }, + ensureDynamicFormConfigurationExists(db, "Student form application", { + formCategory: FormCategory.StudentForm, }), ]); }); @@ -115,55 +105,57 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - //.expect(({ body }) => console.log(inspect(body, { depth: null }))) - .expect({ - hasApprovalAuthorization: true, - id: formSubmission.id, - formCategory: formSubmission.formCategory, - status: formSubmission.submissionStatus, - submittedDate: formSubmission.submittedDate.toISOString(), - submissionItems: [ - { - id: formSubmissionItemA.id, - formType: studentAppealApplicationA.formType, - formCategory: studentAppealApplicationA.formCategory, - dynamicFormConfigurationId: studentAppealApplicationA.id, - submissionData: formSubmissionItemA.submittedData, - formDefinitionName: studentAppealApplicationA.formDefinitionName, - updatedAt: formSubmissionItemA.updatedAt.toISOString(), - currentDecision: { - id: itemADecision1.id, - decisionStatus: itemADecision1.decisionStatus, - decisionDate: itemADecision1.decisionDate.toISOString(), - decisionBy: `${itemADecision1.decisionBy.firstName} ${itemADecision1.decisionBy.lastName}`, - decisionNoteDescription: itemADecision1.decisionNote.description, - }, - previousDecisions: [ - { - id: itemADecision2.id, - decisionStatus: itemADecision2.decisionStatus, - decisionDate: itemADecision2.decisionDate.toISOString(), - decisionBy: `${itemADecision2.decisionBy.firstName} ${itemADecision2.decisionBy.lastName}`, + .expect(({ body }) => + expect(body).toStrictEqual({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: studentAppealApplicationA.formCategory, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + id: itemADecision1.id, + decisionStatus: itemADecision1.decisionStatus, + decisionDate: itemADecision1.decisionDate.toISOString(), + decisionBy: `${itemADecision1.decisionBy.firstName} ${itemADecision1.decisionBy.lastName}`, decisionNoteDescription: - itemADecision2.decisionNote.description, + itemADecision1.decisionNote.description, }, - ], - }, - { - id: formSubmissionItemB.id, - formType: studentAppealApplicationB.formType, - formCategory: studentAppealApplicationB.formCategory, - dynamicFormConfigurationId: studentAppealApplicationB.id, - submissionData: formSubmissionItemB.submittedData, - formDefinitionName: studentAppealApplicationB.formDefinitionName, - updatedAt: formSubmissionItemB.updatedAt.toISOString(), - currentDecision: { - decisionStatus: FormSubmissionDecisionStatus.Pending, + previousDecisions: [ + { + id: itemADecision2.id, + decisionStatus: itemADecision2.decisionStatus, + decisionDate: itemADecision2.decisionDate.toISOString(), + decisionBy: `${itemADecision2.decisionBy.firstName} ${itemADecision2.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision2.decisionNote.description, + }, + ], }, - previousDecisions: [], - }, - ], - }); + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: studentAppealApplicationB.formCategory, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + updatedAt: formSubmissionItemB.updatedAt.toISOString(), + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, + previousDecisions: [], + }, + ], + }), + ); }); it("Should get a form submission as pending, and its decisions as pending without history when the form has multiple decisions, including an approved decision, and the user does not have approval authorization.", async () => { @@ -198,28 +190,29 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect(({ body }) => console.log(inspect(body, { depth: null }))) - .expect({ - hasApprovalAuthorization: false, - id: formSubmission.id, - formCategory: formSubmission.formCategory, - status: formSubmission.submissionStatus, - submittedDate: formSubmission.submittedDate.toISOString(), - submissionItems: [ - { - id: formSubmissionItemA.id, - formType: studentAppealApplicationA.formType, - formCategory: studentAppealApplicationA.formCategory, - dynamicFormConfigurationId: studentAppealApplicationA.id, - submissionData: formSubmissionItemA.submittedData, - formDefinitionName: studentAppealApplicationA.formDefinitionName, - updatedAt: formSubmissionItemA.updatedAt.toISOString(), - currentDecision: { - decisionStatus: FormSubmissionDecisionStatus.Pending, + .expect(({ body }) => + expect(body).toStrictEqual({ + hasApprovalAuthorization: false, + id: formSubmission.id, + formCategory: formSubmission.formCategory, + status: formSubmission.submissionStatus, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: studentAppealApplicationA.formCategory, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, }, - }, - ], - }); + ], + }), + ); }); it("Should get a form submission as completed, and its decisions statuses, including current notes when the user does not have approval authorization.", async () => { @@ -255,7 +248,6 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect(({ body }) => console.log(inspect(body, { depth: null }))) .expect(({ body }) => { expect(body).toStrictEqual({ hasApprovalAuthorization: false, @@ -316,8 +308,7 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect(({ body }) => console.log(inspect(body, { depth: null }))) - .expect(({ body }) => { + .expect(({ body }) => expect(body).toStrictEqual({ hasApprovalAuthorization: true, id: formSubmission.id, @@ -353,8 +344,8 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { ], }, ], - }); - }); + }), + ); }); it("Should get a form submission item, and its decisions statuses, including current notes and audit when the user has approval authorization and an item ID was provided.", async () => { @@ -399,7 +390,6 @@ describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect(({ body }) => console.log(inspect(body, { depth: null }))) .expect(({ body }) => { expect(body).toStrictEqual({ hasApprovalAuthorization: true, diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts index d391777cd3..87d75f1531 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.institutions.controller.getFormSubmission.e2e-spec.ts @@ -12,9 +12,9 @@ import { } from "../../../../testHelpers"; import { createE2EDataSources, - createFakeDynamicFormConfiguration, createFakeInstitutionLocation, E2EDataSources, + ensureDynamicFormConfigurationExists, saveFakeApplication, saveFakeFormSubmissionFromInputTestData, } from "@sims/test-utils"; @@ -57,22 +57,13 @@ describe("FormSubmissionInstitutionsController(e2e)-getFormSubmission", () => { InstitutionTokenTypes.CollegeFUser, collegeFLocation, ); - [ - studentAppealApplicationA, - studentAppealApplicationB, - //studentFormApplication, - ] = await db.dynamicFormConfiguration.save([ - createFakeDynamicFormConfiguration("Student application appeal A", null, { - initialValues: { - formCategory: FormCategory.StudentAppeal, - hasApplicationScope: true, - }, + // Create the form configurations to be used along the tests. + [studentAppealApplicationA, studentAppealApplicationB] = await Promise.all([ + ensureDynamicFormConfigurationExists(db, "Student application appeal A", { + formCategory: FormCategory.StudentAppeal, }), - createFakeDynamicFormConfiguration("Student application appeal B", null, { - initialValues: { - formCategory: FormCategory.StudentAppeal, - hasApplicationScope: true, - }, + ensureDynamicFormConfigurationExists(db, "Student application appeal B", { + formCategory: FormCategory.StudentAppeal, }), ]); }); @@ -119,7 +110,6 @@ describe("FormSubmissionInstitutionsController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(studentToken, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - //.then((response) => { console.log(inspect(response.body, { depth: null })) }) .expect(({ body }) => expect(body).toEqual({ id: formSubmission.id, @@ -200,7 +190,6 @@ describe("FormSubmissionInstitutionsController(e2e)-getFormSubmission", () => { .get(endpoint) .auth(studentToken, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - //.then((response) => { console.log(inspect(response.body, { depth: null })) }) .expect(({ body }) => expect(body).toEqual({ id: formSubmission.id, diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts index 3f48916678..356ac5b8e3 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getFormSubmission.e2e-spec.ts @@ -12,8 +12,8 @@ import { } from "../../../../testHelpers"; import { createE2EDataSources, - createFakeDynamicFormConfiguration, E2EDataSources, + ensureDynamicFormConfigurationExists, saveFakeFormSubmissionFromInputTestData, } from "@sims/test-utils"; import { TestingModule } from "@nestjs/testing"; @@ -43,30 +43,15 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { db.dataSource, AESTGroups.BusinessAdministrators, ); - - [studentAppealApplicationA, studentAppealApplicationB] = - await db.dynamicFormConfiguration.save([ - createFakeDynamicFormConfiguration( - "Student application appeal A", - null, - { - initialValues: { - formCategory: FormCategory.StudentAppeal, - hasApplicationScope: true, - }, - }, - ), - createFakeDynamicFormConfiguration( - "Student application appeal B", - null, - { - initialValues: { - formCategory: FormCategory.StudentAppeal, - hasApplicationScope: true, - }, - }, - ), - ]); + // Create the form configurations to be used along the tests. + [studentAppealApplicationA, studentAppealApplicationB] = await Promise.all([ + ensureDynamicFormConfigurationExists(db, "Student application appeal A", { + formCategory: FormCategory.StudentAppeal, + }), + ensureDynamicFormConfigurationExists(db, "Student application appeal B", { + formCategory: FormCategory.StudentAppeal, + }), + ]); }); beforeEach(async () => { @@ -109,39 +94,39 @@ describe("FormSubmissionStudentsController(e2e)-getFormSubmission", () => { await request(app.getHttpServer()) .get(endpoint) .auth(studentToken, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK) - //.then((response) => { console.log(inspect(response.body, { depth: null })) }) - .expect({ - id: formSubmission.id, - formCategory: FormCategory.StudentAppeal, - status: FormSubmissionStatus.Pending, - submittedDate: formSubmission.submittedDate.toISOString(), - assessedDate: null, - submissionItems: [ - { - id: formSubmissionItemA.id, - formType: studentAppealApplicationA.formType, - formCategory: FormCategory.StudentAppeal, - dynamicFormConfigurationId: studentAppealApplicationA.id, - submissionData: formSubmissionItemA.submittedData, - formDefinitionName: studentAppealApplicationA.formDefinitionName, - currentDecision: { - decisionStatus: FormSubmissionDecisionStatus.Pending, + .expect(({ body }) => + expect(body).toStrictEqual({ + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Pending, + submittedDate: formSubmission.submittedDate.toISOString(), + assessedDate: null, + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, }, - }, - { - id: formSubmissionItemB.id, - formType: studentAppealApplicationB.formType, - formCategory: FormCategory.StudentAppeal, - dynamicFormConfigurationId: studentAppealApplicationB.id, - submissionData: formSubmissionItemB.submittedData, - formDefinitionName: studentAppealApplicationB.formDefinitionName, - currentDecision: { - decisionStatus: FormSubmissionDecisionStatus.Pending, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Pending, + }, }, - }, - ], - }); + ], + }), + ); }); it("Should get a form submission as completed and its decisions statuses when form submission is completed.", async () => { diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getSubmissionForms.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getSubmissionForms.e2e-spec.ts index 0b4862c18e..372e29758d 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getSubmissionForms.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/_tests_/e2e/form-submission.students.controller.getSubmissionForms.e2e-spec.ts @@ -28,8 +28,8 @@ describe("FormSubmissionStudentsController(e2e)-getSubmissionForms", () => { .get(endpoint) .auth(studentToken, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect(({ body }) => { - expect(body).toEqual({ + .expect(({ body }) => + expect(body).toStrictEqual({ configurations: [ { id: expect.any(Number), @@ -102,8 +102,8 @@ describe("FormSubmissionStudentsController(e2e)-getSubmissionForms", () => { hasApplicationScope: true, }, ], - }); - }); + }), + ); }); afterAll(async () => { diff --git a/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts b/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts index a677f66d0f..39e8a8ac77 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/dynamic-form-configuration.ts @@ -12,29 +12,31 @@ import { faker } from "@faker-js/faker"; * Create a fake dynamic form configuration. * @param formType form type. * @param relations entity relations. - * @param options dynamic form configuration options - * - `offeringIntensity` offering intensity. - * - `formDefinitionName` form definition name. + * @param options dynamic form configuration options. + * - `initialValues` initial values for the dynamic form configuration. * @returns fake dynamic form configuration. */ export function createFakeDynamicFormConfiguration( formType: DynamicFormType | string, relations?: { programYear?: ProgramYear }, options?: { - offeringIntensity?: OfferingIntensity; - formDefinitionName?: string; initialValues?: Partial; }, ): DynamicFormConfiguration { const dynamicFormConfiguration = new DynamicFormConfiguration(); dynamicFormConfiguration.formType = formType as DynamicFormType; dynamicFormConfiguration.programYear = relations?.programYear; - dynamicFormConfiguration.offeringIntensity = options?.offeringIntensity; + dynamicFormConfiguration.offeringIntensity = + options?.initialValues?.offeringIntensity; dynamicFormConfiguration.formDefinitionName = - options?.formDefinitionName ?? faker.string.alphanumeric({ length: 50 }); - dynamicFormConfiguration.formCategory = options?.initialValues?.formCategory ?? FormCategory.System; - dynamicFormConfiguration.hasApplicationScope = options?.initialValues?.hasApplicationScope ?? false; - dynamicFormConfiguration.allowBundledSubmission = options?.initialValues?.allowBundledSubmission ?? false; + options?.initialValues?.formDefinitionName ?? + faker.string.alphanumeric({ length: 50 }); + dynamicFormConfiguration.formCategory = + options?.initialValues?.formCategory ?? FormCategory.System; + dynamicFormConfiguration.hasApplicationScope = + options?.initialValues?.hasApplicationScope ?? false; + dynamicFormConfiguration.allowBundledSubmission = + options?.initialValues?.allowBundledSubmission ?? false; return dynamicFormConfiguration; } @@ -43,14 +45,16 @@ export function createFakeDynamicFormConfiguration( * @param db e2e DataSources. * @param formType dynamic form type. * @param options dynamic form configuration options - * - `offeringIntensity` offering intensity. + * - `formCategory` form category. * - `programYear` program year. + * - `offeringIntensity` offering intensity. * @returns dynamic form configuration. */ export async function ensureDynamicFormConfigurationExists( db: E2EDataSources, formType: DynamicFormType | string, options?: { + formCategory?: FormCategory; programYear?: ProgramYear; offeringIntensity?: OfferingIntensity; }, @@ -68,6 +72,7 @@ export async function ensureDynamicFormConfigurationExists( formType: formType as DynamicFormType, programYear: { id: options?.programYear.id }, offeringIntensity: options?.offeringIntensity, + formCategory: options?.formCategory, }, }); if (existingDynamicFormConfiguration) { @@ -76,7 +81,12 @@ export async function ensureDynamicFormConfigurationExists( const dynamicFormConfiguration = createFakeDynamicFormConfiguration( formType as DynamicFormType, { programYear: options?.programYear }, - { offeringIntensity: options?.offeringIntensity }, + { + initialValues: { + formCategory: options?.formCategory, + offeringIntensity: options?.offeringIntensity, + }, + }, ); return db.dynamicFormConfiguration.save(dynamicFormConfiguration); } From 468185e78d81707d1b8d22591646ae7882e31e46 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Wed, 18 Mar 2026 18:49:59 -0700 Subject: [PATCH 6/9] Adjusting data for Ministry user without approval role --- .../form-submission.aest.controller.ts | 15 +- .../form-submission.controller.service.ts | 33 ++-- .../form-submission-approval.service.ts | 16 +- .../form-submission.authorization.ts | 25 +++ .../form-submission/form-submission.models.ts | 10 - .../backend/apps/api/src/services/index.ts | 1 + .../FormSubmissionApproval.vue | 174 +++++++++--------- 7 files changed, 139 insertions(+), 135 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/form-submission.authorization.ts diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts index 511958bc8f..b3cc474fce 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts @@ -17,6 +17,7 @@ import { FORM_SUBMISSION_UPDATE_UNAUTHORIZED, FormSubmissionApprovalService, FormSubmissionService, + hasFormSubmissionApprovalAuthorization, } from "../../services"; import { AllowAuthorizedParty, @@ -107,13 +108,14 @@ export class FormSubmissionAESTController extends BaseController { */ @Get("student/:studentId") async getFormSubmissionHistory( + @UserToken() userToken: IUserToken, @Param("studentId", ParseIntPipe) studentId: number, ): Promise { // Kept the includeBasicDecisionDetails as false since the details controlled by // the flag are not required to be returned by this endpoint. const submissions = await this.formSubmissionControllerService.getFormSubmissions(studentId, { - keepPendingDecisionsWhilePendingFormSubmission: false, + userRoles: userToken.roles, }); return { submissions, @@ -148,11 +150,10 @@ export class FormSubmissionAESTController extends BaseController { `Form submission with ID ${formSubmissionId} not found.`, ); } - const hasApprovalAuthorization = - this.formSubmissionApprovalService.hasApprovalAuthorization( - submission.formCategory, - userToken.roles, - ); + const hasApprovalAuthorization = hasFormSubmissionApprovalAuthorization( + submission.formCategory, + userToken.roles, + ); return { hasApprovalAuthorization, id: submission.id, @@ -190,7 +191,7 @@ export class FormSubmissionAESTController extends BaseController { submission.submissionStatus, item, true, - true, + userToken.roles, ), previousDecisions: hasApprovalAuthorization ? item.decisions diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts index fd4fb72186..4d6fb681d3 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts @@ -1,5 +1,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; -import { FormSubmissionService } from "../../services"; +import { + FormSubmissionService, + hasFormSubmissionApprovalAuthorization, +} from "../../services"; import { FormSubmission, FormSubmissionDecisionStatus, @@ -10,6 +13,7 @@ import { FormSubmissionAPIOutDTO, FormSubmissionItemDecisionAPIOutDTO, } from "./models/form-submission.dto"; +import { Role } from "../../auth"; @Injectable() export class FormSubmissionControllerService { @@ -23,9 +27,8 @@ export class FormSubmissionControllerService { * submission belongs to the student and throw a not found HTTP error if it does not. * - `locationIds` restrict forms with an application scope to the provided locations. Used for institutions to have access * only to the form submissions related to the locations they have access to. - * - `keepPendingDecisionsWhilePendingFormSubmission`, when true, will return "Pending" as the decision status for all items - * if the form submission is still pending. This is used to avoid showing decisions that are not final yet while the form - * submission is not completed. Default to true when not provided to expose less information. + * - `userRoles` when provided, it will be used to determine the access to the decision details + * that the consumer has based on their roles and the form category. * - `includeBasicDecisionDetails` optional flag to include basic decision details, besides * the decision status. Used for institutions to have access to more details than the student * to better support them. Default to false when not provided to expose less information. When keepPendingDecisionsWhilePendingFormSubmission @@ -40,9 +43,9 @@ export class FormSubmissionControllerService { options?: { formSubmissionId?: number; locationIds?: number[]; - keepPendingDecisionsWhilePendingFormSubmission?: boolean; includeBasicDecisionDetails?: boolean; loadSubmittedData?: boolean; + userRoles?: Role[]; }, ): Promise { const submissions = await this.formSubmissionService.getFormSubmissions( @@ -60,15 +63,13 @@ export class FormSubmissionControllerService { // Set default value for the options that define how data will be returned considering the // default behavior to expose less information and avoid showing non-final decisions. - const keepPendingDecisionsWhilePendingFormSubmission = - options?.keepPendingDecisionsWhilePendingFormSubmission ?? true; const includeBasicDecisionDetails = options?.includeBasicDecisionDetails ?? false; return submissions.map((submission) => this.mapSubmissionsToAPIOutDTO( submission, includeBasicDecisionDetails, - keepPendingDecisionsWhilePendingFormSubmission, + options?.userRoles, ), ); } @@ -79,15 +80,13 @@ export class FormSubmissionControllerService { * @param submission form submission record to be converted. * @param includeBasicDecisionDetails flag to indicate if the basic decision details should be included in the response, * besides the status that is always included. - * @param keepPendingDecisionsWhilePendingFormSubmission when true, will return "Pending" as the decision status for all items - * if the form submission is still pending. This is used to avoid showing decisions that are not final yet while the form - * submission is not completed. + * @param userRoles roles of the user to determine access to decision details. * @returns form submission details including individual form items and their details in the API output format. */ private mapSubmissionsToAPIOutDTO( submission: FormSubmission, includeBasicDecisionDetails: boolean, - keepPendingDecisionsWhilePendingFormSubmission: boolean, + userRoles?: Role[], ): FormSubmissionAPIOutDTO { return { id: submission.id, @@ -108,7 +107,7 @@ export class FormSubmissionControllerService { submission.submissionStatus, item, includeBasicDecisionDetails, - keepPendingDecisionsWhilePendingFormSubmission, + userRoles, ), })), }; @@ -130,12 +129,14 @@ export class FormSubmissionControllerService { submissionStatus: FormSubmissionStatus, submissionItem: FormSubmissionItem, includeBasicDecisionDetails: boolean, - keepPendingDecisionsWhilePendingFormSubmission: boolean, + userRoles?: Role[], ): FormSubmissionItemDecisionAPIOutDTO { // Determine if decision details should be restricted based on the form submission status and the flag. const shouldRestrictDecisionDetails = - keepPendingDecisionsWhilePendingFormSubmission && - submissionStatus === FormSubmissionStatus.Pending; + !hasFormSubmissionApprovalAuthorization( + submissionItem.dynamicFormConfiguration.formCategory, + userRoles, + ) && submissionStatus === FormSubmissionStatus.Pending; // Define the status. let decisionStatus = shouldRestrictDecisionDetails ? FormSubmissionDecisionStatus.Pending 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..5ba408b684 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 @@ -25,12 +25,12 @@ import { FORM_SUBMISSION_UPDATE_UNAUTHORIZED, } from "./constants"; import { - FORM_SUBMISSION_APPROVAL_ROLES_MAP, FormItemDecision, FormSubmissionCompletionItem, } from "./form-submission.models"; import { NoteSharedService } from "@sims/services"; import { FormSubmissionActionProcessor } from "./form-submission-actions/form-submission-action-processor"; +import { hasFormSubmissionApprovalAuthorization } from "./form-submission.authorization"; @Injectable() export class FormSubmissionApprovalService { @@ -340,18 +340,6 @@ export class FormSubmissionApprovalService { : FormSubmissionStatus.Completed; } - /** - * Indicates if the form submission item can be updated by the user based on the form category and user roles. - * @param category The category of the form item being updated, used - * to determine the required role for authorization. - * @param userRoles The roles of the user attempting to perform the action. - * @returns true if the user has the required role for the form category, false otherwise. - */ - hasApprovalAuthorization(category: FormCategory, userRoles: Role[]): boolean { - const allowedRole = FORM_SUBMISSION_APPROVAL_ROLES_MAP.get(category); - return allowedRole ? userRoles.includes(allowedRole) : false; - } - /** * Ensures the user authorization to update a form submission item * based on the form category and user roles. @@ -365,7 +353,7 @@ export class FormSubmissionApprovalService { category: FormCategory, userRoles: Role[], ): void { - if (!this.hasApprovalAuthorization(category, userRoles)) { + if (!hasFormSubmissionApprovalAuthorization(category, userRoles)) { throw new CustomNamedError( "User does not have the required role to perform this action.", FORM_SUBMISSION_UPDATE_UNAUTHORIZED, diff --git a/sources/packages/backend/apps/api/src/services/form-submission/form-submission.authorization.ts b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.authorization.ts new file mode 100644 index 0000000000..95fa81d3aa --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.authorization.ts @@ -0,0 +1,25 @@ +import { FormCategory } from "@sims/sims-db"; +import { Role } from "../../auth"; + +/** + * Allowed role to update a form submission item based on the form category. + */ +export const FORM_SUBMISSION_APPROVAL_ROLES_MAP = new Map([ + [FormCategory.StudentAppeal, Role.StudentApproveDeclineAppeals], + [FormCategory.StudentForm, Role.StudentApproveDeclineForms], +]); + +/** + * Indicates if the form submission item can be updated by the user based on the form category and user roles. + * @param category The category of the form item being updated, used + * to determine the required role for authorization. + * @param userRoles The roles of the user attempting to perform the action. + * @returns true if the user has the required role for the form category, false otherwise. + */ +export function hasFormSubmissionApprovalAuthorization( + category: FormCategory, + userRoles: Role[], +): boolean { + const allowedRole = FORM_SUBMISSION_APPROVAL_ROLES_MAP.get(category); + return allowedRole ? userRoles.includes(allowedRole) : false; +} 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..e6f881926e 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 @@ -1,10 +1,8 @@ import { DynamicFormConfiguration, - FormCategory, FormSubmissionDecisionStatus, } from "@sims/sims-db"; import { Parent } from "../../types"; -import { Role } from "../../auth"; /** * Optional data that can be loaded as part of the form. @@ -47,14 +45,6 @@ export type FormSubmissionConfig = FormSubmissionModel & applicationId: number | undefined; }; -/** - * Allowed role to update a form submission item based on the form category. - */ -export const FORM_SUBMISSION_APPROVAL_ROLES_MAP = new Map([ - [FormCategory.StudentAppeal, Role.StudentApproveDeclineAppeals], - [FormCategory.StudentForm, Role.StudentApproveDeclineForms], -]); - export interface FormSubmissionCompletionItem { submissionItemId: number; lastUpdateDate: Date; diff --git a/sources/packages/backend/apps/api/src/services/index.ts b/sources/packages/backend/apps/api/src/services/index.ts index 0f1035ea63..285955a03b 100644 --- a/sources/packages/backend/apps/api/src/services/index.ts +++ b/sources/packages/backend/apps/api/src/services/index.ts @@ -61,6 +61,7 @@ export * from "./application-change-request/application-change-request.service"; export * from "./student-appeal/student-appeal.model"; export * from "./student-appeal/student-appeal-assessment"; export * from "./form-submission/constants"; +export * from "./form-submission/form-submission.authorization"; export * from "./form-submission/form-submission.models"; export * from "./form-submission/form-submission-submit.service"; export * from "./form-submission/form-submission-approval.service"; diff --git a/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue b/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue index 526edfd56c..f67a769600 100644 --- a/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue +++ b/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue @@ -33,100 +33,103 @@ :readonly="decision.decisionSaved" :disabled="readOnly || decision.saveDecisionInProgress" /> - - - +