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..01d09f4896 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 @@ -7,6 +7,7 @@ import { createFakeUser, createFakeEducationProgram, saveFakeInstitutionRestriction, + createFakeRestriction, } from "@sims/test-utils"; import { AESTGroups, @@ -19,6 +20,7 @@ import { import * as request from "supertest"; import { EducationProgram, + FieldRequirementType, Institution, InstitutionLocation, NoteType, @@ -34,22 +36,47 @@ describe("RestrictionAESTController(e2e)-addInstitutionRestriction.", () => { let db: E2EDataSources; let sharedAuditUser: User; let susRestriction: Restriction; + let remitRestriction: Restriction; + // Institution restriction that is applicable institution level. + let institutionOnlyRestriction: Restriction; beforeAll(async () => { const { nestApplication, dataSource } = await createTestingAppModule(); app = nestApplication; db = createE2EDataSources(dataSource); sharedAuditUser = await db.user.save(createFakeUser()); - // Find the SUS restriction to be used as an example of a valid restriction. - susRestriction = await db.restriction.findOne({ + institutionOnlyRestriction = createFakeRestriction({ + initialValues: { + restrictionType: RestrictionType.Institution, + metadata: { + fieldRequirements: { + program: FieldRequirementType.NotAllowed, + location: FieldRequirementType.NotAllowed, + }, + }, + }, + }); + // SUS and REMIT restrictions. + const susRestrictionPromise = db.restriction.findOne({ select: { id: true }, where: { restrictionCode: RestrictionCode.SUS, }, }); + const remitRestrictionPromise = db.restriction.findOne({ + select: { id: true }, + where: { + restrictionCode: RestrictionCode.REMIT, + }, + }); + [susRestriction, remitRestriction] = await Promise.all([ + susRestrictionPromise, + remitRestrictionPromise, + db.restriction.save(institutionOnlyRestriction), + ]); }); - it("Should add multiple institution restrictions when a valid payload with multiple locations IDs is submitted.", async () => { + it("Should add multiple SUS institution restrictions when a valid payload with program ID and multiple locations IDs is submitted.", async () => { // Arrange const [institution, program, locationIds, [location1, location2]] = await createInstitutionProgramLocations({ numberLocationsToCreate: 2 }); @@ -151,6 +178,178 @@ describe("RestrictionAESTController(e2e)-addInstitutionRestriction.", () => { ]); }); + it("Should add multiple REMIT institution restrictions when a valid payload with multiple locations IDs is submitted.", async () => { + // Arrange + const [institution, , locationIds, [location1, location2]] = + await createInstitutionProgramLocations({ + skipProgramCreation: true, + 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: remitRestriction.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 }, + creator: { id: true }, + restrictionNote: { + id: true, + noteType: true, + description: true, + creator: { id: true }, + }, + isActive: true, + }, + relations: { + institution: true, + restriction: true, + location: 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: remitRestriction.id }, + location: { id: location1.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: remitRestriction.id }, + location: { id: location2.id }, + creator: ministryUserAudit, + restrictionNote: { + id: expect.any(Number), + noteType: NoteType.Restriction, + description: "Add institution restriction note.", + creator: ministryUserAudit, + }, + isActive: true, + }, + ]); + }); + + it("Should add a single institution only institution restriction when a valid payload without program ID and location IDs is submitted.", async () => { + // Arrange + const [institution] = await createInstitutionProgramLocations({ + skipProgramCreation: true, + numberLocationsToCreate: 0, + }); + const ministryUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + const endpoint = `/aest/restriction/institution/${institution.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + + // Act/Assert + let createdInstitutionRestrictionId: number; + await request(app.getHttpServer()) + .post(endpoint) + .send({ + restrictionId: institutionOnlyRestriction.id, + noteDescription: "Add institution restriction note.", + }) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.CREATED) + .expect(({ body }) => { + expect(body).toHaveProperty("ids"); + expect(body.ids).toHaveLength(1); + [createdInstitutionRestrictionId] = body.ids; + }); + + // Assert DB changes. + const createdInstitutionRestriction = + await db.institutionRestriction.findOne({ + select: { + id: true, + institution: { id: true }, + restriction: { id: true }, + creator: { id: true }, + restrictionNote: { + id: true, + noteType: true, + description: true, + creator: { id: true }, + }, + isActive: true, + }, + relations: { + institution: true, + restriction: true, + creator: true, + restrictionNote: { creator: true }, + }, + where: { id: createdInstitutionRestrictionId }, + order: { id: "ASC" }, + loadEagerRelations: false, + }); + // Validate created institution restrictions. + const ministryUserAudit = { id: ministryUser.id }; + expect(createdInstitutionRestriction).toEqual({ + id: createdInstitutionRestriction.id, + institution: { id: institution.id }, + restriction: { id: institutionOnlyRestriction.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 +436,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 +541,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, }); @@ -371,6 +570,81 @@ describe("RestrictionAESTController(e2e)-addInstitutionRestriction.", () => { }); }); + it("Should throw a bad request exception on add REMIT restriction when no locations were provided in the request payload.", async () => { + // Arrange + const [institution] = await createInstitutionProgramLocations({ + skipProgramCreation: true, + numberLocationsToCreate: 0, + }); + const endpoint = `/aest/restriction/institution/${institution.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + // Act/Assert + await request(app.getHttpServer()) + .post(endpoint) + .send({ + restrictionId: remitRestriction.id, + noteDescription: "Add institution restriction note.", + }) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + message: "Field requirement error(s): location is required.", + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + it("Should throw a bad request exception on add REMIT restriction when program is provided in the request payload.", async () => { + // Arrange + const [institution, program, locationIds] = + await createInstitutionProgramLocations({ + numberLocationsToCreate: 1, + }); + const endpoint = `/aest/restriction/institution/${institution.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + // Act/Assert + await request(app.getHttpServer()) + .post(endpoint) + .send({ + restrictionId: remitRestriction.id, + locationIds, + programId: program.id, + noteDescription: "Add institution restriction note.", + }) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + message: "Field requirement error(s): program is not allowed.", + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + + it("Should throw a bad request exception on add SUS restriction when no program and locations were provided in the request payload.", async () => { + // Arrange + const [institution] = await createInstitutionProgramLocations({ + skipProgramCreation: true, + numberLocationsToCreate: 0, + }); + const endpoint = `/aest/restriction/institution/${institution.id}`; + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + // Act/Assert + await request(app.getHttpServer()) + .post(endpoint) + .send({ + restrictionId: susRestriction.id, + noteDescription: "Add institution restriction note.", + }) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + message: + "Field requirement error(s): program is required, location is required.", + error: "Bad Request", + statusCode: HttpStatus.BAD_REQUEST, + }); + }); + it("Should throw a bad request exception when no locations were provided.", async () => { // Arrange const endpoint = "/aest/restriction/institution/999999"; diff --git a/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.getReasonsOptionsList.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.getReasonsOptionsList.e2e-spec.ts index abdd45c587..0ca2a8d115 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.getReasonsOptionsList.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/_tests_/e2e/restriction.aest.controller.getReasonsOptionsList.e2e-spec.ts @@ -1,6 +1,7 @@ import { HttpStatus, INestApplication } from "@nestjs/common"; import { E2EDataSources, + RestrictionCode, createE2EDataSources, createFakeRestriction, } from "@sims/test-utils"; @@ -11,7 +12,13 @@ import { getAESTToken, } from "../../../../testHelpers"; import * as request from "supertest"; -import { RestrictionType } from "@sims/sims-db"; +import { + FieldRequirementType, + Restriction, + RestrictionType, +} from "@sims/sims-db"; +import { RestrictionAPIOutDTO } from "apps/api/src/route-controllers/restriction/models/restriction.dto"; +import { In } from "typeorm"; /** * E2E test restriction category used for tests. @@ -22,11 +29,24 @@ const E2E_RESTRICTION_CATEGORY = "E2E Test Category"; describe("RestrictionAESTController(e2e)-getReasonsOptionsList.", () => { let app: INestApplication; let db: E2EDataSources; + let knownInstitutionRestrictions: Restriction[]; beforeAll(async () => { const { nestApplication, dataSource } = await createTestingAppModule(); app = nestApplication; db = createE2EDataSources(dataSource); + knownInstitutionRestrictions = await db.restriction.find({ + select: { + id: true, + restrictionCode: true, + description: true, + metadata: true, + }, + where: { + restrictionType: RestrictionType.Institution, + restrictionCode: In([RestrictionCode.SUS, RestrictionCode.REMIT]), + }, + }); }); beforeEach(async () => { @@ -37,59 +57,74 @@ describe("RestrictionAESTController(e2e)-getReasonsOptionsList.", () => { }); describe("Should get reasons options list for a restriction type when the request is valid.", () => { - for (const restrictionType of [ - RestrictionType.Provincial, - RestrictionType.Institution, + for (const restrictionInput of [ + { restrictionType: RestrictionType.Provincial, metadata: undefined }, + { + restrictionType: RestrictionType.Institution, + metadata: { + fieldRequirements: { + someRequiredField: FieldRequirementType.Required, + someNotAllowedField: FieldRequirementType.NotAllowed, + }, + }, + }, ]) { - it(`Should get ${restrictionType} restrictions reasons list filtered by specific category when ${restrictionType} restrictions are requested for a category.`, async () => { + it(`Should get ${restrictionInput.restrictionType} restrictions reasons list filtered by specific category when ${restrictionInput.restrictionType} restrictions are requested for a category.`, async () => { // Arrange const e2eTestRestriction = createFakeRestriction({ initialValues: { - restrictionType: restrictionType, + restrictionType: restrictionInput.restrictionType, restrictionCategory: E2E_RESTRICTION_CATEGORY, + metadata: restrictionInput.metadata, }, }); await db.restriction.save(e2eTestRestriction); - const endpoint = `/aest/restriction/reasons?type=${restrictionType}&category=${E2E_RESTRICTION_CATEGORY}`; + const endpoint = `/aest/restriction/reasons?type=${restrictionInput.restrictionType}&category=${E2E_RESTRICTION_CATEGORY}`; const token = await getAESTToken(AESTGroups.BusinessAdministrators); - + const expectedResultItem: RestrictionAPIOutDTO = { + id: e2eTestRestriction.id, + description: `${e2eTestRestriction.restrictionCode} - ${e2eTestRestriction.description}`, + }; + if (restrictionInput.metadata?.fieldRequirements) { + expectedResultItem.fieldRequirements = + restrictionInput.metadata.fieldRequirements; + } // Act/Assert await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) .expect(HttpStatus.OK) - .expect([ - { - id: e2eTestRestriction.id, - description: `${e2eTestRestriction.restrictionCode} - ${e2eTestRestriction.description}`, - }, - ]); + .expect([expectedResultItem]); }); } }); - it("Should throw a bad request exception when requesting federal restrictions reasons.", async () => { + it(`Should get all the ${RestrictionType.Institution} restrictions reasons list when there is no restriction category filter applied.`, async () => { // Arrange - const endpoint = `/aest/restriction/reasons?type=${RestrictionType.Federal}&category=someCategory`; + const endpoint = `/aest/restriction/reasons?type=${RestrictionType.Institution}`; const token = await getAESTToken(AESTGroups.BusinessAdministrators); - // Act/Assert await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.BAD_REQUEST) - .expect({ - message: [ - "type must be one of the following values: Provincial, Institution", - ], - error: "Bad Request", - statusCode: HttpStatus.BAD_REQUEST, + .expect(HttpStatus.OK) + .then((response) => { + const result = response.body; + expect(result).toEqual( + expect.arrayContaining( + knownInstitutionRestrictions.map((restriction) => ({ + id: restriction.id, + description: `${restriction.restrictionCode} - ${restriction.description}`, + fieldRequirements: restriction.metadata?.fieldRequirements, + })), + ), + ); }); }); - it("Should throw a bad request exception when category is not provided.", async () => { + it("Should throw a bad request exception when requesting federal restrictions reasons.", async () => { // Arrange - const endpoint = `/aest/restriction/reasons?type=${RestrictionType.Provincial}`; + const endpoint = `/aest/restriction/reasons?type=${RestrictionType.Federal}&category=someCategory`; const token = await getAESTToken(AESTGroups.BusinessAdministrators); // Act/Assert @@ -99,8 +134,7 @@ describe("RestrictionAESTController(e2e)-getReasonsOptionsList.", () => { .expect(HttpStatus.BAD_REQUEST) .expect({ message: [ - "category must be shorter than or equal to 50 characters", - "category should not be empty", + "type must be one of the following values: Provincial, Institution", ], error: "Bad Request", statusCode: HttpStatus.BAD_REQUEST, 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..5b9534f127 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"; /** @@ -117,15 +119,17 @@ 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 }) - locationIds: number[]; + locationIds?: number[]; /** * Program ID where the restriction is applicable. */ + @IsOptional() @IsPositive() - programId: number; + programId?: number; } /** @@ -168,8 +172,8 @@ export class InstitutionRestrictionsAPIOutDTO { * Active institution restriction details. */ export class InstitutionActiveRestrictionAPIOutDTO { - programId: number; - locationId: number; + programId?: number; + locationId?: number; restrictionCode: string; restrictionActions: RestrictionActionType[]; } @@ -193,7 +197,16 @@ export class RestrictionReasonsOptionsAPIInDTO { /** * Category of the restriction expected to be filtered. */ - @IsNotEmpty() + @IsOptional() @MaxLength(RESTRICTION_CATEGORY_MAX_LENGTH) - category: string; + 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..bc580b7b88 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, @@ -38,6 +39,7 @@ import { AssignInstitutionRestrictionAPIInDTO, InstitutionRestrictionSummaryAPIOutDTO, InstitutionActiveRestrictionsAPIOutDTO, + RestrictionAPIOutDTO, } from "./models/restriction.dto"; import { ApiProcessError, ClientTypeBaseRoute } from "../../types"; import { getUserFullName } from "../../utilities"; @@ -59,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"; @@ -120,7 +122,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 +131,7 @@ export class RestrictionAESTController extends BaseController { return reasons.map((reason) => ({ id: reason.id, description: `${reason.restrictionCode} - ${reason.description}`, + fieldRequirements: reason.metadata?.fieldRequirements, })); } @@ -297,8 +300,8 @@ export class RestrictionAESTController extends BaseController { restrictionCode: institutionRestriction.restriction.restrictionCode, description: institutionRestriction.restriction.description, createdAt: institutionRestriction.createdAt, - locationName: institutionRestriction.location.name, - programName: institutionRestriction.program.name, + locationName: institutionRestriction.location?.name, + programName: institutionRestriction.program?.name, resolvedAt: institutionRestriction.resolvedAt, isActive: institutionRestriction.isActive, })); @@ -400,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) }; @@ -412,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/route-controllers/restriction/restriction.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.controller.service.ts index 88b60fabd3..f38b17b0b4 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/restriction/restriction.controller.service.ts @@ -200,8 +200,8 @@ export class RestrictionControllerService { ); return { items: institutionRestrictions.map((institutionRestriction) => ({ - programId: institutionRestriction.program.id, - locationId: institutionRestriction.location.id, + programId: institutionRestriction.program?.id, + locationId: institutionRestriction.location?.id, restrictionActions: institutionRestriction.restriction.actionType, restrictionCode: institutionRestriction.restriction.restrictionCode, })), 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..323964f9da 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,5 @@ import { Injectable } from "@nestjs/common"; -import { DataSource, EntityManager, Equal, Not } from "typeorm"; +import { DataSource, EntityManager, Equal, Not, Repository } from "typeorm"; import { RecordDataModelService, InstitutionRestriction, @@ -10,12 +10,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 +23,9 @@ import { RESTRICTION_NOT_FOUND, } from "../../constants"; import { NoteSharedService } from "@sims/services"; +import { CreateInstitutionRestrictionModel } from "./models/institution-restriction.model"; +import { validateFieldRequirements } from "../../utilities"; +import { InjectRepository } from "@nestjs/typeorm"; /** * Service layer for institution Restriction. @@ -32,7 +35,8 @@ export class InstitutionRestrictionService extends RecordDataModelService, ) { super(dataSource.getRepository(InstitutionRestriction)); } @@ -73,8 +77,8 @@ 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, ); + // If the restriction is not applicable to locations, create a single institution restriction + // without location. + const locationsToCreateRestriction = uniqueLocationIds.length + ? uniqueLocationIds + : [null]; // New institution restriction creation. - const newRestrictions = locationIds.map((locationId) => { + const newRestrictions = locationsToCreateRestriction.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.restriction = { + id: institutionRestriction.restrictionId, + } as Restriction; + restriction.location = locationId + ? ({ id: locationId } as InstitutionLocation) + : null; + restriction.program = institutionRestriction.programId + ? ({ + id: institutionRestriction.programId, + } as EducationProgram) + : null; restriction.creator = { id: auditUserId } as User; restriction.restrictionNote = note; restriction.isActive = true; @@ -232,60 +248,37 @@ 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 }, - ) - .where("institution.id = :institutionId", { institutionId }) - .getOne(); - // Check the restriction existence. - const restrictionExistsPromise = this.restrictionService.restrictionExists( + const institutionPromise = this.getInstitutionToValidateRestrictions( + institutionId, restrictionId, - RestrictionType.Institution, - { entityManager }, + programId, + locationIds, + 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 +287,49 @@ export class InstitutionRestrictionService extends RecordDataModelService 0) { + const errorMessage = [ + `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}`, + ] + .filter(Boolean) + .join(", ") + .concat("."); 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}.`, + errorMessage, 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", programId], + ["location", locationIds], + ]), + restriction.metadata.fieldRequirements, + ); + if (!fieldValidationResult.isValid) { throw new CustomNamedError( - `Institution restriction ID ${restrictionId} not found.`, - RESTRICTION_NOT_FOUND, + `Field requirement error(s): ${fieldValidationResult.errorMessages.join(", ")}.`, + FIELD_REQUIREMENTS_NOT_VALID, ); } } @@ -435,4 +449,87 @@ export class InstitutionRestrictionService extends RecordDataModelService { + const repo = + options?.entityManager?.getRepository(Restriction) ?? + this.restrictionRepo; + return repo.findOne({ + select: { id: true, metadata: true }, + where: { + id: restrictionId, + restrictionType: RestrictionType.Institution, + }, + }); + } + + /** + * Get institution with active institution restrictions and associated locations and programs + * to validate the creation of new institution restrictions. + * @param institutionId Institution ID. + * @param restrictionId Restriction ID. + * @param programId Program ID. + * @param locationIds Location IDs. + * @param entityManager The entity manager to use for the query. + * @returns Institution with details to validate the creation of new institution restrictions. + */ + private async getInstitutionToValidateRestrictions( + institutionId: number, + restrictionId: number, + programId: number | undefined, + locationIds: number[] | undefined, + entityManager: EntityManager, + ): Promise { + const hasProgram = !!programId; + const hasLocations = !!locationIds?.length; + 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 = [ + "institutionRestriction.isActive = TRUE AND institutionRestriction.restriction.id = :restrictionId", + hasProgram + ? "institutionRestriction.program.id = :programId" + : "institutionRestriction.program.id IS NULL", + hasLocations + ? "institutionRestriction.location.id IN (:...locationIds)" + : "institutionRestriction.location.id IS NULL", + ].join(" AND "); + + return query + .leftJoin( + "institution.restrictions", + "institutionRestriction", + institutionRestrictionCriteria, + ) + .where("institution.id = :institutionId", { institutionId }) + .setParameters({ institutionId, restrictionId, programId, locationIds }) + .getOne(); + } } 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..062b9f4709 --- /dev/null +++ b/sources/packages/backend/apps/api/src/services/restriction/models/institution-restriction.model.ts @@ -0,0 +1,6 @@ +export interface CreateInstitutionRestrictionModel { + restrictionId: number; + noteDescription: string; + locationIds?: number[]; + programId?: number; +} 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/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..f8f72355cc --- /dev/null +++ b/sources/packages/backend/apps/api/src/utilities/metadata-utils.ts @@ -0,0 +1,59 @@ +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 there is no requirement for the field, return without validating. + if (!requirement) { + return; + } + const isValueProvided = Array.isArray(fieldValue) + ? !!fieldValue.length + : !!fieldValue; + if (requirement === FieldRequirementType.Required && !isValueProvided) { + return `${fieldKey} is required`; + } + if (requirement === FieldRequirementType.NotAllowed && isValueProvided) { + return `${fieldKey} is not allowed`; + } +} + +/** + * Validate field requirements. + * @param fieldKeyValues + * @param fieldRequirements + * @returns validation result. + */ +export function validateFieldRequirements( + fieldKeyValues: Map, + fieldRequirements: Record | undefined, +): { isValid: boolean; errorMessages: string[] } { + if (!fieldRequirements) { + throw new Error("Field requirements must be present for validation."); + } + 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/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/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 new file mode 100644 index 0000000000..098449d2f1 --- /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 = '{"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 new file mode 100644 index 0000000000..7294bfc76c --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Insert-remit-restriction.sql @@ -0,0 +1,20 @@ +INSERT INTO + sims.restrictions ( + restriction_type, + restriction_code, + description, + restriction_category, + action_type, + notification_type, + metadata + ) +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, + '{"fieldRequirements":{ "location": "required", "program": "not allowed" }}' :: 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..814f47c24d --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Institution-restrictions-drop-program-location-not-null.sql @@ -0,0 +1,21 @@ +ALTER TABLE + sims.institution_restrictions +ALTER COLUMN + location_id DROP NOT NULL, +ALTER COLUMN + program_id DROP NOT NULL; + +-- Remove the and re-create existing unique index which was based on program_id and location_id being non-nullable +-- and treat null as a value for uniqueness. +DROP INDEX sims.institution_id_location_id_program_id_restriction_id_is_active_unique; + +CREATE UNIQUE INDEX institution_id_location_id_program_id_restriction_id_is_active_unique ON sims.institution_restrictions ( + institution_id, + location_id, + program_id, + restriction_id +) NULLS NOT DISTINCT +WHERE + is_active = TRUE; + +COMMENT ON INDEX sims.institution_id_location_id_program_id_restriction_id_is_active_unique IS 'Ensures only one active restriction per institution, location, program, and restriction combination for active restrictions.'; \ 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/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..6fa4101dab --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Restrictions/Rollback-institution-restrictions-drop-program-location-not-null.sql @@ -0,0 +1,24 @@ +ALTER TABLE + sims.institution_restrictions +ALTER COLUMN + location_id +SET + NOT NULL, +ALTER COLUMN + program_id +SET + NOT NULL; + +-- Remove the and re-create the unique index without the NULL value consideration. +DROP INDEX sims.institution_id_location_id_program_id_restriction_id_is_active_unique; + +CREATE UNIQUE INDEX institution_id_location_id_program_id_restriction_id_is_active_unique ON sims.institution_restrictions ( + institution_id, + location_id, + program_id, + restriction_id +) +WHERE + is_active = TRUE; + +COMMENT ON INDEX sims.institution_id_location_id_program_id_restriction_id_is_active_unique IS 'Ensures only one active restriction per institution, location, program, and restriction combination for active restrictions.'; \ No newline at end of file diff --git a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts index 5dab4e6d0b..c3e9a03b2b 100644 --- a/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts +++ b/sources/packages/backend/libs/integrations/src/services/disbursement-schedule/disbursement-schedule.models.ts @@ -215,11 +215,11 @@ export class InstitutionActiveRestriction extends BaseActiveRestriction { /** * Specific program the restriction applies to. */ - program: EducationProgram; + program?: EducationProgram; /** * Specific location the restriction applies to. */ - location: InstitutionLocation; + location?: InstitutionLocation; } /** @@ -414,8 +414,8 @@ export class EligibleECertDisbursement { const locationId = this.offering.institutionLocation.id; return this.institutionRestrictions.filter( (restriction) => - restriction.program.id === programId && - restriction.location.id === locationId && + restriction.program?.id === programId && + restriction.location?.id === locationId && !this.institutionRestrictionsBypassedIds.includes( restriction.institutionRestrictionId, ), diff --git a/sources/packages/backend/libs/sims-db/src/entities/common.models.ts b/sources/packages/backend/libs/sims-db/src/entities/common.models.ts new file mode 100644 index 0000000000..06e739f47a --- /dev/null +++ b/sources/packages/backend/libs/sims-db/src/entities/common.models.ts @@ -0,0 +1,13 @@ +/** + * 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 not allowed and must not be provided. + */ + NotAllowed = "not allowed", +} 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..92120cca07 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.models"; 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/institution-restriction.model.ts b/sources/packages/backend/libs/sims-db/src/entities/institution-restriction.model.ts index 104caa8bd3..f7735b3f54 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/institution-restriction.model.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/institution-restriction.model.ts @@ -29,20 +29,20 @@ export class InstitutionRestriction extends BaseRestrictionModel { /** * Specific program the restriction applies to. */ - @ManyToOne(() => EducationProgram, { nullable: false }) + @ManyToOne(() => EducationProgram, { nullable: true }) @JoinColumn({ name: "program_id", referencedColumnName: ColumnNames.ID, }) - program: EducationProgram; + program?: EducationProgram; /** * Specific location the restriction applies to. */ - @ManyToOne(() => InstitutionLocation, { nullable: false }) + @ManyToOne(() => InstitutionLocation, { nullable: true }) @JoinColumn({ name: "location_id", referencedColumnName: ColumnNames.ID, }) - location: InstitutionLocation; + location?: InstitutionLocation; } 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..b1d9f69fe4 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 { FieldRequirementType, 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; } /** @@ -112,3 +122,13 @@ export interface ActionEffectiveCondition { export enum ActionEffectiveConditionNames { AviationCredentialTypes = "Aviation credential types", } + +/** + * Restriction metadata. + */ +export interface RestrictionMetadata { + /** + * The restricted party(student or institution) field requirements for the restriction. + */ + fieldRequirements: Record; +} diff --git a/sources/packages/backend/libs/test-utils/src/factories/restriction.ts b/sources/packages/backend/libs/test-utils/src/factories/restriction.ts index e3e8dea85d..036f8b02c5 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/restriction.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/restriction.ts @@ -17,20 +17,21 @@ export function createFakeRestriction(options?: { }): Restriction { const restriction = new Restriction(); restriction.restrictionType = - options?.initialValues.restrictionType ?? RestrictionType.Provincial; + options?.initialValues?.restrictionType ?? RestrictionType.Provincial; restriction.restrictionCategory = - options?.initialValues.restrictionCategory ?? "Other"; + options?.initialValues?.restrictionCategory ?? "Other"; restriction.restrictionCode = - options?.initialValues.restrictionCode ?? + options?.initialValues?.restrictionCode ?? faker.string.alpha({ length: 10, casing: "upper" }); restriction.description = - options?.initialValues.description ?? faker.lorem.words(2); - restriction.actionType = options?.initialValues.actionType ?? [ + options?.initialValues?.description ?? faker.lorem.words(2); + restriction.actionType = options?.initialValues?.actionType ?? [ RestrictionActionType.NoEffect, ]; restriction.notificationType = - options?.initialValues.notificationType ?? + options?.initialValues?.notificationType ?? RestrictionNotificationType.NoEffect; - restriction.isLegacy = options?.initialValues.isLegacy ?? false; + restriction.isLegacy = options?.initialValues?.isLegacy ?? false; + restriction.metadata = options?.initialValues?.metadata; return restriction; } diff --git a/sources/packages/backend/libs/test-utils/src/models/common.model.ts b/sources/packages/backend/libs/test-utils/src/models/common.model.ts index 0f5be55264..dc22592a96 100644 --- a/sources/packages/backend/libs/test-utils/src/models/common.model.ts +++ b/sources/packages/backend/libs/test-utils/src/models/common.model.ts @@ -163,4 +163,8 @@ export enum RestrictionCode { * Institution suspension restriction. */ SUS = "SUS", + /** + * Institution block remittance request restriction. + */ + REMIT = "REMIT", } diff --git a/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue b/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue index a879f0f0b5..90a44d675c 100644 --- a/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue +++ b/sources/packages/web/src/components/institutions/modals/AddRestrictionModal.vue @@ -14,8 +14,10 @@ variant="outlined" :rules="[(v) => checkNullOrEmptyRule(v, 'Reason')]" :loading="loadingData" + @update:model-value="resetFormModelValues()" hide-details="auto" />