From 5c410f9d1c4fe740fc07bdfb55f6624d48f89bb1 Mon Sep 17 00:00:00 2001 From: Dheepak Ramanathan Date: Tue, 17 Feb 2026 13:07:44 -0700 Subject: [PATCH 01/15] initial commit --- ...ctionAddColMetadataUpdateSUSInsertREMIT.ts | 25 +++++++++++++++++++ .../Add-col-metadata-and-update-sus.sql | 14 +++++++++++ .../Restrictions/Insert-remit-restriction.sql | 18 +++++++++++++ ...llback-add-col-metadata-and-update-sus.sql | 3 +++ .../Rollback-insert-remit-restriction.sql | 4 +++ .../sims-db/src/entities/restriction.type.ts | 4 +++ 6 files changed, 68 insertions(+) create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1771299851366-RestrictionAddColMetadataUpdateSUSInsertREMIT.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-add-col-metadata-and-update-sus.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-insert-remit-restriction.sql diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1771299851366-RestrictionAddColMetadataUpdateSUSInsertREMIT.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1771299851366-RestrictionAddColMetadataUpdateSUSInsertREMIT.ts new file mode 100644 index 0000000000..606f60adf0 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1771299851366-RestrictionAddColMetadataUpdateSUSInsertREMIT.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class RestrictionAddColMetadataUpdateSUSInsertREMIT1771299851366 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData("Add-col-metadata-and-update-sus.sql", "Restrictions"), + ); + await queryRunner.query( + getSQLFileData("Insert-remit-restriction.sql", "Restrictions"), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData("Rollback-insert-remit-restriction.sql", "Restrictions"), + ); + await queryRunner.query( + getSQLFileData( + "Rollback-add-col-metadata-and-update-sus.sql", + "Restrictions", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql new file mode 100644 index 0000000000..35c4cd9769 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql @@ -0,0 +1,14 @@ +ALTER TABLE + sims.restrictions +ADD + COLUMN metadata JSONB; + +COMMENT ON COLUMN sims.restrictions.metadata IS 'Restriction metadata.'; + +-- Populate the metadata for SUS restriction. +UPDATE + sims.restrictions +SET + metadata = '{"constraints":{ "location": "required", "program": "required" }}' :: JSONB +WHERE + restriction_code = 'SUS'; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql new file mode 100644 index 0000000000..70c68eb555 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql @@ -0,0 +1,18 @@ +INSERT INTO + sims.restrictions ( + restriction_type, + restriction_code, + description, + restriction_category, + action_type, + notification_type + ) +VALUES + ( + 'Institution' :: sims.restriction_types, + 'REMIT', + 'No direct tuition remittance allowed.', + 'Location', + ARRAY ['No effect'] :: sims.restriction_action_types [], + 'No effect' :: sims.restriction_notification_types + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-add-col-metadata-and-update-sus.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-add-col-metadata-and-update-sus.sql new file mode 100644 index 0000000000..dee40da707 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-add-col-metadata-and-update-sus.sql @@ -0,0 +1,3 @@ +-- Drop column metadata will implicitly rollback the update to the metadata for SUS restriction. +ALTER TABLE + sims.restrictions DROP COLUMN metadata; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-insert-remit-restriction.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-insert-remit-restriction.sql new file mode 100644 index 0000000000..11bfa5f89e --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-insert-remit-restriction.sql @@ -0,0 +1,4 @@ +DELETE FROM + sims.restrictions +WHERE + restriction_code = 'REMIT'; \ No newline at end of file diff --git a/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts b/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts index 22fb900158..ccbef96cb4 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts @@ -15,3 +15,7 @@ export enum RestrictionType { */ Institution = "Institution", } + +export interface RestrictionMetadata { + constraints: Record; +} From 15214a8ff19fc42f6ce7e00b3d71d2b4cb7e4208 Mon Sep 17 00:00:00 2001 From: Dheepak Ramanathan Date: Wed, 18 Feb 2026 09:43:24 -0700 Subject: [PATCH 02/15] add migration --- .../restriction/models/restriction.dto.ts | 13 ++++++++++- .../restriction.aest.controller.ts | 4 +++- .../restriction/restriction.service.ts | 3 ++- ...onRestrictionDropProgramLocationNotNull.ts | 22 +++++++++++++++++++ .../Add-col-metadata-and-update-sus.sql | 2 +- .../Restrictions/Insert-remit-restriction.sql | 6 +++-- ...ictions-drop-program-location-not-null.sql | 6 +++++ ...ictions-drop-program-location-not-null.sql | 10 +++++++++ .../libs/sims-db/src/entities/common.type.ts | 7 ++++++ .../libs/sims-db/src/entities/index.ts | 3 ++- .../sims-db/src/entities/restriction.model.ts | 12 +++++++++- .../sims-db/src/entities/restriction.type.ts | 11 ++++++++-- .../modals/AddRestrictionModal.vue | 6 ----- .../web/src/services/RestrictionService.ts | 2 +- .../web/src/services/http/RestrictionApi.ts | 12 +++++----- .../types/contracts/RestrictionContract.ts | 8 +++++++ 16 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 sources/packages/backend/apps/db-migrations/src/migrations/1771391174102-InstitutionRestrictionDropProgramLocationNotNull.ts create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Institution-restrictions-drop-program-location-not-null.sql create mode 100644 sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-institution-restrictions-drop-program-location-not-null.sql create mode 100644 sources/packages/backend/libs/sims-db/src/entities/common.type.ts diff --git a/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts index 1797c14e5d..c0a016b600 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts @@ -3,6 +3,7 @@ import { ArrayMinSize, IsIn, IsNotEmpty, + IsOptional, IsPositive, MaxLength, } from "class-validator"; @@ -12,6 +13,7 @@ import { RestrictionType, RESTRICTION_CATEGORY_MAX_LENGTH, RestrictionActionType, + FieldRequirementType, } from "@sims/sims-db"; /** @@ -193,7 +195,16 @@ export class RestrictionReasonsOptionsAPIInDTO { /** * Category of the restriction expected to be filtered. */ - @IsNotEmpty() + @IsOptional() @MaxLength(RESTRICTION_CATEGORY_MAX_LENGTH) category: string; } + +/** + * Restriction details. + */ +export class RestrictionAPIOutDTO { + id: number; + description: string; + fieldRequirements?: Record; +} diff --git a/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts index 8969d3aee8..b1c4514ae3 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts @@ -38,6 +38,7 @@ import { AssignInstitutionRestrictionAPIInDTO, InstitutionRestrictionSummaryAPIOutDTO, InstitutionActiveRestrictionsAPIOutDTO, + RestrictionAPIOutDTO, } from "./models/restriction.dto"; import { ApiProcessError, ClientTypeBaseRoute } from "../../types"; import { getUserFullName } from "../../utilities"; @@ -120,7 +121,7 @@ export class RestrictionAESTController extends BaseController { @Get("reasons") async getReasonsOptionsList( @Query() options: RestrictionReasonsOptionsAPIInDTO, - ): Promise { + ): Promise { const reasons = await this.restrictionService.getRestrictionReasonsByCategory( options.type, @@ -129,6 +130,7 @@ export class RestrictionAESTController extends BaseController { return reasons.map((reason) => ({ id: reason.id, description: `${reason.restrictionCode} - ${reason.description}`, + fieldRequirements: reason.metadata?.fieldRequirements, })); } diff --git a/sources/packages/backend/apps/api/src/services/restriction/restriction.service.ts b/sources/packages/backend/apps/api/src/services/restriction/restriction.service.ts index 7b4b65b8d9..f08dc51709 100644 --- a/sources/packages/backend/apps/api/src/services/restriction/restriction.service.ts +++ b/sources/packages/backend/apps/api/src/services/restriction/restriction.service.ts @@ -39,13 +39,14 @@ export class RestrictionService extends RecordDataModelService { */ async getRestrictionReasonsByCategory( restrictionType: RestrictionType.Provincial | RestrictionType.Institution, - restrictionCategory: string, + restrictionCategory?: string, ): Promise { return this.repo.find({ select: { id: true, restrictionCode: true, description: true, + metadata: true, }, where: { restrictionType, diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1771391174102-InstitutionRestrictionDropProgramLocationNotNull.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1771391174102-InstitutionRestrictionDropProgramLocationNotNull.ts new file mode 100644 index 0000000000..c8db8323af --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1771391174102-InstitutionRestrictionDropProgramLocationNotNull.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class InstitutionRestrictionDropProgramLocationNotNull1771391174102 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Institution-restrictions-drop-program-location-not-null.sql", + "Restrictions", + ), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-institution-restrictions-drop-program-location-not-null.sql", + "Restrictions", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql index 35c4cd9769..098449d2f1 100644 --- a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Add-col-metadata-and-update-sus.sql @@ -9,6 +9,6 @@ COMMENT ON COLUMN sims.restrictions.metadata IS 'Restriction metadata.'; UPDATE sims.restrictions SET - metadata = '{"constraints":{ "location": "required", "program": "required" }}' :: JSONB + metadata = '{"fieldRequirements":{ "location": "required", "program": "required" }}' :: JSONB WHERE restriction_code = 'SUS'; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql index 70c68eb555..0d9f515e09 100644 --- a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql @@ -5,7 +5,8 @@ INSERT INTO description, restriction_category, action_type, - notification_type + notification_type, + metadata ) VALUES ( @@ -14,5 +15,6 @@ VALUES 'No direct tuition remittance allowed.', 'Location', ARRAY ['No effect'] :: sims.restriction_action_types [], - 'No effect' :: sims.restriction_notification_types + 'No effect' :: sims.restriction_notification_types, + '{"fieldRequirements":{ "location": "required", "program": "optional" }}' :: JSONB ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Institution-restrictions-drop-program-location-not-null.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Institution-restrictions-drop-program-location-not-null.sql new file mode 100644 index 0000000000..9dbbb530a0 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Institution-restrictions-drop-program-location-not-null.sql @@ -0,0 +1,6 @@ +ALTER TABLE + sims.institution_restrictions +ALTER COLUMN + location_id DROP NOT NULL, +ALTER COLUMN + program_id DROP NOT NULL; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-institution-restrictions-drop-program-location-not-null.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-institution-restrictions-drop-program-location-not-null.sql new file mode 100644 index 0000000000..84bfdcbfd3 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-institution-restrictions-drop-program-location-not-null.sql @@ -0,0 +1,10 @@ +ALTER TABLE + sims.institution_restrictions +ALTER COLUMN + location_id +SET + NOT NULL, +ALTER COLUMN + program_id +SET + NOT NULL; \ No newline at end of file diff --git a/sources/packages/backend/libs/sims-db/src/entities/common.type.ts b/sources/packages/backend/libs/sims-db/src/entities/common.type.ts new file mode 100644 index 0000000000..f765a6622d --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/common.type.ts @@ -0,0 +1,7 @@ +/** + * Field requirement types used in metadata to define the field requirement. + */ +export enum FieldRequirementType { + Required = "required", + Optional = "optional", +} 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 68e9b298cc..d8f7fc4a96 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/index.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/index.ts @@ -1,4 +1,5 @@ export * from "./base.model"; +export * from "./common.type"; export * from "./record.model"; export * from "./supplier-status.type"; export * from "./cas-supplier.model"; @@ -33,8 +34,8 @@ export * from "./offering-intensity.type"; export * from "./cra-income-verification.model"; export * from "./supporting-user-type.type"; export * from "./supporting-user.model"; -export * from "./restriction.model"; export * from "./restriction.type"; +export * from "./restriction.model"; export * from "./student-restriction.model"; export * from "./sfas-individual.model"; export * from "./base-sfas-application.model"; diff --git a/sources/packages/backend/libs/sims-db/src/entities/restriction.model.ts b/sources/packages/backend/libs/sims-db/src/entities/restriction.model.ts index bd580b66a3..653175bc38 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/restriction.model.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/restriction.model.ts @@ -1,7 +1,7 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { TableNames } from "../constant"; import { RecordDataModel } from "./record.model"; -import { RestrictionType } from "."; +import { RestrictionMetadata, RestrictionType } from "."; import { RestrictionNotificationType } from "./restriction-notification-type.type"; import { RestrictionActionType } from "./restriction-action-type.type"; @@ -96,6 +96,16 @@ export class Restriction extends RecordDataModel { nullable: true, }) actionEffectiveConditions?: ActionEffectiveCondition[]; + + /** + * Restriction metadata. + */ + @Column({ + name: "metadata", + type: "jsonb", + nullable: true, + }) + metadata?: RestrictionMetadata; } /** diff --git a/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts b/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts index ccbef96cb4..0a4ef99a93 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/restriction.type.ts @@ -1,3 +1,5 @@ +import { FieldRequirementType } from "."; + /** * Enumeration types for Restriction. */ @@ -15,7 +17,12 @@ export enum RestrictionType { */ Institution = "Institution", } - +/** + * Restriction metadata. + */ export interface RestrictionMetadata { - constraints: Record; + /** + * The restricted party(student or institution) field requirements for the restriction. + */ + fieldRequirements: Record; } diff --git a/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue b/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue index a879f0f0b5..9378094102 100644 --- a/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue +++ b/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue @@ -77,11 +77,6 @@ import { InstitutionService } from "@/services/InstitutionService"; import { RestrictionService } from "@/services/RestrictionService"; import { EducationProgramService } from "@/services/EducationProgramService"; -/** - * Default category to be displayed for institution restrictions. - */ -export const CATEGORY = "Program"; - export default defineComponent({ components: { ModalDialogBase, ErrorSummary }, props: { @@ -135,7 +130,6 @@ export default defineComponent({ await Promise.all([ RestrictionService.shared.getRestrictionReasons( RestrictionType.Institution, - CATEGORY, ), InstitutionService.shared.getAllInstitutionLocations( props.institutionId, diff --git a/sources/packages/web/src/services/RestrictionService.ts b/sources/packages/web/src/services/RestrictionService.ts index 96016bef4a..f427d62e73 100644 --- a/sources/packages/web/src/services/RestrictionService.ts +++ b/sources/packages/web/src/services/RestrictionService.ts @@ -54,7 +54,7 @@ export class RestrictionService { */ async getRestrictionReasons( restrictionType: RestrictionType.Provincial | RestrictionType.Institution, - restrictionCategory: string, + restrictionCategory?: string, ): Promise { return ApiClient.RestrictionApi.getRestrictionReasons( restrictionType, diff --git a/sources/packages/web/src/services/http/RestrictionApi.ts b/sources/packages/web/src/services/http/RestrictionApi.ts index f51b936a34..e8dfad6594 100644 --- a/sources/packages/web/src/services/http/RestrictionApi.ts +++ b/sources/packages/web/src/services/http/RestrictionApi.ts @@ -41,13 +41,13 @@ export class RestrictionApi extends HttpBaseClient { */ async getRestrictionReasons( restrictionType: RestrictionType.Provincial | RestrictionType.Institution, - restrictionCategory: string, + restrictionCategory?: string, ): Promise { - return this.getCall( - this.addClientRoot( - `restriction/reasons?type=${restrictionType}&category=${restrictionCategory}`, - ), - ); + let url = `restriction/reasons?type=${restrictionType}`; + if (restrictionCategory) { + url += `&category=${restrictionCategory}`; + } + return this.getCall(this.addClientRoot(url)); } async getStudentRestrictionDetail( diff --git a/sources/packages/web/src/types/contracts/RestrictionContract.ts b/sources/packages/web/src/types/contracts/RestrictionContract.ts index 3bc41f3d2a..8039d8cbcc 100644 --- a/sources/packages/web/src/types/contracts/RestrictionContract.ts +++ b/sources/packages/web/src/types/contracts/RestrictionContract.ts @@ -129,3 +129,11 @@ export interface RestrictionDetail { resolutionNote?: string; deletionNote?: string; } + +/** + * Field requirement types. + */ +export enum FieldRequirementType { + Required = "required", + Optional = "optional", +} From 3ce99acafa6d7327ad720e4d01bb1aeaa1131624 Mon Sep 17 00:00:00 2001 From: Dheepak Ramanathan Date: Sun, 22 Feb 2026 11:57:52 -0700 Subject: [PATCH 03/15] Add restriction rules. --- .../api/src/constants/error-code.constants.ts | 5 + ...ller.addInstitutionRestriction.e2e-spec.ts | 106 ++++++++- .../restriction/models/restriction.dto.ts | 2 + .../restriction.aest.controller.ts | 15 +- .../institution-restriction.service.ts | 214 ++++++++++++------ .../models/institution-restriction.model.ts | 32 +++ .../backend/apps/api/src/utilities/index.ts | 1 + .../apps/api/src/utilities/metadata-utils.ts | 52 +++++ .../Restrictions/Insert-remit-restriction.sql | 2 +- .../libs/sims-db/src/entities/common.type.ts | 10 + .../modals/AddRestrictionModal.vue | 29 ++- .../web/src/services/RestrictionService.ts | 3 +- .../web/src/services/http/RestrictionApi.ts | 5 +- .../src/services/http/dto/Restriction.dto.ts | 10 + .../types/contracts/RestrictionContract.ts | 10 + 15 files changed, 413 insertions(+), 83 deletions(-) create mode 100644 sources/packages/backend/apps/api/src/services/restriction/models/institution-restriction.model.ts create mode 100644 sources/packages/backend/apps/api/src/utilities/metadata-utils.ts diff --git a/sources/packages/backend/apps/api/src/constants/error-code.constants.ts b/sources/packages/backend/apps/api/src/constants/error-code.constants.ts index 63f23aadcd..f0f3b94849 100644 --- a/sources/packages/backend/apps/api/src/constants/error-code.constants.ts +++ b/sources/packages/backend/apps/api/src/constants/error-code.constants.ts @@ -336,3 +336,8 @@ export const INSTITUTION_RESTRICTION_ALREADY_ACTIVE = */ export const NO_LOCATION_SELECTED_FOR_DESIGNATION = "NO_LOCATION_SELECTED_FOR_DESIGNATION"; + +/** + * Field requirements not valid. + */ +export const FIELD_REQUIREMENTS_NOT_VALID = "FIELD_REQUIREMENTS_NOT_VALID"; diff --git a/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.addInstitutionRestriction.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.addInstitutionRestriction.e2e-spec.ts index fdd96e7f9c..c9d68c99e8 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.addInstitutionRestriction.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.addInstitutionRestriction.e2e-spec.ts @@ -151,6 +151,108 @@ describe("RestrictionAESTController(e2e)-addInstitutionRestriction.", () => { ]); }); + it("Should add multiple institution restrictions when a valid payload with multiple locations IDs is submitted.", async () => { + // Arrange + const [institution, program, locationIds, [location1, location2]] = + await createInstitutionProgramLocations({ numberLocationsToCreate: 2 }); + const ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + const endpoint = `/aest/restriction/institution/${institution.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Act/Assert + let createdInstitutionRestrictionIds: number[]; + await request(app.getHttpServer()) + .post(endpoint) + .send({ + restrictionId: susRestriction.id, + programId: program.id, + locationIds, + noteDescription: "Add institution restriction note.", + }) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.CREATED) + .expect(({ body }) => { + expect(body).toHaveProperty("ids"); + expect(body.ids).toHaveLength(2); + createdInstitutionRestrictionIds = body.ids; + }); + + // Assert DB changes. + const createdInstitutionRestrictions = await db.institutionRestriction.find( + { + select: { + id: true, + institution: { id: true }, + restriction: { id: true }, + location: { id: true }, + program: { id: true }, + creator: { id: true }, + restrictionNote: { + id: true, + noteType: true, + description: true, + creator: { id: true }, + }, + isActive: true, + }, + relations: { + institution: true, + restriction: true, + location: true, + program: true, + creator: true, + restrictionNote: { creator: true }, + }, + where: { id: In(createdInstitutionRestrictionIds) }, + order: { id: "ASC" }, + loadEagerRelations: false, + }, + ); + // Ensure both restrictions have the same note. + const [createdInstitutionRestriction1, createdInstitutionRestriction2] = + createdInstitutionRestrictions; + expect(createdInstitutionRestriction1.restrictionNote.id).toBe( + createdInstitutionRestriction2.restrictionNote.id, + ); + // Validate created institution restrictions. + const ministryUserAudit = { id: ministryUser.id }; + expect(createdInstitutionRestrictions).toEqual([ + { + id: createdInstitutionRestriction1.id, + institution: { id: institution.id }, + restriction: { id: susRestriction.id }, + location: { id: location1.id }, + program: { id: program.id }, + creator: ministryUserAudit, + restrictionNote: { + id: expect.any(Number), + noteType: NoteType.Restriction, + description: "Add institution restriction note.", + creator: ministryUserAudit, + }, + isActive: true, + }, + { + id: createdInstitutionRestriction2.id, + institution: { id: institution.id }, + restriction: { id: susRestriction.id }, + location: { id: location2.id }, + program: { id: program.id }, + creator: ministryUserAudit, + restrictionNote: { + id: expect.any(Number), + noteType: NoteType.Restriction, + description: "Add institution restriction note.", + creator: ministryUserAudit, + }, + isActive: true, + }, + ]); + }); + it("Should create the institution restriction when there is already an institution restriction, but it is inactive.", async () => { // Arrange const [institution, program, locationIds, [location]] = @@ -237,7 +339,7 @@ describe("RestrictionAESTController(e2e)-addInstitutionRestriction.", () => { .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.UNPROCESSABLE_ENTITY) .expect({ - message: `Institution restriction ID ${provincialRestriction.id} not found.`, + message: `Restriction ID ${provincialRestriction.id} not found or invalid.`, error: "Unprocessable Entity", statusCode: HttpStatus.UNPROCESSABLE_ENTITY, }); @@ -342,7 +444,7 @@ describe("RestrictionAESTController(e2e)-addInstitutionRestriction.", () => { .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.UNPROCESSABLE_ENTITY) .expect({ - message: "Institution restriction ID 999999 not found.", + message: "Restriction ID 999999 not found or invalid.", error: "Unprocessable Entity", statusCode: HttpStatus.UNPROCESSABLE_ENTITY, }); diff --git a/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts index c0a016b600..98e6b29fab 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/models/restriction.dto.ts @@ -119,6 +119,7 @@ export class AssignInstitutionRestrictionAPIInDTO extends AssignRestrictionAPIIn * List of location IDs where the restriction is applicable. * A new restriction will be created for each location ID provided. */ + @IsOptional() @ArrayMinSize(1) @ArrayMaxSize(MAX_ALLOWED_LOCATIONS) @IsPositive({ each: true }) @@ -126,6 +127,7 @@ export class AssignInstitutionRestrictionAPIInDTO extends AssignRestrictionAPIIn /** * Program ID where the restriction is applicable. */ + @IsOptional() @IsPositive() programId: number; } diff --git a/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts index b1c4514ae3..65db4da4f3 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.aest.controller.ts @@ -10,6 +10,7 @@ import { InternalServerErrorException, ParseIntPipe, Query, + BadRequestException, } from "@nestjs/common"; import { StudentRestrictionService, @@ -60,9 +61,9 @@ import { RESTRICTION_NOT_ACTIVE, RESTRICTION_IS_DELETED, INSTITUTION_NOT_FOUND, - INSTITUTION_PROGRAM_LOCATION_ASSOCIATION_NOT_FOUND, INSTITUTION_RESTRICTION_ALREADY_ACTIVE, RESTRICTION_NOT_PROVINCIAL, + FIELD_REQUIREMENTS_NOT_VALID, } from "../../constants"; import { RestrictionType } from "@sims/sims-db"; @@ -402,10 +403,7 @@ export class RestrictionAESTController extends BaseController { const createdRestrictions = await this.institutionRestrictionService.addInstitutionRestriction( institutionId, - payload.restrictionId, - payload.locationIds, - payload.programId, - payload.noteDescription, + payload, userToken.userId, ); return { ids: createdRestrictions.map((r) => r.id) }; @@ -414,13 +412,14 @@ export class RestrictionAESTController extends BaseController { switch (error.name) { case INSTITUTION_NOT_FOUND: throw new NotFoundException(error.message); - case RESTRICTION_NOT_FOUND: - case INSTITUTION_PROGRAM_LOCATION_ASSOCIATION_NOT_FOUND: - throw new UnprocessableEntityException(error.message); + case FIELD_REQUIREMENTS_NOT_VALID: + throw new BadRequestException(error.message); case INSTITUTION_RESTRICTION_ALREADY_ACTIVE: throw new UnprocessableEntityException( new ApiProcessError(error.message, error.name), ); + default: + throw new UnprocessableEntityException(error.message); } } throw error; diff --git a/sources/packages/backend/apps/api/src/services/restriction/institution-restriction.service.ts b/sources/packages/backend/apps/api/src/services/restriction/institution-restriction.service.ts index 13b37d3b70..810dba2097 100644 --- a/sources/packages/backend/apps/api/src/services/restriction/institution-restriction.service.ts +++ b/sources/packages/backend/apps/api/src/services/restriction/institution-restriction.service.ts @@ -1,5 +1,11 @@ import { Injectable } from "@nestjs/common"; -import { DataSource, EntityManager, Equal, Not } from "typeorm"; +import { + DataSource, + EntityManager, + Equal, + Not, + SelectQueryBuilder, +} from "typeorm"; import { RecordDataModelService, InstitutionRestriction, @@ -10,12 +16,12 @@ import { Institution, EducationProgram, InstitutionLocation, - RestrictionType, RestrictionNotificationType, + RestrictionType, } from "@sims/sims-db"; import { CustomNamedError } from "@sims/utilities"; -import { RestrictionService } from "../../services"; import { + FIELD_REQUIREMENTS_NOT_VALID, INSTITUTION_NOT_FOUND, INSTITUTION_PROGRAM_LOCATION_ASSOCIATION_NOT_FOUND, INSTITUTION_RESTRICTION_ALREADY_ACTIVE, @@ -23,6 +29,8 @@ import { RESTRICTION_NOT_FOUND, } from "../../constants"; import { NoteSharedService } from "@sims/services"; +import { CreateInstitutionRestrictionModel } from "./models/institution-restriction.model"; +import { validateFieldRequirements } from "../../utilities"; /** * Service layer for institution Restriction. @@ -32,7 +40,6 @@ export class InstitutionRestrictionService extends RecordDataModelService { return this.dataSource.transaction(async (entityManager) => { - const uniqueLocationIds = Array.from(new Set(locationIds)); + const uniqueLocationIds = Array.from( + new Set(institutionRestriction.locationIds ?? []), + ); await this.validateInstitutionRestrictionCreation( institutionId, - restrictionId, + institutionRestriction.restrictionId, + institutionRestriction.programId, uniqueLocationIds, - programId, entityManager, ); // New note creation. const note = await this.noteSharedService.createInstitutionNote( institutionId, NoteType.Restriction, - noteDescription, + institutionRestriction.noteDescription, auditUserId, entityManager, ); // New institution restriction creation. - const newRestrictions = locationIds.map((locationId) => { - const restriction = new InstitutionRestriction(); - restriction.institution = { id: institutionId } as Institution; - restriction.restriction = { id: restrictionId } as Restriction; - restriction.location = { id: locationId } as InstitutionLocation; - restriction.program = { id: programId } as EducationProgram; - restriction.creator = { id: auditUserId } as User; - restriction.restrictionNote = note; - restriction.isActive = true; - return restriction; - }); + const newRestrictions = institutionRestriction.locationIds.map( + (locationId) => { + const restriction = new InstitutionRestriction(); + restriction.institution = { id: institutionId } as Institution; + restriction.restriction = { + id: institutionRestriction.restrictionId, + } as Restriction; + restriction.location = { id: locationId } as InstitutionLocation; + restriction.program = { + id: institutionRestriction.programId, + } as EducationProgram; + restriction.creator = { id: auditUserId } as User; + restriction.restrictionNote = note; + restriction.isActive = true; + return restriction; + }, + ); await entityManager .getRepository(InstitutionRestriction) .insert(newRestrictions); @@ -241,51 +253,29 @@ export class InstitutionRestrictionService extends RecordDataModelService { + const hasProgram = !!programId; + const hasLocations = !!locationIds?.length; // Check institution, location, program association and institution restriction existence. // Execute left joins to allow the validations in a single query and the generation // of more precise error messages. - const institutionPromise = entityManager - .getRepository(Institution) - .createQueryBuilder("institution") - .select([ - "institution.id", - "location.id", - "program.id", - "institutionRestriction.id", - ]) - // Check if the location belongs to the institution. - .leftJoin( - "institution.locations", - "location", - "location.id IN (:...locationIds)", - { locationIds }, - ) - // Check if the program belongs to the institution. - .leftJoin("institution.programs", "program", "program.id = :programId", { - programId, - }) - // Check if an active restriction already exists for the institution. - .leftJoin( - "institution.restrictions", - "institutionRestriction", - "institutionRestriction.isActive = :isActive AND institutionRestriction.location.id IN (:...locationIds) AND institutionRestriction.program.id = :programId AND institutionRestriction.restriction.id = :restrictionId", - { isActive: true, locationIds, programId, restrictionId }, - ) + const institutionPromise = this.buildValidationQuery( + hasProgram, + hasLocations, + entityManager, + ) .where("institution.id = :institutionId", { institutionId }) + .setParameters({ institutionId, restrictionId, programId, locationIds }) .getOne(); - // Check the restriction existence. - const restrictionExistsPromise = this.restrictionService.restrictionExists( - restrictionId, - RestrictionType.Institution, - { entityManager }, - ); - const [institution, restrictionExists] = await Promise.all([ + const restrictionPromise = this.getRestriction(restrictionId, { + entityManager, + }); + const [institution, restriction] = await Promise.all([ institutionPromise, - restrictionExistsPromise, + restrictionPromise, ]); // Execute validations inspecting the results of the above queries. if (!institution) { @@ -294,28 +284,41 @@ export class InstitutionRestrictionService extends RecordDataModelService 0) { throw new CustomNamedError( - `The restriction ID ${restrictionId} is already assigned and active to the institution, program ID ${programId}, and at least one of the location ID(s) ${locationIds}.`, + `The restriction ID ${restrictionId} is already assigned and active to the institution${hasProgram ? `, program ID ${programId}` : ""}${hasLocations ? `, and at least one of the location ID(s) ${locationIds}` : ""}.`, INSTITUTION_RESTRICTION_ALREADY_ACTIVE, ); } - if (institution.locations.length !== locationIds.length) { + if (hasLocations && institution.locations.length !== locationIds.length) { throw new CustomNamedError( `At least one of the location ID(s) ${locationIds} were not associated with the institution.`, INSTITUTION_PROGRAM_LOCATION_ASSOCIATION_NOT_FOUND, ); } - if (!institution.programs.length) { + if (hasProgram && !institution.programs.length) { throw new CustomNamedError( `The specified program ID ${programId} is not associated with the institution.`, INSTITUTION_PROGRAM_LOCATION_ASSOCIATION_NOT_FOUND, ); } - if (!restrictionExists) { + const fieldValidationResult = validateFieldRequirements( + new Map([ + ["program", hasProgram], + ["location", hasLocations], + ]), + restriction.metadata.fieldRequirements, + ); + if (!fieldValidationResult.isValid) { throw new CustomNamedError( - `Institution restriction ID ${restrictionId} not found.`, - RESTRICTION_NOT_FOUND, + fieldValidationResult.errorMessages.join(", "), + FIELD_REQUIREMENTS_NOT_VALID, ); } } @@ -435,4 +438,83 @@ export class InstitutionRestrictionService extends RecordDataModelService { + const repo = + options?.entityManager?.getRepository(Restriction) ?? + this.dataSource.getRepository(Restriction); + return repo.findOne({ + select: { id: true, metadata: true }, + where: { + id: restrictionId, + restrictionType: RestrictionType.Institution, + }, + }); + } + + /** + * Build the restriction validation query based on the existence of location and program criteria. + * @param hasProgram Indicates whether the restriction is applicable to a program. + * @param hasLocations Indicates whether the restriction is applicable to one or more locations. + * @param entityManager The entity manager to use for the query. + * @returns The query builder for the institution restriction validation. + */ + private buildValidationQuery( + hasProgram: boolean, + hasLocations: boolean, + entityManager: EntityManager, + ): SelectQueryBuilder { + const query = entityManager + .getRepository(Institution) + .createQueryBuilder("institution") + .select(["institution.id", "institutionRestriction.id"]); + if (hasLocations) { + query + .addSelect("location.id") + .leftJoin( + "institution.locations", + "location", + "location.id IN (:...locationIds)", + ); + } + if (hasProgram) { + query + .addSelect("program.id") + .leftJoin("institution.programs", "program", "program.id = :programId"); + } + // Build institution restriction criteria to validate + // if there is an active institution restriction for the same program and location combination. + const institutionRestrictionCriteria: string[] = []; + institutionRestrictionCriteria.push( + "institutionRestriction.isActive = TRUE AND institutionRestriction.restriction.id = :restrictionId", + ); + // Program join criteria. + institutionRestrictionCriteria.push( + hasProgram + ? "institutionRestriction.program.id = :programId" + : "institutionRestriction.program.id IS NULL", + ); + // Location join criteria. + institutionRestrictionCriteria.push( + hasLocations + ? "institutionRestriction.location.id IN (:...locationIds)" + : "institutionRestriction.location.id IS NULL", + ); + query.leftJoin( + "institution.restrictions", + "institutionRestriction", + institutionRestrictionCriteria.join(" AND "), + ); + return query; + } } diff --git a/sources/packages/backend/apps/api/src/services/restriction/models/institution-restriction.model.ts b/sources/packages/backend/apps/api/src/services/restriction/models/institution-restriction.model.ts new file mode 100644 index 0000000000..12db98991f --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/restriction/models/institution-restriction.model.ts @@ -0,0 +1,32 @@ +import { FieldRequirementType } from "@sims/sims-db"; +import { + ArrayMaxSize, + ArrayMinSize, + IsNotEmptyObject, + IsPositive, +} from "class-validator"; +/** + * Maximum allowed locations while assigning institution restriction. + * Added a non-real-world limit to avoid leaving the API open to + * receive a very large payloads. + */ +const MAX_ALLOWED_LOCATIONS = 100; +export class InstitutionRestrictionValidationModel { + @ArrayMinSize(1) + @ArrayMaxSize(MAX_ALLOWED_LOCATIONS) + @IsPositive({ each: true }) + locationIds: number[]; + + @IsPositive() + programId: number; + + @IsNotEmptyObject() + fieldRequirements: Record; +} + +export interface CreateInstitutionRestrictionModel { + restrictionId: number; + noteDescription: string; + locationIds?: number[]; + programId?: number; +} diff --git a/sources/packages/backend/apps/api/src/utilities/index.ts b/sources/packages/backend/apps/api/src/utilities/index.ts index 8e68b570c1..b4dac31e42 100644 --- a/sources/packages/backend/apps/api/src/utilities/index.ts +++ b/sources/packages/backend/apps/api/src/utilities/index.ts @@ -13,3 +13,4 @@ export * from "./pagination-utils"; export * from "./address-utils"; export * from "./http-utils"; export * from "./institution-utils"; +export * from "./metadata-utils"; diff --git a/sources/packages/backend/apps/api/src/utilities/metadata-utils.ts b/sources/packages/backend/apps/api/src/utilities/metadata-utils.ts new file mode 100644 index 0000000000..6241eee952 --- /dev/null +++ b/sources/packages/backend/apps/api/src/utilities/metadata-utils.ts @@ -0,0 +1,52 @@ +import { FieldRequirementType } from "@sims/sims-db"; + +/** + * Validates a field requirement based on the provided field requirements. + * @param fieldKey The key of the field to validate. + * @param fieldValue The value of the field to validate. + * @param fieldRequirements The field requirements to validate against. + * @returns An error message if the field does not meet the requirement, otherwise undefined. + */ +function validateFieldRequirement( + fieldKey: string, + fieldValue: unknown, + fieldRequirements: Record, +): undefined | string { + const requirement = fieldRequirements[fieldKey]; + if (requirement === FieldRequirementType.Optional) { + return; + } + if (requirement === FieldRequirementType.Required && !fieldValue) { + return `${fieldKey} is required`; + } + if (requirement === FieldRequirementType.NotAllowed && fieldValue) { + return `${fieldKey} is not allowed`; + } +} + +/** + * Validate field requirements. + * @param fieldKeyValues + * @param fieldRequirements + * @returns validation result. + */ +export function validateFieldRequirements( + fieldKeyValues: Map, + fieldRequirements: Record, +): { isValid: boolean; errorMessages: string[] } { + const errorMessages: string[] = []; + for (const [fieldKey, fieldValue] of fieldKeyValues.entries()) { + const validationError = validateFieldRequirement( + fieldKey, + fieldValue, + fieldRequirements, + ); + if (validationError) { + errorMessages.push(validationError); + } + } + return { + isValid: !errorMessages.length, + errorMessages, + }; +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql index 0d9f515e09..7294bfc76c 100644 --- a/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql @@ -16,5 +16,5 @@ VALUES 'Location', ARRAY ['No effect'] :: sims.restriction_action_types [], 'No effect' :: sims.restriction_notification_types, - '{"fieldRequirements":{ "location": "required", "program": "optional" }}' :: JSONB + '{"fieldRequirements":{ "location": "required", "program": "not allowed" }}' :: JSONB ); \ No newline at end of file diff --git a/sources/packages/backend/libs/sims-db/src/entities/common.type.ts b/sources/packages/backend/libs/sims-db/src/entities/common.type.ts index f765a6622d..73b753b3d1 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/common.type.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/common.type.ts @@ -2,6 +2,16 @@ * Field requirement types used in metadata to define the field requirement. */ export enum FieldRequirementType { + /** + * The field is required and must be provided. + */ Required = "required", + /** + * The field is optional and may be provided or omitted. + */ Optional = "optional", + /** + * The field is not allowed and must not be provided. + */ + NotAllowed = "not allowed", } diff --git a/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue b/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue index 9378094102..0553f8b35d 100644 --- a/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue +++ b/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue @@ -16,6 +16,7 @@ :loading="loadingData" hide-details="auto" />