diff --git a/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/_tests_/e2e/designation-agreement.aest.controller.updateDesignationAgreement.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/_tests_/e2e/designation-agreement.aest.controller.updateDesignationAgreement.e2e-spec.ts new file mode 100644 index 0000000000..59a684691e --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/_tests_/e2e/designation-agreement.aest.controller.updateDesignationAgreement.e2e-spec.ts @@ -0,0 +1,279 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { DesignationAgreementStatus, NoteType, User } from "@sims/sims-db"; +import { + AESTGroups, + BEARER_AUTH_TYPE, + createTestingAppModule, + getAESTToken, + getAESTUser, +} from "../../../../testHelpers"; +import { + E2EDataSources, + createE2EDataSources, + createFakeDesignationAgreement, + createFakeInstitution, + createFakeInstitutionLocation, + createFakeUser, +} from "@sims/test-utils"; + +describe("DesignationAgreementAESTController(e2e)-updateDesignationAgreement", () => { + let app: INestApplication; + let db: E2EDataSources; + let fakeUser: User; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + db = createE2EDataSources(dataSource); + fakeUser = await db.user.save(createFakeUser()); + }); + + it("Should approve a designation agreement and persist all fields when all payload locations belong to the institution.", async () => { + // Arrange + const institution = await db.institution.save(createFakeInstitution()); + const fakeLocation = createFakeInstitutionLocation({ institution }); + const savedLocation = await db.institutionLocation.save(fakeLocation); + const fakeDesignation = createFakeDesignationAgreement( + { + fakeInstitution: institution, + fakeInstitutionLocations: [savedLocation], + fakeUser, + }, + { + initialValue: { designationStatus: DesignationAgreementStatus.Pending }, + }, + ); + const savedDesignation = + await db.designationAgreement.save(fakeDesignation); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/designation-agreement/${savedDesignation.id}`; + + // Act + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + designationStatus: DesignationAgreementStatus.Approved, + startDate: "2026-01-01", + endDate: "2027-12-31", + locationsDesignations: [ + { locationId: savedLocation.id, approved: true }, + ], + note: "Designation approved.", + }) + .expect(HttpStatus.OK); + + // Assert + const auditUser = await getAESTUser( + db.dataSource, + AESTGroups.BusinessAdministrators, + ); + const updatedDesignation = await db.designationAgreement.findOne({ + select: { + id: true, + designationStatus: true, + startDate: true, + endDate: true, + designationAgreementLocations: { + id: true, + institutionLocation: { id: true }, + approved: true, + }, + institution: { + id: true, + notes: { id: true, noteType: true, description: true }, + }, + assessedBy: { id: true }, + assessedDate: true, + }, + relations: { + designationAgreementLocations: { institutionLocation: true }, + institution: { notes: true }, + assessedBy: true, + }, + where: { id: savedDesignation.id }, + loadEagerRelations: false, + }); + expect(updatedDesignation).toEqual({ + id: savedDesignation.id, + designationStatus: DesignationAgreementStatus.Approved, + startDate: "2026-01-01", + endDate: "2027-12-31", + designationAgreementLocations: [ + { + id: expect.any(Number), + institutionLocation: { id: savedLocation.id }, + approved: true, + }, + ], + institution: { + id: institution.id, + notes: [ + { + id: expect.any(Number), + noteType: NoteType.Designation, + description: "Designation approved.", + }, + ], + }, + assessedBy: { id: auditUser.id }, + assessedDate: expect.any(Date), + }); + }); + + it("Should decline a designation agreement.", async () => { + // Arrange + const institution = await db.institution.save(createFakeInstitution()); + const fakeLocation = createFakeInstitutionLocation({ institution }); + const savedLocation = await db.institutionLocation.save(fakeLocation); + const fakeDesignation = createFakeDesignationAgreement( + { + fakeInstitution: institution, + fakeInstitutionLocations: [savedLocation], + fakeUser, + }, + { + initialValue: { designationStatus: DesignationAgreementStatus.Pending }, + }, + ); + const savedDesignation = + await db.designationAgreement.save(fakeDesignation); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/designation-agreement/${savedDesignation.id}`; + + // Act + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + designationStatus: DesignationAgreementStatus.Declined, + note: "Designation declined.", + }) + .expect(HttpStatus.OK); + + // Assert + const updatedDesignation = await db.designationAgreement.findOneBy({ + id: savedDesignation.id, + }); + expect(updatedDesignation.designationStatus).toBe( + DesignationAgreementStatus.Declined, + ); + }); + + it("Should return not found when the designation agreement does not exist.", async () => { + // Arrange + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/designation-agreement/99999999`; + + // Act/Assert + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + designationStatus: DesignationAgreementStatus.Declined, + note: "Some note.", + }) + .expect(HttpStatus.NOT_FOUND) + .expect({ + statusCode: HttpStatus.NOT_FOUND, + message: + "Designation agreement not found or it has been declined already.", + error: "Not Found", + }); + }); + + it("Should return unprocessable entity when approved location does not belong to the designation institution.", async () => { + // Arrange + const institution = await db.institution.save(createFakeInstitution()); + const fakeLocation = createFakeInstitutionLocation({ institution }); + const savedLocation = await db.institutionLocation.save(fakeLocation); + const fakeDesignation = createFakeDesignationAgreement( + { + fakeInstitution: institution, + fakeInstitutionLocations: [savedLocation], + fakeUser, + }, + { + initialValue: { designationStatus: DesignationAgreementStatus.Pending }, + }, + ); + const savedDesignation = + await db.designationAgreement.save(fakeDesignation); + // Create a location that belongs to a different institution. + const unrelatedLocation = await db.institutionLocation.save( + createFakeInstitutionLocation(), + ); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/designation-agreement/${savedDesignation.id}`; + + // Act/Assert + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + designationStatus: DesignationAgreementStatus.Approved, + startDate: "2026-01-01", + endDate: "2027-12-31", + locationsDesignations: [ + { locationId: unrelatedLocation.id, approved: true }, + ], + note: "Attempt with unrelated location.", + }) + .expect(HttpStatus.UNPROCESSABLE_ENTITY) + .expect({ + message: + "One or more locations provided do not belong to designation institution.", + error: "Unprocessable Entity", + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + }); + + it("Should return unprocessable entity when an approved location is missing an institution location code.", async () => { + // Arrange + const institution = await db.institution.save(createFakeInstitution()); + // Create a location without an institution code to simulate locations + // submitted with the "I do not have an institution location code" option selected. + const fakeLocation = createFakeInstitutionLocation({ institution }); + fakeLocation.institutionCode = undefined; + const savedLocation = await db.institutionLocation.save(fakeLocation); + const fakeDesignation = createFakeDesignationAgreement( + { + fakeInstitution: institution, + fakeInstitutionLocations: [savedLocation], + fakeUser, + }, + { + initialValue: { designationStatus: DesignationAgreementStatus.Pending }, + }, + ); + const savedDesignation = + await db.designationAgreement.save(fakeDesignation); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/designation-agreement/${savedDesignation.id}`; + + // Act/Assert + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + designationStatus: DesignationAgreementStatus.Approved, + startDate: "2026-01-01", + endDate: "2027-12-31", + locationsDesignations: [ + { locationId: savedLocation.id, approved: true }, + ], + note: "Attempt to approve location without code.", + }) + .expect(HttpStatus.UNPROCESSABLE_ENTITY) + .expect({ + message: + "One or more approved locations are missing an institution location code.", + error: "Unprocessable Entity", + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + }); + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/designation-agreement.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/designation-agreement.aest.controller.ts index f8c9418e31..0ca075f44b 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/designation-agreement.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/designation-agreement/designation-agreement.aest.controller.ts @@ -127,7 +127,7 @@ export class DesignationAgreementAESTController extends BaseController { }) @ApiUnprocessableEntityResponse({ description: - "One or more locations provided do not belong to designation institution.", + "One or more locations provided do not belong to designation institution or are missing an institution location code.", }) async updateDesignationAgreement( @Param("designationId", ParseIntPipe) designationId: number, @@ -157,6 +157,18 @@ export class DesignationAgreementAESTController extends BaseController { "One or more locations provided do not belong to designation institution.", ); } + const approvedLocationIds = payload.locationsDesignations + .filter((location) => location.approved) + .map((location) => location.locationId); + if ( + await this.institutionLocationService.hasAnyLocationWithoutCode( + approvedLocationIds, + ) + ) { + throw new UnprocessableEntityException( + "One or more approved locations are missing an institution location code.", + ); + } } await this.designationAgreementService.updateDesignation( designationId, diff --git a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/_tests_/e2e/institution-location.aest.controller.getInstitutionLocation.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/_tests_/e2e/institution-location.aest.controller.getInstitutionLocation.e2e-spec.ts new file mode 100644 index 0000000000..d0e3669352 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/_tests_/e2e/institution-location.aest.controller.getInstitutionLocation.e2e-spec.ts @@ -0,0 +1,94 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + AESTGroups, + BEARER_AUTH_TYPE, + createTestingAppModule, + getAESTToken, +} from "../../../../testHelpers"; +import { + E2EDataSources, + createE2EDataSources, + createFakeInstitutionLocation, +} from "@sims/test-utils"; + +describe("InstitutionLocationAESTController(e2e)-getInstitutionLocation", () => { + let app: INestApplication; + let db: E2EDataSources; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + db = createE2EDataSources(dataSource); + }); + + it("Should not return the institution code property when no code is assigned.", async () => { + // Arrange + // Create a location without an institution code to simulate locations + // submitted with the "I do not have an institution location code" option selected. + const location = createFakeInstitutionLocation(); + location.institutionCode = undefined; + const savedLocation = await db.institutionLocation.save(location); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/location/${savedLocation.id}`; + + // Act/Assert + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + expect(response.body.institutionCode).toBeNull(); + }); + + it("Should return institution location with all fields when an institution code is assigned.", async () => { + // Arrange + const savedLocation = await db.institutionLocation.save( + createFakeInstitutionLocation(), + ); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/location/${savedLocation.id}`; + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect({ + locationName: savedLocation.name, + institutionCode: savedLocation.institutionCode, + noInstitutionCode: false, + primaryContactFirstName: savedLocation.primaryContact.firstName, + primaryContactLastName: savedLocation.primaryContact.lastName, + primaryContactEmail: savedLocation.primaryContact.email, + primaryContactPhone: savedLocation.primaryContact.phone, + addressLine1: savedLocation.data.address.addressLine1, + provinceState: savedLocation.data.address.provinceState, + country: savedLocation.data.address.country, + city: savedLocation.data.address.city, + postalCode: savedLocation.data.address.postalCode, + canadaPostalCode: savedLocation.data.address.postalCode, + selectedCountry: savedLocation.data.address.selectedCountry, + }); + }); + + it("Should return not found when the institution location does not exist.", async () => { + // Arrange + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/location/99999999`; + + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.NOT_FOUND) + .expect({ + statusCode: HttpStatus.NOT_FOUND, + message: "Institution Location was not found.", + error: "Not Found", + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/_tests_/e2e/institution-location.aest.controller.update.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/_tests_/e2e/institution-location.aest.controller.update.e2e-spec.ts new file mode 100644 index 0000000000..e1f856d7bf --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/_tests_/e2e/institution-location.aest.controller.update.e2e-spec.ts @@ -0,0 +1,166 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + AESTGroups, + BEARER_AUTH_TYPE, + createTestingAppModule, + getAESTToken, +} from "../../../../testHelpers"; +import { + E2EDataSources, + createE2EDataSources, + createFakeInstitutionLocation, +} from "@sims/test-utils"; +import { DUPLICATE_INSTITUTION_LOCATION_CODE } from "../../../../constants"; + +describe("InstitutionLocationAESTController(e2e)-update", () => { + let app: INestApplication; + let db: E2EDataSources; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + db = createE2EDataSources(dataSource); + }); + + it("Should update all institution location fields and persist them correctly.", async () => { + // Arrange + const location = createFakeInstitutionLocation(); + const savedLocation = await db.institutionLocation.save(location); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/location/${savedLocation.id}`; + + // Act + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + locationName: "Updated Location Name", + institutionCode: "UPDT", + primaryContactFirstName: "John", + primaryContactLastName: "Doe", + primaryContactEmail: "john.doe@testmail.com", + primaryContactPhone: "(250) 555-1234", + addressLine1: "123 Updated Street", + city: "Vancouver", + country: "canada", + postalCode: "B1B1B1", + selectedCountry: "Canada", + provinceState: "BC", + canadaPostalCode: "B1B1B1", + }) + .expect(HttpStatus.OK); + + // Assert + const updatedLocation = await db.institutionLocation.findOne({ + select: { + id: true, + name: true, + institutionCode: true, + data: true, + primaryContact: true, + }, + where: { id: savedLocation.id }, + }); + expect(updatedLocation).toEqual({ + id: savedLocation.id, + name: "Updated Location Name", + institutionCode: "UPDT", + data: { + address: { + addressLine1: "123 Updated Street", + city: "Vancouver", + country: "canada", + postalCode: "B1B1B1", + selectedCountry: "Canada", + provinceState: "BC", + }, + }, + primaryContact: { + firstName: "John", + lastName: "Doe", + email: "john.doe@testmail.com", + phone: "(250) 555-1234", + }, + }); + }); + + it("Should save institution location with null code when an empty institution code is submitted.", async () => { + // Arrange + const location = createFakeInstitutionLocation(); + const savedLocation = await db.institutionLocation.save(location); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/location/${savedLocation.id}`; + const address = savedLocation.data.address; + + // Act + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + locationName: savedLocation.name, + institutionCode: "", + primaryContactFirstName: savedLocation.primaryContact.firstName, + primaryContactLastName: savedLocation.primaryContact.lastName, + primaryContactEmail: savedLocation.primaryContact.email, + primaryContactPhone: savedLocation.primaryContact.phone, + addressLine1: address.addressLine1, + city: address.city, + country: address.country, + postalCode: address.postalCode, + selectedCountry: address.selectedCountry, + provinceState: address.provinceState, + canadaPostalCode: address.postalCode, + }) + .expect(HttpStatus.OK); + + // Assert + const updatedLocation = await db.institutionLocation.findOneBy({ + id: savedLocation.id, + }); + expect(updatedLocation.institutionCode).toBeNull(); + }); + + it("Should return unprocessable entity when trying to update with a code already assigned to another location in the same institution.", async () => { + // Arrange + // Create two locations for the same institution so both share the same institutionCode conflict check scope. + const locationA = createFakeInstitutionLocation(); + const savedLocationA = await db.institutionLocation.save(locationA); + const locationB = createFakeInstitutionLocation({ + institution: savedLocationA.institution, + }); + const savedLocationB = await db.institutionLocation.save(locationB); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/location/${savedLocationB.id}`; + const address = savedLocationB.data.address; + + // Act/Assert — attempt to assign locationA's code to locationB. + await request(app.getHttpServer()) + .patch(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .send({ + locationName: savedLocationB.name, + institutionCode: savedLocationA.institutionCode, + primaryContactFirstName: savedLocationB.primaryContact.firstName, + primaryContactLastName: savedLocationB.primaryContact.lastName, + primaryContactEmail: savedLocationB.primaryContact.email, + primaryContactPhone: savedLocationB.primaryContact.phone, + addressLine1: address.addressLine1, + city: address.city, + country: address.country, + postalCode: address.postalCode, + selectedCountry: address.selectedCountry, + provinceState: address.provinceState, + canadaPostalCode: address.postalCode, + }) + .expect(HttpStatus.UNPROCESSABLE_ENTITY) + .expect({ + message: "Duplicate institution location code.", + errorType: DUPLICATE_INSTITUTION_LOCATION_CODE, + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.controller.service.ts index 0ec0d796b4..8a4e7f18be 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.controller.service.ts @@ -51,6 +51,7 @@ export class InstitutionLocationControllerService { primaryContactPhone: el.primaryContact?.phone, }, institutionCode: el.institutionCode, + noInstitutionCode: !el.institutionCode, }; }, ); @@ -95,6 +96,7 @@ export class InstitutionLocationControllerService { return { locationName: institutionLocation.name, institutionCode: institutionLocation.institutionCode, + noInstitutionCode: !institutionLocation.institutionCode, primaryContactFirstName: institutionLocation.primaryContact.firstName, primaryContactLastName: institutionLocation.primaryContact.lastName, primaryContactEmail: institutionLocation.primaryContact.email, diff --git a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.institutions.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.institutions.controller.ts index 4c4c2b3ec2..4d80fb4d88 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.institutions.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/institution-location.institutions.controller.ts @@ -204,6 +204,7 @@ export class InstitutionLocationInstitutionsController extends BaseController { return { locationName: institutionLocation.name, institutionCode: institutionLocation.institutionCode, + noInstitutionCode: !institutionLocation.institutionCode, primaryContactFirstName: institutionLocation.primaryContact.firstName, primaryContactLastName: institutionLocation.primaryContact.lastName, primaryContactEmail: institutionLocation.primaryContact.email, diff --git a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/models/institution-location.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/models/institution-location.dto.ts index b991c181a7..7b9d056d6a 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/institution-locations/models/institution-location.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/institution-locations/models/institution-location.dto.ts @@ -1,4 +1,10 @@ -import { Allow, IsEmail, IsNotEmpty, Length } from "class-validator"; +import { + Allow, + IsEmail, + IsNotEmpty, + IsOptional, + Length, +} from "class-validator"; import { AddressAPIOutDTO, AddressDetailsAPIInDTO, @@ -16,6 +22,8 @@ export class InstitutionLocationFormAPIInDTO extends AddressDetailsAPIInDTO { @Allow() institutionCode: string; @Allow() + noInstitutionCode: boolean; + @Allow() primaryContactFirstName: string; @Allow() primaryContactLastName: string; @@ -47,8 +55,8 @@ export class AESTInstitutionLocationAPIInDTO extends AddressDetailsAPIInDTO { primaryContactPhone: string; @IsNotEmpty() locationName: string; - @IsNotEmpty() - institutionCode: string; + @IsOptional() + institutionCode?: string; } /** @@ -56,7 +64,8 @@ export class AESTInstitutionLocationAPIInDTO extends AddressDetailsAPIInDTO { */ export class InstitutionLocationDetailsAPIOutDTO extends AddressDetailsAPIOutDTO { locationName: string; - institutionCode: string; + institutionCode?: string; + noInstitutionCode: boolean; primaryContactFirstName: string; primaryContactLastName: string; primaryContactEmail: string; @@ -80,7 +89,8 @@ export class InstitutionLocationAPIOutDTO { address: AddressAPIOutDTO; }; primaryContact: InstitutionPrimaryContactAPIOutDTO; - institutionCode: string; + institutionCode?: string; + noInstitutionCode: boolean; designationStatus: DesignationStatus; } diff --git a/sources/packages/backend/apps/api/src/services/institution-location/institution-location.models.ts b/sources/packages/backend/apps/api/src/services/institution-location/institution-location.models.ts index 80e64c2674..36365f1745 100644 --- a/sources/packages/backend/apps/api/src/services/institution-location/institution-location.models.ts +++ b/sources/packages/backend/apps/api/src/services/institution-location/institution-location.models.ts @@ -14,11 +14,12 @@ export interface LocationWithDesignationStatus { } /** - * Service model for AEST user location update. + * Service model for institution user location create. */ export interface InstitutionLocationModel extends AddressInfo { locationName: string; - institutionCode: string; + institutionCode?: string; + noInstitutionCode?: boolean; primaryContactFirstName: string; primaryContactLastName: string; primaryContactEmail: string; diff --git a/sources/packages/backend/apps/api/src/services/institution-location/institution-location.service.ts b/sources/packages/backend/apps/api/src/services/institution-location/institution-location.service.ts index 307d016b15..7d14114d65 100644 --- a/sources/packages/backend/apps/api/src/services/institution-location/institution-location.service.ts +++ b/sources/packages/backend/apps/api/src/services/institution-location/institution-location.service.ts @@ -40,16 +40,21 @@ export class InstitutionLocationService extends RecordDataModelService { - const isInstitutionCodeDuplicate = await this.hasLocationCodeForInstitution( - institutionLocationData.institutionCode, - { locationId }, - ); - - if (isInstitutionCodeDuplicate) { - throw new CustomNamedError( - "Duplicate institution location code.", - DUPLICATE_INSTITUTION_LOCATION_CODE, - ); + const normalizedInstitutionCode = + institutionLocationData.institutionCode?.trim() || null; + if (normalizedInstitutionCode) { + const isInstitutionCodeDuplicate = + await this.hasLocationCodeForInstitution(normalizedInstitutionCode, { + locationId, + }); + if (isInstitutionCodeDuplicate) { + throw new CustomNamedError( + "Duplicate institution location code.", + DUPLICATE_INSTITUTION_LOCATION_CODE, + ); + } } const saveLocation: InstitutionLocation = { @@ -131,7 +141,7 @@ export class InstitutionLocationService extends RecordDataModelService { + const locationWithoutCode = await this.repo + .createQueryBuilder("location") + .select("1") + .where("location.id IN (:...locationIds)", { locationIds }) + .andWhere("location.institutionCode IS NULL") + .limit(1) + .getRawOne(); + return !!locationWithoutCode; + } + /** * Check if location code is already registered for the institution. * @param locationCode location code. @@ -332,7 +359,7 @@ export class InstitutionLocationService extends RecordDataModelService { - return this.buildLocationQuery(locationId, undefined).getRawOne(); + return this.buildLocationQuery(locationId).getRawOne(); } /** diff --git a/sources/packages/backend/libs/sims-db/src/entities/institution-location.model.ts b/sources/packages/backend/libs/sims-db/src/entities/institution-location.model.ts index 07ff8f89ce..e8ff2e7583 100644 --- a/sources/packages/backend/libs/sims-db/src/entities/institution-location.model.ts +++ b/sources/packages/backend/libs/sims-db/src/entities/institution-location.model.ts @@ -43,8 +43,9 @@ export class InstitutionLocation extends RecordDataModel { @Column({ name: "institution_code", + nullable: true, }) - institutionCode: string; + institutionCode?: string; @Column({ name: "primary_contact", diff --git a/sources/packages/forms/package-lock.json b/sources/packages/forms/package-lock.json index 490da0b6dd..d050716fa8 100644 --- a/sources/packages/forms/package-lock.json +++ b/sources/packages/forms/package-lock.json @@ -791,4 +791,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/sources/packages/forms/src/form-definitions/institutionlocation.json b/sources/packages/forms/src/form-definitions/institutionlocation.json index 4ce5edc04c..8b13ef0d5f 100644 --- a/sources/packages/forms/src/form-definitions/institutionlocation.json +++ b/sources/packages/forms/src/form-definitions/institutionlocation.json @@ -65,30 +65,97 @@ "maxLength": 100 }, "key": "locationName", - "attributes": { - "data-cy": "locationName" - }, "type": "textfield", "input": true }, { "label": "Institution location code", - "tooltip": "Unique 4 digit alpha code assigned the first time an institution is designated in Canada. If your institution has not yet been designated, input a random 4-letter code as a temporary Institution Location Code, and contact SABC to revise this code once your permanent code has been received. This code must be a valid federal education institution code prior to students submitting applications", + "applyMaskOn": "change", "tableView": true, "case": "uppercase", "validate": { "required": true, + "pattern": "[A-Z]*", "minLength": 4, - "maxLength": 4, - "pattern": "[A-Z]*" + "maxLength": 4 }, + "validateWhenHidden": false, "key": "institutionCode", - "attributes": { - "data-cy": "institutionCode" - }, + "logic": [ + { + "name": "Disable and clear when no institution location code", + "trigger": { + "type": "simple", + "simple": { + "show": true, + "when": "noInstitutionCode", + "eq": "true" + } + }, + "actions": [ + { + "name": "Disable field", + "type": "property", + "property": { + "label": "Disabled", + "value": "disabled", + "type": "boolean" + }, + "state": true + }, + { + "name": "Clear value", + "type": "value", + "value": "value = '';" + }, + { + "name": "Required false", + "type": "property", + "property": { + "label": "Required", + "value": "validate.required", + "type": "boolean" + }, + "state": false + } + ] + } + ], "type": "textfield", "input": true }, + { + "label": "I do not have an institution location code.", + "tableView": false, + "defaultValue": false, + "key": "noInstitutionCode", + "type": "checkbox", + "input": true + }, + { + "label": "Missing institution location code", + "tag": "p", + "className": "alert alert-warning fa fa-exclamation-triangle w-100", + "attrs": [ + { + "attr": "", + "value": "" + } + ], + "content": " Missing institution location code\n
Please note: You will be able to create this location without an institution location code. However, you will not be able to designate this location until you obtain a federally issued code from StudentAid BC. Once you submit your location please email designat@gov.bc.ca and request they provide you with the code and update the location information.", + "refreshOnChange": true, + "customClass": "banner-warning", + "hidden": true, + "key": "noInstitutionCodeBanner", + "conditional": { + "show": true, + "when": "noInstitutionCode", + "eq": "true" + }, + "type": "htmlelement", + "input": false, + "tableView": false + }, { "label": "Address line 1", "tableView": false, @@ -97,9 +164,6 @@ "maxLength": 100 }, "key": "addressLine1", - "attributes": { - "data-cy": "addressLine1" - }, "type": "textfield", "input": true }, @@ -110,9 +174,6 @@ "maxLength": 100 }, "key": "addressLine2", - "attributes": { - "data-cy": "addressLine2" - }, "type": "textfield", "input": true }, @@ -142,9 +203,6 @@ "onlyAvailableItems": true }, "key": "selectedCountry", - "attributes": { - "data-cy": "selectedCountry" - }, "type": "select", "input": true } @@ -228,9 +286,6 @@ "when": "selectedCountry", "eq": "Canada" }, - "attributes": { - "data-cy": "provinceState" - }, "type": "select", "input": true }, @@ -248,9 +303,6 @@ "when": "selectedCountry", "eq": "other" }, - "attributes": { - "data-cy": "otherCountry" - }, "type": "textfield", "input": true }, @@ -290,9 +342,6 @@ "maxLength": 100 }, "key": "city", - "attributes": { - "data-cy": "city" - }, "type": "textfield", "input": true, "hideOnChildrenHidden": false @@ -324,9 +373,6 @@ "when": "selectedCountry", "eq": "Canada" }, - "attributes": { - "data-cy": "canadaPostalCode" - }, "type": "textfield", "input": true, "hideOnChildrenHidden": false @@ -344,9 +390,6 @@ "when": "selectedCountry", "eq": "other" }, - "attributes": { - "data-cy": "otherPostalCode" - }, "type": "textfield", "input": true }, @@ -431,9 +474,6 @@ "maxLength": 100 }, "key": "primaryContactFirstName", - "attributes": { - "data-cy": "primaryContactFirstName" - }, "type": "textfield", "input": true, "hideOnChildrenHidden": false @@ -456,9 +496,6 @@ "maxLength": 100 }, "key": "primaryContactLastName", - "attributes": { - "data-cy": "primaryContactLastName" - }, "type": "textfield", "input": true, "hideOnChildrenHidden": false @@ -489,9 +526,6 @@ "required": true }, "key": "primaryContactEmail", - "attributes": { - "data-cy": "primaryContactEmail" - }, "type": "email", "input": true } @@ -516,9 +550,6 @@ "maxLength": 20 }, "key": "primaryContactPhone", - "attributes": { - "data-cy": "primaryContactPhone" - }, "type": "textfield", "input": true, "hideOnChildrenHidden": false diff --git a/sources/packages/web/src/components/aest/institution/modals/ApproveDenyDesignationModal.vue b/sources/packages/web/src/components/aest/institution/modals/ApproveDenyDesignationModal.vue index e2902bf7b6..bccb401db5 100644 --- a/sources/packages/web/src/components/aest/institution/modals/ApproveDenyDesignationModal.vue +++ b/sources/packages/web/src/components/aest/institution/modals/ApproveDenyDesignationModal.vue @@ -1,6 +1,6 @@ + :primary-label="submitLabel" + @primary-click="submit" + @secondary-click="cancel" + :disable-primary-button="notAllowed" + /> + + @@ -70,16 +96,14 @@