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..c0fe59d386 --- /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,446 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + AESTGroups, + BEARER_AUTH_TYPE, + createTestingAppModule, + getAESTToken, + getAESTUser, +} from "../../../../testHelpers"; +import { + createE2EDataSources, + E2EDataSources, + ensureDynamicFormConfigurationExists, + saveFakeFormSubmissionFromInputTestData, +} from "@sims/test-utils"; +import { + DynamicFormConfiguration, + FormCategory, + FormSubmissionDecisionStatus, + FormSubmissionStatus, + User, +} from "@sims/sims-db"; + +describe("FormSubmissionAESTController(e2e)-getFormSubmission", () => { + let app: INestApplication; + let db: E2EDataSources; + let ministryUser: User; + let studentAppealApplicationA: DynamicFormConfiguration; + let studentAppealApplicationB: DynamicFormConfiguration; + let studentFormApplication: DynamicFormConfiguration; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + db = createE2EDataSources(dataSource); + ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + // Create the form configurations to be used along the tests. + [ + studentAppealApplicationA, + studentAppealApplicationB, + studentFormApplication, + ] = await Promise.all([ + ensureDynamicFormConfigurationExists(db, "Student application appeal A", { + formCategory: FormCategory.StudentAppeal, + }), + ensureDynamicFormConfigurationExists(db, "Student application appeal B", { + formCategory: FormCategory.StudentAppeal, + }), + ensureDynamicFormConfigurationExists(db, "Student form application", { + formCategory: FormCategory.StudentForm, + }), + ]); + }); + + 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); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body).toStrictEqual({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Pending, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + id: itemADecision1.id, + decisionStatus: FormSubmissionDecisionStatus.Approved, + decisionDate: itemADecision1.decisionDate.toISOString(), + decisionBy: `${itemADecision1.decisionBy.firstName} ${itemADecision1.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, + previousDecisions: [ + { + id: itemADecision2.id, + decisionStatus: FormSubmissionDecisionStatus.Pending, + decisionDate: itemADecision2.decisionDate.toISOString(), + decisionBy: `${itemADecision2.decisionBy.firstName} ${itemADecision2.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision2.decisionNote.description, + }, + ], + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + 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); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body).toStrictEqual({ + hasApprovalAuthorization: false, + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Pending, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + 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 decision statuses, including decision 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); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body).toStrictEqual({ + hasApprovalAuthorization: false, + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Completed, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentAppealApplicationA.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationA.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentAppealApplicationA.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + decisionStatus: FormSubmissionDecisionStatus.Approved, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, + }, + ], + }); + }); + }); + + it("Should get a form submission as completed, and its decision 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); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body).toStrictEqual({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: FormCategory.StudentForm, + status: FormSubmissionStatus.Completed, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemA.id, + formType: studentFormApplication.formType, + formCategory: FormCategory.StudentForm, + dynamicFormConfigurationId: studentFormApplication.id, + submissionData: formSubmissionItemA.submittedData, + formDefinitionName: studentFormApplication.formDefinitionName, + updatedAt: formSubmissionItemA.updatedAt.toISOString(), + currentDecision: { + id: itemADecision1.id, + decisionStatus: FormSubmissionDecisionStatus.Approved, + decisionDate: itemADecision1.decisionDate.toISOString(), + decisionBy: `${itemADecision1.decisionBy.firstName} ${itemADecision1.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision1.decisionNote.description, + }, + previousDecisions: [ + { + id: itemADecision2.id, + decisionStatus: FormSubmissionDecisionStatus.Pending, + decisionDate: itemADecision2.decisionDate.toISOString(), + decisionBy: `${itemADecision2.decisionBy.firstName} ${itemADecision2.decisionBy.lastName}`, + decisionNoteDescription: + itemADecision2.decisionNote.description, + }, + ], + }, + ], + }), + ); + }); + + it("Should get a form submission item, and its decision 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.StudentAppeal, + 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); + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body).toStrictEqual({ + hasApprovalAuthorization: true, + id: formSubmission.id, + formCategory: FormCategory.StudentAppeal, + status: FormSubmissionStatus.Completed, + submittedDate: formSubmission.submittedDate.toISOString(), + submissionItems: [ + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + updatedAt: formSubmissionItemB.updatedAt.toISOString(), + currentDecision: { + id: itemBDecision1.id, + decisionStatus: FormSubmissionDecisionStatus.Declined, + decisionDate: itemBDecision1.decisionDate.toISOString(), + decisionBy: `${itemBDecision1.decisionBy.firstName} ${itemBDecision1.decisionBy.lastName}`, + decisionNoteDescription: + itemBDecision1.decisionNote.description, + }, + previousDecisions: [ + { + id: itemBDecision2.id, + decisionStatus: FormSubmissionDecisionStatus.Pending, + 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.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..c789ded359 --- /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,267 @@ +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, + createFakeInstitutionLocation, + E2EDataSources, + ensureDynamicFormConfigurationExists, + 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, + ); + // 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, + }), + ]); + }); + + 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) + .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 decision 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) + .expect(({ body }) => + expect(body).toEqual({ + id: formSubmission.id, + applicationId: application.id, + applicationNumber: application.applicationNumber, + 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, + 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: [], + }); + 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 new file mode 100644 index 0000000000..34c6f4f9d1 --- /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,259 @@ +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, + E2EDataSources, + ensureDynamicFormConfigurationExists, + 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; + let studentAppealApplicationB: DynamicFormConfiguration; + + beforeAll(async () => { + const { nestApplication, dataSource, module } = + await createTestingAppModule(); + app = nestApplication; + appModule = module; + db = createE2EDataSources(dataSource); + ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + // 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 () => { + 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 (no 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, + 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) + .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, + }, + }, + ], + }), + ); + }); + + 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, + submissionStatus: FormSubmissionStatus.Completed, + auditUser: ministryUser, + formSubmissionItems: [ + { + dynamicFormConfiguration: studentAppealApplicationA, + decisions: [ + { + decisionStatus: FormSubmissionDecisionStatus.Approved, + }, + ], + }, + { + dynamicFormConfiguration: studentAppealApplicationB, + 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) + .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, + }, + }, + { + id: formSubmissionItemB.id, + formType: studentAppealApplicationB.formType, + formCategory: FormCategory.StudentAppeal, + dynamicFormConfigurationId: studentAppealApplicationB.id, + submissionData: formSubmissionItemB.submittedData, + formDefinitionName: studentAppealApplicationB.formDefinitionName, + 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, + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); 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..45eee202d5 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,9 +28,9 @@ describe("FormSubmissionStudentsController(e2e)-getSubmissionForms", () => { .get(endpoint) .auth(studentToken, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect(({ body }) => { - expect(body).toEqual({ - configurations: [ + .expect(({ body }) => + expect(body.configurations).toEqual( + expect.arrayContaining([ { id: expect.any(Number), formDefinitionName: "studentexceptionalexpenseappeal", @@ -101,9 +101,9 @@ describe("FormSubmissionStudentsController(e2e)-getSubmissionForms", () => { allowBundledSubmission: true, hasApplicationScope: true, }, - ], - }); - }); + ]), + ), + ); }); afterAll(async () => { 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..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, @@ -36,6 +37,7 @@ import { AuthorizedParties, IUserToken, Role, UserGroups } from "../../auth"; import { FormSubmissionCompletionAPIInDTO, FormSubmissionItemDecisionAPIInDTO, + FormSubmissionItemMinistryAPIOutDTO, FormSubmissionMinistryAPIOutDTO, FormSubmissionPendingSummaryAPIOutDTO, FormSubmissionsAPIOutDTO, @@ -106,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, @@ -147,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, @@ -160,43 +162,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, + userToken.roles, + ), + 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..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, ), })), }; @@ -118,22 +117,26 @@ 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, - 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 ac2f078b6d..6f88cfe5d2 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,13 +25,13 @@ 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 { NotificationActionsService } from "@sims/services/notifications"; +import { hasFormSubmissionApprovalAuthorization } from "./form-submission.authorization"; @Injectable() export class FormSubmissionApprovalService { @@ -357,18 +357,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. @@ -382,7 +370,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..3315226fda --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.authorization.ts @@ -0,0 +1,28 @@ +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 { + if (!userRoles?.length) { + return false; + } + 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 3ef9f255c4..f2106920e6 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. @@ -48,14 +46,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/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..dee96fcaa6 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,28 +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, + 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.offeringIntensity = + options?.initialValues?.offeringIntensity; dynamicFormConfiguration.formDefinitionName = - options?.formDefinitionName ?? faker.string.alphanumeric({ length: 50 }); - dynamicFormConfiguration.formCategory = FormCategory.System; - dynamicFormConfiguration.hasApplicationScope = false; - dynamicFormConfiguration.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; } @@ -42,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, + formType: DynamicFormType | string, options?: { + formCategory?: FormCategory; programYear?: ProgramYear; offeringIntensity?: OfferingIntensity; }, @@ -61,21 +66,28 @@ export async function ensureDynamicFormConfigurationExists( offeringIntensity: true, formType: true, formDefinitionName: true, + formCategory: true, }, relations: { programYear: true }, where: { - formType, - programYear: { id: options?.programYear.id }, + formType: formType as DynamicFormType, + programYear: { id: options?.programYear?.id }, offeringIntensity: options?.offeringIntensity, + formCategory: options?.formCategory, }, }); if (existingDynamicFormConfiguration) { return existingDynamicFormConfiguration; } const dynamicFormConfiguration = createFakeDynamicFormConfiguration( - formType, + formType as DynamicFormType, { programYear: options?.programYear }, - { offeringIntensity: options?.offeringIntensity }, + { + initialValues: { + formCategory: options?.formCategory, + offeringIntensity: options?.offeringIntensity, + }, + }, ); return db.dynamicFormConfiguration.save(dynamicFormConfiguration); } 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..f4bb91f61d 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"; @@ -10,15 +11,19 @@ import { faker } from "@faker-js/faker"; * @param relations form submission item relations. * - `dynamicFormConfiguration` dynamic form configuration for the item. * - `currentDecision` current decision. When not provided the item will have no decision (Pending). + * - `creator` user who created the form submission item. * @returns a form submission item not yet persisted. */ export function createFakeFormSubmissionItem(relations: { dynamicFormConfiguration: DynamicFormConfiguration; currentDecision?: FormSubmissionItemDecision; + creator: 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.creator; 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..f34236dbb8 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,157 @@ 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"; + +/** + * Test input data for creating form submission decisions. + */ +export interface FormSubmissionDecisionTestInputData { + decisionStatus: FormSubmissionDecisionStatus; +} + +/** + * Test input data for creating form submission items, + * including the decisions to be created for each item. + */ +export interface FormSubmissionItemTestInputData { + /** + * 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 to be associated with the form submission. When not provided, + * the application students will be used or a new student is created. + */ + student?: Student; + /** + * Application associated with the form submission. + * If not provided, the form submission will not be associated with any application. + */ + application?: Application; + /** + * Form category for the form submission. + */ + formCategory: FormCategory; + /** + * Form submission status. Defaults to `Pending` when not provided. + * If some status other than `Pending` is provided, the assessedDate and assessedBy fields will + * be automatically set with the current date and the audit user, respectively. + */ + submissionStatus: FormSubmissionStatus; + /** + * User who performed the audit. + */ + auditUser: User; + /** + * Form submission items to be created. + * Can be provided with decisions to be created for each item. + * If decisions are provided, the first one will be considered + * the current decision for the item. + */ + 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, + testInputData: FormSubmissionTestInputData, +): Promise { + const now = new Date(); + 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 = testInputData.formCategory; + formSubmission.submissionStatus = testInputData.submissionStatus; + if (testInputData.submissionStatus !== FormSubmissionStatus.Pending) { + formSubmission.assessedDate = now; + formSubmission.assessedBy = testInputData.auditUser; + } + formSubmission.formSubmissionItems = []; + await db.formSubmission.save(formSubmission); + for (const itemInputData of testInputData.formSubmissionItems) { + const submissionItem = createFakeFormSubmissionItem({ + dynamicFormConfiguration: itemInputData.dynamicFormConfiguration, + creator: student.user, + }); + 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 decisionTestInputData of itemInputData.decisions) { + const decision = new FormSubmissionItemDecision(); + decision.formSubmissionItem = submissionItem; + decision.decisionStatus = decisionTestInputData.decisionStatus; + decision.creator = testInputData.auditUser; + decision.createdAt = now; + decision.decisionBy = testInputData.auditUser; + decision.decisionDate = now; + decision.modifier = testInputData.auditUser; + decision.updatedAt = now; + // 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); + } + await db.formSubmissionItemDecision.save(submissionItem.decisions); + } + // 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; +} /** * Saves a fake form submission with one or more items to the database. @@ -63,11 +208,11 @@ export async function saveFakeFormSubmission( const numberOfItems = options?.numberOfItems ?? 1; formSubmission.formSubmissionItems = Array.from( { length: numberOfItems }, - () => { - const item = createFakeFormSubmissionItem({ dynamicFormConfiguration }); - item.creator = student.user; - return item; - }, + () => + createFakeFormSubmissionItem({ + dynamicFormConfiguration, + creator: student.user, + }), ); return db.formSubmission.save(formSubmission); 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" /> - - - +