From e5507d38a7086246eb523f99622cb0b98286fdd3 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Mon, 19 Jan 2026 16:06:16 -0800 Subject: [PATCH 01/13] Initial draft DB migrations --- ...388343-CreateFormSubmissionRelatedTypes.ts | 19 ++++++++++++ ...768863415862-CreateFormSubmissionsTable.ts | 19 ++++++++++++ ...63437173-CreateFormSubmissionItemsTable.ts | 22 ++++++++++++++ ...ionsAddFormSubmissionGroupingTypeColumn.ts | 22 ++++++++++++++ ...d-form-submission-grouping-type-column.sql | 6 ++++ ...d-form-submission-grouping-type-column.sql | 4 +++ .../Create-form-submission-items-table.sql | 14 +++++++++ ...ack-create-form-submission-items-table.sql | 1 + .../Create-form-submissions-table.sql | 30 +++++++++++++++++++ ...Rollback-create-form-submissions-table.sql | 1 + .../Create-form-submission-related-types.sql | 21 +++++++++++++ ...k-create-form-submission-related-types.sql | 3 ++ 12 files changed, 162 insertions(+) create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1768863388343-CreateFormSubmissionRelatedTypes.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1768863415862-CreateFormSubmissionsTable.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1768863437173-CreateFormSubmissionItemsTable.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1768863470903-DynamicFormConfigurationsAddFormSubmissionGroupingTypeColumn.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Add-form-submission-grouping-type-column.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Rollback-add-form-submission-grouping-type-column.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Create-form-submission-items-table.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Rollback-create-form-submission-items-table.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Create-form-submissions-table.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Rollback-create-form-submissions-table.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Types/Rollback-create-form-submission-related-types.sql 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..d446032673 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Add-form-submission-grouping-type-column.sql @@ -0,0 +1,6 @@ +ALTER TABLE + sims.dynamic_form_configurations +add + COLUMN form_submission_grouping_type sims.form_submission_grouping_types; + +COMMENT ON COLUMN sims.dynamic_form_configurations.form_submission_grouping_type IS 'Indicates how the form submissions are grouped, such as part of an application bundle, standalone application, or standalone student form.'; \ 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..a2e99c9e9a --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/DynamicFormConfigurations/Rollback-add-form-submission-grouping-type-column.sql @@ -0,0 +1,4 @@ +ALTER TABLE + sims.dynamic_form_configurations +DROP + COLUMN form_submission_grouping_type; \ 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..adb412c5a8 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissionItems/Create-form-submission-items-table.sql @@ -0,0 +1,14 @@ +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, + submission_status sims.form_submission_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. + -- Creator and modifier are not provided as the configurations cannot be created or updated by application users. + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); 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..78e8daefed --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Create-form-submissions-table.sql @@ -0,0 +1,30 @@ +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, + submission_grouping_type sims.form_submission_grouping_types NOT NULL, + submission_status sims.form_submission_status NOT NULL, + assessed_date TIMESTAMP WITH TIME ZONE, + assessed_by INT REFERENCES sims.users (id), + assessed_student_note_id INT REFERENCES sims.notes (id), + CONSTRAINT form_submissions_application_id_constraint CHECK ( + ( + submission_grouping_type IN ( + 'Application bundle' :: sims.form_submission_grouping_types, + 'Application standalone' :: sims.form_submission_grouping_types + ) + AND application_id IS NOT NULL + ) + OR ( + submission_grouping_type NOT IN ( + 'Application bundle' :: sims.form_submission_grouping_types, + 'Application standalone' :: sims.form_submission_grouping_types + ) + ) + ), + -- Audit columns. + -- Creator and modifier are not provided as the configurations cannot be created or updated by application users. + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); \ 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..4cbf965cea --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql @@ -0,0 +1,21 @@ +CREATE TYPE sims.form_submission_grouping_types AS ENUM( + 'Application bundle', + 'Application standalone', + 'Student standalone' +); +COMMENT ON TYPE sims.form_submission_grouping_types IS 'Defines how forms can be grouped when submitted. An application bundle groups multiple forms together as part of a single application process. An application standalone refers to individual forms that are submitted independently of an application. A student standalone refers to forms that are submitted independently and are associated directly with a student, rather than an application.'; + +CREATE TYPE sims.form_submission_status AS ENUM( + 'Pending', + 'Approved', + 'Declined' +); +COMMENT ON TYPE sims.form_submission_status IS 'Status of a form submission, indicating whether it is pending, approved, or declined. A form submission may have one to many items that are assessed individually, but the overall submission status provides the final decision status.'; + +CREATE TYPE sims.form_submission_item_status AS ENUM( + 'Pending', + 'Approved', + 'Declined' +); +COMMENT ON TYPE sims.form_submission_item_status IS 'Status of a form submission item, indicating whether it is pending, approved, or declined. Each item within a form submission may 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.'; + 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..6ad963a14c --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Types/Rollback-create-form-submission-related-types.sql @@ -0,0 +1,3 @@ +DROP TYPE sims.form_submission_grouping_types; +DROP TYPE sims.form_submission_status; +DROP TYPE sims.form_submission_item_status; From 18c8b27e672f6e3ce5c73b533a69506d955c6b25 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Wed, 21 Jan 2026 13:20:48 -0800 Subject: [PATCH 02/13] updating DB migrations for a close to final approach --- ...d-form-submission-grouping-type-column.sql | 6 +++- .../Create-form-submission-items-table.sql | 11 +++--- .../Create-form-submissions-table.sql | 34 ++++++++++++------- .../Create-form-submission-related-types.sql | 17 +++++++--- 4 files changed, 45 insertions(+), 23 deletions(-) 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 index d446032673..266cb3ac3d 100644 --- 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 @@ -1,6 +1,10 @@ ALTER TABLE sims.dynamic_form_configurations -add +ADD + COLUMN form_category sims.form_category_types NOT NULL DEFAULT 'System', +ADD COLUMN form_submission_grouping_type sims.form_submission_grouping_types; +COMMENT ON COLUMN sims.dynamic_form_configurations.form_category IS 'Indicates the category of the form.'; + COMMENT ON COLUMN sims.dynamic_form_configurations.form_submission_grouping_type IS 'Indicates how the form submissions are grouped, such as part of an application bundle, standalone application, or standalone student form.'; \ 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 index adb412c5a8..b9c9b4b7d5 100644 --- 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 @@ -1,14 +1,15 @@ CREATE TABLE sims.form_submission_items( id SERIAL PRIMARY KEY, - form_submission_id INT REFERENCES sims.form_submissions(id) NOT NULL, + 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, + submitted_data jsonb NOT NULL, submission_status sims.form_submission_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. - -- Creator and modifier are not provided as the configurations cannot be created or updated by application users. created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_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) +); \ No newline at end of file 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 index 78e8daefed..bf8d00b047 100644 --- 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 @@ -7,24 +7,32 @@ CREATE TABLE sims.form_submissions( submission_status sims.form_submission_status NOT NULL, assessed_date TIMESTAMP WITH TIME ZONE, assessed_by INT REFERENCES sims.users (id), - assessed_student_note_id INT REFERENCES sims.notes (id), + assessed_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 application related submissions have an application ID. CONSTRAINT form_submissions_application_id_constraint CHECK ( ( - submission_grouping_type IN ( - 'Application bundle' :: sims.form_submission_grouping_types, - 'Application standalone' :: sims.form_submission_grouping_types - ) + submission_grouping_type = 'Application bundle' :: sims.form_submission_grouping_types, AND application_id IS NOT NULL ) OR ( - submission_grouping_type NOT IN ( - 'Application bundle' :: sims.form_submission_grouping_types, - 'Application standalone' :: sims.form_submission_grouping_types - ) + submission_grouping_type != 'Application bundle' :: sims.form_submission_grouping_types ) ), - -- Audit columns. - -- Creator and modifier are not provided as the configurations cannot be created or updated by application users. - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + -- 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 + AND assessed_note_id IS NOT NULL + ) + OR ( + submission_status = 'Pending' :: sims.form_submission_status + ) + ) ); \ No newline at end of file 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 index 4cbf965cea..04883357f8 100644 --- 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 @@ -1,15 +1,24 @@ +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_grouping_types AS ENUM( 'Application bundle', - 'Application standalone', 'Student standalone' ); -COMMENT ON TYPE sims.form_submission_grouping_types IS 'Defines how forms can be grouped when submitted. An application bundle groups multiple forms together as part of a single application process. An application standalone refers to individual forms that are submitted independently of an application. A student standalone refers to forms that are submitted independently and are associated directly with a student, rather than an application.'; + +COMMENT ON TYPE sims.form_submission_grouping_types IS 'Defines how forms can be grouped when submitted. An application bundle groups multiple forms together as part of a single application process. A student standalone refers to forms that are submitted independently and are associated directly with a student, rather than an application.'; CREATE TYPE sims.form_submission_status AS ENUM( 'Pending', - 'Approved', + 'Completed', 'Declined' ); + COMMENT ON TYPE sims.form_submission_status IS 'Status of a form submission, indicating whether it is pending, approved, or declined. A form submission may have one to many items that are assessed individually, but the overall submission status provides the final decision status.'; CREATE TYPE sims.form_submission_item_status AS ENUM( @@ -17,5 +26,5 @@ CREATE TYPE sims.form_submission_item_status AS ENUM( 'Approved', 'Declined' ); -COMMENT ON TYPE sims.form_submission_item_status IS 'Status of a form submission item, indicating whether it is pending, approved, or declined. Each item within a form submission may 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.'; +COMMENT ON TYPE sims.form_submission_item_status IS 'Status of a form submission item, indicating whether it is pending, approved, or declined. Each item within a form submission may 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 From cea46797104620dbe1fb0527f8edfa7bc1c4ccce Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Thu, 22 Jan 2026 09:23:32 -0800 Subject: [PATCH 03/13] DB migrations - Entity Models - WIP --- sims.code-workspace | 79 +++++++------- ...d-form-submission-grouping-type-column.sql | 35 +++++- ...d-form-submission-grouping-type-column.sql | 13 ++- .../Create-form-submission-items-table.sql | 2 +- .../Create-form-submissions-table.sql | 37 ++++++- .../Create-form-submission-related-types.sql | 6 +- ...k-create-form-submission-related-types.sql | 6 +- .../backend/libs/sims-db/src/constant.ts | 1 + .../src/entities/form-category.type.ts | 19 ++++ .../entities/form-submission-grouping.type.ts | 16 +++ .../entities/form-submission-status.type.ts | 23 ++++ .../src/entities/form-submission.model.ts | 101 ++++++++++++++++++ .../libs/sims-db/src/entities/index.ts | 3 + 13 files changed, 292 insertions(+), 49 deletions(-) create mode 100644 sources/packages/backend/libs/sims-db/src/entities/form-category.type.ts create mode 100644 sources/packages/backend/libs/sims-db/src/entities/form-submission-grouping.type.ts create mode 100644 sources/packages/backend/libs/sims-db/src/entities/form-submission-status.type.ts create mode 100644 sources/packages/backend/libs/sims-db/src/entities/form-submission.model.ts diff --git a/sims.code-workspace b/sims.code-workspace index e97c1826c0..deb03ac753 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", @@ -217,12 +218,12 @@ "PTSSR", "PTWTHD", "roomandboardcostsappeal", - "stepparentwaiverappeal", "SABC", "sbsd", "SFAS", "siteprotected", "SSRN", + "stepparentwaiverappeal", "studentadditionaltransportationappeal", "studentdependantsappeal", "studentdisabilityappeal", @@ -237,14 +238,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/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 index 266cb3ac3d..eca3099f97 100644 --- 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 @@ -7,4 +7,37 @@ ADD COMMENT ON COLUMN sims.dynamic_form_configurations.form_category IS 'Indicates the category of the form.'; -COMMENT ON COLUMN sims.dynamic_form_configurations.form_submission_grouping_type IS 'Indicates how the form submissions are grouped, such as part of an application bundle, standalone application, or standalone student form.'; \ No newline at end of file +COMMENT ON COLUMN sims.dynamic_form_configurations.form_submission_grouping_type IS 'Indicates how the form submissions are grouped, such as part of an application bundle, standalone application, or standalone student form.'; + +INSERT INTO + sims.dynamic_form_configurations ( + form_type, + form_definition_name, + form_category, + form_submission_grouping_type + ) +VALUES + ( + 'Room and board costs', + 'roomandboardcostsappeal', + 'Student appeal', + 'Application bundle' + ), + ( + 'Step-parent waiver', + 'stepparentwaiverappeal', + 'Student appeal', + 'Application bundle' + ), + ( + 'Modified independent', + 'modifiedindependentappeal', + 'Student appeal', + 'Student standalone' + ), + ( + 'Non-punitive withdrawal', + 'nonpunitivewithdrawalform', + 'Student form', + 'Student standalone' + ); \ 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 index a2e99c9e9a..7ce43940d6 100644 --- 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 @@ -1,4 +1,13 @@ ALTER TABLE + sims.dynamic_form_configurations DROP COLUMN form_category, + DROP COLUMN form_submission_grouping_type; + +DELETE FROM sims.dynamic_form_configurations -DROP - COLUMN form_submission_grouping_type; \ No newline at end of file +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 index b9c9b4b7d5..c679d3d509 100644 --- 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 @@ -3,7 +3,7 @@ CREATE TABLE sims.form_submission_items( 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, - submission_status sims.form_submission_status 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), 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 index bf8d00b047..4714fcd4a5 100644 --- 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 @@ -3,6 +3,7 @@ CREATE TABLE sims.form_submissions( 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_grouping_type sims.form_submission_grouping_types NOT NULL, submission_status sims.form_submission_status NOT NULL, assessed_date TIMESTAMP WITH TIME ZONE, @@ -16,7 +17,7 @@ CREATE TABLE sims.form_submissions( -- Ensure application related submissions have an application ID. CONSTRAINT form_submissions_application_id_constraint CHECK ( ( - submission_grouping_type = 'Application bundle' :: sims.form_submission_grouping_types, + submission_grouping_type = 'Application bundle' :: sims.form_submission_grouping_types AND application_id IS NOT NULL ) OR ( @@ -35,4 +36,36 @@ CREATE TABLE sims.form_submissions( submission_status = 'Pending' :: sims.form_submission_status ) ) -); \ No newline at end of file +); + +-- Table and column comments for sims.form_submissions. +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_grouping_type IS 'Grouping type of the submission. All forms within a submission share the same grouping type. 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.'; + +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 'Timestamp when the record was created.'; + +COMMENT ON COLUMN sims.form_submissions.updated_at IS 'Timestamp when the record was last updated.'; + +--creator +--modifier +-- Optional: document constraints for clarity. +COMMENT ON CONSTRAINT form_submissions_application_id_constraint ON sims.form_submissions IS 'Ensures application_id is present when submission_grouping_type is Application bundle or Application standalone.'; + +COMMENT ON CONSTRAINT form_submissions_assessed_fields_required_constraint ON sims.form_submissions IS 'Requires assessed_date, assessed_by, and assessed_student_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/Types/Create-form-submission-related-types.sql b/sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql index 04883357f8..2c4e4bde73 100644 --- 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 @@ -19,12 +19,12 @@ CREATE TYPE sims.form_submission_status AS ENUM( 'Declined' ); -COMMENT ON TYPE sims.form_submission_status IS 'Status of a form submission, indicating whether it is pending, approved, or declined. A form submission may have one to many items that are assessed individually, but the overall submission status provides the final decision status.'; +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.'; -CREATE TYPE sims.form_submission_item_status AS ENUM( +CREATE TYPE sims.form_submission_decision_status AS ENUM( 'Pending', 'Approved', 'Declined' ); -COMMENT ON TYPE sims.form_submission_item_status IS 'Status of a form submission item, indicating whether it is pending, approved, or declined. Each item within a form submission may 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 +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 may 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 index 6ad963a14c..a172e1e692 100644 --- 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 @@ -1,3 +1,7 @@ +DROP TYPE sims.form_category_types; + DROP TYPE sims.form_submission_grouping_types; + DROP TYPE sims.form_submission_status; -DROP TYPE sims.form_submission_item_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 c0af9b1e73..3d41e204f0 100644 --- a/sources/packages/backend/libs/sims-db/src/constant.ts +++ b/sources/packages/backend/libs/sims-db/src/constant.ts @@ -72,6 +72,7 @@ export const TableNames = { BetaUsersAuthorizations: "beta_users_authorizations", SFASBridgeLogs: "sfas_bridge_logs", DynamicFormConfigurations: "dynamic_form_configurations", + FormSubmissions: "form_submissions", }; export const INSTITUTION_TYPE_BC_PUBLIC = 1; 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-grouping.type.ts b/sources/packages/backend/libs/sims-db/src/entities/form-submission-grouping.type.ts new file mode 100644 index 0000000000..0b8c35cf34 --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission-grouping.type.ts @@ -0,0 +1,16 @@ +/** + * Defines how forms can be grouped when submitted. + */ +export enum FormSubmissionGrouping { + /** + * An application bundle groups multiple forms together + * as part of a single application process. + */ + ApplicationBundle = "Application bundle", + /** + * A student standalone refers to forms that are submitted + * independently and are associated directly with a student, + * rather than an application. + */ + StudentStandalone = "Student standalone", +} 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..95a4f34761 --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission-status.type.ts @@ -0,0 +1,23 @@ +/** + * 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", + /** + * None of the forms within the submission were approved. + * The submission process is completed, but this entire + * submission was declined and can have its actions ignored. + */ + 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..fb241242e6 --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/form-submission.model.ts @@ -0,0 +1,101 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { + Application, + FormCategoryType, + FormSubmissionGroupingType, + Student, +} from "."; +import { ColumnNames, TableNames } from "../constant"; +import { RecordDataModel } from "./record.model"; +import { FormSubmissionStatus } from "@sims/sims-db/entities/form-submission-status.type"; + +/** + * + */ +@Entity({ name: TableNames.FormSubmissions }) +export class FormSubmission extends RecordDataModel { + @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 related to this form submission + * when the submission is regarding an application. + */ + @ManyToOne(() => Application, { + cascade: ["update"], + nullable: true, + }) + @JoinColumn({ + name: "application_id", + referencedColumnName: ColumnNames.ID, + }) + application?: Application; + /** + * Date that the student submitted the form. + */ + @Column({ + name: "submitted_date", + type: "timestamptz", + nullable: false, + }) + submittedDate: Date; + /** + * + */ + @Column({ + name: "form_category", + type: "enum", + enum: FormCategoryType, + enumName: "FormCategoryType", + }) + formCategory: FormCategoryType; + /** + * + */ + @Column({ + name: "submission_grouping_type", + type: "enum", + enum: FormSubmissionGroupingType, + enumName: "FormSubmissionGroupingType", + }) + submissionGrouping: FormSubmissionGroupingType; + /** + * + */ + @Column({ + name: "submission_status", + type: "enum", + enum: FormSubmissionStatus, + enumName: "FormSubmissionStatus", + }) + submissionStatus: FormSubmissionStatus; + + /** + * Individual appeals that belongs to the same request. + */ + // @OneToMany( + // () => StudentAppealRequest, + // (studentAppealRequest) => studentAppealRequest.studentAppeal, + // { + // eager: false, + // cascade: true, + // nullable: false, + // }, + // ) + // appealRequests: StudentAppealRequest[]; +} 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 9ec63db664..9a63b67d81 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/index.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/index.ts @@ -114,3 +114,6 @@ export * from "./sfas-bridge-log.model"; export * from "./application-edit-status.type"; export * from "./dynamic-form-type"; export * from "./dynamic-form-configuration.model"; +export * from "./form-submission-grouping.type"; +export * from "./form-category.type"; +export * from "./form-submission.model"; From 9dc999072837132730f54c32aefd0073e4d1e536 Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Thu, 22 Jan 2026 16:07:09 -0800 Subject: [PATCH 04/13] DB migrations close to final --- .../Create-form-submission-items-table.sql | 46 +++++++++- .../Create-form-submissions-table.sql | 22 +++-- .../Create-form-submission-related-types.sql | 2 +- .../backend/libs/sims-db/src/constant.ts | 1 + .../dynamic-form-configuration.model.ts | 32 ++++++- .../form-submission-decision-status.type.ts | 20 +++++ .../entities/form-submission-item.model.ts | 89 +++++++++++++++++++ .../src/entities/form-submission.model.ts | 84 ++++++++++------- .../libs/sims-db/src/entities/index.ts | 1 + 9 files changed, 254 insertions(+), 43 deletions(-) create mode 100644 sources/packages/backend/libs/sims-db/src/entities/form-submission-decision-status.type.ts create mode 100644 sources/packages/backend/libs/sims-db/src/entities/form-submission-item.model.ts 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 index c679d3d509..8304239117 100644 --- 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 @@ -11,5 +11,47 @@ CREATE TABLE sims.form_submission_items( 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) -); \ No newline at end of file + 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/FormSubmissions/Create-form-submissions-table.sql b/sources/packages/backend/apps/db-migrations/src/sql/FormSubmissions/Create-form-submissions-table.sql index 4714fcd4a5..9df6e401cf 100644 --- 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 @@ -38,14 +38,14 @@ CREATE TABLE sims.form_submissions( ) ); --- Table and column comments for sims.form_submissions. +-- 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.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.'; @@ -53,19 +53,23 @@ COMMENT ON COLUMN sims.form_submissions.form_category IS 'Category of the form. COMMENT ON COLUMN sims.form_submissions.submission_grouping_type IS 'Grouping type of the submission. All forms within a submission share the same grouping type. 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.'; +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 'Timestamp when the record was created.'; +COMMENT ON COLUMN sims.form_submissions.assessed_note_id IS 'Ministry note associated with the submission assessment.'; -COMMENT ON COLUMN sims.form_submissions.updated_at IS 'Timestamp when the record was last updated.'; +COMMENT ON COLUMN sims.form_submissions.created_at IS 'Record creation timestamp.'; ---creator ---modifier --- Optional: document constraints for clarity. -COMMENT ON CONSTRAINT form_submissions_application_id_constraint ON sims.form_submissions IS 'Ensures application_id is present when submission_grouping_type is Application bundle or Application standalone.'; +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_application_id_constraint ON sims.form_submissions IS 'Ensures application_id is present when submission_grouping_type is application-related.'; COMMENT ON CONSTRAINT form_submissions_assessed_fields_required_constraint ON sims.form_submissions IS 'Requires assessed_date, assessed_by, and assessed_student_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/Types/Create-form-submission-related-types.sql b/sources/packages/backend/apps/db-migrations/src/sql/Types/Create-form-submission-related-types.sql index 2c4e4bde73..270943bc9f 100644 --- 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 @@ -27,4 +27,4 @@ CREATE TYPE sims.form_submission_decision_status AS ENUM( '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 may 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 +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/libs/sims-db/src/constant.ts b/sources/packages/backend/libs/sims-db/src/constant.ts index 3d41e204f0..1440ac2d83 100644 --- a/sources/packages/backend/libs/sims-db/src/constant.ts +++ b/sources/packages/backend/libs/sims-db/src/constant.ts @@ -73,6 +73,7 @@ export const TableNames = { SFASBridgeLogs: "sfas_bridge_logs", DynamicFormConfigurations: "dynamic_form_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/entities/dynamic-form-configuration.model.ts b/sources/packages/backend/libs/sims-db/src/entities/dynamic-form-configuration.model.ts index 2a8edbe597..6357487e1a 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,14 @@ import { ManyToOne, PrimaryGeneratedColumn, } from "typeorm"; -import { BaseModel, DynamicFormType, OfferingIntensity, ProgramYear } from "."; +import { + BaseModel, + DynamicFormType, + FormCategory, + FormSubmissionGrouping, + OfferingIntensity, + ProgramYear, +} from "."; import { ColumnNames, TableNames } from "../constant"; /** @@ -59,4 +66,27 @@ 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; + + /** + * Indicates how the form submissions are grouped, such as part + * of an application bundle or standalone student form. + */ + @Column({ + name: "form_submission_grouping_type", + type: "enum", + enum: FormSubmissionGrouping, + enumName: "FormSubmissionGrouping", + }) + formSubmissionGroupingType: FormSubmissionGrouping; } 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..545ebfecac --- /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: "submission_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 }) + @JoinColumn({ + name: "decision_note_id", + referencedColumnName: ColumnNames.ID, + }) + decisionNote?: Note; +} 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 index fb241242e6..41e66de8e7 100644 --- 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 @@ -3,28 +3,36 @@ import { Entity, JoinColumn, ManyToOne, + OneToOne, PrimaryGeneratedColumn, } from "typeorm"; import { Application, - FormCategoryType, - FormSubmissionGroupingType, + FormCategory, + FormSubmissionGrouping, + FormSubmissionStatus, + Note, Student, + User, } from "."; import { ColumnNames, TableNames } from "../constant"; import { RecordDataModel } from "./record.model"; -import { FormSubmissionStatus } from "@sims/sims-db/entities/form-submission-status.type"; /** - * + * 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. + * A form submission may or may not be linked to an application, + * but it must be linked to a student. */ @ManyToOne(() => Student) @JoinColumn({ @@ -33,11 +41,10 @@ export class FormSubmission extends RecordDataModel { }) student: Student; /** - * Application related to this form submission - * when the submission is regarding an application. + * Application associated with the submission when the grouping + * requires it (e.g., Application bundle). */ @ManyToOne(() => Application, { - cascade: ["update"], nullable: true, }) @JoinColumn({ @@ -46,7 +53,7 @@ export class FormSubmission extends RecordDataModel { }) application?: Application; /** - * Date that the student submitted the form. + * Date and time when the submission was received. */ @Column({ name: "submitted_date", @@ -55,27 +62,30 @@ export class FormSubmission extends RecordDataModel { }) 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: FormCategoryType, - enumName: "FormCategoryType", + enum: FormCategory, + enumName: "FormCategory", }) - formCategory: FormCategoryType; + formCategory: FormCategory; /** - * + * Grouping type of the submission. All forms within a submission share the same + * grouping type. This column is denormalized from the form items for easier querying. */ @Column({ name: "submission_grouping_type", type: "enum", - enum: FormSubmissionGroupingType, - enumName: "FormSubmissionGroupingType", + enum: FormSubmissionGrouping, + enumName: "FormSubmissionGrouping", }) - submissionGrouping: FormSubmissionGroupingType; + submissionGrouping: FormSubmissionGrouping; /** - * + * 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", @@ -84,18 +94,32 @@ export class FormSubmission extends RecordDataModel { enumName: "FormSubmissionStatus", }) submissionStatus: FormSubmissionStatus; - /** - * Individual appeals that belongs to the same request. + * 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. */ - // @OneToMany( - // () => StudentAppealRequest, - // (studentAppealRequest) => studentAppealRequest.studentAppeal, - // { - // eager: false, - // cascade: true, - // nullable: false, - // }, - // ) - // appealRequests: StudentAppealRequest[]; + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ + name: "assessed_by", + referencedColumnName: ColumnNames.ID, + }) + assessedBy?: User; + /** + * Ministry note associated with the submission assessment. + */ + @OneToOne(() => Note, { nullable: true }) + @JoinColumn({ + name: "assessed_note_id", + referencedColumnName: ColumnNames.ID, + }) + assessedNote?: Note; } 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 9a63b67d81..02048715b3 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/index.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/index.ts @@ -116,4 +116,5 @@ export * from "./dynamic-form-type"; export * from "./dynamic-form-configuration.model"; export * from "./form-submission-grouping.type"; export * from "./form-category.type"; +export * from "./form-submission-status.type"; export * from "./form-submission.model"; From 3f9ee3ea02bfd7291beba614ba884859608475fa Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Thu, 22 Jan 2026 16:22:47 -0800 Subject: [PATCH 05/13] Minor comment fix --- .../Add-form-submission-grouping-type-column.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index eca3099f97..a13370d187 100644 --- 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 @@ -7,8 +7,10 @@ ADD COMMENT ON COLUMN sims.dynamic_form_configurations.form_category IS 'Indicates the category of the form.'; -COMMENT ON COLUMN sims.dynamic_form_configurations.form_submission_grouping_type IS 'Indicates how the form submissions are grouped, such as part of an application bundle, standalone application, or standalone student form.'; +COMMENT ON COLUMN sims.dynamic_form_configurations.form_submission_grouping_type IS 'Indicates how the form submissions are grouped, such as part of an application bundle or standalone student form.'; +-- 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, From c950bff17ca83314eec76d505af62e9bc35a064c Mon Sep 17 00:00:00 2001 From: Andrew Boni Signori Date: Tue, 27 Jan 2026 13:20:53 -0800 Subject: [PATCH 06/13] WIP UI --- .../apps/api/src/app.students.module.ts | 4 + .../dynamic-form-configuration.controller.ts | 32 +- .../models/dynamic-form-configuration.dto.ts | 25 +- .../form-submission.students.controller.ts | 280 ++++++++++++ .../models/form-submission.dto.ts | 72 +++ .../apps/api/src/route-controllers/index.ts | 2 + .../dynamic-form-configuration.service.ts | 62 ++- .../src/services/form-submission/constants.ts | 4 + .../form-submission/form-submission.models.ts | 8 + .../form-submission.service.ts | 138 ++++++ ...al-action-processor.processActions.spec.ts | 158 +++++++ ...nt-appeal-action.getActionRequests.spec.ts | 72 +++ ...nt-appeal-action.hasApprovedAction.spec.ts | 91 ++++ .../unit/test-student-appeal-action.ts | 43 ++ .../student-appeal-action-processor.ts | 64 +++ .../actions/student-appeal-action.ts | 76 ++++ ...student-appeal-create-assessment-action.ts | 67 +++ ...peal-update-modified-independent-action.ts | 58 +++ .../student-form-assessment/index.ts | 5 + .../student-appeal-assessment.service.ts | 201 ++++++++ .../backend/apps/api/src/services/index.ts | 2 + .../Create-form-submission-related-types.sql | 8 +- .../backend/libs/sims-db/src/data-source.ts | 4 + .../dynamic-form-configuration.model.ts | 2 +- .../entities/form-submission-status.type.ts | 6 - .../src/entities/form-submission.model.ts | 13 + .../libs/sims-db/src/entities/index.ts | 1 + .../students/StudentAppealSharedForm.vue | 4 +- .../useDynamicFormsConfigurations.ts | 97 ++++ .../packages/web/src/router/StudentRoutes.ts | 3 +- .../DynamicFormConfigurationService.ts | 9 +- .../src/services/FormSubmissionsService.ts | 24 + .../web/src/services/http/ApiClient.ts | 2 + .../http/DynamicFormConfigurationApi.ts | 10 +- .../src/services/http/FormSubmissionsApi.ts | 20 + .../http/dto/DynamicFormConfiguration.dto.ts | 9 + .../services/http/dto/FormSubmission.dto.ts | 39 ++ .../contracts/FormSubmissionContracts.ts | 70 +++ sources/packages/web/src/types/index.ts | 1 + .../web/src/views/student/AppStudent.vue | 2 +- .../views/student/appeal/StudentAppeal.vue | 2 +- .../StudentAppealSharedFormSubmission.vue | 7 +- .../forms/StudentFormsSelector copy.vue.new | 430 ++++++++++++++++++ .../student/forms/StudentFormsSelector.vue | 269 +++++++++++ 44 files changed, 2460 insertions(+), 36 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts create mode 100644 sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/constants.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action-processor.processActions.spec.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.getActionRequests.spec.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.hasApprovedAction.spec.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/test-student-appeal-action.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action-processor.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-create-assessment-action.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-update-modified-independent-action.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/index.ts create mode 100644 sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/student-appeal-assessment.service.ts create mode 100644 sources/packages/web/src/composables/useDynamicFormsConfigurations.ts create mode 100644 sources/packages/web/src/services/FormSubmissionsService.ts create mode 100644 sources/packages/web/src/services/http/FormSubmissionsApi.ts create mode 100644 sources/packages/web/src/services/http/dto/FormSubmission.dto.ts create mode 100644 sources/packages/web/src/types/contracts/FormSubmissionContracts.ts create mode 100644 sources/packages/web/src/views/student/forms/StudentFormsSelector copy.vue.new create mode 100644 sources/packages/web/src/views/student/forms/StudentFormsSelector.vue 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 626c68e52b..42ac4370b3 100644 --- a/sources/packages/backend/apps/api/src/app.students.module.ts +++ b/sources/packages/backend/apps/api/src/app.students.module.ts @@ -30,6 +30,7 @@ import { AnnouncementService, ApplicationRestrictionBypassService, InstitutionRestrictionService, + FormSubmissionService, } from "./services"; import { ApplicationStudentsController, @@ -80,6 +81,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: [ @@ -105,6 +107,7 @@ import { ObjectStorageModule } from "@sims/integrations/object-storage"; ScholasticStandingStudentsController, AnnouncementStudentsController, SupportingUserStudentsController, + FormSubmissionStudentsController, ], providers: [ AnnouncementService, @@ -160,6 +163,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/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..89673bc705 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,37 @@ 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 { + formDefinitionName: dynamicForm.formDefinitionName, + formType: dynamicForm.formType, + formCategory: dynamicForm.formCategory, + formSubmissionGrouping: dynamicForm.formSubmissionGrouping, + }; + } + + @Get("student-forms") + async getDynamicFormConfigurationsByCategory(): Promise { + const formsConfigurations = + this.dynamicFormConfigurationService.getDynamicStudentForms(); + return { + configurations: formsConfigurations.map((configuration) => ({ + formDefinitionName: configuration.formDefinitionName, + formType: configuration.formType, + formCategory: configuration.formCategory, + formSubmissionGrouping: configuration.formSubmissionGrouping, + })), + }; } } 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..88a4473b52 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,31 @@ -import { OfferingIntensity } from "@sims/sims-db"; +import { + FormCategory, + FormSubmissionGrouping, + OfferingIntensity, +} from "@sims/sims-db"; import { IsEnum, IsOptional, IsPositive } from "class-validator"; export class DynamicFormConfigurationAPIOutDTO { formDefinitionName: string; + formType: string; + formCategory: FormCategory; + formSubmissionGrouping?: FormSubmissionGrouping; +} + +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.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..5743f659e4 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/form-submission.students.controller.ts @@ -0,0 +1,280 @@ +import { + Controller, + Post, + Body, + NotFoundException, + UnprocessableEntityException, + BadRequestException, + Get, +} from "@nestjs/common"; +import { + ApplicationService, + DynamicFormConfigurationService, + FormService, + FormSubmissionModel, + FormSubmissionService, + StudentAppealService, +} from "../../services"; +import { + FormSubmissionAPIInDTO, + FormSubmissionAPIOutDTO, + FormSubmissionsAPIOutDTO, +} 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 } 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, + private readonly dynamicFormConfigurationService: DynamicFormConfigurationService, + ) { + 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, + submissionItems: submission.formSubmissionItems.map((item) => ({ + formType: item.dynamicFormConfiguration.formType, + status: item.decisionStatus, + })), + applicationId: submission.application?.id, + applicationNumber: submission.application?.applicationNumber, + assessedDate: submission.assessedDate, + submittedDate: submission.submittedDate, + }; + }, + ); + return { submissions }; + } + + /** + * 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 submitApplicationAppeal( + @Body() payload: FormSubmissionAPIInDTO, + @UserToken() userToken: StudentUserToken, + ): Promise { + 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, + ), + ); + } + } + // Validate that all the forms in the submission are + // recognized and share the same grouping context. + for (const submissionItem of payload.items) { + if ( + !this.dynamicFormConfigurationService.configurationExists( + submissionItem.dynamicConfigurationId, + ) + ) { + throw new UnprocessableEntityException( + "One or more forms in the submission are not recognized.", + "UNKNOWN_FORM_CONFIGURATION", + ); + } + const hasSameGroupContext = + this.dynamicFormConfigurationService.hasGroupContext( + submissionItem.dynamicConfigurationId, + payload.formCategory, + payload.submissionGrouping, + ); + if (!hasSameGroupContext) { + throw new UnprocessableEntityException( + "All forms in the submission must share the same grouping context.", + "MISMATCHED_FORM_GROUPING_CONTEXT", + ); + } + } + + // const submittedFormNames = payload.studentAppealRequests.map( + // (request) => request.formName, + // ); + + // 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. + // this.studentAppealControllerService.validateAppealFormNames( + // submittedFormNames, + // eligibleApplication.currentAssessment.eligibleApplicationAppeals, + // ); + + const existingFormSubmission = + await this.formSubmissionService.hasPendingFormSubmission( + userToken.studentId, + payload.applicationId, + payload.formCategory, + payload.submissionGrouping, + ); + if (existingFormSubmission) { + throw new UnprocessableEntityException( + new ApiProcessError( + "There is already a form submission pending a decision for the same context.", + "FORM_SUBMISSION_PENDING_DECISION", + ), + ); + } + let dryRunSubmissionResults: DryRunSubmissionResult[] = []; + try { + const dryRunPromise: Promise[] = + payload.items.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; + } + const formConfiguration = + this.dynamicFormConfigurationService.getDynamicFormById( + submissionItem.dynamicConfigurationId, + ); + return this.formService.dryRunSubmission( + formConfiguration.formDefinitionName, + submissionItem.formData, + ); + }); + 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( + (itemSubmissionResult) => + ({ + dynamicConfigurationId: payload.items.find( + (item) => + this.dynamicFormConfigurationService.getDynamicFormById( + item.dynamicConfigurationId, + ).formType === itemSubmissionResult.formName, + ).dynamicConfigurationId, + formData: itemSubmissionResult.data.data, + files: payload.items.find( + (item) => + this.dynamicFormConfigurationService.getDynamicFormById( + item.dynamicConfigurationId, + ).formType === itemSubmissionResult.formName, + ).files, + }) as FormSubmissionModel, + ); + + const studentAppeal = await this.formSubmissionService.saveFormSubmission( + userToken.studentId, + payload.applicationId, + payload.formCategory, + payload.submissionGrouping, + 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..e3dd0d2b66 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/form-submission/models/form-submission.dto.ts @@ -0,0 +1,72 @@ +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + ArrayMinSize, + IsDefined, + IsEnum, + IsIn, + IsPositive, + ValidateIf, + ValidateNested, +} from "class-validator"; +import { JsonMaxSize } from "../../../utilities/class-validation"; +import { JSON_10KB } from "../../../constants"; +import { Parent } from "../../../types"; +import { + FormSubmissionGrouping, + FormSubmissionStatus, +} from "@sims/sims-db/entities"; +import { FormCategory } from "@sims/sims-db"; +import { FormSubmissionDecisionStatus } from "@sims/sims-db/entities/form-submission-decision-status.type"; + +class FormSubmissionItemAPIOutDTO { + formType: string; + status: FormSubmissionDecisionStatus; +} + +export class FormSubmissionAPIOutDTO { + id: number; + formCategory: FormCategory; + status: FormSubmissionStatus; + applicationId?: number; + applicationNumber?: string; + submittedDate: Date; + assessedDate?: Date; + submissionItems: FormSubmissionItemAPIOutDTO[]; +} + +export class FormSubmissionsAPIOutDTO { + submissions: FormSubmissionAPIOutDTO[]; +} + +export class FormSubmissionItemAPIInDTO { + @IsPositive() + dynamicConfigurationId: number; + @IsDefined() + @JsonMaxSize(JSON_10KB) + formData: { + programYear?: string; + parents?: Parent[]; + } & Record; + @IsDefined() + files: string[]; +} + +export class FormSubmissionAPIInDTO { + @IsIn([FormCategory.StudentAppeal, FormCategory.StudentForm]) + formCategory: FormCategory; + @IsEnum(FormSubmissionGrouping) + submissionGrouping: FormSubmissionGrouping; + @ValidateIf( + (submission) => + submission.submissionGrouping === + FormSubmissionGrouping.ApplicationBundle, + ) + @IsPositive() + applicationId?: number; + @ArrayMinSize(1) + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => FormSubmissionItemAPIInDTO) + items: FormSubmissionItemAPIInDTO[]; +} 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 2166edd591..e4f63cebf1 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/index.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/index.ts @@ -103,3 +103,5 @@ export * from "./application-change-request/application-change-request.aest.cont export * from "./supporting-user/supporting-user.students.controller"; export * from "./disbursement-schedule/disbursement-schedule.aest.controller"; export * from "./disbursement-schedule/models/disbursement-schedule.dto"; +export * from "./form-submission/models/form-submission.dto"; +export * from "./form-submission/form-submission.students.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..f6e7872d61 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,17 @@ import { InjectRepository } from "@nestjs/typeorm"; import { DynamicFormConfiguration, DynamicFormType, + FormCategory, + FormSubmissionGrouping, 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 +37,8 @@ export class DynamicFormConfigurationService { programYear: { id: true }, offeringIntensity: true, formDefinitionName: true, + formCategory: true, + formSubmissionGrouping: true, }, relations: { programYear: true, @@ -38,25 +47,70 @@ 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), + ); + } + + configurationExists(configurationId: number): boolean { + return !!this.getDynamicFormById(configurationId); + } + + hasGroupContext( + configurationId: number, + formCategory: FormCategory, + formSubmissionGrouping: FormSubmissionGrouping, + ): boolean { + const configuration = this.getDynamicFormById(configurationId); + + return ( + configuration.formCategory === formCategory && + configuration.formSubmissionGrouping === formSubmissionGrouping + ); } } 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..f834a94da5 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.models.ts @@ -0,0 +1,8 @@ +/** + * Service model for student appeal. + */ +export interface FormSubmissionModel { + dynamicConfigurationId: number; + formData: unknown; + files: string[]; +} 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..4d18159cb7 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/form-submission.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, IsNull, Repository } from "typeorm"; +import { + Application, + User, + FileOriginType, + Student, + FormSubmission, + FormCategory, + FormSubmissionGrouping, + FormSubmissionStatus, + FormSubmissionItem, + DynamicFormConfiguration, +} from "@sims/sims-db"; +import { StudentFileService } from "../student-file/student-file.service"; +import { InjectRepository } from "@nestjs/typeorm"; +import { FormSubmissionModel } from "apps/api/src/services/form-submission/form-submission.models"; +import { FormSubmissionDecisionStatus } from "@sims/sims-db/entities/form-submission-decision-status.type"; + +/** + * Service layer for Student appeals. + */ +@Injectable() +export class FormSubmissionService { + constructor( + private readonly dataSource: DataSource, + private readonly studentFileService: StudentFileService, + @InjectRepository(FormSubmission) + private readonly formSubmissionRepo: Repository, + ) {} + + async saveFormSubmission( + studentId: number, + applicationId: number | undefined, + formCategory: FormCategory, + submissionGrouping: FormSubmissionGrouping, + 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.submissionGrouping = submissionGrouping; + 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, + submissionGrouping: FormSubmissionGrouping, + ): Promise { + return this.formSubmissionRepo.exists({ + where: { + student: { id: studentId }, + application: applicationId ? { id: applicationId } : IsNull(), + formCategory: formCategory, + submissionGrouping: submissionGrouping, + 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, + }, + application: { id: true, applicationNumber: true }, + }, + relations: { + formSubmissionItems: { dynamicFormConfiguration: true }, + application: true, + }, + where: [{ student: { id: studentId } }], + order: { submittedDate: "DESC", formSubmissionItems: { id: "ASC" } }, + }); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action-processor.processActions.spec.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action-processor.processActions.spec.ts new file mode 100644 index 0000000000..b5ec9c4393 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action-processor.processActions.spec.ts @@ -0,0 +1,158 @@ +import { Mocked, TestBed } from "@suites/unit"; +import { + StudentAppealActionsProcessor, + StudentAppealCreateAssessmentAction, + StudentAppealUpdateModifiedIndependentAction, +} from "../../.."; +import { + StudentAppeal, + StudentAppealActionType, + StudentAppealRequest, +} from "@sims/sims-db"; +import { EntityManager } from "typeorm"; + +describe("StudentAppealActionsProcessor-processActions", () => { + let studentAppealActionsProcessor: StudentAppealActionsProcessor; + let createAssessmentAction: Mocked; + let updateModifiedIndependentAction: Mocked; + + beforeAll(async () => { + const { unit, unitRef } = await TestBed.solitary( + StudentAppealActionsProcessor, + ).compile(); + studentAppealActionsProcessor = unit; + createAssessmentAction = unitRef.get(StudentAppealCreateAssessmentAction); + updateModifiedIndependentAction = unitRef.get( + StudentAppealUpdateModifiedIndependentAction, + ); + // Allow setting the action types directly to avoid spying on getters + // that would not work as expected for protected properties. + // Required to match the types defined by the DEFAULT_ACTION_TYPE. + (createAssessmentAction as object)["actionType"] = + StudentAppealActionType.CreateStudentAppealAssessment; + (updateModifiedIndependentAction as object)["actionType"] = + StudentAppealActionType.UpdateModifiedIndependent; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("Should execute multiple actions and pass the parameters as expected when multiple requests with different actions are part of the same appeal.", async () => { + // Arrange + const auditUserId = 123; + const auditDate = new Date(); + const entityManager = {} as EntityManager; + const studentAppeal = new StudentAppeal(); + studentAppeal.appealRequests = [ + { + submittedData: { + actions: [createAssessmentAction.actionType], + }, + } as StudentAppealRequest, + { + submittedData: { + actions: [updateModifiedIndependentAction.actionType], + }, + } as StudentAppealRequest, + ]; + // Act + await studentAppealActionsProcessor.processActions( + studentAppeal, + auditUserId, + auditDate, + entityManager, + ); + + // Assert + expect(createAssessmentAction.process).toHaveBeenCalledTimes(1); + expect(createAssessmentAction.process).toHaveBeenCalledWith( + studentAppeal, + auditUserId, + auditDate, + entityManager, + ); + expect(updateModifiedIndependentAction.process).toHaveBeenCalledTimes(1); + expect(updateModifiedIndependentAction.process).toHaveBeenCalledWith( + studentAppeal, + auditUserId, + auditDate, + entityManager, + ); + }); + + it("Should execute actions uniquely when multiple requests with the same actions are part of the same appeal.", async () => { + // Arrange + const studentAppeal = new StudentAppeal(); + studentAppeal.appealRequests = [ + { + submittedData: { + actions: [createAssessmentAction.actionType], + }, + } as StudentAppealRequest, + { + submittedData: { + actions: [createAssessmentAction.actionType], + }, + } as StudentAppealRequest, + ]; + + // Act + await studentAppealActionsProcessor.processActions( + studentAppeal, + 1, + new Date(), + null, + ); + + // Assert + expect(createAssessmentAction.process).toHaveBeenCalledTimes(1); + expect(updateModifiedIndependentAction.process).toHaveBeenCalledTimes(0); + }); + + it("Should throw an error when the appeal request has an unknown action.", async () => { + // Arrange + const studentAppeal = new StudentAppeal(); + studentAppeal.id = 456; + (studentAppeal as object)["appealRequests"] = [ + { + submittedData: { actions: ["UnknownActionType"] }, + }, + ]; + + // Act/Assert + await expect( + studentAppealActionsProcessor.processActions( + studentAppeal, + 1, + new Date(), + null, + ), + ).rejects.toThrow( + `One or more action types associated with the student appeal ID 456 are not recognized: UnknownActionType.`, + ); + }); + + it("Should execute the default action when an appeal request has no actions defined.", async () => { + // Arrange + const studentAppeal = new StudentAppeal(); + studentAppeal.appealRequests = [ + { + // No actions property provided, should fall back to DEFAULT_ACTION_TYPE. + submittedData: {}, + } as StudentAppealRequest, + ]; + + // Act + await studentAppealActionsProcessor.processActions( + studentAppeal, + 123, + new Date(), + null, + ); + + // Assert + expect(createAssessmentAction.process).toHaveBeenCalledTimes(1); + expect(updateModifiedIndependentAction.process).toHaveBeenCalledTimes(0); + }); +}); diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.getActionRequests.spec.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.getActionRequests.spec.ts new file mode 100644 index 0000000000..709f25c20f --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.getActionRequests.spec.ts @@ -0,0 +1,72 @@ +import { + StudentAppeal, + StudentAppealRequest, + StudentAppealActionType, +} from "@sims/sims-db"; +import { TestStudentAppealAction } from "./test-student-appeal-action"; + +describe("StudentAppealAction-getActionRequests", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("Should return only the requests that include the matching action type.", () => { + // Arrange + const action = new TestStudentAppealAction( + StudentAppealActionType.UpdateModifiedIndependent, + ); + const studentAppeal = new StudentAppeal(); + const requestWithMatch = { + submittedData: { + actions: [StudentAppealActionType.UpdateModifiedIndependent], + }, + } as StudentAppealRequest; + const requestWithoutMatch = { + submittedData: { + actions: [StudentAppealActionType.CreateStudentAppealAssessment], + }, + } as StudentAppealRequest; + studentAppeal.appealRequests = [requestWithMatch, requestWithoutMatch]; + + // Act + const result = action.exposedGetActionRequests(studentAppeal); + + // Assert + expect(result).toEqual([requestWithMatch]); + }); + + it("Should include requests without actions only when the action type is the DEFAULT one (CreateStudentAppealAssessment).", () => { + // Arrange + const defaultAction = new TestStudentAppealAction( + StudentAppealActionType.CreateStudentAppealAssessment, + ); + const nonDefaultAction = new TestStudentAppealAction( + StudentAppealActionType.UpdateModifiedIndependent, + ); + const studentAppeal = new StudentAppeal(); + const requestWithoutActions = { + submittedData: {}, + } as StudentAppealRequest; + const requestWithDifferentAction = { + submittedData: { + actions: [StudentAppealActionType.UpdateModifiedIndependent], + }, + } as StudentAppealRequest; + studentAppeal.appealRequests = [ + requestWithoutActions, + requestWithDifferentAction, + ]; + + // Act + const resultForDefault = + defaultAction.exposedGetActionRequests(studentAppeal); + const resultForNonDefault = + nonDefaultAction.exposedGetActionRequests(studentAppeal); + + // Assert + // Default action should include the request without actions. + expect(resultForDefault).toEqual([requestWithoutActions]); + // Non-default action should NOT include the request without actions. + expect(resultForNonDefault).toEqual([requestWithDifferentAction]); + }); +}); diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.hasApprovedAction.spec.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.hasApprovedAction.spec.ts new file mode 100644 index 0000000000..12a6e0ae1b --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/student-appeal-action.hasApprovedAction.spec.ts @@ -0,0 +1,91 @@ +import { + StudentAppeal, + StudentAppealRequest, + StudentAppealStatus, + StudentAppealActionType, +} from "@sims/sims-db"; +import { TestStudentAppealAction } from "./test-student-appeal-action"; + +describe("StudentAppealAction-hasApprovedAction", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("Should throw an error when there are no associated requests for the action.", () => { + // Arrange + const action = new TestStudentAppealAction( + StudentAppealActionType.UpdateModifiedIndependent, + ); + const studentAppeal = new StudentAppeal(); + // Provide requests, but none associated with the action under test. + studentAppeal.appealRequests = [ + { + submittedData: { + actions: [ + // Different action than the one being tested. + StudentAppealActionType.CreateStudentAppealAssessment, + ], + }, + } as StudentAppealRequest, + { + // No actions, defaults to CreateStudentAppealAssessment (not the tested action). + submittedData: {}, + } as StudentAppealRequest, + ]; + + // Act/Assert + expect(() => action.exposedHasApprovedAction(studentAppeal)).toThrow( + `Status cannot be determined without associated appeals requests for the action ${StudentAppealActionType.UpdateModifiedIndependent}.`, + ); + }); + + it("Should return true when at least one associated request is approved.", () => { + // Arrange + const action = new TestStudentAppealAction( + StudentAppealActionType.UpdateModifiedIndependent, + ); + const studentAppeal = new StudentAppeal(); + studentAppeal.appealRequests = [ + { + appealStatus: StudentAppealStatus.Declined, + submittedData: { + actions: [StudentAppealActionType.UpdateModifiedIndependent], + }, + } as StudentAppealRequest, + { + appealStatus: StudentAppealStatus.Approved, + submittedData: { + actions: [StudentAppealActionType.UpdateModifiedIndependent], + }, + } as StudentAppealRequest, + ]; + + // Act + const result = action.exposedHasApprovedAction(studentAppeal); + + // Assert + expect(result).toBe(true); + }); + + it("Should return false when there are associated requests but none is approved.", () => { + // Arrange + const action = new TestStudentAppealAction( + StudentAppealActionType.UpdateModifiedIndependent, + ); + const studentAppeal = new StudentAppeal(); + studentAppeal.appealRequests = [ + { + appealStatus: StudentAppealStatus.Declined, + submittedData: { + actions: [StudentAppealActionType.UpdateModifiedIndependent], + }, + } as StudentAppealRequest, + ]; + + // Act + const result = action.exposedHasApprovedAction(studentAppeal); + + // Assert + expect(result).toBe(false); + }); +}); diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/test-student-appeal-action.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/test-student-appeal-action.ts new file mode 100644 index 0000000000..9f8537b306 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/_tests_/unit/test-student-appeal-action.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + StudentAppeal, + StudentAppealActionType, + StudentAppealRequest, +} from "@sims/sims-db"; +import { EntityManager } from "typeorm"; +import { StudentAppealAction } from "../../student-appeal-action"; + +/** + * Concrete action used only for testing the protected methods. + */ +export class TestStudentAppealAction extends StudentAppealAction { + constructor(private readonly type: StudentAppealActionType) { + super(); + } + + get actionType(): StudentAppealActionType { + return this.type; + } + + /** + * Not used in the tests. + */ + async process( + _studentAppeal: StudentAppeal, + _auditUserId: number, + _auditDate: Date, + _entityManager: EntityManager, + ): Promise { + throw new Error("Method not implemented."); + } + + exposedHasApprovedAction(studentAppeal: StudentAppeal): boolean { + return this.hasApprovedAction(studentAppeal); + } + + exposedGetActionRequests( + studentAppeal: StudentAppeal, + ): StudentAppealRequest[] { + return this.getActionRequests(studentAppeal); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action-processor.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action-processor.ts new file mode 100644 index 0000000000..6858d93e16 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action-processor.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; +import { StudentAppeal } from "@sims/sims-db"; +import { EntityManager } from "typeorm"; +import { + DEFAULT_ACTION_TYPE, + StudentAppealAction, + StudentAppealCreateAssessmentAction, + StudentAppealUpdateModifiedIndependentAction, +} from ".."; + +/** + * Keeps a list of all available student appeal actions that can potentially + * be processed and execute them based on the appeal requests action types. + */ +@Injectable() +export class StudentAppealActionsProcessor { + private readonly actions: StudentAppealAction[]; + + constructor( + createAssessmentAction: StudentAppealCreateAssessmentAction, + updateModifiedIndependentAction: StudentAppealUpdateModifiedIndependentAction, + ) { + this.actions = [createAssessmentAction, updateModifiedIndependentAction]; + } + + /** + * Process the actions associated with a student appeal. + * Checks for the appeals requests action types and process + * the corresponding actions as required. + * @param studentAppeal the student appeal to process actions for. + * @param auditUserId the ID of the user performing the action. + * @param auditDate the date the action is being performed. + * @param entityManager entity manager to allow the query to happen within a transaction. + */ + async processActions( + studentAppeal: StudentAppeal, + auditUserId: number, + auditDate: Date, + entityManager: EntityManager, + ): Promise { + const actionTypes = studentAppeal.appealRequests.flatMap( + (request) => request.submittedData.actions ?? DEFAULT_ACTION_TYPE, + ); + // Get unique action types to avoid processing the same action multiple times. + const uniqueActionsTypes: Set = new Set(actionTypes); + // Ensure every action type is known. + const unknownActions = [...uniqueActionsTypes].filter((requestActionType) => + this.actions.every((action) => action.actionType !== requestActionType), + ); + if (unknownActions.length) { + throw new Error( + `One or more action types associated with the student appeal ID ${studentAppeal.id} are not recognized: ${unknownActions}.`, + ); + } + const actionsToProcess = this.actions.filter((action) => + uniqueActionsTypes.has(action.actionType), + ); + // Process all actions in parallel. + const actionsPromises = actionsToProcess.map((action) => + action.process(studentAppeal, auditUserId, auditDate, entityManager), + ); + await Promise.all(actionsPromises); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action.ts new file mode 100644 index 0000000000..89ffed1b57 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-action.ts @@ -0,0 +1,76 @@ +import { + StudentAppeal, + StudentAppealStatus, + StudentAppealActionType, + StudentAppealRequest, +} from "@sims/sims-db"; +import { EntityManager } from "typeorm"; + +/** + * Default action types for student appeal processing + * when none is provided in the appeal requests. + */ +export const DEFAULT_ACTION_TYPE = [ + StudentAppealActionType.CreateStudentAppealAssessment, +]; + +/** + * Actions that can be performed on student appeals during the + * assessment (approval/decline) process. + */ +export abstract class StudentAppealAction { + /** + * Process the student appeal action. + * @param studentAppeal student appeal to process. + * @param auditUserId ID of the user performing the action. + * @param auditDate date the action is being performed. + * @param entityManager entity manager to use for database operations. + */ + abstract process( + studentAppeal: StudentAppeal, + auditUserId: number, + auditDate: Date, + entityManager: EntityManager, + ): Promise; + + /** + * Type of action being performed. + */ + abstract get actionType(): StudentAppealActionType; + + /** + * Check if an action associated with an appeal is approved based on its requests statuses. + * To an action be considered approved, at least one of the appeal requests + * associated with the action must be approved. + * @param studentAppeal student appeal to check. + * @returns true if the action is approved, false otherwise. + */ + protected hasApprovedAction(studentAppeal: StudentAppeal): boolean { + const actionRequests = this.getActionRequests(studentAppeal); + if (!actionRequests.length) { + // An action should not be processed if there are no associated requests, + // which means that at least one request must be present if the action was executed. + throw new Error( + `Status cannot be determined without associated appeals requests for the action ${this.actionType}.`, + ); + } + return actionRequests.some( + (request) => request.appealStatus === StudentAppealStatus.Approved, + ); + } + + /** + * Filter only the requests that are associated with this action. + * @param studentAppeal appeal and its requests. + * @returns the requests associated with this action. + */ + protected getActionRequests( + studentAppeal: StudentAppeal, + ): StudentAppealRequest[] { + return studentAppeal.appealRequests.filter((request) => + (request.submittedData.actions ?? DEFAULT_ACTION_TYPE).includes( + this.actionType, + ), + ); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-create-assessment-action.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-create-assessment-action.ts new file mode 100644 index 0000000000..ab54c011a7 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-create-assessment-action.ts @@ -0,0 +1,67 @@ +import { + Application, + AssessmentTriggerType, + StudentAppeal, + StudentAssessment, + StudentAppealActionType, +} from "@sims/sims-db"; +import { StudentAppealAction } from "./student-appeal-action"; +import { EntityManager } from "typeorm"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class StudentAppealCreateAssessmentAction extends StudentAppealAction { + /** + * Type of action being performed. + */ + get actionType(): StudentAppealActionType { + return StudentAppealActionType.CreateStudentAppealAssessment; + } + + /** + * Create a new assessment for the student appeal. + * @param studentAppeal student appeal to process. + * @param auditUserId ID of the user performing the action. + * @param auditDate date the action is being performed. + * @param entityManager entity manager to use for database operations. + */ + async process( + studentAppeal: StudentAppeal, + auditUserId: number, + auditDate: Date, + entityManager: EntityManager, + ): Promise { + if (!this.hasApprovedAction(studentAppeal)) { + // If none of the appeal requests with this action were approved, no assessment should be created. + return; + } + if (!studentAppeal.application) { + throw new Error( + `Cannot create assessment for student appeal ID ${studentAppeal.id} because it is not linked to an application.`, + ); + } + // Create the new assessment to be processed. + const auditUser = { id: auditUserId }; + const newAssessment = { + application: { id: studentAppeal.application.id } as Application, + offering: { + id: studentAppeal.application.currentAssessment.offering.id, + }, + triggerType: AssessmentTriggerType.StudentAppeal, + studentAppeal: { id: studentAppeal.id }, + creator: auditUser, + createdAt: auditDate, + submittedBy: auditUser, + submittedDate: auditDate, + } as StudentAssessment; + await entityManager.getRepository(StudentAssessment).insert(newAssessment); + await entityManager.getRepository(Application).update( + { id: studentAppeal.application.id }, + { + currentAssessment: { id: newAssessment.id }, + modifier: auditUser, + updatedAt: auditDate, + }, + ); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-update-modified-independent-action.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-update-modified-independent-action.ts new file mode 100644 index 0000000000..17acbdf139 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/actions/student-appeal-update-modified-independent-action.ts @@ -0,0 +1,58 @@ +import { + ModifiedIndependentStatus, + Student, + StudentAppeal, + StudentAppealActionType, + StudentAppealStatus, +} from "@sims/sims-db"; +import { StudentAppealAction } from "./student-appeal-action"; +import { EntityManager } from "typeorm"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class StudentAppealUpdateModifiedIndependentAction extends StudentAppealAction { + /** + * Type of action being performed. + */ + get actionType(): StudentAppealActionType { + return StudentAppealActionType.UpdateModifiedIndependent; + } + + /** + * Updates the student's modified independent status based on the appeal's approval status. + * @param studentAppeal student appeal to process. + * @param auditUserId ID of the user performing the action. + * @param auditDate date the action is being performed. + * @param entityManager entity manager to use for database operations. + */ + async process( + studentAppeal: StudentAppeal, + auditUserId: number, + auditDate: Date, + entityManager: EntityManager, + ): Promise { + const appealRequests = this.getActionRequests(studentAppeal); + if (appealRequests.length !== 1) { + throw new Error( + `Expected 1 appeal request for action ${this.actionType}, but found ${appealRequests.length}.`, + ); + } + const [appealRequest] = appealRequests; + const modifiedIndependentStatus = + appealRequest.appealStatus === StudentAppealStatus.Approved + ? ModifiedIndependentStatus.Approved + : ModifiedIndependentStatus.Declined; + const auditUser = { id: auditUserId }; + await entityManager.getRepository(Student).update( + { id: studentAppeal.student.id }, + { + modifiedIndependentStatus, + modifiedIndependentAppealRequest: { id: appealRequest.id }, + modifiedIndependentStatusUpdatedBy: auditUser, + modifiedIndependentStatusUpdatedOn: auditDate, + modifier: auditUser, + updatedAt: auditDate, + }, + ); + } +} diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/index.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/index.ts new file mode 100644 index 0000000000..1b75c3961e --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/index.ts @@ -0,0 +1,5 @@ +export * from "./actions/student-appeal-action"; +export * from "./actions/student-appeal-create-assessment-action"; +export * from "./actions/student-appeal-update-modified-independent-action"; +export * from "./actions/student-appeal-action-processor"; +export * from "./student-appeal-assessment.service"; diff --git a/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/student-appeal-assessment.service.ts b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/student-appeal-assessment.service.ts new file mode 100644 index 0000000000..01d481d40c --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/form-submission/student-form-assessment/student-appeal-assessment.service.ts @@ -0,0 +1,201 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, EntityManager } from "typeorm"; +import { + ApplicationStatus, + NoteType, + StudentAppeal, + StudentAppealStatus, + User, +} from "@sims/sims-db"; +import { StudentAppealRequestApproval } from "../form-submission.models"; +import { StudentAppealRequestsService } from "../../student-appeal-request/student-appeal-request.service"; +import { CustomNamedError } from "@sims/utilities"; +import { + STUDENT_APPEAL_INVALID_OPERATION, + STUDENT_APPEAL_NOT_FOUND, +} from "../constants"; +import { NotificationActionsService } from "@sims/services/notifications"; +import { NoteSharedService } from "@sims/services"; +import { StudentAppealActionsProcessor } from "."; + +/** + * Service layer for Student appeals. + */ +@Injectable() +export class StudentAppealAssessmentService { + constructor( + private readonly dataSource: DataSource, + private readonly studentAppealRequestsService: StudentAppealRequestsService, + private readonly notificationActionsService: NotificationActionsService, + private readonly noteSharedService: NoteSharedService, + private readonly studentAppealActionsProcessor: StudentAppealActionsProcessor, + ) {} + + /** + * Update all student appeals requests at once. + * @param appealId appeal ID to be retrieved. + * @param approvals all appeal requests that must be updated with + * an approved/declined status. All requests that belongs to the + * appeal must be provided. + * @param auditUserId user that should be considered the one that is + * causing the changes. + */ + async assessRequests( + appealId: number, + approvals: StudentAppealRequestApproval[], + auditUserId: number, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + const appealToUpdate = await this.getAppealForAssessment( + appealId, + approvals, + entityManager, + ); + const auditUser = { id: auditUserId } as User; + const auditDate = new Date(); + const isStudentOnlyAppeal = appealToUpdate.application === null; + const noteType = isStudentOnlyAppeal + ? NoteType.General + : NoteType.Application; + for (const approval of approvals) { + // Create the new note. + const note = await this.noteSharedService.createStudentNote( + appealToUpdate.student.id, + noteType, + approval.noteDescription, + auditUserId, + entityManager, + ); + // The method getAppealForAssessment already ensures that the + // appeal requests to be updated belongs to the appeal and all + // requests are present, hence no further validation is required here. + const requestToUpdate = appealToUpdate.appealRequests.find( + (request) => request.id === approval.id, + ); + // Update the appeal request status to allow any further request property + // returned by getAppealForAssessment to be used in the actions processing. + requestToUpdate.appealStatus = approval.appealStatus; + requestToUpdate.note = note; + requestToUpdate.modifier = auditUser; + requestToUpdate.updatedAt = auditDate; + requestToUpdate.assessedBy = auditUser; + requestToUpdate.assessedDate = auditDate; + } + // Save appeals and its requests. + const updatedStudentAppeal = await entityManager + .getRepository(StudentAppeal) + .save(appealToUpdate); + // Process any actions associated with the appeal assessment request. + await this.studentAppealActionsProcessor.processActions( + updatedStudentAppeal, + auditUserId, + auditDate, + entityManager, + ); + // Create student notification when ministry completes student appeal. + const studentUser = appealToUpdate.student.user; + await this.notificationActionsService.saveChangeRequestCompleteNotification( + { + givenNames: studentUser.firstName, + lastName: studentUser.lastName, + toAddress: studentUser.email, + userId: studentUser.id, + }, + auditUserId, + entityManager, + ); + }); + } + + /** + * Get the student appeal information required to process their approval or decline. + * @param appealId appeal ID to be retrieved. + * @param approvals all appeal requests that must be updated with + * an approved/declined status. All requests that belongs to the + * appeal must be provided. + * @param entityManager entity manager to allow the query to happen within a transaction. + * @returns the student appeal to be assessed. + */ + private async getAppealForAssessment( + appealId: number, + approvals: StudentAppealRequestApproval[], + entityManager: EntityManager, + ): Promise { + const appealRequestsIDs = approvals.map((approval) => approval.id); + const appealToUpdate = await entityManager + .getRepository(StudentAppeal) + .createQueryBuilder("studentAppeal") + .select([ + "studentAppeal.id", + "studentAssessment.id", + "currentAssessment.id", + "offering.id", + "appealRequest.id", + "appealRequest.submittedData", + "application.id", + "application.applicationStatus", + "student.id", + "user.id", + "user.firstName", + "user.lastName", + "user.email", + ]) + .innerJoin("studentAppeal.appealRequests", "appealRequest") + .innerJoin("studentAppeal.student", "student") + .innerJoin("student.user", "user") + .leftJoin("studentAppeal.application", "application") + .leftJoin("application.currentAssessment", "currentAssessment") + .leftJoin("currentAssessment.offering", "offering") + .leftJoin("studentAppeal.studentAssessment", "studentAssessment") + .where("studentAppeal.id = :appealId", { appealId }) + // Ensures that the provided appeal requests IDs belongs to the appeal. + .andWhere("appealRequest.id IN (:...requestIDs)", { + requestIDs: appealRequestsIDs, + }) + // Ensures that all appeal requests are on pending status. + .andWhere( + `NOT EXISTS(${this.studentAppealRequestsService + .appealsByStatusQueryObject(StudentAppealStatus.Pending, false) + .getSql()})`, + ) + .getOne(); + + if (!appealToUpdate) { + throw new CustomNamedError( + `Not able to find the appeal or the appeal has requests different from '${StudentAppealStatus.Pending}'.`, + STUDENT_APPEAL_NOT_FOUND, + ); + } + + // If there is an application associated with the appeal, validate its status. + if ( + appealToUpdate.application && + appealToUpdate.application.applicationStatus !== + ApplicationStatus.Completed + ) { + throw new CustomNamedError( + `The application associated with the appeal is expected to be in '${ApplicationStatus.Completed}' status.`, + STUDENT_APPEAL_INVALID_OPERATION, + ); + } + + if (appealToUpdate.studentAssessment) { + throw new CustomNamedError( + "An assessment was already created to this student appeal.", + STUDENT_APPEAL_INVALID_OPERATION, + ); + } + + // If a student's appeal has, for instance, 3 requests, all must be updated at once. + // The query already ensured that only pending requests will be selected and that + // the student appeal also has nothing different than pending requests. + if (approvals.length !== appealToUpdate.appealRequests.length) { + throw new CustomNamedError( + "The appeal requests must be updated all at once. The appeals requests received does not represents the entire set of records that must be updated.", + STUDENT_APPEAL_INVALID_OPERATION, + ); + } + + return appealToUpdate; + } +} diff --git a/sources/packages/backend/apps/api/src/services/index.ts b/sources/packages/backend/apps/api/src/services/index.ts index c37ee84d71..04385c17ca 100644 --- a/sources/packages/backend/apps/api/src/services/index.ts +++ b/sources/packages/backend/apps/api/src/services/index.ts @@ -61,3 +61,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/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 index 270943bc9f..9fc595ca3f 100644 --- 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 @@ -13,13 +13,9 @@ CREATE TYPE sims.form_submission_grouping_types AS ENUM( COMMENT ON TYPE sims.form_submission_grouping_types IS 'Defines how forms can be grouped when submitted. An application bundle groups multiple forms together as part of a single application process. A student standalone refers to forms that are submitted independently and are associated directly with a student, rather than an application.'; -CREATE TYPE sims.form_submission_status AS ENUM( - 'Pending', - 'Completed', - 'Declined' -); +CREATE TYPE sims.form_submission_status AS ENUM('Pending', 'Completed'); -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.'; +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 status is marked as Completed.'; CREATE TYPE sims.form_submission_decision_status AS ENUM( 'Pending', 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 6a3eac314c..99c361dcef 100644 --- a/sources/packages/backend/libs/sims-db/src/data-source.ts +++ b/sources/packages/backend/libs/sims-db/src/data-source.ts @@ -67,6 +67,8 @@ import { SFASApplicationDependant, SFASApplicationDisbursement, DynamicFormConfiguration, + FormSubmission, + FormSubmissionItem, } from "./entities"; import { ClusterNode, ClusterOptions, RedisOptions } from "ioredis"; import { @@ -232,4 +234,6 @@ export const DBEntities = [ BetaUsersAuthorizations, SFASBridgeLog, DynamicFormConfiguration, + 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 6357487e1a..25fe962d2e 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 @@ -88,5 +88,5 @@ export class DynamicFormConfiguration extends BaseModel { enum: FormSubmissionGrouping, enumName: "FormSubmissionGrouping", }) - formSubmissionGroupingType: FormSubmissionGrouping; + formSubmissionGrouping: FormSubmissionGrouping; } 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 index 95a4f34761..ee2ef91617 100644 --- 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 @@ -14,10 +14,4 @@ export enum FormSubmissionStatus { * process is completed. */ Completed = "Completed", - /** - * None of the forms within the submission were approved. - * The submission process is completed, but this entire - * submission was declined and can have its actions ignored. - */ - 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 index 41e66de8e7..193bb18830 100644 --- 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 @@ -3,6 +3,7 @@ import { Entity, JoinColumn, ManyToOne, + OneToMany, OneToOne, PrimaryGeneratedColumn, } from "typeorm"; @@ -10,6 +11,7 @@ import { Application, FormCategory, FormSubmissionGrouping, + FormSubmissionItem, FormSubmissionStatus, Note, Student, @@ -122,4 +124,15 @@ export class FormSubmission extends RecordDataModel { referencedColumnName: ColumnNames.ID, }) assessedNote?: Note; + /** + * 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 02048715b3..caaa764728 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/index.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/index.ts @@ -118,3 +118,4 @@ export * from "./form-submission-grouping.type"; 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/students/StudentAppealSharedForm.vue b/sources/packages/web/src/components/students/StudentAppealSharedForm.vue index f95ef95597..d97a66c8b1 100644 --- a/sources/packages/web/src/components/students/StudentAppealSharedForm.vue +++ b/sources/packages/web/src/components/students/StudentAppealSharedForm.vue @@ -11,7 +11,7 @@ >
- Appeal Submission + Submission
- Appeal History + History
diff --git a/sources/packages/web/src/composables/useDynamicFormsConfigurations.ts b/sources/packages/web/src/composables/useDynamicFormsConfigurations.ts new file mode 100644 index 0000000000..b70c034a58 --- /dev/null +++ b/sources/packages/web/src/composables/useDynamicFormsConfigurations.ts @@ -0,0 +1,97 @@ +import { + ApplicationDetailHeader, + ApplicationEditStatus, + ApplicationStatus, + OfferingIntensity, + StatusChipTypes, +} from "@/types"; +import { useFormatters } from "@/composables/useFormatters"; + +export function useApplication() { + const mapApplicationChipStatus = ( + status: ApplicationStatus, + ): StatusChipTypes => { + switch (status) { + case ApplicationStatus.Completed: + return StatusChipTypes.Success; + case ApplicationStatus.InProgress: + case ApplicationStatus.Assessment: + case ApplicationStatus.Enrolment: + return StatusChipTypes.Warning; + case ApplicationStatus.Cancelled: + return StatusChipTypes.Error; + case ApplicationStatus.Draft: + case ApplicationStatus.Submitted: + return StatusChipTypes.Default; + default: + return StatusChipTypes.Inactive; + } + }; + + const mapApplicationDetailHeader = ( + application: Required, + ): Record => { + const { dateOnlyLongString } = useFormatters(); + let studyDates; + if (application.applicationStartDate && application.applicationEndDate) { + studyDates = `${dateOnlyLongString( + application.applicationStartDate, + )} - ${dateOnlyLongString(application.applicationEndDate)}`; + } else if ( + application.data.studystartDate && + application.data.studyendDate + ) { + studyDates = `${dateOnlyLongString( + application.data.studystartDate, + )} - ${dateOnlyLongString(application.data.studyendDate)}`; + } + + return { + "Application #": application.applicationNumber ?? "-", + Institution: application.applicationInstitutionName ?? "-", + "Study dates": studyDates ?? "-", + Type: application.applicationOfferingIntensity, + }; + }; + + /** + * Application edit status targeting students. + * @param editStatus application edit status. + * @returns student friendly edit status. + */ + const mapApplicationEditStatusForStudents = ( + editStatus: ApplicationEditStatus, + ): string => { + switch (editStatus) { + case ApplicationEditStatus.ChangeDeclined: + return "Declined"; + case ApplicationEditStatus.ChangeCancelled: + return "Cancelled"; + case ApplicationEditStatus.ChangedWithApproval: + return "Changed"; + default: + return editStatus; + } + }; + + /** + * Application edit status targeting the Ministry. + * @param editStatus application edit status. + * @returns Ministry friendly edit status. + */ + const mapApplicationEditStatusForMinistry = ( + editStatus: ApplicationEditStatus, + ): string => { + if (editStatus === ApplicationEditStatus.ChangedWithApproval) { + return "Changed"; + } + return editStatus; + }; + + return { + mapApplicationChipStatus, + mapApplicationDetailHeader, + mapApplicationEditStatusForStudents, + mapApplicationEditStatusForMinistry, + }; +} diff --git a/sources/packages/web/src/router/StudentRoutes.ts b/sources/packages/web/src/router/StudentRoutes.ts index 3ecd7ab80b..8465fbc953 100644 --- a/sources/packages/web/src/router/StudentRoutes.ts +++ b/sources/packages/web/src/router/StudentRoutes.ts @@ -23,7 +23,8 @@ import ReportParentInformation from "@/views/student/ReportParentInformation.vue import ViewScholasticStanding from "@/views/student/ViewScholasticStanding.vue"; // Student Appeal import StudentAppeal from "@/views/student/appeal/StudentAppeal.vue"; -import StudentAppealSharedFormSubmission from "@/views/student/appeal/StudentAppealSharedFormSubmission.vue"; +//import StudentAppealSharedFormSubmission from "@/views/student/appeal/StudentAppealSharedFormSubmission.vue"; +import StudentAppealSharedFormSubmission from "@/views/student/forms/StudentFormsSelector.vue"; import StudentAppealSharedFormHistory from "@/views/student/appeal/StudentAppealSharedFormHistory.vue"; import StudentAppealSubmit from "@/views/student/appeal/StudentAppealSubmit.vue"; diff --git a/sources/packages/web/src/services/DynamicFormConfigurationService.ts b/sources/packages/web/src/services/DynamicFormConfigurationService.ts index ad7b0292e1..a56c949b2a 100644 --- a/sources/packages/web/src/services/DynamicFormConfigurationService.ts +++ b/sources/packages/web/src/services/DynamicFormConfigurationService.ts @@ -1,5 +1,8 @@ import ApiClient from "@/services/http/ApiClient"; -import { DynamicFormConfigurationAPIOutDTO } from "@/services/http/dto"; +import { + DynamicFormConfigurationAPIOutDTO, + DynamicFormConfigurationsAPIOutDTO, +} from "@/services/http/dto"; import { DynamicFormType, OfferingIntensity } from "@/types"; export class DynamicFormConfigurationService { @@ -27,4 +30,8 @@ export class DynamicFormConfigurationService { options, ); } + + async getDynamicFormConfigurationsByCategory(): Promise { + return ApiClient.DynamicFormConfigurationApi.getDynamicFormConfigurationsByCategory(); + } } diff --git a/sources/packages/web/src/services/FormSubmissionsService.ts b/sources/packages/web/src/services/FormSubmissionsService.ts new file mode 100644 index 0000000000..b197405289 --- /dev/null +++ b/sources/packages/web/src/services/FormSubmissionsService.ts @@ -0,0 +1,24 @@ +import ApiClient from "@/services/http/ApiClient"; +import { + FormSubmissionAPIInDTO, + FormSubmissionsAPIOutDTO, +} from "@/services/http/dto/FormSubmission.dto"; + +export class FormSubmissionsService { + // Share Instance + private static instance: FormSubmissionsService; + + static get shared(): FormSubmissionsService { + return this.instance || (this.instance = new this()); + } + + async getFormSubmissionSummary(): Promise { + return ApiClient.FormSubmissions.getFormSubmissionSummary(); + } + + async submitApplicationAppeal( + payload: FormSubmissionAPIInDTO, + ): Promise { + await ApiClient.FormSubmissions.submitApplicationAppeal(payload); + } +} diff --git a/sources/packages/web/src/services/http/ApiClient.ts b/sources/packages/web/src/services/http/ApiClient.ts index 13af48430e..894817d579 100644 --- a/sources/packages/web/src/services/http/ApiClient.ts +++ b/sources/packages/web/src/services/http/ApiClient.ts @@ -34,6 +34,7 @@ import { CASInvoiceApi } from "@/services/http/CASInvoiceApi"; import { DynamicFormConfigurationApi } from "@/services/http/DynamicFormConfigurationApi"; import { ApplicationChangeRequestApi } from "@/services/http/ApplicationChangeRequestApi"; import { DisbursementScheduleApi } from "@/services/http/DisbursementScheduleApi"; +import { FormSubmissionsApi } from "@/services/http/FormSubmissionsApi"; const ApiClient = { AuditApi: new AuditApi(), @@ -73,6 +74,7 @@ const ApiClient = { DynamicFormConfigurationApi: new DynamicFormConfigurationApi(), ApplicationChangeRequestApi: new ApplicationChangeRequestApi(), DisbursementSchedule: new DisbursementScheduleApi(), + FormSubmissions: new FormSubmissionsApi(), }; export default ApiClient; diff --git a/sources/packages/web/src/services/http/DynamicFormConfigurationApi.ts b/sources/packages/web/src/services/http/DynamicFormConfigurationApi.ts index a1ff12a4f1..6384d39b4b 100644 --- a/sources/packages/web/src/services/http/DynamicFormConfigurationApi.ts +++ b/sources/packages/web/src/services/http/DynamicFormConfigurationApi.ts @@ -1,6 +1,9 @@ import { DynamicFormType, OfferingIntensity } from "@/types"; import HttpBaseClient from "./common/HttpBaseClient"; -import { DynamicFormConfigurationAPIOutDTO } from "@/services/http/dto"; +import { + DynamicFormConfigurationAPIOutDTO, + DynamicFormConfigurationsAPIOutDTO, +} from "@/services/http/dto"; export class DynamicFormConfigurationApi extends HttpBaseClient { /** @@ -30,4 +33,9 @@ export class DynamicFormConfigurationApi extends HttpBaseClient { } return this.getCall(url); } + + async getDynamicFormConfigurationsByCategory(): Promise { + const url = `dynamic-form-configuration/student-forms`; + return this.getCall(url); + } } diff --git a/sources/packages/web/src/services/http/FormSubmissionsApi.ts b/sources/packages/web/src/services/http/FormSubmissionsApi.ts new file mode 100644 index 0000000000..b2a7150e5c --- /dev/null +++ b/sources/packages/web/src/services/http/FormSubmissionsApi.ts @@ -0,0 +1,20 @@ +import HttpBaseClient from "@/services/http/common/HttpBaseClient"; +import { + FormSubmissionAPIInDTO, + FormSubmissionsAPIOutDTO, +} from "@/services/http/dto/FormSubmission.dto"; + +/** + * Http API client for Form Submissions. + */ +export class FormSubmissionsApi extends HttpBaseClient { + async getFormSubmissionSummary(): Promise { + return this.getCall(this.addClientRoot("form-submission")); + } + + async submitApplicationAppeal( + payload: FormSubmissionAPIInDTO, + ): Promise { + await this.postCall(this.addClientRoot("form-submission"), payload); + } +} diff --git a/sources/packages/web/src/services/http/dto/DynamicFormConfiguration.dto.ts b/sources/packages/web/src/services/http/dto/DynamicFormConfiguration.dto.ts index b43b921ad2..eb70bfbafb 100644 --- a/sources/packages/web/src/services/http/dto/DynamicFormConfiguration.dto.ts +++ b/sources/packages/web/src/services/http/dto/DynamicFormConfiguration.dto.ts @@ -1,3 +1,12 @@ +import { FormCategory, FormSubmissionGrouping } from "@/types"; + export interface DynamicFormConfigurationAPIOutDTO { formDefinitionName: string; + formType: string; + formCategory: FormCategory; + formSubmissionGrouping?: FormSubmissionGrouping; +} + +export class DynamicFormConfigurationsAPIOutDTO { + configurations: DynamicFormConfigurationAPIOutDTO[]; } diff --git a/sources/packages/web/src/services/http/dto/FormSubmission.dto.ts b/sources/packages/web/src/services/http/dto/FormSubmission.dto.ts new file mode 100644 index 0000000000..6be2b00252 --- /dev/null +++ b/sources/packages/web/src/services/http/dto/FormSubmission.dto.ts @@ -0,0 +1,39 @@ +import { + FormCategory, + FormSubmissionDecisionStatus, + FormSubmissionGrouping, + FormSubmissionStatus, +} from "@/types"; + +interface FormSubmissionItemAPIOutDTO { + formType: string; + status: FormSubmissionDecisionStatus; +} + +export interface FormSubmissionAPIOutDTO { + id: number; + formCategory: FormCategory; + status: FormSubmissionStatus; + applicationId?: number; + applicationNumber?: string; + submittedDate: Date; + assessedDate?: Date; + submissionItems: FormSubmissionItemAPIOutDTO[]; +} + +export interface FormSubmissionsAPIOutDTO { + submissions: FormSubmissionAPIOutDTO[]; +} + +export interface FormSubmissionItemAPIInDTO { + dynamicConfigurationId: number; + formData: unknown; + files: string[]; +} + +export interface FormSubmissionAPIInDTO { + formCategory: FormCategory; + submissionGrouping: FormSubmissionGrouping; + applicationId?: number; + items: FormSubmissionItemAPIInDTO[]; +} diff --git a/sources/packages/web/src/types/contracts/FormSubmissionContracts.ts b/sources/packages/web/src/types/contracts/FormSubmissionContracts.ts new file mode 100644 index 0000000000..e92c6213f6 --- /dev/null +++ b/sources/packages/web/src/types/contracts/FormSubmissionContracts.ts @@ -0,0 +1,70 @@ +/** + * Defines how forms can be grouped when submitted. + */ +export enum FormSubmissionGrouping { + /** + * An application bundle groups multiple forms together + * as part of a single application process. + */ + ApplicationBundle = "Application bundle", + /** + * A student standalone refers to forms that are submitted + * independently and are associated directly with a student, + * rather than an application. + */ + StudentStandalone = "Student standalone", +} + +/** + * 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", +} + +/** + * 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", +} + +/** + * 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/web/src/types/index.ts b/sources/packages/web/src/types/index.ts index f0c5b89ad1..be71d552ed 100644 --- a/sources/packages/web/src/types/index.ts +++ b/sources/packages/web/src/types/index.ts @@ -51,3 +51,4 @@ export * from "@/types/contracts/CASInvoiceBatchContracts"; export * from "@/types/contracts/CASInvoiceStatus"; export * from "@/types/contracts/DynamicFormConfigurationContracts"; export * from "@/types/contracts/ApplicationExceptionRequestStatus"; +export * from "@/types/contracts/FormSubmissionContracts"; diff --git a/sources/packages/web/src/views/student/AppStudent.vue b/sources/packages/web/src/views/student/AppStudent.vue index 4010fe4432..8d09988f76 100644 --- a/sources/packages/web/src/views/student/AppStudent.vue +++ b/sources/packages/web/src/views/student/AppStudent.vue @@ -42,7 +42,7 @@ name: StudentRoutesConst.STUDENT_APPEAL_SUBMISSION, }" prepend-icon="fa:fa fa-balance-scale" - >AppealsForms