diff --git a/sources/packages/backend/apps/api/src/app.aest.module.ts b/sources/packages/backend/apps/api/src/app.aest.module.ts index 29af7a5877..d390dbdace 100644 --- a/sources/packages/backend/apps/api/src/app.aest.module.ts +++ b/sources/packages/backend/apps/api/src/app.aest.module.ts @@ -46,6 +46,7 @@ import { FormSubmissionActionProcessor, FormSubmissionCreateAppealAssessmentAction, FormSubmissionUpdateModifiedIndependentAction, + FormSubmissionService, } from "./services"; import { SupportingUserAESTController, @@ -237,6 +238,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; FormSubmissionUpdateModifiedIndependentAction, FormSubmissionActionProcessor, FormSubmissionApprovalService, + FormSubmissionService, ], }) export class AppAESTModule {} diff --git a/sources/packages/backend/apps/api/src/app.institutions.module.ts b/sources/packages/backend/apps/api/src/app.institutions.module.ts index d29a5d8b99..baa7d1f6b2 100644 --- a/sources/packages/backend/apps/api/src/app.institutions.module.ts +++ b/sources/packages/backend/apps/api/src/app.institutions.module.ts @@ -35,6 +35,7 @@ import { SupportingUserService, ApplicationRestrictionBypassService, InstitutionRestrictionService, + FormSubmissionService, } from "./services"; import { ApplicationControllerService, @@ -78,6 +79,8 @@ import { ProgramYearControllerService, ReportControllerService, AnnouncementInstitutionsController, + FormSubmissionInstitutionsController, + FormSubmissionControllerService, } from "./route-controllers"; import { AuthModule } from "./auth/auth.module"; import { @@ -126,6 +129,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; ApplicationOfferingChangeRequestInstitutionsController, ProgramYearInstitutionsController, ReportInstitutionsController, + FormSubmissionInstitutionsController, ], providers: [ AnnouncementService, @@ -196,6 +200,8 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; SupportingUserService, DisbursementScheduleSharedService, InstitutionRestrictionService, + FormSubmissionService, + FormSubmissionControllerService, ], }) export class AppInstitutionsModule {} diff --git a/sources/packages/backend/apps/api/src/app.students.module.ts b/sources/packages/backend/apps/api/src/app.students.module.ts index 457389035c..01c23a6033 100644 --- a/sources/packages/backend/apps/api/src/app.students.module.ts +++ b/sources/packages/backend/apps/api/src/app.students.module.ts @@ -29,6 +29,7 @@ import { AnnouncementService, ApplicationRestrictionBypassService, InstitutionRestrictionService, + FormSubmissionSubmitService, FormSubmissionService, } from "./services"; import { @@ -58,6 +59,7 @@ import { SupportingUserControllerService, SupportingUserStudentsController, FormSubmissionStudentsController, + FormSubmissionControllerService, } from "./route-controllers"; import { AuthModule } from "./auth/auth.module"; import { ConfigModule } from "@sims/utilities/config"; @@ -183,6 +185,8 @@ import { SupplementaryDataParents, SupplementaryDataLoader, // Form Submission Service. + FormSubmissionSubmitService, + FormSubmissionControllerService, FormSubmissionService, ], }) diff --git a/sources/packages/backend/apps/api/src/route-controllers/assessment/assessment.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/assessment/assessment.controller.service.ts index 612afa583a..7670e41dd7 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/assessment/assessment.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/assessment/assessment.controller.service.ts @@ -21,6 +21,7 @@ import { MASKED_MSFAA_NUMBER, ApplicationOfferingChangeRequestService, MASKED_MONEY_AMOUNT, + FormSubmissionService, } from "../../services"; import { AssessmentNOAAPIOutDTO, @@ -55,6 +56,7 @@ export class AssessmentControllerService { constructor( private readonly assessmentService: StudentAssessmentService, private readonly studentAppealService: StudentAppealService, + private readonly formSubmissionService: FormSubmissionService, private readonly studentScholasticStandingsService: StudentScholasticStandingsService, private readonly educationProgramOfferingService: EducationProgramOfferingService, private readonly applicationExceptionService: ApplicationExceptionService, @@ -488,6 +490,31 @@ export class AssessmentControllerService { return studentAppealArray; } + /** + * Get student appeals that not generated an assessment yet, which are usually pending or denied appeals. + * @param applicationId application to which the requests are retrieved. + * @param studentId applicant student. + * @returns pending and denied student appeals. + */ + async getPendingAndDeniedStudentAppeals( + applicationId: number, + studentId?: number, + ): Promise { + const formSubmissions = + await this.formSubmissionService.getNonCompletedStudentAppeals( + applicationId, + studentId, + ); + const submissions: RequestAssessmentSummaryAPIOutDTO[] = + formSubmissions.map((submission) => ({ + id: submission.id, + submittedDate: submission.submittedDate, + status: submission.submissionStatus, + requestType: RequestAssessmentTypeAPIOutDTO.StudentAppeal, + })); + return submissions; + } + /** * Get history of approved assessment requests and * unsuccessful scholastic standings change requests(which will not create new assessment) @@ -517,6 +544,7 @@ export class AssessmentControllerService { offeringId: assessment.offering.id, programId: assessment.offering.educationProgram.id, studentAppealId: assessment.studentAppeal?.id, + formSubmissionId: assessment.formSubmission?.id, applicationOfferingChangeRequestId: assessment.applicationOfferingChangeRequest?.id, applicationExceptionId: assessment.application.applicationException?.id, @@ -600,6 +628,10 @@ export class AssessmentControllerService { applicationId, options?.studentId, ); + const formSubmissionAppeals = await this.getPendingAndDeniedStudentAppeals( + applicationId, + options?.studentId, + ); const applicationOfferingChangeRequests = await this.getApplicationOfferingChangeRequestsByStatus( applicationId, @@ -613,6 +645,7 @@ export class AssessmentControllerService { ); return requestAssessmentSummary .concat(appeals) + .concat(formSubmissionAppeals) .concat(applicationOfferingChangeRequests) .sort(this.sortAssessmentHistory); } diff --git a/sources/packages/backend/apps/api/src/route-controllers/assessment/models/assessment.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/assessment/models/assessment.dto.ts index fcaafd4b1b..c625a34f24 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/assessment/models/assessment.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/assessment/models/assessment.dto.ts @@ -7,6 +7,7 @@ import { AssessmentTriggerType, COEStatus, DisbursementScheduleStatus, + FormSubmissionStatus, NOTE_DESCRIPTION_MAX_LENGTH, OfferingIntensity, OfferingStatus, @@ -49,6 +50,7 @@ export enum RequestAssessmentTypeAPIOutDTO { type RequestAssessmentSummaryStatus = | StudentAppealStatus + | FormSubmissionStatus | ApplicationExceptionStatus | OfferingStatus | ApplicationOfferingChangeRequestStatus; @@ -70,6 +72,7 @@ export class AssessmentHistorySummaryAPIOutDTO { offeringId?: number; programId?: number; studentAppealId?: number; + formSubmissionId?: number; applicationOfferingChangeRequestId?: number; applicationExceptionId?: number; studentScholasticStandingId?: number; 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 f1801194f1..832b6dc465 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 @@ -76,7 +76,7 @@ export class FormSubmissionAESTController extends BaseController { @Query("itemId", new ParseIntPipe({ optional: true })) itemId?: number, ): Promise { const submission = - await this.formSubmissionApprovalService.getFormSubmissionsById( + await this.formSubmissionApprovalService.getFormSubmissionById( formSubmissionId, { itemId }, ); @@ -110,21 +110,24 @@ export class FormSubmissionAESTController extends BaseController { dynamicFormConfigurationId: item.dynamicFormConfiguration.id, submissionData: item.submittedData, formDefinitionName: item.dynamicFormConfiguration.formDefinitionName, - decisionStatus: - item.currentDecision?.decisionStatus ?? - FormSubmissionDecisionStatus.Pending, updatedAt: item.updatedAt, currentDecision: hasApprovalAuthorization && item.currentDecision ? { id: item.currentDecision.id, - decisionStatus: item.currentDecision.decisionStatus, + decisionStatus: + item.currentDecision?.decisionStatus ?? + FormSubmissionDecisionStatus.Pending, decisionDate: item.currentDecision.decisionDate, decisionBy: getUserFullName(item.currentDecision.decisionBy), decisionNoteDescription: item.currentDecision.decisionNote.description, } - : undefined, + : { + decisionStatus: + item.currentDecision?.decisionStatus ?? + FormSubmissionDecisionStatus.Pending, + }, previousDecisions: hasApprovalAuthorization ? item.decisions .filter((decision) => decision.id !== item.currentDecision.id) @@ -152,7 +155,8 @@ export class FormSubmissionAESTController extends BaseController { @ApiUnprocessableEntityResponse({ description: "The form submission item has been updated since it was last retrieved or " + - "decisions cannot be made on items belonging to a form submission that is not pending.", + "decisions cannot be made on items belonging to a form submission that is not pending or " + + "the application associated with the form submission is not in completed status.", }) @Roles(...FORM_SUBMISSION_UPDATE_ROLES) @Patch("items/:formSubmissionItemId/decision") @@ -204,7 +208,8 @@ export class FormSubmissionAESTController extends BaseController { "the provided form submission items do not match the form submission items currently saved for this submission or " + "form submission item not found in the form submission or " + "form submission item has been updated since it was last retrieved or " + - "final decision cannot be made when some decisions are still pending.", + "final decision cannot be made when some decisions are still pending or " + + "the application associated with the form submission is not in completed status.", }) @Roles(...FORM_SUBMISSION_UPDATE_ROLES) @Patch(":formSubmissionId/complete") 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 new file mode 100644 index 0000000000..70445e2ca2 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.controller.service.ts @@ -0,0 +1,94 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { FormSubmissionService } from "../../services"; +import { + FormSubmissionDecisionStatus, + FormSubmissionItem, + FormSubmissionStatus, +} from "@sims/sims-db"; +import { + FormSubmissionAPIOutDTO, + FormSubmissionItemDecisionAPIOutDTO, +} from "./models/form-submission.dto"; + +@Injectable() +export class FormSubmissionControllerService { + constructor(private readonly formSubmissionService: FormSubmissionService) {} + + /** + * Get the details of a form submission, including the individual form items and their details. + * @param formSubmissionId ID of the form submission to retrieve the details for. + * @param options. + * - `studentId`: optional ID used to validate the institution access to the student data. + * - `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. + * @returns form submission details. + */ + async getFormSubmission( + formSubmissionId: number, + studentId: number, + options?: { + includeBasicDecisionDetails?: boolean; + applicationId?: number; + }, + ): Promise { + const submission = await this.formSubmissionService.getFormSubmissionById( + formSubmissionId, + studentId, + { applicationId: options?.applicationId }, + ); + if (!submission) { + throw new NotFoundException( + `Form submission with ID ${formSubmissionId} not found.`, + ); + } + return { + id: submission.id, + formCategory: submission.formCategory, + status: submission.submissionStatus, + 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, + currentDecision: this.mapCurrentDecision( + submission.submissionStatus, + item, + !!options?.includeBasicDecisionDetails, + ), + })), + }; + } + + /** + * 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. + * @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( + submissionStatus: FormSubmissionStatus, + submissionItem: FormSubmissionItem, + includeBasicDecisionDetails: boolean, + ): FormSubmissionItemDecisionAPIOutDTO { + if (submissionStatus === FormSubmissionStatus.Pending) { + // For pending submissions, the decision details should not be returned. + return { decisionStatus: FormSubmissionDecisionStatus.Pending }; + } + return { + decisionStatus: submissionItem.currentDecision.decisionStatus, + decisionNoteDescription: includeBasicDecisionDetails + ? submissionItem.currentDecision.decisionNote.description + : undefined, + }; + } +} diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.institutions.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.institutions.controller.ts new file mode 100644 index 0000000000..f7d601c768 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.institutions.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common"; +import { AuthorizedParties } from "../../auth"; +import { + AllowAuthorizedParty, + HasStudentDataAccess, + IsBCPublicInstitution, +} from "../../auth/decorators"; +import { ApiNotFoundResponse, ApiTags } from "@nestjs/swagger"; +import BaseController from "../BaseController"; +import { ClientTypeBaseRoute } from "../../types"; +import { FormSubmissionControllerService } from "./form-submission.controller.service"; +import { FormSubmissionAPIOutDTO } from "./models/form-submission.dto"; + +@AllowAuthorizedParty(AuthorizedParties.institution) +@IsBCPublicInstitution() +@Controller("form-submission") +@ApiTags(`${ClientTypeBaseRoute.Institution}-form-submission`) +export class FormSubmissionInstitutionsController extends BaseController { + constructor( + private readonly formSubmissionControllerService: FormSubmissionControllerService, + ) { + super(); + } + + /** + * Get the details of a form submission, including the individual form items and their details. + * Please note currently the institution can only access form submissions related to their students + * and applications. + * @param formSubmissionId ID of the form submission to retrieve the details for. + * @param studentId student ID for authorization and to ensure the form submission belongs + * to the institution's student. + * @param applicationId application ID to ensure the institution has access to the + * student's application related to the form submission. + * @returns form submission details including individual form items and their details. + */ + @ApiNotFoundResponse({ description: "Form submission not found." }) + @HasStudentDataAccess("studentId", "applicationId") + @Get( + "student/:studentId/application/:applicationId/form-submission/:formSubmissionId", + ) + async getFormSubmission( + @Param("studentId", ParseIntPipe) studentId: number, + @Param("applicationId", ParseIntPipe) applicationId: number, + @Param("formSubmissionId", ParseIntPipe) formSubmissionId: number, + ): Promise { + return this.formSubmissionControllerService.getFormSubmission( + formSubmissionId, + studentId, + { includeBasicDecisionDetails: true, applicationId }, + ); + } +} diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts index 2d40cd19d0..b15272e464 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts @@ -3,6 +3,8 @@ import { Body, Controller, Get, + Param, + ParseIntPipe, Post, Query, UnprocessableEntityException, @@ -11,7 +13,7 @@ import { DynamicFormConfigurationService, FORM_SUBMISSION_INVALID_DYNAMIC_DATA, FORM_SUBMISSION_PENDING_DECISION, - FormSubmissionService, + FormSubmissionSubmitService, } from "../../services"; import { AuthorizedParties, StudentUserToken } from "../../auth"; import { @@ -21,6 +23,7 @@ import { } from "../../auth/decorators"; import { ApiBadRequestResponse, + ApiNotFoundResponse, ApiTags, ApiUnprocessableEntityResponse, } from "@nestjs/swagger"; @@ -29,6 +32,7 @@ import { ApiProcessError, ClientTypeBaseRoute } from "../../types"; import { FormCategory } from "@sims/sims-db"; import { FormSubmissionAPIInDTO, + FormSubmissionAPIOutDTO, FormSubmissionConfigurationsAPIOutDTO, FormSupplementaryDataAPIInDTO, FormSupplementaryDataAPIOutDTO, @@ -36,6 +40,7 @@ import { import { PrimaryIdentifierAPIOutDTO } from "../models/primary.identifier.dto"; import { CustomNamedError } from "@sims/utilities"; import { SupplementaryDataLoader } from "../../services/form-submission/form-supplementary-data/form-supplementary-data-loader"; +import { FormSubmissionControllerService } from "./form-submission.controller.service"; @AllowAuthorizedParty(AuthorizedParties.student) @RequiresStudentAccount() @@ -44,8 +49,9 @@ import { SupplementaryDataLoader } from "../../services/form-submission/form-sup export class FormSubmissionStudentsController extends BaseController { constructor( private readonly dynamicFormConfigurationService: DynamicFormConfigurationService, - private readonly formSubmissionService: FormSubmissionService, + private readonly formSubmissionSubmitService: FormSubmissionSubmitService, private readonly supplementaryDataLoader: SupplementaryDataLoader, + private readonly formSubmissionControllerService: FormSubmissionControllerService, ) { super(); } @@ -94,6 +100,23 @@ export class FormSubmissionStudentsController extends BaseController { }; } + /** + * Get the details of a form submission, including the individual form items and their details. + * @param formSubmissionId ID of the form submission to retrieve the details for. + * @returns form submission details including individual form items and their details. + */ + @ApiNotFoundResponse({ description: "Form submission not found." }) + @Get(":formSubmissionId") + async getFormSubmission( + @Param("formSubmissionId", ParseIntPipe) formSubmissionId: number, + @UserToken() userToken: StudentUserToken, + ): Promise { + return this.formSubmissionControllerService.getFormSubmission( + formSubmissionId, + userToken.studentId, + ); + } + /** * Executes a dynamic form submission for the Ministry decision. * Each form will have an individual decision associated with and upon its @@ -121,12 +144,13 @@ export class FormSubmissionStudentsController extends BaseController { @UserToken() userToken: StudentUserToken, ): Promise { try { - const submission = await this.formSubmissionService.saveFormSubmission( - userToken.studentId, - payload.applicationId, - payload.items, - userToken.userId, - ); + const submission = + await this.formSubmissionSubmitService.saveFormSubmission( + userToken.studentId, + payload.applicationId, + payload.items, + userToken.userId, + ); return { id: submission.id, }; diff --git a/sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts index 3efb063508..0024e145dc 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts @@ -58,7 +58,7 @@ export class FormSubmissionConfigurationsAPIOutDTO { * This is a basic representation of a form submission properties to be extended * for Ministry, Student, and Institutions. */ -abstract class FormSubmissionAPIOutDTO { +abstract class FormSubmissionBaseAPIOutDTO { id: number; formCategory: FormCategory; status: FormSubmissionStatus; @@ -72,36 +72,56 @@ abstract class FormSubmissionAPIOutDTO { * This is a basic representation of a form submission item properties to be extended * for Ministry, Student, and Institutions. */ -abstract class FormSubmissionItemAPIOutDTO { +abstract class FormSubmissionItemBaseAPIOutDTO { id: number; formType: string; formCategory: FormCategory; - /** - * Current decision status for this form submission item. - * When no decision is made yet, the status will be assumed to be Pending. - */ - decisionStatus: FormSubmissionDecisionStatus; dynamicFormConfigurationId: number; submissionData: unknown; formDefinitionName: string; } /** - * Current decision associated with a form submission item. + * Current decision associated with a form submission + * item used by Student and Institution. */ -class FormSubmissionItemDecisionAPIOutDTO { - id: number; +export class FormSubmissionItemDecisionAPIOutDTO { + id?: number; decisionStatus: FormSubmissionDecisionStatus; + decisionNoteDescription?: string; +} + +/** + * Current decision associated with a form submission item + * with additional details for the Ministry, including the decision date and decision by. + */ +export class FormSubmissionItemDecisionMinistryAPIOutDTO extends FormSubmissionItemDecisionAPIOutDTO { decisionDate?: Date; decisionBy?: string; - decisionNoteDescription?: string; +} + +export class FormSubmissionItemAPIOutDTO extends FormSubmissionItemBaseAPIOutDTO { + /** + * Current decision details for this form submission item. The current decision is the most recent decision made on + * this item and represents the current state of the item. + * Optionally include decision information if the user has the necessary permissions to view the decision details. + */ + currentDecision: FormSubmissionItemDecisionAPIOutDTO; +} + +/** + * Form submission with one to many forms for the Ministry, + * including the individual form items. + */ +export class FormSubmissionAPIOutDTO extends FormSubmissionBaseAPIOutDTO { + submissionItems: FormSubmissionItemAPIOutDTO[]; } /** * Individual form items that will be part of a form submission with one to many forms * for the Ministry, including the decision details. */ -class FormSubmissionItemMinistryAPIOutDTO extends FormSubmissionItemAPIOutDTO { +export class FormSubmissionItemMinistryAPIOutDTO extends FormSubmissionItemBaseAPIOutDTO { /** * Most recent update date for this form submission item. This is used to determine if the item is outdated when * submitting a decision on it, to prevent overwriting a more recent decision. @@ -112,20 +132,20 @@ class FormSubmissionItemMinistryAPIOutDTO extends FormSubmissionItemAPIOutDTO { * this item and represents the current state of the item. * Optionally include decision information if the user has the necessary permissions to view the decision details. */ - currentDecision?: FormSubmissionItemDecisionAPIOutDTO; + currentDecision: FormSubmissionItemDecisionMinistryAPIOutDTO; /** * Decision history for this form submission item. The decision history includes all decisions made on this but * the current one that is sent separately in the currentDecision property. * Optionally include decision history information if the user has the necessary permissions to view the decision details. */ - previousDecisions?: FormSubmissionItemDecisionAPIOutDTO[]; + previousDecisions?: FormSubmissionItemDecisionMinistryAPIOutDTO[]; } /** * Form submission with one to many forms for the Ministry, * including the individual form items. */ -export class FormSubmissionMinistryAPIOutDTO extends FormSubmissionAPIOutDTO { +export class FormSubmissionMinistryAPIOutDTO extends FormSubmissionBaseAPIOutDTO { hasApprovalAuthorization: boolean; submissionItems: FormSubmissionItemMinistryAPIOutDTO[]; } diff --git a/sources/packages/backend/apps/api/src/route-controllers/index.ts b/sources/packages/backend/apps/api/src/route-controllers/index.ts index 547576db60..3b27dd13f0 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/index.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/index.ts @@ -105,6 +105,8 @@ export * from "./disbursement-schedule/disbursement-schedule.aest.controller"; export * from "./disbursement-schedule/models/disbursement-schedule.dto"; export * from "./system-lookup-configuration/models/system-lookup-configuration.dto"; export * from "./system-lookup-configuration/system-lookup-configuration.controller"; +export * from "./form-submission/form-submission.controller.service"; export * from "./form-submission/form-submission.students.controller"; export * from "./form-submission/models/form-submission.dto"; export * from "./form-submission/form-submission.aest.controller"; +export * from "./form-submission/form-submission.institutions.controller"; diff --git a/sources/packages/backend/apps/api/src/services/form-submission/constants.ts b/sources/packages/backend/apps/api/src/services/form-submission/constants.ts index 11e0008c44..790aa22d19 100644 --- a/sources/packages/backend/apps/api/src/services/form-submission/constants.ts +++ b/sources/packages/backend/apps/api/src/services/form-submission/constants.ts @@ -64,3 +64,11 @@ export const FORM_SUBMISSION_ITEM_OUTDATED = "FORM_SUBMISSION_ITEM_OUTDATED"; */ export const FORM_SUBMISSION_UPDATE_UNAUTHORIZED = "FORM_SUBMISSION_UPDATE_UNAUTHORIZED"; +/** + * The application associated with the form submission is not in completed status. + * Ensures data change will not happen when the application is not in the expected state, + * for instance, if a previously completed application has a change request and is no + * longer the current version. + */ +export const FORM_SUBMISSION_RELATED_APPLICATION_NOT_IN_EXPECTED_STATE = + "FORM_SUBMISSION_RELATED_APPLICATION_NOT_IN_EXPECTED_STATE"; 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 8b01b2b41d..dda6439089 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 @@ -12,6 +12,7 @@ import { NoteType, FormCategory, FormSubmissionItemDecision, + ApplicationStatus, } from "@sims/sims-db"; import { CustomNamedError } from "@sims/utilities"; import { @@ -20,6 +21,7 @@ import { FORM_SUBMISSION_ITEM_OUTDATED, FORM_SUBMISSION_NOT_FOUND, FORM_SUBMISSION_NOT_PENDING, + FORM_SUBMISSION_RELATED_APPLICATION_NOT_IN_EXPECTED_STATE, FORM_SUBMISSION_UPDATE_UNAUTHORIZED, } from "./constants"; import { @@ -48,7 +50,7 @@ export class FormSubmissionApprovalService { * form submission items array. * @returns The form submission if found, otherwise null. */ - async getFormSubmissionsById( + async getFormSubmissionById( formSubmissionId: number, options?: { itemId?: number }, ): Promise { @@ -132,7 +134,11 @@ export class FormSubmissionApprovalService { const submissionItem = await formSubmissionItemRepo.findOne({ select: { id: true, - formSubmission: { id: true, submissionStatus: true }, + formSubmission: { + id: true, + submissionStatus: true, + application: { id: true, applicationStatus: true }, + }, updatedAt: true, dynamicFormConfiguration: { id: true, @@ -140,7 +146,7 @@ export class FormSubmissionApprovalService { }, }, relations: { - formSubmission: true, + formSubmission: { application: true }, dynamicFormConfiguration: true, }, where: { id: submissionItemId }, @@ -155,6 +161,16 @@ export class FormSubmissionApprovalService { submissionItem.dynamicFormConfiguration.formCategory, userRoles, ); + if ( + submissionItem.formSubmission.application?.id && + submissionItem.formSubmission.application.applicationStatus !== + ApplicationStatus.Completed + ) { + throw new CustomNamedError( + "The application associated with the form submission is not in completed status.", + FORM_SUBMISSION_RELATED_APPLICATION_NOT_IN_EXPECTED_STATE, + ); + } if ( submissionItem.updatedAt.getTime() !== formItemDecision.lastUpdateDate.getTime() @@ -241,6 +257,7 @@ export class FormSubmissionApprovalService { student: { id: true }, submissionStatus: true, formCategory: true, + application: { id: true, applicationStatus: true }, formSubmissionItems: { id: true, updatedAt: true, @@ -253,6 +270,7 @@ export class FormSubmissionApprovalService { }, relations: { student: true, + application: true, formSubmissionItems: { currentDecision: { decisionNote: true } }, }, where: { id: submissionId }, @@ -267,6 +285,16 @@ export class FormSubmissionApprovalService { formSubmission.formCategory, userRoles, ); + if ( + formSubmission.application?.id && + formSubmission.application.applicationStatus !== + ApplicationStatus.Completed + ) { + throw new CustomNamedError( + "The application associated with the form submission is not in completed status.", + FORM_SUBMISSION_RELATED_APPLICATION_NOT_IN_EXPECTED_STATE, + ); + } if (formSubmission.submissionStatus !== FormSubmissionStatus.Pending) { throw new CustomNamedError( "Final decision cannot be made on a form submission with status different than pending.", diff --git a/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts b/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts new file mode 100644 index 0000000000..04c1a5331b --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission-submit.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { + Application, + User, + FileOriginType, + Student, + FormSubmission, + FormSubmissionStatus, + FormSubmissionItem, + DynamicFormConfiguration, + FormCategory, +} from "@sims/sims-db"; +import { StudentFileService } from "../student-file/student-file.service"; +import { + FormSubmissionConfig, + FormSubmissionModel, +} from "./form-submission.models"; +import { + DynamicFormConfigurationService, + FORM_SUBMISSION_INVALID_DYNAMIC_DATA, + FORM_SUBMISSION_UNKNOWN_FORM_CONFIGURATION, + FormService, +} from "../../services"; +import { CustomNamedError, processInParallel } from "@sims/utilities"; +import { DryRunSubmissionResult } from "../../types"; +import { FormSubmissionValidator } from "./form-submission-validator"; +import { SupplementaryDataLoader } from "./form-supplementary-data"; + +/** + * Manages how the form submissions are submitted, including the validations, + * ensuring the necessary supplementary data is loaded, and how the form + * submission and related items are saved in the database. + */ +@Injectable() +export class FormSubmissionSubmitService { + constructor( + private readonly dataSource: DataSource, + private readonly studentFileService: StudentFileService, + private readonly dynamicFormConfigurationService: DynamicFormConfigurationService, + private readonly formService: FormService, + private readonly formSubmissionValidator: FormSubmissionValidator, + private readonly supplementaryDataLoader: SupplementaryDataLoader, + ) {} + + /** + * Saves a form submission, including the related form submission items, executing validations + * and ensuring the necessary supplementary data is loaded for the submission. + * @param studentId ID of the student associated with the form submission. + * @param applicationId ID of the application associated with the form submission, if applicable. + * @param submissionItems form submission items to be saved as part of the form submission. + * @param auditUserId ID of the user performing the operation, used for auditing purposes. + * @returns the saved form submission with the related form submission items. + */ + async saveFormSubmission( + studentId: number, + applicationId: number | undefined, + submissionItems: FormSubmissionModel[], + auditUserId: number, + ): Promise { + const submissionConfigs = this.convertToFormSubmissionConfigs( + submissionItems, + applicationId, + ); + // Validate the form submission based on different validators. + await this.formSubmissionValidator.validate(submissionConfigs, studentId); + // Ensure all form submissions have the necessary supplementary data loaded to be processed + // in the dry run submission, such as parents and program year for application-scoped forms. + await this.supplementaryDataLoader.loadSupplementaryData( + submissionConfigs, + studentId, + ); + // Process all the dry run submissions to validate the requests. + const validatedItems = await processInParallel( + (submissionConfig) => this.getValidatedFormSubmission(submissionConfig), + submissionConfigs, + ); + const [referenceSubmissionConfig] = submissionConfigs; + // Save the form submission and the related items. + return this.dataSource.transaction(async (entityManager) => { + const now = new Date(); + const creator = { id: auditUserId } as User; + const formSubmission = new FormSubmission(); + formSubmission.student = { id: studentId } as Student; + formSubmission.application = { id: applicationId } as Application; + formSubmission.submittedDate = now; + formSubmission.submissionStatus = FormSubmissionStatus.Pending; + formSubmission.formCategory = referenceSubmissionConfig.formCategory; + formSubmission.creator = creator; + formSubmission.createdAt = now; + formSubmission.formSubmissionItems = validatedItems.map( + (submissionItem) => + ({ + dynamicFormConfiguration: { + id: submissionItem.dynamicConfigurationId, + } as DynamicFormConfiguration, + submittedData: submissionItem.formData, + creator: creator, + createdAt: now, + }) as FormSubmissionItem, + ); + const uniqueFileNames: string[] = validatedItems.flatMap( + (submissionItem) => submissionItem.files, + ); + if (uniqueFileNames.length) { + const fileOrigin = + referenceSubmissionConfig.formCategory === FormCategory.StudentAppeal + ? FileOriginType.Appeal + : FileOriginType.Student; + await this.studentFileService.updateStudentFiles( + studentId, + auditUserId, + uniqueFileNames, + fileOrigin, + + { entityManager: entityManager }, + ); + } + // TODO: send notification. + return entityManager.getRepository(FormSubmission).save(formSubmission); + }); + } + + /** + * Converts the form submission models to form submission configurations, + * making the association of the form submission items with the related form + * configurations and preparing the data for validation and saving. + * @param submissionItems form submission models to be converted. + * @param applicationId application ID to be associated with the form submissions. + * Only required for forms that have application scope. + * @returns an array of form submission configurations. + */ + private convertToFormSubmissionConfigs( + submissionItems: FormSubmissionModel[], + applicationId: number | undefined = undefined, + ): FormSubmissionConfig[] { + return submissionItems.map((submissionItem) => { + const config = this.dynamicFormConfigurationService.getFormById( + submissionItem.dynamicConfigurationId, + ); + if (!config) { + throw new CustomNamedError( + "One or more forms configurations in the submission are not recognized.", + FORM_SUBMISSION_UNKNOWN_FORM_CONFIGURATION, + ); + } + return { + applicationId, + ...submissionItem, + ...config, + }; + }); + } + + /** + * Executes a dry run submission for the given form + * submission configuration to validate the request. + * @param submissionItem form submission configuration to be validated. + * @returns validated form submission model. + */ + private async getValidatedFormSubmission( + submissionItem: FormSubmissionConfig, + ): Promise { + let submissionResult: DryRunSubmissionResult; + try { + submissionResult = await this.formService.dryRunSubmission( + submissionItem.formDefinitionName, + submissionItem.formData, + ); + } catch (error: unknown) { + throw new Error("Dry run submission failed due to unknown reason.", { + cause: error, + }); + } + if (!submissionResult.valid) { + throw new CustomNamedError( + "Not able to complete the submission due to an invalid request.", + FORM_SUBMISSION_INVALID_DYNAMIC_DATA, + ); + } + return { + dynamicConfigurationId: submissionItem.dynamicConfigurationId, + formData: submissionResult.data.data, + files: submissionItem.files, + }; + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts index 816e0d1334..36a4137dae 100644 --- a/sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts @@ -1,187 +1,109 @@ import { Injectable } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { In, Repository } from "typeorm"; import { - Application, - User, - FileOriginType, - Student, + FormCategory, FormSubmission, FormSubmissionStatus, - FormSubmissionItem, - DynamicFormConfiguration, - FormCategory, } from "@sims/sims-db"; -import { StudentFileService } from "../student-file/student-file.service"; -import { - FormSubmissionConfig, - FormSubmissionModel, -} from "./form-submission.models"; -import { - DynamicFormConfigurationService, - FORM_SUBMISSION_INVALID_DYNAMIC_DATA, - FORM_SUBMISSION_UNKNOWN_FORM_CONFIGURATION, - FormService, -} from "../../services"; -import { CustomNamedError, processInParallel } from "@sims/utilities"; -import { DryRunSubmissionResult } from "../../types"; -import { FormSubmissionValidator } from "./form-submission-validator"; -import { SupplementaryDataLoader } from "./form-supplementary-data"; +import { InjectRepository } from "@nestjs/typeorm"; -/** - * Manages how the form submissions are processed, including the validations, - * ensuring the necessary supplementary data is loaded, and how the form - * submission and related items are saved in the database. - */ @Injectable() export class FormSubmissionService { constructor( - private readonly dataSource: DataSource, - private readonly studentFileService: StudentFileService, - private readonly dynamicFormConfigurationService: DynamicFormConfigurationService, - private readonly formService: FormService, - private readonly formSubmissionValidator: FormSubmissionValidator, - private readonly supplementaryDataLoader: SupplementaryDataLoader, + @InjectRepository(FormSubmission) + private readonly formSubmissionRepo: Repository, ) {} /** - * Saves a form submission, including the related form submission items, executing validations - * and ensuring the necessary supplementary data is loaded for the submission. - * @param studentId ID of the student associated with the form submission. - * @param applicationId ID of the application associated with the form submission, if applicable. - * @param submissionItems form submission items to be saved as part of the form submission. - * @param auditUserId ID of the user performing the operation, used for auditing purposes. - * @returns the saved form submission with the related form submission items. + * Gets all non-completed form submissions for a given application. + * Pending or fully declined form submissions are considered non-completed, and are + * usually displayed separated from the completed ones that potentially generated + * assessment. + * @param applicationId + * @param studentId + * @returns */ - async saveFormSubmission( - studentId: number, - applicationId: number | undefined, - submissionItems: FormSubmissionModel[], - auditUserId: number, - ): Promise { - const submissionConfigs = this.convertToFormSubmissionConfigs( - submissionItems, - applicationId, - ); - // Validate the form submission based on different validators. - await this.formSubmissionValidator.validate(submissionConfigs, studentId); - // Ensure all form submissions have the necessary supplementary data loaded to be processed - // in the dry run submission, such as parents and program year for application-scoped forms. - await this.supplementaryDataLoader.loadSupplementaryData( - submissionConfigs, - studentId, - ); - // Process all the dry run submissions to validate the requests. - const validatedItems = await processInParallel( - (submissionConfig) => this.getValidatedFormSubmission(submissionConfig), - submissionConfigs, - ); - const [referenceSubmissionConfig] = submissionConfigs; - // Save the form submission and the related items. - return this.dataSource.transaction(async (entityManager) => { - const now = new Date(); - const creator = { id: auditUserId } as User; - const formSubmission = new FormSubmission(); - formSubmission.student = { id: studentId } as Student; - formSubmission.application = { id: applicationId } as Application; - formSubmission.submittedDate = now; - formSubmission.submissionStatus = FormSubmissionStatus.Pending; - formSubmission.formCategory = referenceSubmissionConfig.formCategory; - formSubmission.creator = creator; - formSubmission.createdAt = now; - formSubmission.formSubmissionItems = validatedItems.map( - (submissionItem) => - ({ - dynamicFormConfiguration: { - id: submissionItem.dynamicConfigurationId, - } as DynamicFormConfiguration, - submittedData: submissionItem.formData, - creator: creator, - createdAt: now, - }) as FormSubmissionItem, - ); - const uniqueFileNames: string[] = validatedItems.flatMap( - (submissionItem) => submissionItem.files, - ); - if (uniqueFileNames.length) { - const fileOrigin = - referenceSubmissionConfig.formCategory === FormCategory.StudentAppeal - ? FileOriginType.Appeal - : FileOriginType.Student; - await this.studentFileService.updateStudentFiles( - studentId, - auditUserId, - uniqueFileNames, - fileOrigin, - - { entityManager: entityManager }, - ); - } - // TODO: send notification. - return entityManager.getRepository(FormSubmission).save(formSubmission); + async getNonCompletedStudentAppeals( + applicationId: number, + studentId?: number, + ): Promise { + return this.formSubmissionRepo.find({ + select: { + id: true, + submissionStatus: true, + submittedDate: true, + }, + where: { + application: { id: applicationId }, + student: { id: studentId }, + formCategory: FormCategory.StudentAppeal, + submissionStatus: In([ + FormSubmissionStatus.Pending, + FormSubmissionStatus.Declined, + ]), + }, }); } /** - * Converts the form submission models to form submission configurations, - * making the association of the form submission items with the related form - * configurations and preparing the data for validation and saving. - * @param submissionItems form submission models to be converted. - * @param applicationId application ID to be associated with the form submissions. - * Only required for forms that have application scope. - * @returns an array of form submission configurations. + * Gets a form submission by its ID. + * @param formSubmissionId The ID of the form submission to retrieve. + * @param studentId student ID for authorization. + * @param options method options. + * - `applicationId`: optional application ID to ensure the form submission + * belongs to the application related to the institution's student. + * @returns The form submission if found, otherwise null. */ - private convertToFormSubmissionConfigs( - submissionItems: FormSubmissionModel[], - applicationId: number | undefined = undefined, - ): FormSubmissionConfig[] { - return submissionItems.map((submissionItem) => { - const config = this.dynamicFormConfigurationService.getFormById( - submissionItem.dynamicConfigurationId, - ); - if (!config) { - throw new CustomNamedError( - "One or more forms configurations in the submission are not recognized.", - FORM_SUBMISSION_UNKNOWN_FORM_CONFIGURATION, - ); - } - return { - applicationId, - ...submissionItem, - ...config, - }; + async getFormSubmissionById( + formSubmissionId: number, + studentId: number, + options?: { + applicationId?: number; + }, + ): Promise { + return this.formSubmissionRepo.findOne({ + select: { + id: true, + submissionStatus: true, + submittedDate: true, + formCategory: true, + application: { + id: true, + applicationNumber: true, + }, + formSubmissionItems: { + id: true, + dynamicFormConfiguration: { + id: true, + formType: true, + formCategory: true, + formDefinitionName: true, + }, + submittedData: true, + updatedAt: true, + currentDecision: { + id: true, + decisionStatus: true, + decisionNote: { + id: true, + description: true, + }, + }, + }, + }, + relations: { + application: true, + formSubmissionItems: { + dynamicFormConfiguration: true, + currentDecision: { decisionNote: true }, + }, + }, + where: { + id: formSubmissionId, + student: { id: studentId }, + application: { id: options?.applicationId }, + }, + order: { formSubmissionItems: { id: "ASC" } }, }); } - - /** - * Executes a dry run submission for the given form - * submission configuration to validate the request. - * @param submissionItem form submission configuration to be validated. - * @returns validated form submission model. - */ - private async getValidatedFormSubmission( - submissionItem: FormSubmissionConfig, - ): Promise { - let submissionResult: DryRunSubmissionResult; - try { - submissionResult = await this.formService.dryRunSubmission( - submissionItem.formDefinitionName, - submissionItem.formData, - ); - } catch (error: unknown) { - throw new Error("Dry run submission failed due to unknown reason.", { - cause: error, - }); - } - if (!submissionResult.valid) { - throw new CustomNamedError( - "Not able to complete the submission due to an invalid request.", - FORM_SUBMISSION_INVALID_DYNAMIC_DATA, - ); - } - return { - dynamicConfigurationId: submissionItem.dynamicConfigurationId, - formData: submissionResult.data.data, - files: submissionItem.files, - }; - } } diff --git a/sources/packages/backend/apps/api/src/services/index.ts b/sources/packages/backend/apps/api/src/services/index.ts index a7b5c6e2fb..54eba7d21a 100644 --- a/sources/packages/backend/apps/api/src/services/index.ts +++ b/sources/packages/backend/apps/api/src/services/index.ts @@ -62,8 +62,9 @@ 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.models"; -export * from "./form-submission/form-submission.service"; +export * from "./form-submission/form-submission-submit.service"; export * from "./form-submission/form-submission-approval.service"; +export * from "./form-submission/form-submission.service"; export * from "./form-submission/form-submission-actions/form-submission-action-processor"; export * from "./form-submission/form-submission-actions/form-submission-create-appeal-assessment-action"; export * from "./form-submission/form-submission-actions/form-submission-update-modified-independent-action"; diff --git a/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts b/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts index 64e196884d..e350190375 100644 --- a/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts +++ b/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts @@ -250,6 +250,7 @@ export class StudentAssessmentService extends RecordDataModelService { switch (data.triggerType) { case AssessmentTriggerType.StudentAppeal: - context.emit("viewStudentAppeal", data.studentAppealId); + context.emit( + "viewStudentAppeal", + data.formSubmissionId ?? data.studentAppealId, + ); break; case AssessmentTriggerType.ApplicationOfferingChange: context.emit( diff --git a/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue b/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue index a83c0238e4..ab34dc2b0e 100644 --- a/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue +++ b/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue @@ -1,14 +1,17 @@ diff --git a/sources/packages/web/src/views/institution/student/applicationDetails/InstitutionAssessmentsSummary.vue b/sources/packages/web/src/views/institution/student/applicationDetails/InstitutionAssessmentsSummary.vue index dee07c9a67..c9e5e411b5 100644 --- a/sources/packages/web/src/views/institution/student/applicationDetails/InstitutionAssessmentsSummary.vue +++ b/sources/packages/web/src/views/institution/student/applicationDetails/InstitutionAssessmentsSummary.vue @@ -32,6 +32,7 @@ import { defineComponent, computed } from "vue"; import { AssessmentTriggerType } from "@/types"; import RequestAssessment from "@/components/common/students/assessment/Request.vue"; import HistoryAssessment from "@/components/common/students/assessment/History.vue"; +import { useFeatureToggles } from "@/composables"; export default defineComponent({ components: { @@ -50,6 +51,7 @@ export default defineComponent({ }, setup(props) { const router = useRouter(); + const { isFormSubmissionEnabled } = useFeatureToggles(); // The assessment trigger types for which the request form must be visible by default. const assessmentRequestViewTypes = [ @@ -57,13 +59,24 @@ export default defineComponent({ AssessmentTriggerType.OriginalAssessment, ]; - const goToStudentAppeal = (appealId: number) => { + const goToStudentAppeal = (id: number) => { + if (isFormSubmissionEnabled.value) { + router.push({ + name: InstitutionRoutesConst.APPLICATION_FORM_SUBMISSION_VIEW, + params: { + studentId: props.studentId, + formSubmissionId: id, + applicationId: props.applicationId, + }, + }); + return; + } router.push({ name: InstitutionRoutesConst.STUDENT_APPLICATION_APPEAL_REQUESTS_APPROVAL, params: { studentId: props.studentId, applicationId: props.applicationId, - appealId, + appealId: id, }, }); }; diff --git a/sources/packages/web/src/views/student/form-submissions/FormSubmission.vue b/sources/packages/web/src/views/student/form-submissions/FormSubmission.vue index 389f493b14..c22940b64c 100644 --- a/sources/packages/web/src/views/student/form-submissions/FormSubmission.vue +++ b/sources/packages/web/src/views/student/form-submissions/FormSubmission.vue @@ -20,6 +20,7 @@