diff --git a/sims.code-workspace b/sims.code-workspace index 33cf529057..ea7874bd87 100644 --- a/sims.code-workspace +++ b/sims.code-workspace @@ -2,32 +2,32 @@ "folders": [ { "name": "Web UI", - "path": "./sources/packages/web" + "path": "./sources/packages/web", }, { "name": "Backend", - "path": "./sources/packages/backend" + "path": "./sources/packages/backend", }, { "name": "Forms", - "path": "./sources/packages/forms" + "path": "./sources/packages/forms", }, { "name": "DevOps", - "path": "./devops" + "path": "./devops", }, { "name": "Load Tests", - "path": "./sources/packages/load-test" + "path": "./sources/packages/load-test", }, { "name": "Sources", - "path": "./sources" + "path": "./sources", }, { "name": "All", - "path": "." - } + "path": ".", + }, ], "extensions": { "recommendations": [ @@ -40,8 +40,8 @@ "adpyke.vscode-sql-formatter", "redhat.vscode-yaml", "mongodb.mongodb-vscode", - "sonarsource.sonarlint-vscode" - ] + "sonarsource.sonarlint-vscode", + ], }, "tasks": { "version": "2.0.0", @@ -51,83 +51,83 @@ "type": "shell", "command": "make deploy-camunda-definitions", "options": { - "cwd": "${workspaceFolder:Sources}" + "cwd": "${workspaceFolder:Sources}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "Deploy Form Definitions", "type": "shell", "command": "make deploy-form-definitions", "options": { - "cwd": "${workspaceFolder:Sources}" + "cwd": "${workspaceFolder:Sources}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "Database - Run Migrations", "type": "shell", "command": "npm run migration:run", "options": { - "cwd": "${workspaceFolder:Backend}" + "cwd": "${workspaceFolder:Backend}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "Database - Clean E2E Test DB", "type": "shell", "command": "npm run db:seed:test:clean", "options": { - "cwd": "${workspaceFolder:Backend}" + "cwd": "${workspaceFolder:Backend}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "Database - Reset for API E2E Tests", "type": "shell", "command": "npm run db:seed:test:clean && npm run test:e2e:api:seed", "options": { - "cwd": "${workspaceFolder:Backend}" + "cwd": "${workspaceFolder:Backend}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "Database - Reset for Queue-consumers E2E Tests", "type": "shell", "command": "npm run db:seed:test:clean && npm run test:e2e:queue-consumers:seed", "options": { - "cwd": "${workspaceFolder:Backend}" + "cwd": "${workspaceFolder:Backend}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "View Outdated Web Packages", "type": "shell", "command": "npm outdated", "options": { - "cwd": "${workspaceFolder:Web UI}" + "cwd": "${workspaceFolder:Web UI}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "View Outdated Backend Packages", "type": "shell", "command": "npm outdated", "options": { - "cwd": "${workspaceFolder:Backend}" + "cwd": "${workspaceFolder:Backend}", }, - "problemMatcher": [] + "problemMatcher": [], }, { "label": "View Outdated Forms Packages", "type": "shell", "command": "npm outdated", "options": { - "cwd": "${workspaceFolder:Forms}" + "cwd": "${workspaceFolder:Forms}", }, - "problemMatcher": [] - } - ] + "problemMatcher": [], + }, + ], }, "settings": { "eslint.workingDirectories": [{ "mode": "auto" }], @@ -135,7 +135,7 @@ "**/bin": true, "**/obj": true, "**/dist": true, - "**/node_modules": true + "**/node_modules": true, }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, @@ -145,8 +145,8 @@ "editor.codeActionsOnSave": { "source.addMissingImports": "explicit", "source.fixAll.eslint": "explicit", - "source.removeUnusedImports": "always" - } + "source.removeUnusedImports": "always", + }, }, "typescript.preferences.importModuleSpecifier": "non-relative", "cSpell.words": [ @@ -200,6 +200,7 @@ "MSFAA", "MULT", "NOAAPI", + "nonpunitivewithdrawalform", "NSLSC", "Overaward", "overawards", @@ -238,14 +239,14 @@ "unparse", "VALD", "WTHD", - "Zeebe" + "Zeebe", ], "[json]": { - "editor.defaultFormatter": "vscode.json-language-features" + "editor.defaultFormatter": "vscode.json-language-features", }, "[sql]": { - "editor.defaultFormatter": "adpyke.vscode-sql-formatter" + "editor.defaultFormatter": "adpyke.vscode-sql-formatter", }, - "sql-formatter.uppercase": true - } + "sql-formatter.uppercase": true, + }, } 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 099cf1c264..207b1f9b7f 100644 --- a/sources/packages/backend/apps/api/src/app.aest.module.ts +++ b/sources/packages/backend/apps/api/src/app.aest.module.ts @@ -42,6 +42,7 @@ import { StudentAppealAssessmentService, StudentAppealCreateAssessmentAction, StudentAppealUpdateModifiedIndependentAction, + FormSubmissionService, } from "./services"; import { SupportingUserAESTController, @@ -91,6 +92,7 @@ import { ApplicationChangeRequestAESTController, DynamicFormAESTController, DisbursementScheduleAESTController, + FormSubmissionAESTController, } from "./route-controllers"; import { AuthModule } from "./auth/auth.module"; import { @@ -147,6 +149,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; ApplicationChangeRequestAESTController, DynamicFormAESTController, DisbursementScheduleAESTController, + FormSubmissionAESTController, ], providers: [ ApplicationExceptionControllerService, @@ -226,6 +229,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; StudentAppealActionsProcessor, StudentAppealCreateAssessmentAction, StudentAppealUpdateModifiedIndependentAction, + 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..4d828dec5f 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, @@ -196,6 +197,7 @@ import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; SupportingUserService, DisbursementScheduleSharedService, InstitutionRestrictionService, + FormSubmissionService, ], }) 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 b5ec079658..2ed7b2e167 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, + FormSubmissionService, } from "./services"; import { ApplicationStudentsController, @@ -79,6 +80,7 @@ import { import { ATBCIntegrationModule } from "@sims/integrations/atbc-integration"; import { ECertIntegrationModule } from "@sims/integrations/esdc-integration"; import { ObjectStorageModule } from "@sims/integrations/object-storage"; +import { FormSubmissionStudentsController } from "apps/api/src/route-controllers/form-submission/form-submission.students.controller"; @Module({ imports: [ @@ -104,6 +106,7 @@ import { ObjectStorageModule } from "@sims/integrations/object-storage"; ScholasticStandingStudentsController, AnnouncementStudentsController, SupportingUserStudentsController, + FormSubmissionStudentsController, ], providers: [ AnnouncementService, @@ -158,6 +161,7 @@ import { ObjectStorageModule } from "@sims/integrations/object-storage"; SupportingUserControllerService, DisbursementScheduleSharedService, InstitutionRestrictionService, + FormSubmissionService, ], }) export class AppStudentsModule {} 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 6ef99435e7..29191ff66f 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 @@ -23,6 +23,7 @@ import { MASKED_MSFAA_NUMBER, ApplicationOfferingChangeRequestService, MASKED_MONEY_AMOUNT, + FormSubmissionService, } from "../../services"; import { AssessmentNOAAPIOutDTO, @@ -72,6 +73,7 @@ export class AssessmentControllerService { private readonly educationProgramOfferingService: EducationProgramOfferingService, private readonly applicationExceptionService: ApplicationExceptionService, private readonly applicationOfferingChangeRequestService: ApplicationOfferingChangeRequestService, + private readonly formSubmissionService: FormSubmissionService, ) {} /** @@ -573,10 +575,18 @@ export class AssessmentControllerService { })); return requestAssessmentSummary.concat(applicationExceptionArray); } - const appeals = await this.getPendingAndDeniedAppeals( - applicationId, - options?.studentId, - ); + // Get application appeals requests. + const nonCompletedAppeals = + await this.formSubmissionService.getNonCompletedAppealsSubmissions( + applicationId, + ); + const appealsRequests = nonCompletedAppeals.map((appeal) => ({ + id: appeal.id, + submittedDate: appeal.submittedDate, + status: appeal.submissionStatus, + requestType: RequestAssessmentTypeAPIOutDTO.StudentAppeal, + })); + const applicationOfferingChangeRequests = await this.getApplicationOfferingChangeRequestsByStatus( applicationId, @@ -588,8 +598,9 @@ export class AssessmentControllerService { ], { studentId: options?.studentId }, ); + return requestAssessmentSummary - .concat(appeals) + .concat(appealsRequests) .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 799d569d22..f89d9c4a69 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 @@ -5,6 +5,7 @@ import { Assessment, AssessmentStatus, AssessmentTriggerType, + FormSubmissionStatus, NOTE_DESCRIPTION_MAX_LENGTH, OfferingIntensity, OfferingStatus, @@ -49,7 +50,8 @@ type RequestAssessmentSummaryStatus = | StudentAppealStatus | ApplicationExceptionStatus | OfferingStatus - | ApplicationOfferingChangeRequestStatus; + | ApplicationOfferingChangeRequestStatus + | FormSubmissionStatus; export class RequestAssessmentSummaryAPIOutDTO { id: number; diff --git a/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/dynamic-form-configuration.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/dynamic-form-configuration.controller.ts index cdd70fc2e0..a773cd0716 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/dynamic-form-configuration.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/dynamic-form-configuration.controller.ts @@ -18,6 +18,7 @@ import { DynamicFormType } from "@sims/sims-db"; import { DynamicFormConfigurationAPIInDTO, DynamicFormConfigurationAPIOutDTO, + DynamicFormConfigurationsAPIOutDTO, } from ".."; @AllowAuthorizedParty( @@ -66,16 +67,43 @@ export class DynamicFormConfigurationController extends BaseController { ); } } - const formDefinitionName = - this.dynamicFormConfigurationService.getDynamicFormName(formType, { + const dynamicForm = this.dynamicFormConfigurationService.getDynamicForm( + formType, + { programYearId: dynamicFormOptions?.programYearId, offeringIntensity: dynamicFormOptions?.offeringIntensity, - }); - if (!formDefinitionName) { + }, + ); + if (!dynamicForm) { throw new UnprocessableEntityException( `Dynamic form configuration for ${formType} not found.`, ); } - return { formDefinitionName }; + return { + id: dynamicForm.id, + formDefinitionName: dynamicForm.formDefinitionName, + formType: dynamicForm.formType, + formCategory: dynamicForm.formCategory, + formDescription: dynamicForm.formDescription, + allowBundledSubmission: dynamicForm.allowBundledSubmission, + hasApplicationScope: dynamicForm.hasApplicationScope, + }; + } + + @Get("student-forms") + async getDynamicFormConfigurationsByCategory(): Promise { + const formsConfigurations = + this.dynamicFormConfigurationService.getDynamicStudentForms(); + return { + configurations: formsConfigurations.map((configuration) => ({ + id: configuration.id, + formDefinitionName: configuration.formDefinitionName, + formType: configuration.formType, + formCategory: configuration.formCategory, + formDescription: configuration.formDescription, + allowBundledSubmission: configuration.allowBundledSubmission, + hasApplicationScope: configuration.hasApplicationScope, + })), + }; } } diff --git a/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/models/dynamic-form-configuration.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/models/dynamic-form-configuration.dto.ts index 03a7d44a21..c1319b4cfa 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/models/dynamic-form-configuration.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/dynamic-form-configuration/models/dynamic-form-configuration.dto.ts @@ -1,8 +1,30 @@ -import { OfferingIntensity } from "@sims/sims-db"; +import { FormCategory, OfferingIntensity } from "@sims/sims-db"; import { IsEnum, IsOptional, IsPositive } from "class-validator"; export class DynamicFormConfigurationAPIOutDTO { + id: number; formDefinitionName: string; + formType: string; + formCategory: FormCategory; + formDescription: string; + allowBundledSubmission: boolean; + hasApplicationScope: boolean; +} + +export class DynamicFormConfigurationsAPIOutDTO { + configurations: DynamicFormConfigurationAPIOutDTO[]; +} + +export enum FormCategoryAPIInDTO { + /** + * Appeals related forms. + */ + StudentAppeal = "Student appeal", + /** + * Any form submitted by a student that does not fall under + * the appeals process and have multiple applications. + */ + StudentForm = "Student form", } export class DynamicFormConfigurationAPIInDTO { 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 new file mode 100644 index 0000000000..e0b70e1b26 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.aest.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Patch, + Put, +} from "@nestjs/common"; +import { FormSubmissionService } from "../../services"; +import { AuthorizedParties } from "../../auth/authorized-parties.enum"; +import { AllowAuthorizedParty, Groups, UserToken } from "../../auth/decorators"; +import { ApiTags } from "@nestjs/swagger"; +import BaseController from "../BaseController"; +import { ClientTypeBaseRoute } from "../../types"; +import { IUserToken, UserGroups } from "apps/api/src/auth"; +import { + FormSubmissionItemDecisionAPIInDTO, + FormSubmissionMinistryAPIOutDTO, +} from "./models/form-submission.dto"; +import { PrimaryIdentifierAPIOutDTO } from "apps/api/src/route-controllers/models/primary.identifier.dto"; + +@AllowAuthorizedParty(AuthorizedParties.aest) +@Groups(UserGroups.AESTUser) +@Controller("form-submission") +@ApiTags(`${ClientTypeBaseRoute.AEST}-form-submission`) +export class FormSubmissionAESTController extends BaseController { + constructor(private readonly formSubmissionService: FormSubmissionService) { + super(); + } + + @Get(":formSubmissionId") + async getFormSubmission( + @Param("formSubmissionId", ParseIntPipe) formSubmissionId: number, + ): Promise { + const submission = + await this.formSubmissionService.getFormSubmissionsById(formSubmissionId); + return { + id: submission.id, + formCategory: submission.formCategory, + status: submission.submissionStatus, + applicationId: submission.application?.id, + applicationNumber: submission.application?.applicationNumber, + assessedDate: submission.assessedDate, + submittedDate: submission.submittedDate, + submissionItems: submission.formSubmissionItems.map((item) => ({ + id: item.id, + formType: item.dynamicFormConfiguration.formType, + formCategory: item.dynamicFormConfiguration.formCategory, + decisionStatus: item.decisionStatus, + decisionDate: item.decisionDate, + decisionNoteDescription: item.decisionNote?.description, + dynamicFormConfigurationId: item.dynamicFormConfiguration.id, + submissionData: item.submittedData, + formDefinitionName: item.dynamicFormConfiguration.formDefinitionName, + })), + }; + } + + @Put("items/:formSubmissionItemId/decision") + async submitItemDecision( + @Param("formSubmissionItemId", ParseIntPipe) formSubmissionItemId: number, + @Body() payload: FormSubmissionItemDecisionAPIInDTO, + @UserToken() userToken: IUserToken, + ): Promise { + const updatedItem = await this.formSubmissionService.saveFormSubmissionItem( + formSubmissionItemId, + payload.decisionStatus, + payload.noteDescription, + userToken.userId, + ); + return { id: updatedItem.id }; + } + + @Patch(":formSubmissionId/complete") + async completeFormSubmission( + @Param("formSubmissionId", ParseIntPipe) formSubmissionId: number, + @UserToken() userToken: IUserToken, + ): Promise { + return this.formSubmissionService.completeFormSubmission( + formSubmissionId, + userToken.userId, + ); + } +} 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 new file mode 100644 index 0000000000..8439a84a71 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts @@ -0,0 +1,293 @@ +import { + Controller, + Post, + Body, + NotFoundException, + UnprocessableEntityException, + BadRequestException, + Get, + Param, +} from "@nestjs/common"; +import { + ApplicationService, + FormService, + FormSubmissionModel, + FormSubmissionService, + StudentAppealService, +} from "../../services"; +import { + FormSubmissionAPIInDTO, + FormSubmissionStudentAPIOutDTO, + FormSubmissionStudentSummaryAPIOutDTO, +} from "./models/form-submission.dto"; +import { PrimaryIdentifierAPIOutDTO } from "../models/primary.identifier.dto"; +import { AuthorizedParties } from "../../auth/authorized-parties.enum"; +import { + AllowAuthorizedParty, + RequiresStudentAccount, + UserToken, +} from "../../auth/decorators"; +import { StudentUserToken } from "../../auth/userToken.interface"; +import { + ApiTags, + ApiNotFoundResponse, + ApiUnprocessableEntityResponse, + ApiBadRequestResponse, +} from "@nestjs/swagger"; +import BaseController from "../BaseController"; +import { + ClientTypeBaseRoute, + ApiProcessError, + DryRunSubmissionResult, +} from "../../types"; +import { + APPLICATION_CHANGE_NOT_ELIGIBLE, + APPLICATION_IS_NOT_ELIGIBLE_FOR_AN_APPEAL, +} from "../../constants"; +import { getSupportingUserParents } from "../../utilities"; +import { Application, FormCategory } from "@sims/sims-db"; + +@AllowAuthorizedParty(AuthorizedParties.student) +@RequiresStudentAccount() +@Controller("form-submission") +@ApiTags(`${ClientTypeBaseRoute.Student}-form-submission`) +export class FormSubmissionStudentsController extends BaseController { + constructor( + private readonly studentAppealService: StudentAppealService, + private readonly applicationService: ApplicationService, + private readonly formService: FormService, + private readonly formSubmissionService: FormSubmissionService, + ) { + super(); + } + + @Get() + async getFormSubmissionSummary( + @UserToken() userToken: StudentUserToken, + ): Promise { + const studentSubmissions = + await this.formSubmissionService.getFormSubmissionsByStudentId( + userToken.studentId, + ); + const submissions = studentSubmissions.map( + (submission) => { + return { + id: submission.id, + formCategory: submission.formCategory, + status: submission.submissionStatus, + applicationId: submission.application?.id, + applicationNumber: submission.application?.applicationNumber, + assessedDate: submission.assessedDate, + submittedDate: submission.submittedDate, + submissionItems: submission.formSubmissionItems.map((item) => ({ + id: item.id, + formType: item.dynamicFormConfiguration.formType, + formCategory: item.dynamicFormConfiguration.formCategory, + decisionStatus: item.decisionStatus, + decisionDate: item.decisionDate, + dynamicFormConfigurationId: item.dynamicFormConfiguration.id, + submissionData: item.submittedData, + formDefinitionName: + item.dynamicFormConfiguration.formDefinitionName, + })), + }; + }, + ); + return { submissions }; + } + + @Get(":formSubmissionId") + async getFormSubmission( + @Param("formSubmissionId") formSubmissionId: number, + @UserToken() userToken: StudentUserToken, + ): Promise { + const submission = await this.formSubmissionService.getFormSubmissionsById( + formSubmissionId, + { studentId: userToken.studentId }, + ); + return { + id: submission.id, + formCategory: submission.formCategory, + status: submission.submissionStatus, + applicationId: submission.application?.id, + applicationNumber: submission.application?.applicationNumber, + assessedDate: submission.assessedDate, + submittedDate: submission.submittedDate, + submissionItems: submission.formSubmissionItems.map((item) => ({ + formType: item.dynamicFormConfiguration.formType, + formCategory: item.dynamicFormConfiguration.formCategory, + decisionStatus: item.decisionStatus, + decisionDate: item.decisionDate, + dynamicFormConfigurationId: item.dynamicFormConfiguration.id, + submissionData: item.submittedData, + formDefinitionName: item.dynamicFormConfiguration.formDefinitionName, + })), + }; + } + + /** + * Submit a student appeal associated with an application. + * @param applicationId application for which the appeal is submitted. + * @param payload student appeal with appeal requests. + */ + @ApiNotFoundResponse({ + description: + "Application either not found or not eligible to submit change request/appeal.", + }) + @ApiUnprocessableEntityResponse({ + description: + "Only one change request/appeal can be submitted at a time for each application. " + + "When your current request is approved or denied by StudentAid BC, you will be able to submit a new one or " + + "the submitted appeal form(s) are not eligible for the application or " + + "the application is not eligible to submit an appeal or " + + "the application is no longer eligible to submit change request/appeal.", + }) + @ApiBadRequestResponse({ + description: + "Not able to submit change request/appeal due to invalid request.", + }) + @Post() + async submitForm( + @Body() payload: FormSubmissionAPIInDTO, + @UserToken() userToken: StudentUserToken, + ): Promise { + const submissionConfigs = + this.formSubmissionService.convertToFormSubmissionConfigs(payload.items); + // Validate the form configurations in the submission items. + this.formSubmissionService.validatedFormConfiguration( + submissionConfigs, + payload.applicationId, + ); + const [referenceConfig] = submissionConfigs; + if ( + referenceConfig.formCategory === FormCategory.StudentAppeal && + payload.applicationId + ) { + // Ensures the appeals are validated based on the eligibility criteria used for fetching the + // eligible applications for appeal using getEligibleApplicationsForAppeal endpoint. + const [eligibleApplication] = + await this.studentAppealService.getEligibleApplicationsForAppeal( + userToken.studentId, + { applicationId: payload.applicationId }, + ); + if (!eligibleApplication) { + throw new UnprocessableEntityException( + new ApiProcessError( + "The application is not eligible to submit an appeal.", + APPLICATION_IS_NOT_ELIGIBLE_FOR_AN_APPEAL, + ), + ); + } + // Validate if all the submitted forms are eligible appeals for the application. + const eligibleAppealForms = new Set( + eligibleApplication.currentAssessment.eligibleApplicationAppeals, + ); + const formNames = submissionConfigs.map( + (config) => config.formDefinitionName, + ); + const ineligibleFormNames = formNames.filter( + (formName) => !eligibleAppealForms.has(formName), + ); + if (ineligibleFormNames.length) { + throw new UnprocessableEntityException( + `The submitted appeal form(s) ${ineligibleFormNames.join(", ")} is/are not eligible for the application.`, + ); + } + } + let application: Application; + if (payload.applicationId) { + // Execute application validations. + application = await this.applicationService.getApplicationToRequestAppeal( + payload.applicationId, + userToken.studentId, + ); + if (!application) { + throw new NotFoundException( + "Given application either does not exist or is not complete to submit an appeal.", + ); + } + if (application.isArchived) { + throw new UnprocessableEntityException( + new ApiProcessError( + `This application is no longer eligible to submit an appeal.`, + APPLICATION_CHANGE_NOT_ELIGIBLE, + ), + ); + } + } + // Check if there is any existing form submission pending a decision for the same context. + const existingFormSubmission = + await this.formSubmissionService.hasPendingFormSubmission( + userToken.studentId, + payload.applicationId, + referenceConfig.formCategory, + ); + if (existingFormSubmission) { + throw new UnprocessableEntityException( + new ApiProcessError( + "There is already a form submission pending a decision for the same context.", + "FORM_SUBMISSION_PENDING_DECISION", + ), + ); + } + // Process all the dry run submissions to validate the requests. + let dryRunSubmissionResults: DryRunSubmissionResult[] = []; + try { + const dryRunPromise: Promise[] = + submissionConfigs.map((submissionItem) => { + // Check if the form has any inputs which are required to be populated at the server side + // during the dry run submission. + if (submissionItem.formData.programYear) { + submissionItem.formData.programYear = + application?.programYear.programYear; + } + if (submissionItem.formData.parents) { + const parents = getSupportingUserParents( + application?.supportingUsers, + ); + submissionItem.formData.parents = parents; + } + return this.formService.dryRunSubmission( + submissionItem.formDefinitionName, + submissionItem.formData, + { dynamicConfigurationId: submissionItem.dynamicConfigurationId }, + ); + }); + dryRunSubmissionResults = await Promise.all(dryRunPromise); + } catch (error: unknown) { + throw new Error("Dry run submission failed due to unknown reason.", { + cause: error, + }); + } + const invalidRequest = dryRunSubmissionResults.some( + (result) => !result.valid, + ); + if (invalidRequest) { + throw new BadRequestException( + "Not able to complete the submission due to an invalid request.", + ); + } + // Generate the data to be persisted based on the result of the dry run submission. + const formItems = dryRunSubmissionResults.map((dryRunResult) => { + const submissionConfig = submissionConfigs.find( + (config) => + config.dynamicConfigurationId === dryRunResult.dynamicConfigurationId, + ); + return { + dynamicConfigurationId: dryRunResult.dynamicConfigurationId, + formData: dryRunResult.data.data, + files: submissionConfig.files, + } as FormSubmissionModel; + }); + const studentAppeal = await this.formSubmissionService.saveFormSubmission( + userToken.studentId, + payload.applicationId, + referenceConfig.formCategory, + formItems, + userToken.userId, + ); + return { + id: studentAppeal.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 new file mode 100644 index 0000000000..679a59bb2d --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts @@ -0,0 +1,105 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + ArrayMinSize, + IsDefined, + IsEnum, + IsNotEmpty, + IsOptional, + IsPositive, + MaxLength, + ValidateNested, +} from "class-validator"; +import { JsonMaxSize } from "../../../utilities/class-validation"; +import { JSON_10KB } from "../../../constants"; +import { Parent } from "../../../types"; +import { + FormSubmissionStatus, + NOTE_DESCRIPTION_MAX_LENGTH, +} from "@sims/sims-db/entities"; +import { FormCategory } from "@sims/sims-db"; +import { FormSubmissionDecisionStatus } from "@sims/sims-db/entities/form-submission-decision-status.type"; + +// Base classes for submission DTOs and submission items. + +abstract class FormSubmissionAPIOutDTO { + id: number; + formCategory: FormCategory; + status: FormSubmissionStatus; + applicationId?: number; + applicationNumber?: string; + submittedDate: Date; + assessedDate?: Date; +} + +abstract class FormSubmissionItemAPIOutDTO { + formType: string; + formCategory: FormCategory; + decisionStatus: FormSubmissionDecisionStatus; + decisionDate?: Date; + dynamicFormConfigurationId: number; + submissionData: unknown; + formDefinitionName: string; +} + +// Submission summary (history). + +export class FormSubmissionStudentSummaryAPIOutDTO { + submissions: FormSubmissionStudentAPIOutDTO[]; +} + +export class FormSubmissionMinistrySummaryAPIOutDTO { + submissions: FormSubmissionMinistryAPIOutDTO[]; +} + +// Get submission and items. + +class FormSubmissionItemMinistryAPIOutDTO extends FormSubmissionItemAPIOutDTO { + decisionNoteDescription?: string; +} + +export class FormSubmissionMinistryAPIOutDTO extends FormSubmissionAPIOutDTO { + submissionItems: FormSubmissionItemMinistryAPIOutDTO[]; +} + +class FormSubmissionItemStudentAPIOutDTO extends FormSubmissionItemAPIOutDTO {} + +export class FormSubmissionStudentAPIOutDTO extends FormSubmissionAPIOutDTO { + submissionItems: FormSubmissionItemStudentAPIOutDTO[]; +} + +// Student submission. + +export class FormSubmissionItemAPIInDTO { + @IsPositive() + dynamicConfigurationId: number; + @IsDefined() + @JsonMaxSize(JSON_10KB) + formData: { + programYear?: string; + parents?: Parent[]; + } & Record; + @IsDefined() + files: string[]; +} + +export class FormSubmissionAPIInDTO { + @IsOptional() + @IsPositive() + applicationId?: number; + @ArrayMinSize(1) + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => FormSubmissionItemAPIInDTO) + items: FormSubmissionItemAPIInDTO[]; +} + +// Ministry submission. + +export class FormSubmissionItemDecisionAPIInDTO { + @IsEnum(FormSubmissionDecisionStatus) + decisionStatus: FormSubmissionDecisionStatus; + @IsNotEmpty() + @MaxLength(NOTE_DESCRIPTION_MAX_LENGTH) + noteDescription: string; +} 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 5c4f45f2ac..a3ed3a229b 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/index.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/index.ts @@ -105,3 +105,6 @@ 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/models/form-submission.dto"; +export * from "./form-submission/form-submission.students.controller"; +export * from "./form-submission/form-submission.aest.controller"; diff --git a/sources/packages/backend/apps/api/src/services/dynamic-form-configuration/dynamic-form-configuration.service.ts b/sources/packages/backend/apps/api/src/services/dynamic-form-configuration/dynamic-form-configuration.service.ts index a1238b74c5..840c9a6ace 100644 --- a/sources/packages/backend/apps/api/src/services/dynamic-form-configuration/dynamic-form-configuration.service.ts +++ b/sources/packages/backend/apps/api/src/services/dynamic-form-configuration/dynamic-form-configuration.service.ts @@ -3,10 +3,16 @@ import { InjectRepository } from "@nestjs/typeorm"; import { DynamicFormConfiguration, DynamicFormType, + FormCategory, OfferingIntensity, } from "@sims/sims-db"; import { Repository } from "typeorm"; +const STUDENT_FORM_CATEGORIES = new Set([ + FormCategory.StudentForm, + FormCategory.StudentAppeal, +]); + @Injectable() export class DynamicFormConfigurationService { /** @@ -30,6 +36,10 @@ export class DynamicFormConfigurationService { programYear: { id: true }, offeringIntensity: true, formDefinitionName: true, + formCategory: true, + formDescription: true, + allowBundledSubmission: true, + hasApplicationScope: true, }, relations: { programYear: true, @@ -38,25 +48,53 @@ export class DynamicFormConfigurationService { } /** - * Get form definition name by form type and program year. + * Get form definition by form type and program year. * @param dynamicFormType dynamic form type. * @param options dynamic form options * - `programYearId` program year id. * - `offeringIntensity` offering intensity. - * @returns form definition name. + * @returns form definition */ + getDynamicForm( + dynamicFormType: DynamicFormType, + options?: { programYearId?: number; offeringIntensity?: OfferingIntensity }, + ): DynamicFormConfiguration | undefined { + const programYearId = options?.programYearId ?? null; + const offeringIntensity = options?.offeringIntensity ?? null; + return this.dynamicFormConfigurations.find( + (dynamicFormConfiguration) => + dynamicFormConfiguration.formType === dynamicFormType && + dynamicFormConfiguration.programYear.id === programYearId && + dynamicFormConfiguration.offeringIntensity === offeringIntensity, + ); + } + getDynamicFormName( dynamicFormType: DynamicFormType, options?: { programYearId?: number; offeringIntensity?: OfferingIntensity }, ): string | undefined { const programYearId = options?.programYearId ?? null; const offeringIntensity = options?.offeringIntensity ?? null; - const dynamicForm = this.dynamicFormConfigurations.find( + const form = this.dynamicFormConfigurations.find( (dynamicFormConfiguration) => dynamicFormConfiguration.formType === dynamicFormType && dynamicFormConfiguration.programYear.id === programYearId && dynamicFormConfiguration.offeringIntensity === offeringIntensity, ); - return dynamicForm?.formDefinitionName; + return form?.formDefinitionName ?? undefined; + } + + getDynamicFormById( + configurationId: number, + ): DynamicFormConfiguration | undefined { + return this.dynamicFormConfigurations.find( + (configuration) => configuration.id === configurationId, + ); + } + + getDynamicStudentForms(): DynamicFormConfiguration[] { + return this.dynamicFormConfigurations.filter((dynamicFormConfiguration) => + STUDENT_FORM_CATEGORIES.has(dynamicFormConfiguration.formCategory), + ); } } 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 new file mode 100644 index 0000000000..130fb3a347 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/constants.ts @@ -0,0 +1,4 @@ +export const STUDENT_FORM_SUBMISSION_NOT_FOUND = "STUDENT_APPEAL_NOT_FOUND"; +// export const STUDENT_APPEAL_INVALID_OPERATION = +// "STUDENT_APPEAL_INVALID_OPERATION"; +// export const PROGRAM_YEAR_2025_26_START_DATE = "2025-08-01"; 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 new file mode 100644 index 0000000000..632e6274fe --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts @@ -0,0 +1,23 @@ +import { DynamicFormConfiguration } from "@sims/sims-db/entities"; +import { Parent } from "../../types"; + +/** + * Service model for student appeal. + */ +export interface FormSubmissionModel { + dynamicConfigurationId: number; + formData: { + programYear?: string; + parents?: Parent[]; + } & Record; + files: string[]; +} + +export type FormSubmissionConfig = FormSubmissionModel & + Pick< + DynamicFormConfiguration, + | "formDefinitionName" + | "formCategory" + | "hasApplicationScope" + | "allowBundledSubmission" + >; 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 new file mode 100644 index 0000000000..1be78c7c42 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts @@ -0,0 +1,376 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, IsNull, Not, Repository } from "typeorm"; +import { + Application, + User, + FileOriginType, + Student, + FormSubmission, + FormCategory, + FormSubmissionStatus, + FormSubmissionItem, + DynamicFormConfiguration, + Note, + NoteType, +} from "@sims/sims-db"; +import { StudentFileService } from "../student-file/student-file.service"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + FormSubmissionConfig, + FormSubmissionModel, +} from "./form-submission.models"; +import { FormSubmissionDecisionStatus } from "@sims/sims-db/entities/form-submission-decision-status.type"; +import { DynamicFormConfigurationService } from "../../services"; +import { CustomNamedError } from "@sims/utilities"; + +/** + * Service layer for Student appeals. + */ +@Injectable() +export class FormSubmissionService { + constructor( + private readonly dataSource: DataSource, + private readonly studentFileService: StudentFileService, + private readonly dynamicFormConfigurationService: DynamicFormConfigurationService, + @InjectRepository(FormSubmission) + private readonly formSubmissionRepo: Repository, + @InjectRepository(FormSubmissionItem) + private readonly formSubmissionItemRepo: Repository, + ) {} + + async saveFormSubmission( + studentId: number, + applicationId: number | undefined, + formCategory: FormCategory, + submissionItems: FormSubmissionModel[], + auditUserId: number, + ): Promise { + 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 = formCategory; + formSubmission.creator = creator; + formSubmission.createdAt = now; + formSubmission.formSubmissionItems = submissionItems.map( + (submissionItem) => + ({ + dynamicFormConfiguration: { + id: submissionItem.dynamicConfigurationId, + } as DynamicFormConfiguration, + submittedData: submissionItem.formData, + decisionStatus: FormSubmissionDecisionStatus.Pending, + creator: creator, + createdAt: now, + }) as FormSubmissionItem, + ); + const uniqueFileNames: string[] = submissionItems.flatMap( + (submissionItem) => submissionItem.files, + ); + if (uniqueFileNames.length) { + await this.studentFileService.updateStudentFiles( + studentId, + auditUserId, + uniqueFileNames, + // TODO: change to appeal? + FileOriginType.Student, + { entityManager: entityManager }, + ); + } + // TODO: send notification. + return entityManager.getRepository(FormSubmission).save(formSubmission); + }); + } + + /** + * Checks if there is pending form submission for the submission context. + * @param studentId student ID related to the appeal. + * @param formCategory form category that, together with submission grouping, + * identifies the type of form submission. + * @param submissionGrouping submission grouping that, together with form category, + * identifies the type of form submission. + * @param options query options. + * - `applicationId` application ID to filter the submission, when applicable. + * @returns true if exists, false otherwise. + */ + async hasPendingFormSubmission( + studentId: number, + applicationId: number | undefined, + formCategory: FormCategory, + ): Promise { + return this.formSubmissionRepo.exists({ + where: { + student: { id: studentId }, + application: applicationId ? { id: applicationId } : IsNull(), + formCategory: formCategory, + submissionStatus: FormSubmissionStatus.Pending, + }, + }); + } + + async getFormSubmissionsByStudentId( + studentId: number, + ): Promise { + return this.formSubmissionRepo.find({ + select: { + id: true, + formCategory: true, + submissionStatus: true, + submittedDate: true, + assessedDate: true, + formSubmissionItems: { + id: true, + dynamicFormConfiguration: { + id: true, + formType: true, + }, + decisionStatus: true, + decisionDate: true, + }, + application: { id: true, applicationNumber: true }, + }, + relations: { + formSubmissionItems: { dynamicFormConfiguration: true }, + application: true, + }, + where: { student: { id: studentId } }, + order: { submittedDate: "DESC", formSubmissionItems: { id: "ASC" } }, + }); + } + + async getFormSubmissionsById( + formSubmissionId: number, + options?: { studentId?: number }, + ): Promise { + return this.formSubmissionRepo.findOne({ + select: { + id: true, + submissionStatus: true, + submittedDate: true, + assessedDate: true, + formCategory: true, + formSubmissionItems: { + id: true, + dynamicFormConfiguration: { + id: true, + formType: true, + formCategory: true, + formDefinitionName: true, + }, + submittedData: true, + decisionStatus: true, + decisionDate: true, + decisionNote: { id: true, description: true }, + }, + }, + relations: { + formSubmissionItems: { + dynamicFormConfiguration: true, + decisionNote: true, + }, + }, + where: { id: formSubmissionId, student: { id: options?.studentId } }, + }); + } + + convertToFormSubmissionConfigs( + submissionItems: FormSubmissionModel[], + ): FormSubmissionConfig[] { + return submissionItems.map((submissionItem) => { + const config = this.dynamicFormConfigurationService.getDynamicFormById( + submissionItem.dynamicConfigurationId, + ); + if (!config) { + throw new CustomNamedError( + "One or more forms in the submission are not recognized.", + "UNKNOWN_FORM_CONFIGURATION", + ); + } + return { + ...submissionItem, + ...config, + }; + }); + } + + validatedFormConfiguration( + submissionItems: FormSubmissionConfig[], + applicationId: number | undefined, + ): void { + const validationModels = + this.convertToFormSubmissionConfigs(submissionItems); + // Validate if all forms share the same scope. + if (applicationId) { + const hasApplicationScope = validationModels.every( + (validationModel) => validationModel.hasApplicationScope, + ); + if (!hasApplicationScope) { + throw new CustomNamedError( + "All forms in the submission must have application scope if an application ID is provided.", + "MIXED_FORM_APPLICATION_SCOPE", + ); + } + } + // Validate if the forms allow bundled submissions when there are multiple items. + const hasAllowedItemsQuantity = + validationModels.length === 1 || + (validationModels.length > 1 && + validationModels.every((item) => item.allowBundledSubmission)); + if (!hasAllowedItemsQuantity) { + throw new CustomNamedError( + "One or more forms in the submission do not allow bundled submissions.", + "BUNDLED_SUBMISSION_FORMS_NOT_ALLOWED", + ); + } + // Validate if all forms share the same category. + const [referenceForm] = validationModels; + const allSameCategory = validationModels.every( + (validationModel) => + validationModel.formCategory === referenceForm.formCategory, + ); + if (!allSameCategory) { + throw new CustomNamedError( + "All forms in the submission must share the same form category.", + "MIXED_FORM_CATEGORIES", + ); + } + } + + /** + * Get student appeals submissions that are not in pending status. + * The non-pending submissions should be either pending for decision + * or had all their individual items declined. + * @param applicationId application ID to filter the submissions. + * @returns list of non-completed student appeals submissions. + */ + async getNonCompletedAppealsSubmissions( + applicationId: number, + ): Promise { + return this.formSubmissionRepo.find({ + select: { + id: true, + submissionStatus: true, + submittedDate: true, + }, + where: { + formCategory: FormCategory.StudentAppeal, + application: { id: applicationId }, + submissionStatus: Not(FormSubmissionStatus.Pending), + }, + }); + } + + async saveFormSubmissionItem( + submissionItemId: number, + decisionStatus: FormSubmissionDecisionStatus, + noteDescription: string, + auditUserId: number, + ): Promise { + const submissionItem = await this.formSubmissionItemRepo.findOne({ + select: { + id: true, + decisionNote: { id: true }, + formSubmission: { id: true, submissionStatus: true }, + }, + relations: { decisionNote: true, formSubmission: true }, + where: { id: submissionItemId }, + }); + if (!submissionItem) { + throw new CustomNamedError( + `Form submission item with ID ${submissionItemId} not found.`, + "FORM_SUBMISSION_ITEM_NOT_FOUND", + ); + } + if ( + submissionItem.formSubmission.submissionStatus !== + FormSubmissionStatus.Pending + ) { + throw new CustomNamedError( + `Decisions cannot be made on items belonging to a form submission with status ${submissionItem.formSubmission.submissionStatus}.`, + "FORM_SUBMISSION_NOT_PENDING", + ); + } + const now = new Date(); + const auditUser = { id: auditUserId } as User; + submissionItem.decisionStatus = decisionStatus; + submissionItem.decisionBy = auditUser; + submissionItem.decisionDate = now; + submissionItem.modifier = auditUser; + submissionItem.updatedAt = now; + if (submissionItem.decisionNote) { + const note = submissionItem.decisionNote; + note.description = noteDescription; + note.modifier = auditUser; + note.updatedAt = now; + } else { + const note = { description: noteDescription } as Note; + note.description = noteDescription; + note.noteType = NoteType.Application; + note.createdAt = now; + note.creator = auditUser; + submissionItem.decisionNote = note; + } + return this.formSubmissionItemRepo.save(submissionItem); + } + + async completeFormSubmission( + submissionId: number, + auditUserId: number, + ): Promise { + const formSubmission = await this.formSubmissionRepo.findOne({ + select: { + id: true, + submissionStatus: true, + formSubmissionItems: { + id: true, + decisionStatus: true, + }, + }, + relations: { formSubmissionItems: true }, + where: { id: submissionId }, + }); + if (!formSubmission) { + throw new CustomNamedError( + `Form submission with ID ${submissionId} not found.`, + "FORM_SUBMISSION_NOT_FOUND", + ); + } + if (formSubmission.submissionStatus !== FormSubmissionStatus.Pending) { + throw new CustomNamedError( + `Final decision cannot be made on a form submission with status ${formSubmission.submissionStatus}.`, + "FORM_SUBMISSION_NOT_PENDING", + ); + } + let isFullyDeclined = true; + for (const item of formSubmission.formSubmissionItems) { + if (item.decisionStatus === FormSubmissionDecisionStatus.Pending) { + throw new CustomNamedError( + "Final decision cannot be made when some decisions are still pending.", + "FORM_SUBMISSION_DECISION_PENDING", + ); + } + if ( + isFullyDeclined && + item.decisionStatus === FormSubmissionDecisionStatus.Approved + ) { + isFullyDeclined = false; + } + } + + const auditUser = { id: auditUserId } as User; + const now = new Date(); + formSubmission.submissionStatus = isFullyDeclined + ? FormSubmissionStatus.Declined + : FormSubmissionStatus.Completed; + formSubmission.assessedDate = now; + formSubmission.assessedBy = auditUser; + formSubmission.modifier = auditUser; + formSubmission.updatedAt = now; + // TODO: should the student notes relation be created at this point? + return this.formSubmissionRepo.save(formSubmission); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form/form.service.ts b/sources/packages/backend/apps/api/src/services/form/form.service.ts index e43d508f46..1d36eec543 100644 --- a/sources/packages/backend/apps/api/src/services/form/form.service.ts +++ b/sources/packages/backend/apps/api/src/services/form/form.service.ts @@ -98,6 +98,9 @@ export class FormService { async dryRunSubmission( formName: string, data: unknown, + options?: { + dynamicConfigurationId?: number; + }, ): Promise> { try { const authHeader = await this.createAuthHeader(); @@ -106,8 +109,12 @@ export class FormService { { data }, authHeader, ); - - return { valid: true, data: submissionResponse.data, formName }; + return { + valid: true, + data: submissionResponse.data, + formName, + dynamicConfigurationId: options?.dynamicConfigurationId, + }; } catch (error) { if (error.response?.data) { this.logger.warn( @@ -116,7 +123,11 @@ export class FormService { this.logger.warn(error.response.data); } if (error.response.status === HttpStatus.BAD_REQUEST) { - return { valid: false, formName }; + return { + valid: false, + formName, + dynamicConfigurationId: options?.dynamicConfigurationId, + }; } throw error; } @@ -126,7 +137,9 @@ export class FormService { * Creates the expected authorization header to authorize the formio API. * @returns header to be added to HTTP request. */ - private async createAuthHeader() { + private async createAuthHeader(): Promise<{ + headers: Record; + }> { const token = await this.tokenCacheService.getToken(); return { headers: { diff --git a/sources/packages/backend/apps/api/src/services/index.ts b/sources/packages/backend/apps/api/src/services/index.ts index e0eadd9b7b..d5ac9082f0 100644 --- a/sources/packages/backend/apps/api/src/services/index.ts +++ b/sources/packages/backend/apps/api/src/services/index.ts @@ -60,3 +60,5 @@ export * from "./dynamic-form-configuration/dynamic-form-configuration.service"; 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/form-submission.models"; +export * from "./form-submission/form-submission.service"; diff --git a/sources/packages/backend/apps/api/src/types/form.ts b/sources/packages/backend/apps/api/src/types/form.ts index 1695cf8fc7..8661a518b2 100644 --- a/sources/packages/backend/apps/api/src/types/form.ts +++ b/sources/packages/backend/apps/api/src/types/form.ts @@ -2,4 +2,5 @@ export interface DryRunSubmissionResult { valid: boolean; data?: { data: T }; formName: string; + dynamicConfigurationId?: number; } diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1768863388343-CreateFormSubmissionRelatedTypes.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1768863388343-CreateFormSubmissionRelatedTypes.ts new file mode 100644 index 0000000000..4e41130c94 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1768863388343-CreateFormSubmissionRelatedTypes.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class CreateFormSubmissionRelatedTypes1768863388343 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData("Create-form-submission-related-types.sql", "Types"), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-create-form-submission-related-types.sql", + "Types", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1768863415862-CreateFormSubmissionsTable.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1768863415862-CreateFormSubmissionsTable.ts new file mode 100644 index 0000000000..0cd4031069 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1768863415862-CreateFormSubmissionsTable.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class CreateFormSubmissionsTable1768863415862 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData("Create-form-submissions-table.sql", "FormSubmissions"), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-create-form-submissions-table.sql", + "FormSubmissions", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1768863437173-CreateFormSubmissionItemsTable.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1768863437173-CreateFormSubmissionItemsTable.ts new file mode 100644 index 0000000000..93dcb139f4 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1768863437173-CreateFormSubmissionItemsTable.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class CreateFormSubmissionItemsTable1768863437173 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Create-form-submission-items-table.sql", + "FormSubmissionItems", + ), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-create-form-submission-items-table.sql", + "FormSubmissionItems", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1768863470903-DynamicFormConfigurationsAddFormSubmissionGroupingTypeColumn.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1768863470903-DynamicFormConfigurationsAddFormSubmissionGroupingTypeColumn.ts new file mode 100644 index 0000000000..730d593ae4 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1768863470903-DynamicFormConfigurationsAddFormSubmissionGroupingTypeColumn.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class DynamicFormConfigurationsAddFormSubmissionGroupingTypeColumn1768863470903 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Add-form-submission-grouping-type-column.sql", + "DynamicFormConfigurations", + ), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-add-form-submission-grouping-type-column.sql", + "DynamicFormConfigurations", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Add-form-submission-grouping-type-column.sql b/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Add-form-submission-grouping-type-column.sql new file mode 100644 index 0000000000..bc86b9d67f --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Add-form-submission-grouping-type-column.sql @@ -0,0 +1,70 @@ +ALTER TABLE + sims.dynamic_form_configurations +ADD + COLUMN form_category sims.form_category_types NOT NULL DEFAULT 'System', +ADD + COLUMN form_description VARCHAR (1000), +ADD + COLUMN has_application_scope BOOLEAN NOT NULL DEFAULT TRUE, +ADD + COLUMN allow_bundled_submission BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE + sims.dynamic_form_configurations +ALTER COLUMN + has_application_scope DROP DEFAULT, +ALTER COLUMN + allow_bundled_submission DROP DEFAULT; + +COMMENT ON COLUMN sims.dynamic_form_configurations.form_category IS 'Indicates the category of the form.'; + +COMMENT ON COLUMN sims.dynamic_form_configurations.form_description IS 'Provides a description of the form to be shown to the student.'; + +COMMENT ON COLUMN sims.dynamic_form_configurations.has_application_scope IS 'Indicates whether the form must be associated with a Student Application.'; + +COMMENT ON COLUMN sims.dynamic_form_configurations.allow_bundled_submission IS 'Indicates whether this form can be part of submission that would included multiple forms.'; + +-- Insert new dynamic form configurations with appropriate categories and grouping types +-- for existing appeals and the new 'Non-punitive withdrawal' form. +INSERT INTO + sims.dynamic_form_configurations ( + form_type, + form_definition_name, + form_category, + form_description, + has_application_scope, + allow_bundled_submission + ) +VALUES + ( + 'Room and board costs', + 'roomandboardcostsappeal', + 'Student appeal', + 'A StudentAid BC Room and Board appeal allows students living with parents, step-parents, sponsors, or legal guardians to include, in their financial assessment, costs for room and board that they are required to pay, when those individuals cannot afford to provide this support for free.', + TRUE, + TRUE + ), + ( + 'Step-parent waiver', + 'stepparentwaiverappeal', + 'Student appeal', + 'A StudentAid BC step-parent waiver appeal allows dependent students to request that a step-parent''s income be excluded from their financial assessment if the step-parent has not assumed financial responsibility and does not claim the student as a dependent. This appeal is generally for recent marriages or common-law relationships (within 5 years).', + TRUE, + TRUE + ), + ( + 'Modified independent', + 'modifiedindependentappeal', + 'Student appeal', + 'A StudentAid BC Modified Independent Appeal allows a student, normally classified as dependent, to be reassessed as an independent student. This is approved if they have a severe, permanent, and often long-term (1+ year) family relationship breakdown with no communication, or if they were previously in child welfare custody.', + FALSE, + FALSE + ), + ( + 'Non-punitive withdrawal', + 'nonpunitivewithdrawalform', + 'Student form', + 'A StudentAid BC non-punitive withdrawal appeal allows students to remove a withdrawal from their record, preventing it from counting against future funding eligibility. This appeal is used when studies were stopped due to exceptional, documented circumstances—such as medical illness, family emergency, or institutional closure—rather than academic failure.', + FALSE, + FALSE + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Rollback-add-form-submission-grouping-type-column.sql b/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Rollback-add-form-submission-grouping-type-column.sql new file mode 100644 index 0000000000..21f476bcac --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Rollback-add-form-submission-grouping-type-column.sql @@ -0,0 +1,15 @@ +ALTER TABLE + sims.dynamic_form_configurations DROP COLUMN form_category, + DROP COLUMN form_description, + DROP COLUMN has_application_scope, + DROP COLUMN allow_bundled_submission; + +DELETE FROM + sims.dynamic_form_configurations +WHERE + form_definition_name IN ( + 'roomandboardcostsappeal', + 'stepparentwaiverappeal', + 'modifiedindependentappeal', + 'nonpunitivewithdrawalform' + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Create-form-submission-items-table.sql b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Create-form-submission-items-table.sql new file mode 100644 index 0000000000..8304239117 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Create-form-submission-items-table.sql @@ -0,0 +1,57 @@ +CREATE TABLE sims.form_submission_items( + id SERIAL PRIMARY KEY, + form_submission_id INT REFERENCES sims.form_submissions(id) NOT NULL, + dynamic_form_configuration_id INT REFERENCES sims.dynamic_form_configurations(id) NOT NULL, + submitted_data jsonb NOT NULL, + decision_status sims.form_submission_decision_status NOT NULL, + decision_date TIMESTAMP WITH TIME ZONE, + decision_by INT REFERENCES sims.users (id), + decision_note_id INT REFERENCES sims.notes (id), + -- Audit columns. + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + creator INT NOT NULL REFERENCES sims.users(id) NOT NULL, + modifier INT NULL DEFAULT NULL REFERENCES sims.users(id), + -- Ensure assessed fields are all provided when submission status is not pending. + CONSTRAINT form_submission_items_decision_fields_required_constraint CHECK ( + ( + decision_status != 'Pending' :: sims.form_submission_decision_status + AND decision_date IS NOT NULL + AND decision_by IS NOT NULL + AND decision_note_id IS NOT NULL + ) + OR ( + decision_status = 'Pending' :: sims.form_submission_decision_status + ) + ) +); + +-- Table and column comments for sims.form_submission_items. +COMMENT ON TABLE sims.form_submission_items IS 'Individual forms submitted for a decision that are part of a form submission process. A submission can contain one or more form submission items, each representing a specific form filled out by the user.'; + +COMMENT ON COLUMN sims.form_submission_items.id IS 'Primary key of the form submission item.'; + +COMMENT ON COLUMN sims.form_submission_items.form_submission_id IS 'Parent form submission that this item belongs to.'; + +COMMENT ON COLUMN sims.form_submission_items.dynamic_form_configuration_id IS 'Dynamic form configuration used to render and validate this item.'; + +COMMENT ON COLUMN sims.form_submission_items.submitted_data IS 'Submitted form data payload in JSON format.'; + +COMMENT ON COLUMN sims.form_submission_items.decision_status IS 'Current decision status for this item.'; + +COMMENT ON COLUMN sims.form_submission_items.decision_date IS 'Date and time when the decision was recorded.'; + +COMMENT ON COLUMN sims.form_submission_items.decision_by IS 'Ministry user who made the decision.'; + +COMMENT ON COLUMN sims.form_submission_items.decision_note_id IS 'Note associated with the decision.'; + +COMMENT ON COLUMN sims.form_submission_items.created_at IS 'Timestamp when the record was created.'; + +COMMENT ON COLUMN sims.form_submission_items.updated_at IS 'Timestamp when the record was last updated.'; + +COMMENT ON COLUMN sims.form_submission_items.creator IS 'User ID of the record creator.'; + +COMMENT ON COLUMN sims.form_submission_items.modifier IS 'User ID of the last user who modified the record.'; + +-- Constraints comments. +COMMENT ON CONSTRAINT form_submission_items_decision_fields_required_constraint ON sims.form_submission_items IS 'Requires decision_date, decision_by, and decision_note_id when submission_status is not pending.'; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Rollback-create-form-submission-items-table.sql b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Rollback-create-form-submission-items-table.sql new file mode 100644 index 0000000000..0c1be06bef --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Rollback-create-form-submission-items-table.sql @@ -0,0 +1 @@ +DROP TABLE sims.form_submission_items; diff --git a/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Create-form-submissions-table.sql b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Create-form-submissions-table.sql new file mode 100644 index 0000000000..246e78ed28 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Create-form-submissions-table.sql @@ -0,0 +1,56 @@ +CREATE TABLE sims.form_submissions( + id SERIAL PRIMARY KEY, + student_id INT REFERENCES sims.students(id) NOT NULL, + application_id INT REFERENCES sims.applications(id), + submitted_date TIMESTAMP WITH TIME ZONE NOT NULL, + form_category sims.form_category_types NOT NULL, + submission_status sims.form_submission_status NOT NULL, + assessed_date TIMESTAMP WITH TIME ZONE, + assessed_by INT REFERENCES sims.users (id), + -- Audit columns. + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + creator INT NOT NULL REFERENCES sims.users(id) NOT NULL, + modifier INT NULL DEFAULT NULL REFERENCES sims.users(id), + -- Ensure assessed fields are all provided when submission status is not pending. + CONSTRAINT form_submissions_assessed_fields_required_constraint CHECK ( + ( + submission_status != 'Pending' :: sims.form_submission_status + AND assessed_date IS NOT NULL + AND assessed_by IS NOT NULL + ) + OR ( + submission_status = 'Pending' :: sims.form_submission_status + ) + ) +); + +-- Table and columns comments. +COMMENT ON TABLE sims.form_submissions IS 'Form submissions for Ministry evaluation and decision. Each submission can contain one or more forms where each form is assessed individually.'; + +COMMENT ON COLUMN sims.form_submissions.id IS 'Primary key of the form submission.'; + +COMMENT ON COLUMN sims.form_submissions.student_id IS 'Student associated with the form submission.'; + +COMMENT ON COLUMN sims.form_submissions.application_id IS 'Application associated with the submission when the grouping requires it (e.g., Application bundle).'; + +COMMENT ON COLUMN sims.form_submissions.submitted_date IS 'Date and time when the submission was received.'; + +COMMENT ON COLUMN sims.form_submissions.form_category IS 'Category of the form. All forms for the submission must share the same category. This column is denormalized from the form items for easier querying.'; + +COMMENT ON COLUMN sims.form_submissions.submission_status IS 'Current status of the submission. A submission will be considered completed when all individual form items have been assessed and are no longer in pending state.'; + +COMMENT ON COLUMN sims.form_submissions.assessed_date IS 'Date and time when the submission was assessed. When assessed, the status must be either Completed or Declined.'; + +COMMENT ON COLUMN sims.form_submissions.assessed_by IS 'User who assessed the submission.'; + +COMMENT ON COLUMN sims.form_submissions.created_at IS 'Record creation timestamp.'; + +COMMENT ON COLUMN sims.form_submissions.updated_at IS 'Record update timestamp.'; + +COMMENT ON COLUMN sims.form_submissions.creator IS 'Creator of the record.'; + +COMMENT ON COLUMN sims.form_submissions.modifier IS 'Modifier of the record.'; + +-- Constraints comments. +COMMENT ON CONSTRAINT form_submissions_assessed_fields_required_constraint ON sims.form_submissions IS 'Requires assessed_date, assessed_by, and assessed_note_id when submission_status is not Pending.'; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Rollback-create-form-submissions-table.sql b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Rollback-create-form-submissions-table.sql new file mode 100644 index 0000000000..a0d0a609d1 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Rollback-create-form-submissions-table.sql @@ -0,0 +1 @@ +DROP TABLE sims.form_submissions; diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql b/sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql new file mode 100644 index 0000000000..06e896cb9a --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql @@ -0,0 +1,19 @@ +CREATE TYPE sims.form_category_types AS ENUM( + 'Student appeal', + 'Student form', + 'System' +); + +COMMENT ON TYPE sims.form_category_types IS 'Defines the category of forms.'; + +CREATE TYPE sims.form_submission_status AS ENUM('Pending', 'Completed', 'Declined'); + +COMMENT ON TYPE sims.form_submission_status IS 'Status for form submission that contains one to many forms to be assessed and have a decision assigned. Once all forms within the submission have been assessed, the submission is defined as Completed or Declined where declined indicates all forms were declined and Completed indicates at least one form was approved.'; + +CREATE TYPE sims.form_submission_decision_status AS ENUM( + 'Pending', + 'Approved', + 'Declined' +); + +COMMENT ON TYPE sims.form_submission_decision_status IS 'Status of a form submission item (individual decision), indicating whether it is pending, approved, or declined. Each item within a form submission will be assessed individually, and this status reflects the decision for that specific item. A declined item may be part of an approved submission when some other items were approved.'; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Types/Rollback-create-form-submission-related-types.sql b/sources/packages/backend/apps/db-migrations/src/sql/Types/Rollback-create-form-submission-related-types.sql new file mode 100644 index 0000000000..f951b7cd64 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Types/Rollback-create-form-submission-related-types.sql @@ -0,0 +1,6 @@ +DROP TYPE sims.form_category_types; + +--DROP TYPE sims.form_submission_grouping_types; +DROP TYPE sims.form_submission_status; + +DROP TYPE sims.form_submission_decision_status; \ No newline at end of file diff --git a/sources/packages/backend/libs/sims-db/src/constant.ts b/sources/packages/backend/libs/sims-db/src/constant.ts index 7cd324f67b..6c2b9b0559 100644 --- a/sources/packages/backend/libs/sims-db/src/constant.ts +++ b/sources/packages/backend/libs/sims-db/src/constant.ts @@ -73,6 +73,8 @@ export const TableNames = { SFASBridgeLogs: "sfas_bridge_logs", DynamicFormConfigurations: "dynamic_form_configurations", SystemLookupConfigurations: "system_lookup_configurations", + FormSubmissions: "form_submissions", + FormSubmissionItems: "form_submission_items", }; export const INSTITUTION_TYPE_BC_PUBLIC = 1; diff --git a/sources/packages/backend/libs/sims-db/src/data-source.ts b/sources/packages/backend/libs/sims-db/src/data-source.ts index 50c8612be5..571f76c369 100644 --- a/sources/packages/backend/libs/sims-db/src/data-source.ts +++ b/sources/packages/backend/libs/sims-db/src/data-source.ts @@ -68,6 +68,8 @@ import { SFASApplicationDisbursement, DynamicFormConfiguration, SystemLookupConfiguration, + FormSubmission, + FormSubmissionItem, } from "./entities"; import { ClusterNode, ClusterOptions, RedisOptions } from "ioredis"; import { @@ -234,4 +236,6 @@ export const DBEntities = [ SFASBridgeLog, DynamicFormConfiguration, SystemLookupConfiguration, + FormSubmission, + FormSubmissionItem, ]; diff --git a/sources/packages/backend/libs/sims-db/src/entities/dynamic-form-configuration.model.ts b/sources/packages/backend/libs/sims-db/src/entities/dynamic-form-configuration.model.ts index 2a8edbe597..926d171beb 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/dynamic-form-configuration.model.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/dynamic-form-configuration.model.ts @@ -5,7 +5,13 @@ import { ManyToOne, PrimaryGeneratedColumn, } from "typeorm"; -import { BaseModel, DynamicFormType, OfferingIntensity, ProgramYear } from "."; +import { + BaseModel, + DynamicFormType, + FormCategory, + OfferingIntensity, + ProgramYear, +} from "."; import { ColumnNames, TableNames } from "../constant"; /** @@ -59,4 +65,39 @@ export class DynamicFormConfiguration extends BaseModel { name: "form_definition_name", }) formDefinitionName: string; + + /** + * Indicates the category of the form. + */ + @Column({ + name: "form_category", + type: "enum", + enum: FormCategory, + enumName: "FormCategory", + }) + formCategory: FormCategory; + + /** + * Provides a description of the form to be shown to the student. + */ + @Column({ + name: "form_description", + }) + formDescription: string; + + /** + * Indicates whether the form must be associated with a Student Application. + */ + @Column({ + name: "has_application_scope", + }) + hasApplicationScope: boolean; + + /** + * Indicates whether this form can be part of submission that would included multiple forms. + */ + @Column({ + name: "allow_bundled_submission", + }) + allowBundledSubmission: boolean; } diff --git a/sources/packages/backend/libs/sims-db/src/entities/form-category.type.ts b/sources/packages/backend/libs/sims-db/src/entities/form-category.type.ts new file mode 100644 index 0000000000..88ce1344d0 --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-category.type.ts @@ -0,0 +1,19 @@ +/** + * Defines the category of forms. + */ +export enum FormCategory { + /** + * Appeals related forms. + */ + StudentAppeal = "Student appeal", + /** + * Any form submitted by a student that does not fall under + * the appeals process and have multiple applications. + */ + StudentForm = "Student form", + /** + * Forms used along the system that are not directly + * selected by students. + */ + System = "System", +} diff --git a/sources/packages/backend/libs/sims-db/src/entities/form-submission-decision-status.type.ts b/sources/packages/backend/libs/sims-db/src/entities/form-submission-decision-status.type.ts new file mode 100644 index 0000000000..a702d62ab3 --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission-decision-status.type.ts @@ -0,0 +1,20 @@ +/** + * Status of a form submission item (individual decision), indicating whether it is pending, + * approved, or declined. Each item within a form submission will be assessed individually, + * and this status reflects the decision for that specific item. A declined item may be part + * of an approved submission when some other items were approved. + */ +export enum FormSubmissionDecisionStatus { + /** + * The form submission item is still pending decision. + */ + Pending = "Pending", + /** + * The form submission item has been approved. + */ + Approved = "Approved", + /** + * The form submission item has been declined. + */ + Declined = "Declined", +} diff --git a/sources/packages/backend/libs/sims-db/src/entities/form-submission-item.model.ts b/sources/packages/backend/libs/sims-db/src/entities/form-submission-item.model.ts new file mode 100644 index 0000000000..2fe21353f5 --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission-item.model.ts @@ -0,0 +1,89 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { DynamicFormConfiguration, FormSubmission, Note, User } from "."; +import { ColumnNames, TableNames } from "../constant"; +import { RecordDataModel } from "./record.model"; +import { FormSubmissionDecisionStatus } from "@sims/sims-db/entities/form-submission-decision-status.type"; + +/** + * Individual forms submitted for a decision that are part of a form submission process. + * A submission can contain one or more form submission items, each representing a + * specific form filled out by the user. + */ +@Entity({ name: TableNames.FormSubmissionItems }) +export class FormSubmissionItem extends RecordDataModel { + /** + * Primary key of the form submission item. + */ + @PrimaryGeneratedColumn() + id: number; + /** + * Parent form submission that this item belongs to. + */ + @ManyToOne(() => FormSubmission) + @JoinColumn({ + name: "form_submission_id", + referencedColumnName: ColumnNames.ID, + }) + formSubmission: FormSubmission; + /** + *Dynamic form configuration used to render and validate this item. + */ + @ManyToOne(() => DynamicFormConfiguration) + @JoinColumn({ + name: "dynamic_form_configuration_id", + referencedColumnName: ColumnNames.ID, + }) + dynamicFormConfiguration: DynamicFormConfiguration; + /** + * Submitted form data payload in JSON format. + */ + @Column({ + name: "submitted_data", + type: "jsonb", + }) + submittedData: unknown; + /** + * Current decision status for this item. + */ + @Column({ + name: "decision_status", + type: "enum", + enum: FormSubmissionDecisionStatus, + enumName: "FormSubmissionDecisionStatus", + }) + decisionStatus: FormSubmissionDecisionStatus; + /** + * Date and time when the decision was recorded. + */ + @Column({ + name: "decision_date", + type: "timestamptz", + nullable: true, + }) + decisionDate?: Date; + /** + * Ministry user who made the decision. + */ + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ + name: "decision_by", + referencedColumnName: ColumnNames.ID, + }) + decisionBy?: User; + /** + * Note associated with the decision. + */ + @OneToOne(() => Note, { nullable: true, cascade: ["insert", "update"] }) + @JoinColumn({ + name: "decision_note_id", + referencedColumnName: ColumnNames.ID, + }) + decisionNote?: Note; +} diff --git a/sources/packages/backend/libs/sims-db/src/entities/form-submission-status.type.ts b/sources/packages/backend/libs/sims-db/src/entities/form-submission-status.type.ts new file mode 100644 index 0000000000..db780e13da --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission-status.type.ts @@ -0,0 +1,24 @@ +/** + * Status for form submission that contains one to many + * forms to be assessed and have a decision assigned. + */ +export enum FormSubmissionStatus { + /** + * The submission has one or more forms pending decision. + */ + Pending = "Pending", + /** + * All forms within the submission were assessed and + * are no longer pending. The decisions could be + * approved or declined, either way the submission + * process is completed. + */ + Completed = "Completed", + /** + * All forms within the submission were assessed and + * declined. The submission process is completed. + * This helps to easily identify submissions where + * all forms were declined. + */ + Declined = "Declined", +} diff --git a/sources/packages/backend/libs/sims-db/src/entities/form-submission.model.ts b/sources/packages/backend/libs/sims-db/src/entities/form-submission.model.ts new file mode 100644 index 0000000000..2ab9d7622f --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission.model.ts @@ -0,0 +1,115 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; +import { + Application, + FormCategory, + FormSubmissionItem, + FormSubmissionStatus, + Student, + User, +} from "."; +import { ColumnNames, TableNames } from "../constant"; +import { RecordDataModel } from "./record.model"; + +/** + * Form submissions for Ministry evaluation and decision. Each submission can + * contain one or more forms where each form is assessed individually. + */ +@Entity({ name: TableNames.FormSubmissions }) +export class FormSubmission extends RecordDataModel { + /** + * Primary key identifier. + */ + @PrimaryGeneratedColumn() + id: number; + /** + * Student related to this form submission. + * A form submission may or may not be linked to an application, + * but it must be linked to a student. + */ + @ManyToOne(() => Student) + @JoinColumn({ + name: "student_id", + referencedColumnName: ColumnNames.ID, + }) + student: Student; + /** + * Application associated with the submission when the grouping + * requires it (e.g., Application bundle). + */ + @ManyToOne(() => Application, { + nullable: true, + }) + @JoinColumn({ + name: "application_id", + referencedColumnName: ColumnNames.ID, + }) + application?: Application; + /** + * Date and time when the submission was received. + */ + @Column({ + name: "submitted_date", + type: "timestamptz", + nullable: false, + }) + submittedDate: Date; + /** + * Category of the form. All forms for the submission must share the same category. + * This column is denormalized from the form items for easier querying. + */ + @Column({ + name: "form_category", + type: "enum", + enum: FormCategory, + enumName: "FormCategory", + }) + formCategory: FormCategory; + /** + * Current status of the submission. A submission will be considered completed when all + * individual form items have been assessed and are no longer in pending state. + */ + @Column({ + name: "submission_status", + type: "enum", + enum: FormSubmissionStatus, + enumName: "FormSubmissionStatus", + }) + submissionStatus: FormSubmissionStatus; + /** + * Date and time when the submission was assessed. When assessed, the status must be + * either Completed or Declined. + */ + @Column({ + name: "assessed_date", + type: "timestamptz", + nullable: true, + }) + assessedDate?: Date; + /** + * User who assessed the submission. + */ + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ + name: "assessed_by", + referencedColumnName: ColumnNames.ID, + }) + assessedBy?: User; + /** + * Submission items containing the individual forms submitted for a decision. + */ + @OneToMany( + () => FormSubmissionItem, + (formSubmissionItem) => formSubmissionItem.formSubmission, + { + cascade: ["insert"], + }, + ) + formSubmissionItems: FormSubmissionItem[]; +} diff --git a/sources/packages/backend/libs/sims-db/src/entities/index.ts b/sources/packages/backend/libs/sims-db/src/entities/index.ts index 36cc36c14e..705d1932f7 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/index.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/index.ts @@ -119,3 +119,7 @@ export * from "./dynamic-form-type"; export * from "./dynamic-form-configuration.model"; export * from "./system-lookup-category.type"; export * from "./system-lookup-configuration.model"; +export * from "./form-category.type"; +export * from "./form-submission-status.type"; +export * from "./form-submission.model"; +export * from "./form-submission-item.model"; diff --git a/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue b/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue new file mode 100644 index 0000000000..84e6dd2650 --- /dev/null +++ b/sources/packages/web/src/components/form-submissions/FormSubmissionApproval.vue @@ -0,0 +1,302 @@ + + + diff --git a/sources/packages/web/src/components/form-submissions/FormSubmissionItems.vue b/sources/packages/web/src/components/form-submissions/FormSubmissionItems.vue new file mode 100644 index 0000000000..2cda535186 --- /dev/null +++ b/sources/packages/web/src/components/form-submissions/FormSubmissionItems.vue @@ -0,0 +1,122 @@ + + diff --git a/sources/packages/web/src/components/generic/ErrorSummary.vue b/sources/packages/web/src/components/generic/ErrorSummary.vue index 013b77d32c..cdd5e1a409 100644 --- a/sources/packages/web/src/components/generic/ErrorSummary.vue +++ b/sources/packages/web/src/components/generic/ErrorSummary.vue @@ -1,5 +1,5 @@