diff --git a/sims.code-workspace b/sims.code-workspace index 6d20f73152..8fd7049566 100644 --- a/sims.code-workspace +++ b/sims.code-workspace @@ -90,6 +90,7 @@ }, "typescript.preferences.importModuleSpecifier": "non-relative", "cSpell.words": [ + "addressline", "AEST", "bcag", "BCLM", @@ -110,6 +111,7 @@ "ESDC", "Formio", "golevelup", + "lastupdated", "MBAL", "MSFAA", "NOAAPI", @@ -117,9 +119,16 @@ "Overaward", "overawards", "PEDU", + "postalcode", "SABC", "sbsd", "SFAS", + "siteprotected", + "supplieraddress", + "suppliername", + "suppliernumber", + "supplierprotected", + "suppliersitecode", "timestamptz", "typeorm", "unparse", diff --git a/sources/packages/backend/apps/api/src/route-controllers/models/common.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/models/common.dto.ts index 10e413ad15..e49c5039e2 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/models/common.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/models/common.dto.ts @@ -1,5 +1,5 @@ +import { COUNTRY_CANADA, OTHER_COUNTRY } from "@sims/utilities"; import { IsNotEmpty, IsOptional, ValidateIf } from "class-validator"; -import { COUNTRY_CANADA, OTHER_COUNTRY } from "../utils/address-utils"; /** * Common DTO for Address. diff --git a/sources/packages/backend/apps/api/src/route-controllers/utils/address-utils.ts b/sources/packages/backend/apps/api/src/route-controllers/utils/address-utils.ts index 2757432962..3ac6455c84 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/utils/address-utils.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/utils/address-utils.ts @@ -1,11 +1,6 @@ import { AddressInfo } from "@sims/sims-db"; import { AddressDetailsAPIOutDTO } from "../models/common.dto"; -// 'selectedCountry' in the form will have the value 'other', -// when 'Other'(i.e country other than canada) is selected. -export const OTHER_COUNTRY = "other"; -// 'selectedCountry' in the form will have the value 'canada', -// when 'Canada' is selected. -export const COUNTRY_CANADA = "canada"; +import { OTHER_COUNTRY } from "@sims/utilities"; /** * Util to transform address details for formIO. diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-supplier-integration.scheduler.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-supplier-integration.scheduler.e2e-spec.ts index d18ce61256..b25246f6ca 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-supplier-integration.scheduler.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/cas-integration/_tests_/cas-supplier-integration.scheduler.e2e-spec.ts @@ -1,28 +1,42 @@ import { INestApplication } from "@nestjs/common"; import { createE2EDataSources, + createFakeUser, E2EDataSources, saveFakeCASSupplier, + saveFakeStudent, } from "@sims/test-utils"; -import { QueueNames } from "@sims/utilities"; +import { COUNTRY_CANADA, OTHER_COUNTRY, QueueNames } from "@sims/utilities"; import { CASSupplierIntegrationScheduler } from "../cas-supplier-integration.scheduler"; import { createTestingAppModule, describeProcessorRootTest, mockBullJob, } from "../../../../../test/helpers"; -import { SupplierStatus } from "@sims/sims-db"; +import { ContactInfo, SupplierStatus } from "@sims/sims-db"; import { CAS_LOGON_MOCKED_RESULT, + resetCASServiceMock, SUPPLIER_INFO_FROM_CAS_MOCKED_RESULT, } from "../../../../../test/helpers/mock-utils/cas-service.mock"; import { CASService } from "@sims/integrations/cas/cas.service"; +import { + CASEvaluationStatus, + NotFoundReason, + PreValidationsFailedReason, +} from "../../../../services/cas-supplier/cas-supplier.models"; +import { + createFakeCASCreateSupplierAndSiteResponse, + createFakeCASNotFoundSupplierResponse, +} from "../../../../../test/helpers/mock-utils/cas-response.factory"; +import { SystemUsersService } from "@sims/services"; describe(describeProcessorRootTest(QueueNames.CASSupplierIntegration), () => { let app: INestApplication; let processor: CASSupplierIntegrationScheduler; let db: E2EDataSources; let casServiceMock: CASService; + let systemUsersService: SystemUsersService; const [supplierMockedResult] = SUPPLIER_INFO_FROM_CAS_MOCKED_RESULT.items; beforeAll(async () => { @@ -32,6 +46,7 @@ describe(describeProcessorRootTest(QueueNames.CASSupplierIntegration), () => { casServiceMock: casServiceMockFromAppModule, } = await createTestingAppModule(); app = nestApplication; + systemUsersService = nestApplication.get(SystemUsersService); db = createE2EDataSources(dataSource); casServiceMock = casServiceMockFromAppModule; // Processor under test. @@ -40,6 +55,14 @@ describe(describeProcessorRootTest(QueueNames.CASSupplierIntegration), () => { beforeEach(async () => { jest.clearAllMocks(); + resetCASServiceMock(casServiceMock); + // Update existing records to avoid conflicts between tests. + await db.casSupplier.update( + { + supplierStatus: SupplierStatus.PendingSupplierVerification, + }, + { supplierStatus: SupplierStatus.VerifiedManually }, + ); }); it("Should finalize CAS supplier process with success when no supplier found.", async () => { @@ -66,9 +89,25 @@ describe(describeProcessorRootTest(QueueNames.CASSupplierIntegration), () => { expect(casServiceMock.getSupplierInfoFromCAS).not.toHaveBeenCalled(); }); - it("Should update CAS supplier table when found pending supplier information to be updated.", async () => { - const savedCASSupplier = await saveFakeCASSupplier(db); - const student = savedCASSupplier.student; + it("Should update CAS supplier table when found pending supplier information to be updated with an active address match.", async () => { + // Arrange + // Created a student with same address line 1 and postal code from the expected CAS mocked result. + // Postal code has a white space that is expected to be removed. + const student = await saveFakeStudent(db.dataSource, undefined, { + initialValue: { + contactInfo: { + address: { + addressLine1: "3350 DOUGLAS ST", + city: "Victoria", + country: "Canada", + selectedCountry: COUNTRY_CANADA, + provinceState: "BC", + postalCode: "V8Z 7X9", + }, + } as ContactInfo, + }, + }); + const savedCASSupplier = await saveFakeCASSupplier(db, { student }); // Queued job. const mockedJob = mockBullJob(); @@ -86,9 +125,10 @@ describe(describeProcessorRootTest(QueueNames.CASSupplierIntegration), () => { mockedJob.containLogMessages([ "Found 1 records to be updated.", "Logon successful.", - `Requesting info for CAS supplier id ${savedCASSupplier.id}.`, - "Updating CAS supplier table.", - "CAS supplier integration executed.", + `Processing student CAS supplier ID: ${savedCASSupplier.id}.`, + `CAS evaluation result status: ${CASEvaluationStatus.ActiveSupplierAndSiteFound}.`, + "Active CAS supplier and site found.", + "Updated CAS supplier for the student.", ]), ).toBe(true); @@ -121,4 +161,218 @@ describe(describeProcessorRootTest(QueueNames.CASSupplierIntegration), () => { supplierMockedResult.suppliername, ); }); + + it("Should update CAS supplier table to manual intervention when student does not have a first name.", async () => { + // Arrange + const referenceDate = new Date(); + const user = createFakeUser(); + user.firstName = null; + const student = await saveFakeStudent(db.dataSource, { user }); + const savedCASSupplier = await saveFakeCASSupplier(db, { student }); + + // Queued job. + const mockedJob = mockBullJob(); + + // Act + const result = await processor.processCASSupplierInformation(mockedJob.job); + + // Assert + expect(result).toStrictEqual([ + "Process finalized with success.", + "Pending suppliers to update found: 1.", + "Records updated: 1.", + "Attention, process finalized with success but some errors and/or warnings messages may require some attention.", + "Error(s): 0, Warning(s): 1, Info: 12", + ]); + expect( + mockedJob.containLogMessages([ + "Found 1 records to be updated.", + "Logon successful.", + `Not possible to retrieve CAS supplier information because some pre-validations were not fulfilled. Reason(s): ${PreValidationsFailedReason.GivenNamesNotPresent}.`, + ]), + ).toBe(true); + // Assert the API methods were not called. + expect(casServiceMock.getSupplierInfoFromCAS).not.toHaveBeenCalled(); + // Assert DB was updated. + const updateCASSupplier = await db.casSupplier.findOne({ + select: { + id: true, + isValid: true, + supplierStatus: true, + updatedAt: true, + modifier: { id: true }, + }, + relations: { + modifier: true, + }, + where: { + id: savedCASSupplier.id, + }, + }); + expect(updateCASSupplier).toEqual({ + id: savedCASSupplier.id, + isValid: false, + supplierStatus: SupplierStatus.ManualIntervention, + updatedAt: expect.any(Date), + modifier: { id: systemUsersService.systemUser.id }, + }); + // Ensure updatedAt was updated. + expect(updateCASSupplier.updatedAt.getTime()).toBeGreaterThan( + referenceDate.getTime(), + ); + }); + + it("Should update CAS supplier table to manual intervention when student address is not from Canada.", async () => { + // Arrange + const student = await saveFakeStudent(db.dataSource, undefined, { + initialValue: { + contactInfo: { + address: { + selectedCountry: OTHER_COUNTRY, + }, + } as ContactInfo, + }, + }); + const savedCASSupplier = await saveFakeCASSupplier(db, { student }); + + // Queued job. + const mockedJob = mockBullJob(); + + // Act + const result = await processor.processCASSupplierInformation(mockedJob.job); + + // Assert + expect(result).toStrictEqual([ + "Process finalized with success.", + "Pending suppliers to update found: 1.", + "Records updated: 1.", + "Attention, process finalized with success but some errors and/or warnings messages may require some attention.", + "Error(s): 0, Warning(s): 1, Info: 12", + ]); + expect( + mockedJob.containLogMessages([ + "Found 1 records to be updated.", + "Logon successful.", + `Not possible to retrieve CAS supplier information because some pre-validations were not fulfilled. Reason(s): ${PreValidationsFailedReason.NonCanadianAddress}.`, + ]), + ).toBe(true); + // Assert the API methods were not called. + expect(casServiceMock.getSupplierInfoFromCAS).not.toHaveBeenCalled(); + // Assert DB was updated. + const updateCASSupplier = await db.casSupplier.findOne({ + select: { + id: true, + isValid: true, + supplierStatus: true, + }, + where: { + id: savedCASSupplier.id, + }, + }); + expect(updateCASSupplier).toEqual({ + id: savedCASSupplier.id, + isValid: false, + supplierStatus: SupplierStatus.ManualIntervention, + }); + }); + + it( + "Should create a new supplier and site on CAS and update CAS suppliers table when " + + "the student was not found on CAS and the request to create the supplier and site was successful.", + async () => { + // Arrange + const referenceDate = new Date(); + const savedCASSupplier = await saveFakeCASSupplier(db); + // Configure CAS mock to return an empty result for the GetSupplier + // and a successful result for the CreateSupplierAndSite. + casServiceMock.getSupplierInfoFromCAS = jest.fn(() => + Promise.resolve(createFakeCASNotFoundSupplierResponse()), + ); + const createSupplierAndSiteResponse = + createFakeCASCreateSupplierAndSiteResponse(); + casServiceMock.createSupplierAndSite = jest.fn(() => + Promise.resolve(createSupplierAndSiteResponse), + ); + + // Queued job. + const mockedJob = mockBullJob(); + + // Act + const result = await processor.processCASSupplierInformation( + mockedJob.job, + ); + + // Assert + expect(result).toStrictEqual([ + "Process finalized with success.", + "Pending suppliers to update found: 1.", + "Records updated: 1.", + ]); + expect( + mockedJob.containLogMessages([ + "Found 1 records to be updated.", + "Logon successful.", + `Processing student CAS supplier ID: ${savedCASSupplier.id}.`, + `CAS evaluation result status: ${CASEvaluationStatus.NotFound}.`, + `No active CAS supplier found. Reason: ${NotFoundReason.SupplierNotFound}.`, + "Created supplier and site on CAS.", + "Updated CAS supplier and site for the student.", + ]), + ).toBe(true); + // Assert the API methods were called. + expect(casServiceMock.getSupplierInfoFromCAS).toHaveBeenCalled(); + expect(casServiceMock.createSupplierAndSite).toHaveBeenCalled(); + // Assert DB was updated. + const updateCASSupplier = await db.casSupplier.findOne({ + select: { + id: true, + supplierNumber: true, + supplierName: true, + status: true, + lastUpdated: true, + supplierAddress: true as unknown, + supplierStatus: true, + supplierStatusUpdatedOn: true, + isValid: true, + updatedAt: true, + modifier: { id: true }, + }, + relations: { + modifier: true, + }, + where: { + id: savedCASSupplier.id, + }, + }); + const [submittedAddress] = + createSupplierAndSiteResponse.submittedData.SupplierAddress; + expect(updateCASSupplier).toEqual({ + id: savedCASSupplier.id, + supplierNumber: createSupplierAndSiteResponse.response.supplierNumber, + supplierName: createSupplierAndSiteResponse.submittedData.SupplierName, + status: "ACTIVE", + lastUpdated: expect.any(Date), + supplierAddress: { + supplierSiteCode: + createSupplierAndSiteResponse.response.supplierSiteCode, + addressLine1: submittedAddress.AddressLine1, + city: submittedAddress.City, + provinceState: submittedAddress.Province, + country: submittedAddress.Country, + postalCode: submittedAddress.PostalCode, + status: "ACTIVE", + lastUpdated: expect.any(String), + }, + supplierStatus: SupplierStatus.Verified, + supplierStatusUpdatedOn: expect.any(Date), + isValid: true, + updatedAt: expect.any(Date), + modifier: { id: systemUsersService.systemUser.id }, + }); + // Ensure updatedAt was updated. + expect(updateCASSupplier.updatedAt.getTime()).toBeGreaterThan( + referenceDate.getTime(), + ); + }, + ); }); diff --git a/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts b/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts index b27b736fc8..c03580563e 100644 --- a/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts +++ b/sources/packages/backend/apps/queue-consumers/src/queue-consumers.module.ts @@ -64,6 +64,10 @@ import { ApplicationService, WorkflowEnqueuerService, StudentFileService, + CASActiveSupplierNotFoundProcessor, + CASPreValidationsProcessor, + CASActiveSupplierFoundProcessor, + CASActiveSupplierAndSiteFoundProcessor, } from "./services"; import { SFASIntegrationModule } from "@sims/integrations/sfas-integration"; import { ATBCIntegrationModule } from "@sims/integrations/atbc-integration"; @@ -150,6 +154,10 @@ import { ObjectStorageService } from "@sims/integrations/object-storage"; ApplicationChangesReportIntegrationScheduler, StudentApplicationNotificationsScheduler, SIMSToSFASIntegrationScheduler, + CASActiveSupplierNotFoundProcessor, + CASPreValidationsProcessor, + CASActiveSupplierFoundProcessor, + CASActiveSupplierAndSiteFoundProcessor, ], controllers: [HealthController], }) diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-and-site-found-processor.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-and-site-found-processor.ts new file mode 100644 index 0000000000..1d52354095 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-and-site-found-processor.ts @@ -0,0 +1,99 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SystemUsersService } from "@sims/services"; +import { CASSupplier, SupplierStatus } from "@sims/sims-db"; +import { ProcessSummary } from "@sims/utilities/logger"; +import { + CASEvaluationResult, + CASEvaluationStatus, + StudentSupplierToProcess, +} from "../cas-supplier.models"; +import { Repository } from "typeorm"; +import { CASAuthDetails } from "@sims/integrations/cas"; +import { CASEvaluationResultProcessor, ProcessorResult } from "."; + +/** + * Process the active supplier and site information found on CAS. + */ +@Injectable() +export class CASActiveSupplierAndSiteFoundProcessor extends CASEvaluationResultProcessor { + constructor( + private readonly systemUsersService: SystemUsersService, + @InjectRepository(CASSupplier) + private readonly casSupplierRepo: Repository, + ) { + super(); + } + + /** + * Update student supplier based on the supplier and site information found on CAS. + * @param studentSupplier student supplier information from SIMS. + * @param evaluationResult evaluation result to be processed. + * @param _auth authentication token needed for possible + * CAS API interactions. + * @param summary current process log. + * @returns processor result. + */ + async process( + studentSupplier: StudentSupplierToProcess, + evaluationResult: CASEvaluationResult, + _auth: CASAuthDetails, + summary: ProcessSummary, + ): Promise { + if ( + evaluationResult.status !== CASEvaluationStatus.ActiveSupplierAndSiteFound + ) { + throw new Error("Incorrect CAS evaluation result processor selected."); + } + summary.info("Active CAS supplier and site found."); + try { + const address = evaluationResult.matchedAddress; + const supplierAddressToUpdate = { + supplierSiteCode: address.suppliersitecode, + addressLine1: address.addressline1, + addressLine2: address.addressline2, + city: address.city, + provinceState: address.province, + country: address.country, + postalCode: address.postalcode, + status: address.status, + siteProtected: address.siteprotected, + lastUpdated: new Date(address.lastupdated), + }; + const now = new Date(); + const systemUser = this.systemUsersService.systemUser; + const supplierToUpdate = evaluationResult.activeSupplier; + const updateResult = await this.casSupplierRepo.update( + { + id: studentSupplier.casSupplierID, + }, + { + supplierNumber: supplierToUpdate.suppliernumber, + supplierName: supplierToUpdate.suppliername, + status: supplierToUpdate.status, + supplierProtected: supplierToUpdate.supplierprotected === "Y", + lastUpdated: new Date(supplierToUpdate.lastupdated), + supplierAddress: supplierAddressToUpdate, + supplierStatus: SupplierStatus.Verified, + supplierStatusUpdatedOn: now, + isValid: true, + updatedAt: now, + modifier: systemUser, + }, + ); + if (updateResult.affected) { + summary.info("Updated CAS supplier for the student."); + return { isSupplierUpdated: true }; + } + summary.error( + "The update of the CAS supplier for the student did not result in the expected affected rows number.", + ); + } catch (error: unknown) { + summary.error( + "Error while updating CAS supplier for the student.", + error, + ); + } + return { isSupplierUpdated: false }; + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-found-processor.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-found-processor.ts new file mode 100644 index 0000000000..d266086dbc --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-found-processor.ts @@ -0,0 +1,85 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SystemUsersService } from "@sims/services"; +import { CASSupplier, SupplierStatus } from "@sims/sims-db"; +import { ProcessSummary } from "@sims/utilities/logger"; +import { + CASEvaluationResult, + CASEvaluationStatus, + StudentSupplierToProcess, +} from "../cas-supplier.models"; +import { Repository } from "typeorm"; +import { CASAuthDetails } from "@sims/integrations/cas"; +import { CASEvaluationResultProcessor, ProcessorResult } from "."; + +/** + * Process the active supplier information found on CAS. + */ +@Injectable() +export class CASActiveSupplierFoundProcessor extends CASEvaluationResultProcessor { + constructor( + private readonly systemUsersService: SystemUsersService, + @InjectRepository(CASSupplier) + private readonly casSupplierRepo: Repository, + ) { + super(); + } + + /** + * Update student supplier based on the supplier information found on CAS. + * @param studentSupplier student supplier information from SIMS. + * @param evaluationResult evaluation result to be processed. + * @param _auth authentication token needed for possible + * CAS API interactions. + * @param summary current process log. + * @returns processor result. + */ + async process( + studentSupplier: StudentSupplierToProcess, + evaluationResult: CASEvaluationResult, + _auth: CASAuthDetails, + summary: ProcessSummary, + ): Promise { + if (evaluationResult.status !== CASEvaluationStatus.ActiveSupplierFound) { + throw new Error("Incorrect CAS evaluation result processor selected."); + } + summary.info("Active CAS supplier found."); + try { + // TODO: Create the site, populate supplierAddress, and set isValid to true; + const supplierToUpdate = evaluationResult.activeSupplier; + const now = new Date(); + const systemUser = this.systemUsersService.systemUser; + const updateResult = await this.casSupplierRepo.update( + { + id: studentSupplier.casSupplierID, + }, + { + supplierNumber: supplierToUpdate.suppliernumber, + supplierName: supplierToUpdate.suppliername, + status: supplierToUpdate.status, + supplierProtected: supplierToUpdate.supplierprotected === "Y", + lastUpdated: new Date(supplierToUpdate.lastupdated), + supplierAddress: null, + supplierStatus: SupplierStatus.Verified, + supplierStatusUpdatedOn: now, + isValid: false, + updatedAt: now, + modifier: systemUser, + }, + ); + if (updateResult.affected) { + summary.info("Updated CAS supplier for the student."); + return { isSupplierUpdated: true }; + } + summary.error( + "The update of the CAS supplier for the student did not result in the expected affected rows number.", + ); + } catch (error: unknown) { + summary.error( + "Error while updating CAS supplier for the student.", + error, + ); + } + return { isSupplierUpdated: false }; + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-not-found-processor.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-not-found-processor.ts new file mode 100644 index 0000000000..9e0848ad5b --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-active-supplier-not-found-processor.ts @@ -0,0 +1,119 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SystemUsersService } from "@sims/services"; +import { CASSupplier, SupplierStatus } from "@sims/sims-db"; +import { ProcessSummary } from "@sims/utilities/logger"; +import { + CASEvaluationResult, + CASEvaluationStatus, + StudentSupplierToProcess, +} from "../cas-supplier.models"; +import { Repository } from "typeorm"; +import { + CASAuthDetails, + CASService, + CreateSupplierAndSiteResponse, +} from "@sims/integrations/cas"; +import { CASEvaluationResultProcessor, ProcessorResult } from "."; + +/** + * Process a student that was not found on CAS. + */ +@Injectable() +export class CASActiveSupplierNotFoundProcessor extends CASEvaluationResultProcessor { + constructor( + private readonly casService: CASService, + private readonly systemUsersService: SystemUsersService, + @InjectRepository(CASSupplier) + private readonly casSupplierRepo: Repository, + ) { + super(); + } + + /** + * Create the new supplier and site on CAS using the student information. + * @param studentSupplier student supplier information from SIMS. + * @param evaluationResult evaluation result to be processed. + * @param auth authentication token needed for possible + * CAS API interactions. + * @param summary current process log. + * @returns processor result. + */ + async process( + studentSupplier: StudentSupplierToProcess, + evaluationResult: CASEvaluationResult, + auth: CASAuthDetails, + summary: ProcessSummary, + ): Promise { + if (evaluationResult.status !== CASEvaluationStatus.NotFound) { + throw new Error("Incorrect CAS evaluation result processor selected."); + } + summary.info( + `No active CAS supplier found. Reason: ${evaluationResult.reason}.`, + ); + let result: CreateSupplierAndSiteResponse; + try { + const address = studentSupplier.address; + result = await this.casService.createSupplierAndSite(auth.access_token, { + firstName: studentSupplier.firstName, + lastName: studentSupplier.lastName, + sin: studentSupplier.sin, + emailAddress: studentSupplier.email, + supplierSite: { + addressLine1: address.addressLine1, + city: address.city, + provinceCode: address.provinceState, + postalCode: address.postalCode, + }, + }); + summary.info("Created supplier and site on CAS."); + } catch (error: unknown) { + summary.error("Error while creating supplier and site on CAS.", error); + return { isSupplierUpdated: false }; + } + try { + const [submittedAddress] = result.submittedData.SupplierAddress; + const now = new Date(); + const systemUser = this.systemUsersService.systemUser; + const updateResult = await this.casSupplierRepo.update( + { + id: studentSupplier.casSupplierID, + }, + { + supplierNumber: result.response.supplierNumber, + supplierName: result.submittedData.SupplierName, + status: "ACTIVE", + lastUpdated: now, + supplierAddress: { + supplierSiteCode: result.response.supplierSiteCode, + addressLine1: submittedAddress.AddressLine1, + city: submittedAddress.City, + provinceState: submittedAddress.Province, + country: submittedAddress.Country, + postalCode: submittedAddress.PostalCode, + status: "ACTIVE", + lastUpdated: now, + }, + supplierStatus: SupplierStatus.Verified, + supplierStatusUpdatedOn: now, + isValid: true, + updatedAt: now, + modifier: systemUser, + }, + ); + if (updateResult.affected) { + summary.info("Updated CAS supplier and site for the student."); + return { isSupplierUpdated: true }; + } + summary.error( + "The update of the CAS supplier and site for the student did not result in the expected affected rows number.", + ); + } catch (error: unknown) { + summary.error( + "Unexpected error while updating supplier and site for the student.", + error, + ); + return { isSupplierUpdated: false }; + } + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-evaluation-result-processor.models.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-evaluation-result-processor.models.ts new file mode 100644 index 0000000000..20d1b230c5 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-evaluation-result-processor.models.ts @@ -0,0 +1,6 @@ +/** + * CAS evaluation processor result. + */ +export interface ProcessorResult { + isSupplierUpdated: boolean; +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-evaluation-result-processor.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-evaluation-result-processor.ts new file mode 100644 index 0000000000..c99f6694a2 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-evaluation-result-processor.ts @@ -0,0 +1,31 @@ +import { ProcessSummary } from "@sims/utilities/logger"; +import { CASAuthDetails } from "@sims/integrations/cas"; +import { + CASEvaluationResult, + StudentSupplierToProcess, +} from "../cas-supplier.models"; +import { ProcessorResult } from "."; + +/** + * Process the result of a student evaluation to determine what + * should be execute to ensure this student will have a CAS + * supplier and a site code associated. + */ +export abstract class CASEvaluationResultProcessor { + /** + * When implemented in a derived class, execute the process + * to associate a CAS supplier and a site code to a student. + * @param studentSupplier student supplier information from SIMS. + * @param evaluationResult evaluation result to be processed. + * @param auth authentication token needed for possible + * CAS API interactions. + * @param summary current process log. + * @returns processor result. + */ + abstract process( + studentSupplier: StudentSupplierToProcess, + evaluationResult: CASEvaluationResult, + auth: CASAuthDetails, + summary: ProcessSummary, + ): Promise; +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-pre-validations-processor.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-pre-validations-processor.ts new file mode 100644 index 0000000000..0dca8b3286 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/cas-pre-validations-processor.ts @@ -0,0 +1,82 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { SystemUsersService } from "@sims/services"; +import { CASSupplier, SupplierStatus } from "@sims/sims-db"; +import { ProcessSummary } from "@sims/utilities/logger"; +import { + CASEvaluationResult, + CASEvaluationStatus, + StudentSupplierToProcess, +} from "../cas-supplier.models"; +import { Repository } from "typeorm"; +import { CASEvaluationResultProcessor, ProcessorResult } from "."; +import { CASAuthDetails } from "@sims/integrations/cas"; + +/** + * Assert the student can be added to CAS. + */ +@Injectable() +export class CASPreValidationsProcessor extends CASEvaluationResultProcessor { + constructor( + private readonly systemUsersService: SystemUsersService, + @InjectRepository(CASSupplier) + private readonly casSupplierRepo: Repository, + ) { + super(); + } + + /** + * Process the result of a failed pre-validation, setting + * the student CAS supplier for manual intervention. + * @param studentSupplier student supplier information from SIMS. + * @param evaluationResult evaluation result to be processed. + * @param _auth authentication token needed for possible + * CAS API interactions. + * @param summary current process log. + * @returns processor result. + */ + async process( + studentSupplier: StudentSupplierToProcess, + evaluationResult: CASEvaluationResult, + _auth: CASAuthDetails, + summary: ProcessSummary, + ): Promise { + if (evaluationResult.status !== CASEvaluationStatus.PreValidationsFailed) { + throw new Error("Incorrect CAS evaluation result processor selected."); + } + summary.warn( + `Not possible to retrieve CAS supplier information because some pre-validations were not fulfilled. Reason(s): ${evaluationResult.reasons.join( + ", ", + )}.`, + ); + try { + const now = new Date(); + const systemUser = this.systemUsersService.systemUser; + const updateResult = await this.casSupplierRepo.update( + { + id: studentSupplier.casSupplierID, + }, + { + supplierStatus: SupplierStatus.ManualIntervention, + supplierStatusUpdatedOn: now, + isValid: false, + updatedAt: now, + modifier: systemUser, + }, + ); + if (updateResult.affected) { + summary.info("Updated CAS supplier for manual intervention."); + return { isSupplierUpdated: true }; + } + summary.error( + "The update of the CAS supplier for manual intervention did not result in the expected affected rows number.", + ); + } catch (error: unknown) { + summary.error( + "Unexpected error while updating CAS to manual intervention for the student.", + error, + ); + return { isSupplierUpdated: false }; + } + } +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/index.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/index.ts new file mode 100644 index 0000000000..cf3cb9f7ca --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-evaluation-result-processor/index.ts @@ -0,0 +1,6 @@ +export * from "./cas-evaluation-result-processor"; +export * from "./cas-active-supplier-found-processor"; +export * from "./cas-active-supplier-and-site-found-processor"; +export * from "./cas-active-supplier-not-found-processor"; +export * from "./cas-pre-validations-processor"; +export * from "./cas-evaluation-result-processor.models"; diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.models.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.models.ts new file mode 100644 index 0000000000..ee264347d4 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.models.ts @@ -0,0 +1,108 @@ +import { + CASSupplierResponseItem, + CASSupplierResponseItemAddress, +} from "@sims/integrations/cas"; +import { AddressInfo } from "@sims/sims-db"; + +/** + * Possible results for a student CAS supplier evaluation. + */ +export enum CASEvaluationStatus { + /** + * Some conditions to retrieve the CAS information from CAS are not fulfilled. + */ + PreValidationsFailed = "PreValidationsFailed", + /** + * Found an active CAS supplier for the student. + */ + ActiveSupplierFound = "ActiveSupplierFound", + /** + * Found an active CAS supplier and an address match for the student. + */ + ActiveSupplierAndSiteFound = "ActiveSupplierAndSiteFound", + /** + * An active CAS supplier was not found. + */ + NotFound = "NotFound", +} + +/** + * Possible manual interventions. + */ +export enum PreValidationsFailedReason { + GivenNamesNotPresent = "Given names not present", + NonCanadianAddress = "Non-Canadian address", +} + +/** + * Possible reasons that a CAS supplier was considered not found. + */ +export enum NotFoundReason { + SupplierNotFound = "Supplier not found", + NoActiveSupplierFound = "Supplier found but not active", +} + +/** + * CAS pre-validations to ensure a student can be added to CAS. + */ +export interface CASPreValidationsResult { + status: CASEvaluationStatus.PreValidationsFailed; + reasons: PreValidationsFailedReason[]; +} + +/** + * Active CAS supplier found on CAS. + */ +export interface CASFoundSupplierResult { + status: CASEvaluationStatus.ActiveSupplierFound; + /** + * CAS active supplier. + */ + activeSupplier: CASSupplierResponseItem; +} + +/** + * Active CAS supplier found on CAS. + */ +export interface CASFoundSupplierAndSiteResult { + status: CASEvaluationStatus.ActiveSupplierAndSiteFound; + /** + * CAS active supplier. + */ + activeSupplier: CASSupplierResponseItem; + /** + * CAS site that matches with the student address. + */ + matchedAddress: CASSupplierResponseItemAddress; +} + +/** + * No active supplier found on CAS. + */ +export interface CASNotFoundSupplierResult { + status: CASEvaluationStatus.NotFound; + reason: NotFoundReason; +} + +/** + * Evaluation results that required different processing. + */ +export type CASEvaluationResult = + | CASNotFoundSupplierResult + | CASFoundSupplierAndSiteResult + | CASFoundSupplierResult + | CASPreValidationsResult; + +/** + * Information from the CAS supplier currently associated + * with a student that has some pending verifications + * to be executed. + */ +export interface StudentSupplierToProcess { + sin: string; + firstName?: string; + lastName: string; + email: string; + address: AddressInfo; + casSupplierID: number; +} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.service.ts index b3b4c6071a..fdd1876eb0 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/cas-supplier/cas-supplier.service.ts @@ -1,48 +1,63 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { SystemUsersService } from "@sims/services"; -import { CASSupplier, SupplierAddress, SupplierStatus } from "@sims/sims-db"; +import { CASSupplier, SupplierStatus } from "@sims/sims-db"; import { ProcessSummary } from "@sims/utilities/logger"; -import { Repository, UpdateResult } from "typeorm"; -import { CASService } from "@sims/integrations/cas/cas.service"; -import { CustomNamedError } from "@sims/utilities"; +import { Repository } from "typeorm"; import { + CASService, CASAuthDetails, - CASSupplierResponse, - CASSupplierResponseItem, - CASSupplierResponseItemAddress, -} from "@sims/integrations/cas/models/cas-supplier-response.model"; + formatAddress, + formatPostalCode, +} from "@sims/integrations/cas"; +import { CustomNamedError, isAddressFromCanada } from "@sims/utilities"; import { CAS_AUTH_ERROR } from "@sims/integrations/constants"; +import { + CASEvaluationResult, + CASEvaluationStatus, + NotFoundReason, + PreValidationsFailedReason, + StudentSupplierToProcess, +} from "./cas-supplier.models"; +import { + CASActiveSupplierNotFoundProcessor, + CASPreValidationsProcessor, + CASActiveSupplierFoundProcessor, + CASEvaluationResultProcessor, + CASActiveSupplierAndSiteFoundProcessor, +} from "./cas-evaluation-result-processor"; @Injectable() export class CASSupplierIntegrationService { constructor( private readonly casService: CASService, - private readonly systemUsersService: SystemUsersService, @InjectRepository(CASSupplier) private readonly casSupplierRepo: Repository, + private readonly casPreValidationsProcessor: CASPreValidationsProcessor, + private readonly casActiveSupplierFoundProcessor: CASActiveSupplierFoundProcessor, + private readonly casActiveSupplierNotFoundProcessor: CASActiveSupplierNotFoundProcessor, + private readonly casActiveSupplierAndSiteFoundProcessor: CASActiveSupplierAndSiteFoundProcessor, ) {} /** * CAS integration process. * Logs on CAS supplier API and request the supplier information for the students with pending supplier information. * @param parentProcessSummary parent process summary. - * @param casSuppliers pending CAS suppliers. + * @param studentSuppliers pending CAS suppliers. * @returns a number of update records. */ async executeCASIntegrationProcess( parentProcessSummary: ProcessSummary, - casSuppliers: CASSupplier[], + studentSuppliers: StudentSupplierToProcess[], ): Promise { let suppliersUpdated = 0; const summary = new ProcessSummary(); parentProcessSummary.children(summary); try { - summary.info("Logging on CAS..."); + summary.info("Logging on CAS."); const auth = await this.casService.logon(); summary.info("Logon successful."); - suppliersUpdated = await this.requestCASAndUpdateSuppliers( - casSuppliers, + suppliersUpdated = await this.processSuppliers( + studentSuppliers, summary, auth, ); @@ -57,166 +72,173 @@ export class CASSupplierIntegrationService { } /** - * For each pending CAS supplier, request supplier information to CAS API and update local table. - * @param casSuppliers pending CAS suppliers. + * For each pending CAS supplier, evaluate student current data and decide + * how to proceed to ensure student will have a supplier number and site + * code associated. + * @param studentSuppliers pending CAS suppliers. * @param parentProcessSummary parent log summary. * @param auth CAS auth details. - * @returns true if updated a record. + * @returns number of updated records. */ - private async requestCASAndUpdateSuppliers( - casSuppliers: CASSupplier[], + private async processSuppliers( + studentSuppliers: StudentSupplierToProcess[], parentProcessSummary: ProcessSummary, auth: CASAuthDetails, ): Promise { let suppliersUpdated = 0; - for (const casSupplier of casSuppliers) { + for (const studentSupplier of studentSuppliers) { const summary = new ProcessSummary(); parentProcessSummary.children(summary); - summary.info(`Requesting info for CAS supplier id ${casSupplier.id}.`); + summary.info( + `Processing student CAS supplier ID: ${studentSupplier.casSupplierID}.`, + ); try { - const supplierResponse = await this.casService.getSupplierInfoFromCAS( - auth.access_token, - casSupplier.student.sinValidation.sin, - casSupplier.student.user.lastName, + // Check the current status of the student data and its supplier information. + const evaluationResult = await this.evaluateCASSupplier( + studentSupplier, + auth, + ); + summary.info( + `CAS evaluation result status: ${evaluationResult.status}.`, ); - if (await this.updateSupplier(supplierResponse, summary, casSupplier)) { + // Decide the process to be executed. + const processor = this.getCASSupplierProcess(evaluationResult.status); + // Execute the process. + const processResult = await processor.process( + studentSupplier, + evaluationResult, + auth, + summary, + ); + if (processResult.isSupplierUpdated) { suppliersUpdated++; } } catch (error: unknown) { - summary.error( - "Error while requesting or updating CAS suppliers.", - error, - ); + // Log the error and allow the process to continue checking the + // remaining student suppliers. + summary.error("Unexpected error while processing supplier.", error); } } return suppliersUpdated; } /** - * Updates supplier if finds an item from the response with an active address. - * @param supplierResponse CAS supplier response. - * @param summary log summary. - * @param casSuppliers pending CAS suppliers. - * @returns true if updated a record. + * Get the processor associated to the CAS evaluation status result. + * @param status evaluation result status. + * @returns processor. */ - private async updateSupplier( - supplierResponse: CASSupplierResponse, - summary: ProcessSummary, - casSupplier: CASSupplier, - ): Promise { - if (!supplierResponse.items.length) { - summary.info("No supplier found on CAS."); - return false; - } else { - const [supplierInfo] = supplierResponse.items; - const activeSupplierItemAddress = - this.getActiveSupplierItemAddress(supplierInfo); - if (activeSupplierItemAddress) { - summary.info("Updating CAS supplier table."); - try { - const updateResult = await this.updateCASSupplier( - casSupplier.id, - supplierInfo, - SupplierStatus.Verified, - ); - if (updateResult.affected) { - return true; - } - } catch (error: unknown) { - throw new Error( - "Unexpected error while updating CAS supplier table.", - error, - ); - } - } else { - summary.info("No active supplier address found on CAS."); - } + private getCASSupplierProcess( + status: CASEvaluationStatus, + ): CASEvaluationResultProcessor { + switch (status) { + case CASEvaluationStatus.PreValidationsFailed: + return this.casPreValidationsProcessor; + case CASEvaluationStatus.ActiveSupplierFound: + return this.casActiveSupplierFoundProcessor; + case CASEvaluationStatus.ActiveSupplierAndSiteFound: + return this.casActiveSupplierAndSiteFoundProcessor; + case CASEvaluationStatus.NotFound: + return this.casActiveSupplierNotFoundProcessor; + default: + throw new Error("Invalid CAS evaluation result status."); } - return false; } /** - * Gets the first active supplier address from a list supplier response item. - * @param casSupplierResponseItem CAS supplier response item. - * @returns CAS supplier response item address. + * Decide the current state of the student supplier on SIMS + * and return the next process to be executed. + * @param studentSupplier student CAS supplier to be evaluated. + * @param auth authentication token needed for possible + * CAS API interactions. + * @returns evaluation result to be processed next. */ - private getActiveSupplierItemAddress( - casSupplierResponseItem: CASSupplierResponseItem, - ): CASSupplierResponseItemAddress { - if (casSupplierResponseItem.status !== "ACTIVE") { - return undefined; + private async evaluateCASSupplier( + studentSupplier: StudentSupplierToProcess, + auth: CASAuthDetails, + ): Promise { + const preValidationsFailedReasons: PreValidationsFailedReason[] = []; + if (!studentSupplier.firstName) { + preValidationsFailedReasons.push( + PreValidationsFailedReason.GivenNamesNotPresent, + ); + } + if (!isAddressFromCanada(studentSupplier.address)) { + preValidationsFailedReasons.push( + PreValidationsFailedReason.NonCanadianAddress, + ); + } + if (preValidationsFailedReasons.length) { + return { + status: CASEvaluationStatus.PreValidationsFailed, + reasons: preValidationsFailedReasons, + }; } - return casSupplierResponseItem.supplieraddress.find( - (address) => address.status === "ACTIVE", + const supplierResponse = await this.casService.getSupplierInfoFromCAS( + auth.access_token, + studentSupplier.sin, + studentSupplier.lastName, ); - } - - /** - * Updates CAS supplier table. - * @param casSupplierId CAS supplier id to be updated. - * @param casSupplierResponseItem CAS supplier response item from CAS request. - * @param supplierStatus CAS supplier status to be updated. - * @returns update result. - */ - async updateCASSupplier( - casSupplierId: number, - casSupplierResponseItem: CASSupplierResponseItem, - supplierStatus: SupplierStatus, - ): Promise { - // When multiple exists, only the active one should be saved. - // We will not be saving the array received at this moment, only a single entry from the received list should be persisted as JSONB. - const activeSupplierAddress = this.getActiveSupplierItemAddress( - casSupplierResponseItem, + if (!supplierResponse.items.length) { + return { + status: CASEvaluationStatus.NotFound, + reason: NotFoundReason.SupplierNotFound, + }; + } + // Check if there is at least one active supplier. + const casResponseActiveSupplier = supplierResponse.items.find( + (supplier) => supplier.status === "ACTIVE", ); - let supplierAddressToUpdate: SupplierAddress = null; - if (activeSupplierAddress) { - supplierAddressToUpdate = { - supplierSiteCode: activeSupplierAddress.suppliersitecode, - addressLine1: activeSupplierAddress.addressline1, - addressLine2: activeSupplierAddress.addressline2, - city: activeSupplierAddress.city, - provinceState: activeSupplierAddress.province, - country: activeSupplierAddress.country, - postalCode: activeSupplierAddress.postalcode, - status: activeSupplierAddress.status, - siteProtected: activeSupplierAddress.siteprotected, - lastUpdated: new Date(activeSupplierAddress.lastupdated), + if (!casResponseActiveSupplier) { + return { + status: CASEvaluationStatus.NotFound, + reason: NotFoundReason.NoActiveSupplierFound, }; } - const now = new Date(); - const systemUser = this.systemUsersService.systemUser; - return this.casSupplierRepo.update( - { - id: casSupplierId, - }, - { - supplierNumber: casSupplierResponseItem.suppliernumber, - supplierName: casSupplierResponseItem.suppliername, - status: casSupplierResponseItem.status, - supplierProtected: casSupplierResponseItem.supplierprotected === "Y", - lastUpdated: new Date(casSupplierResponseItem.lastupdated), - supplierAddress: supplierAddressToUpdate, - supplierStatus, - supplierStatusUpdatedOn: now, - isValid: true, - updatedAt: now, - modifier: systemUser, - }, + // Get a matching address, if exists. + // Address must be active and have the same address line 1 + // and postal code considering the CAS formats. + const casFormattedStudentAddress = formatAddress( + studentSupplier.address.addressLine1, ); + const casFormattedPostalCode = formatPostalCode( + studentSupplier.address.postalCode, + ); + const casResponseMatchedAddress = + casResponseActiveSupplier.supplieraddress?.find((address) => { + return ( + address.status === "ACTIVE" && + address.addressline1 === casFormattedStudentAddress && + address.postalcode === casFormattedPostalCode + ); + }); + if (casResponseMatchedAddress) { + return { + status: CASEvaluationStatus.ActiveSupplierAndSiteFound, + activeSupplier: casResponseActiveSupplier, + matchedAddress: casResponseMatchedAddress, + }; + } + return { + status: CASEvaluationStatus.ActiveSupplierFound, + activeSupplier: casResponseActiveSupplier, + }; } /** * Gets a list of CAS suppliers to be updated from CAS supplier table. * @returns a list of CAS suppliers to be updated. */ - async getStudentsToUpdateSupplierInformation(): Promise { - return this.casSupplierRepo.find({ + async getStudentsToUpdateSupplierInformation(): Promise< + StudentSupplierToProcess[] + > { + const pendingStudentCASSuppliers = await this.casSupplierRepo.find({ select: { id: true, student: { id: true, sinValidation: { sin: true }, - user: { lastName: true }, + user: { firstName: true, lastName: true }, + contactInfo: true as unknown, }, }, relations: { @@ -228,5 +250,15 @@ export class CASSupplierIntegrationService { student: { sinValidation: { isValidSIN: true } }, }, }); + return ( + pendingStudentCASSuppliers?.map((supplier) => ({ + sin: supplier.student.sinValidation.sin, + firstName: supplier.student.user.firstName, + lastName: supplier.student.user.lastName, + email: supplier.student.user.email, + address: supplier.student.contactInfo.address, + casSupplierID: supplier.id, + })) ?? [] + ); } } diff --git a/sources/packages/backend/apps/queue-consumers/src/services/index.ts b/sources/packages/backend/apps/queue-consumers/src/services/index.ts index 43d4886cb9..28c7fc4a09 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/index.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/index.ts @@ -3,3 +3,4 @@ export * from "./application/application.service"; export * from "./workflow/workflow-enqueuer.service"; export * from "./cas-supplier/cas-supplier.service"; export * from "./student-file/student-file.service"; +export * from "./cas-supplier/cas-evaluation-result-processor"; diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts index ddead5729e..c0e93c2f88 100644 --- a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-response.factory.ts @@ -1,4 +1,8 @@ -import { CASSupplierResponse } from "@sims/integrations/cas/models/cas-supplier-response.model"; +import { + CASSupplierResponse, + CreateSupplierAndSiteResponse, +} from "@sims/integrations/cas/models/cas-service.model"; +import * as faker from "faker"; /** * Creates a fake CAS supplier response. @@ -47,3 +51,47 @@ export function createFakeCASSupplierResponse(): CASSupplierResponse { count: 1, }; } + +/** + * Creates a empty fake CAS supplier response. + * @returns empty CAS supplier response. + */ +export function createFakeCASNotFoundSupplierResponse(): CASSupplierResponse { + return { + items: [], + hasMore: false, + limit: 0, + offset: 0, + count: 0, + }; +} + +/** + * Create a fake CreateSupplierAndSite response. + * @returns fake CreateSupplierAndSite response. + */ +export function createFakeCASCreateSupplierAndSiteResponse(): CreateSupplierAndSiteResponse { + return { + submittedData: { + SupplierName: "DOE, JOHN", + SubCategory: "Individual", + Sin: faker.datatype.number({ min: 100000000, max: 999999999 }).toString(), + SupplierAddress: [ + { + AddressLine1: faker.address.streetAddress(false).toUpperCase(), + City: "Victoria", + Province: "BC", + Country: "CA", + PostalCode: "H1H1H1", + EmailAddress: faker.internet.email(), + }, + ], + }, + response: { + supplierNumber: faker.datatype + .number({ min: 1000000, max: 9999999 }) + .toString(), + supplierSiteCode: "001", + }, + }; +} diff --git a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts index eb4593bd75..d122d7f486 100644 --- a/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts +++ b/sources/packages/backend/apps/queue-consumers/test/helpers/mock-utils/cas-service.mock.ts @@ -16,13 +16,19 @@ export const SUPPLIER_INFO_FROM_CAS_MOCKED_RESULT = */ export function createCASServiceMock(): CASService { const mockedCASService = {} as CASService; + resetCASServiceMock(mockedCASService); + return mockedCASService; +} + +/** + * Reset CAS service mock to its original mocks. + * @param mockedCASService mock to be reset. + */ +export function resetCASServiceMock(mockedCASService: CASService): void { mockedCASService.logon = jest.fn(() => Promise.resolve(CAS_LOGON_MOCKED_RESULT), ); - mockedCASService.getSupplierInfoFromCAS = jest.fn(() => Promise.resolve(SUPPLIER_INFO_FROM_CAS_MOCKED_RESULT), ); - - return mockedCASService; } diff --git a/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts b/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts new file mode 100644 index 0000000000..f59746ae3f --- /dev/null +++ b/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts @@ -0,0 +1,51 @@ +import { convertToASCII } from "@sims/utilities"; + +const CAS_SUPPLIER_NAME_MAX_LENGTH = 80; +const CAS_ADDRESS_MAX_LENGTH = 35; +const CAS_CITY_MAX_LENGTH = 25; + +/** + * Format the full name in the expected format (last name, given names). + * Ensure only ASCII characters are present, make all upper case, + * and enforce the maximum length accepted by CAS. + * @param firstName first name (given names). + * @param lastName last name. + * @returns formatted full name. + */ +export function formatUserName(firstName: string, lastName: string): string { + const formattedName = `${lastName}, ${firstName}`.substring( + 0, + CAS_SUPPLIER_NAME_MAX_LENGTH, + ); + return convertToASCII(formattedName).toUpperCase(); +} + +/** + * Ensure only ASCII characters are present, make all upper case, + * and enforce the maximum length accepted by CAS. + * @param address address to be formatted. + * @returns formatted address. + */ +export function formatAddress(address: string): string { + return convertToASCII( + address.substring(0, CAS_ADDRESS_MAX_LENGTH), + ).toUpperCase(); +} + +/** + * Ensure city has the max length expected by CAS. + * @param address city to be formatted. + * @returns formatted city. + */ +export function formatCity(city: string): string { + return city.substring(0, CAS_CITY_MAX_LENGTH); +} + +/** + * Remove postal code white spaces and make all upper case. + * @param postalCode postal code to be formatted. + * @returns formatted postal code. + */ +export function formatPostalCode(postalCode: string): string { + return postalCode.replace(/\s/g, "").toUpperCase(); +} diff --git a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts index 3c624fb30f..72ecafab55 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts @@ -2,14 +2,27 @@ import { Injectable, LoggerService } from "@nestjs/common"; import { CASAuthDetails, CASSupplierResponse, -} from "./models/cas-supplier-response.model"; + CreateSupplierAndSiteData, + CreateSupplierAndSiteResponse, + CreateSupplierAndSiteSubmittedData, +} from "./models/cas-service.model"; import { AxiosRequestConfig } from "axios"; import { HttpService } from "@nestjs/axios"; import { CASIntegrationConfig, ConfigService } from "@sims/utilities/config"; import { stringify } from "querystring"; -import { CustomNamedError, convertToASCII } from "@sims/utilities"; +import { + CustomNamedError, + convertToASCII, + parseJSONError, +} from "@sims/utilities"; import { CAS_AUTH_ERROR } from "@sims/integrations/constants"; import { InjectLogger } from "@sims/utilities/logger"; +import { + formatAddress, + formatCity, + formatPostalCode, + formatUserName, +} from "@sims/integrations/cas"; @Injectable() export class CASService { @@ -43,7 +56,9 @@ export class CASService { const response = await this.httpService.axiosRef.post(url, data, config); return response.data; } catch (error: unknown) { - this.logger.error(`Error while logging on CAS API. ${error}`); + this.logger.error( + `Error while logging on CAS API. ${parseJSONError(error)}`, + ); throw new CustomNamedError( "Could not authenticate on CAS.", CAS_AUTH_ERROR, @@ -82,6 +97,73 @@ export class CASService { return response?.data; } + /** + * Create supplier and site. + * @param token authentication token. + * @param supplierData data to be used for supplier and site creation. + * @returns submitted data and CAS response. + */ + async createSupplierAndSite( + token: string, + supplierData: CreateSupplierAndSiteData, + ): Promise { + const url = `${this.casIntegrationConfig.baseUrl}/cfs/supplier/`; + try { + const config: AxiosRequestConfig = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + const submittedData: CreateSupplierAndSiteSubmittedData = { + SupplierName: formatUserName( + supplierData.lastName, + supplierData.firstName, + ), + SubCategory: "Individual", + Sin: supplierData.sin, + SupplierAddress: [ + { + AddressLine1: formatAddress(supplierData.supplierSite.addressLine1), + City: formatCity(supplierData.supplierSite.city), + Province: supplierData.supplierSite.provinceCode, + Country: "CA", + PostalCode: formatPostalCode(supplierData.supplierSite.postalCode), + EmailAddress: supplierData.emailAddress, + }, + ], + }; + const response = await this.httpService.axiosRef.post( + url, + submittedData, + config, + ); + return { + submittedData, + response: { + supplierNumber: response.data.SUPPLIER_NUMBER, + supplierSiteCode: this.extractSupplierSiteCode( + response.data.SUPPLIER_SITE_CODE, + ), + }, + }; + } catch (error: unknown) { + throw new Error("Error while creating supplier and site on CAS.", { + cause: error, + }); + } + } + + /** + * Replace the characters [] and white spaces from the supplier + * site code returned from CAS (e.g. '[001] '). + * @param casSupplierSiteCode supplier site code returned from CAS. + * @returns supplier site code expected to be persisted for later + * use (e.g. 001); + */ + private extractSupplierSiteCode(casSupplierSiteCode: string): string { + return casSupplierSiteCode.replace(/\[|]|\s/g, ""); + } + @InjectLogger() logger: LoggerService; } diff --git a/sources/packages/backend/libs/integrations/src/cas/index.ts b/sources/packages/backend/libs/integrations/src/cas/index.ts new file mode 100644 index 0000000000..ee63b516f6 --- /dev/null +++ b/sources/packages/backend/libs/integrations/src/cas/index.ts @@ -0,0 +1,3 @@ +export * from "./models/cas-service.model"; +export * from "./cas.service"; +export * from "./cas-formatters"; diff --git a/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts new file mode 100644 index 0000000000..cfb8d01845 --- /dev/null +++ b/sources/packages/backend/libs/integrations/src/cas/models/cas-service.model.ts @@ -0,0 +1,108 @@ +import { CASSupplierRecordStatus, CASSupplierSiteStatus } from "@sims/sims-db"; + +export class CASSupplierResponse { + items: CASSupplierResponseItem[]; + hasMore: boolean; + limit: number; + offset: number; + count: number; +} +export class CASSupplierResponseItem { + suppliernumber: string; + suppliername: string; + subcategory: string; + sin: string; + providerid?: string; + businessnumber?: null; + status: CASSupplierRecordStatus; + supplierprotected?: "Y" | "N" | null; + standardindustryclassification?: string; + lastupdated: string; + supplieraddress: CASSupplierResponseItemAddress[]; +} + +export class CASSupplierResponseItemAddress { + suppliersitecode: string; + addressline1: string; + addressline2?: string; + addressline3?: string; + city: string; + province: string; + country: string; + postalcode: string; + emailaddress?: string; + accountnumber?: string; + branchnumber?: string; + banknumber?: string; + eftadvicepref?: string; + providerid?: string; + status: CASSupplierSiteStatus; + siteprotected?: string; + lastupdated: string; +} + +export class CASAuthDetails { + access_token: string; + token_type: string; + expires_in: number; +} + +/** + * Information needed for supplier and site creation on CAS. + */ +export class CreateSupplierAndSiteData { + firstName: string; + lastName: string; + sin: string; + emailAddress: string; + supplierSite: CreateSupplierSite; +} + +/** + * Site information needed for supplier and site creation on CAS. + */ +export class CreateSupplierSite { + addressLine1: string; + city: string; + provinceCode: string; + postalCode: string; +} + +/** + * Result from a supplier and site creation. + */ +export class CreateSupplierAndSiteResult { + supplierNumber: string; + supplierSiteCode: string; +} + +/** + * Data used during the creation of a supplier and site on CAS. + * Some data transformation is needed to follow the CAS requirements. + * This payload is used to submit the data and also to be returned to + * the consumer, allowing it to be aware of the data actually submitted. + */ +export class CreateSupplierAndSiteSubmittedData { + SupplierName: string; + SubCategory: string; + Sin: string; + SupplierAddress: [ + { + AddressLine1: string; + City: string; + Province: string; + Country: string; + PostalCode: string; + EmailAddress: string; + }, + ]; +} + +/** + * Combination of the CAS supplier and site creation + * submitted data and its API response. + */ +export class CreateSupplierAndSiteResponse { + submittedData: CreateSupplierAndSiteSubmittedData; + response: CreateSupplierAndSiteResult; +} diff --git a/sources/packages/backend/libs/integrations/src/cas/models/cas-supplier-response.model.ts b/sources/packages/backend/libs/integrations/src/cas/models/cas-supplier-response.model.ts deleted file mode 100644 index 7c84741613..0000000000 --- a/sources/packages/backend/libs/integrations/src/cas/models/cas-supplier-response.model.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CASSupplierRecordStatus, CASSupplierSiteStatus } from "@sims/sims-db"; - -export class CASSupplierResponse { - items: CASSupplierResponseItem[]; - hasMore: boolean; - limit: number; - offset: number; - count: number; -} -export class CASSupplierResponseItem { - suppliernumber: string; - suppliername: string; - subcategory: string; - sin: string; - providerid?: string; - businessnumber?: null; - status: CASSupplierRecordStatus; - supplierprotected?: "Y" | "N" | null; - standardindustryclassification?: string; - lastupdated: string; - supplieraddress: CASSupplierResponseItemAddress[]; -} - -export class CASSupplierResponseItemAddress { - suppliersitecode: string; - addressline1: string; - addressline2?: string; - addressline3?: string; - city: string; - province: string; - country: string; - postalcode: string; - emailaddress?: string; - accountnumber?: string; - branchnumber?: string; - banknumber?: string; - eftadvicepref?: string; - providerid?: string; - status: CASSupplierSiteStatus; - siteprotected?: string; - lastupdated: string; -} - -export class CASAuthDetails { - access_token: string; - token_type: string; - expires_in: number; -} diff --git a/sources/packages/backend/libs/test-utils/src/factories/student.ts b/sources/packages/backend/libs/test-utils/src/factories/student.ts index 0bbc15e189..a608e188e6 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/student.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/student.ts @@ -3,7 +3,8 @@ import { DisabilityStatus, SINValidation, Student, User } from "@sims/sims-db"; import { createFakeUser } from "@sims/test-utils"; import { DataSource } from "typeorm"; import { createFakeSINValidation } from "./sin-validation"; -import { getISODateOnlyString } from "@sims/utilities"; +import { COUNTRY_CANADA, getISODateOnlyString } from "@sims/utilities"; + // TODO: the parameter user must be moved to relations and all the references must be // updated. export function createFakeStudent( @@ -20,8 +21,8 @@ export function createFakeStudent( address: { addressLine1: faker.address.streetAddress(), city: faker.address.city(), - country: "canada", - selectedCountry: "Canada", + country: "Canada", + selectedCountry: COUNTRY_CANADA, provinceState: "BC", postalCode: faker.address.zipCode(), }, diff --git a/sources/packages/backend/libs/utilities/src/address-utils.ts b/sources/packages/backend/libs/utilities/src/address-utils.ts new file mode 100644 index 0000000000..051b85a87b --- /dev/null +++ b/sources/packages/backend/libs/utilities/src/address-utils.ts @@ -0,0 +1,17 @@ +import { AddressInfo } from "@sims/sims-db"; + +// 'selectedCountry' in the student contact info will have the value 'other', +// when 'Other'(i.e country other than canada) is selected. +export const OTHER_COUNTRY = "other"; +// 'selectedCountry' in the student contact info will have the value 'canada', +// when 'Canada' is selected. +export const COUNTRY_CANADA = "canada"; + +/** + * Inspects the address info to determine if the address is from Canada. + * @param address address info to be inspected. + * @returns true if the address is from Canada, otherwise false. + */ +export function isAddressFromCanada(address: AddressInfo) { + return address.selectedCountry === COUNTRY_CANADA; +} diff --git a/sources/packages/backend/libs/utilities/src/index.ts b/sources/packages/backend/libs/utilities/src/index.ts index d9a136d01b..874e8af4b2 100644 --- a/sources/packages/backend/libs/utilities/src/index.ts +++ b/sources/packages/backend/libs/utilities/src/index.ts @@ -14,3 +14,4 @@ export * from "./integration-utils"; export * from "./math-utils"; export * from "./specialized-string-builder"; export * from "./string-utils"; +export * from "./address-utils";