diff --git a/sources/packages/backend/apps/api/src/route-controllers/application/application.students.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/application/application.students.controller.ts index 73fd3c7e76..3eeb891fec 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/application/application.students.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/application/application.students.controller.ts @@ -24,7 +24,6 @@ import { APPLICATION_NOT_FOUND, APPLICATION_NOT_VALID, EducationProgramOfferingService, - StudentAssessmentService, INVALID_OPERATION_IN_THE_CURRENT_STATUS, ASSESSMENT_INVALID_OPERATION_IN_THE_CURRENT_STATE, CRAIncomeVerificationService, @@ -89,7 +88,6 @@ export class ApplicationStudentsController extends BaseController { private readonly programYearService: ProgramYearService, private readonly offeringService: EducationProgramOfferingService, private readonly confirmationOfEnrollmentService: ConfirmationOfEnrollmentService, - private readonly assessmentService: StudentAssessmentService, private readonly applicationControllerService: ApplicationControllerService, private readonly craIncomeVerificationService: CRAIncomeVerificationService, private readonly supportingUserService: SupportingUserService, @@ -236,16 +234,14 @@ export class ApplicationStudentsController extends BaseController { studyStartDate, studyEndDate, ); - const { createdAssessment } = - await this.applicationService.submitApplication( - applicationId, - studentToken.userId, - student.id, - programYear.id, - submissionResult.data.data, - payload.associatedFiles, - ); - await this.assessmentService.startAssessment(createdAssessment.id); + await this.applicationService.submitApplication( + applicationId, + studentToken.userId, + student.id, + programYear.id, + submissionResult.data.data, + payload.associatedFiles, + ); } catch (error) { switch (error.name) { case APPLICATION_NOT_FOUND: diff --git a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/student-appeal.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/student-appeal.aest.controller.ts index 9af0debc39..512b29a586 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student-appeal/student-appeal.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student-appeal/student-appeal.aest.controller.ts @@ -12,7 +12,6 @@ import { import { ASSESSMENT_ALREADY_IN_PROGRESS, StudentAppealService, - StudentAssessmentService, } from "../../services"; import { AuthorizedParties } from "../../auth/authorized-parties.enum"; import { @@ -57,7 +56,6 @@ import { StudentAppealControllerService } from "./student-appeal.controller.serv export class StudentAppealAESTController extends BaseController { constructor( private readonly studentAppealService: StudentAppealService, - private readonly studentAssessmentService: StudentAssessmentService, private readonly studentAppealControllerService: StudentAppealControllerService, ) { super(); @@ -107,20 +105,11 @@ export class StudentAppealAESTController extends BaseController { @UserToken() userToken: IUserToken, ): Promise { try { - const savedAppeal = await this.studentAppealService.approveRequests( + await this.studentAppealService.approveRequests( appealId, payload.requests, userToken.userId, ); - - // The appeal approval will create a student assessment to be processed only - // if at least one request was approved, hence sometimes an appeal will not result - // is an assessment creation if all requests are declined. - if (savedAppeal.studentAssessment) { - await this.studentAssessmentService.startAssessment( - savedAppeal.studentAssessment.id, - ); - } } catch (error: unknown) { if (error instanceof CustomNamedError) { switch (error.name) { diff --git a/sources/packages/backend/apps/api/src/route-controllers/student-scholastic-standings/student-scholastic-standings.institutions.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/student-scholastic-standings/student-scholastic-standings.institutions.controller.ts index 2ec9aadfcf..d8e77cd369 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student-scholastic-standings/student-scholastic-standings.institutions.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student-scholastic-standings/student-scholastic-standings.institutions.controller.ts @@ -34,7 +34,6 @@ import { ApplicationWithdrawalImportTextService, FormService, INVALID_OPERATION_IN_THE_CURRENT_STATUS, - StudentAssessmentService, StudentScholasticStandingsService, } from "../../services"; import { ApiProcessError, ClientTypeBaseRoute } from "../../types"; @@ -77,7 +76,6 @@ export class ScholasticStandingInstitutionsController extends BaseController { constructor( private readonly formService: FormService, private readonly studentScholasticStandingsService: StudentScholasticStandingsService, - private readonly studentAssessmentService: StudentAssessmentService, private readonly scholasticStandingControllerService: ScholasticStandingControllerService, private readonly applicationWithdrawalImportTextService: ApplicationWithdrawalImportTextService, ) { @@ -115,20 +113,12 @@ export class ScholasticStandingInstitutionsController extends BaseController { if (!submissionResult.valid) { throw new BadRequestException("Invalid submission."); } - const scholasticStanding = - await this.studentScholasticStandingsService.processScholasticStanding( - locationId, - applicationId, - userToken.userId, - submissionResult.data.data, - ); - - // Start assessment. - if (scholasticStanding.studentAssessment) { - await this.studentAssessmentService.startAssessment( - scholasticStanding.studentAssessment.id, - ); - } + await this.studentScholasticStandingsService.processScholasticStanding( + locationId, + applicationId, + userToken.userId, + submissionResult.data.data, + ); } catch (error: unknown) { if (error instanceof CustomNamedError) { switch (error.name) { diff --git a/sources/packages/backend/apps/api/src/services/education-program-offering/education-program-offering.service.ts b/sources/packages/backend/apps/api/src/services/education-program-offering/education-program-offering.service.ts index 72faf1941a..edf99eef36 100644 --- a/sources/packages/backend/apps/api/src/services/education-program-offering/education-program-offering.service.ts +++ b/sources/packages/backend/apps/api/src/services/education-program-offering/education-program-offering.service.ts @@ -1202,14 +1202,6 @@ export class EducationProgramOfferingService extends RecordDataModelService= maxPromisesAllowed) { // Waits for promises to be process when it reaches maximum allowable parallel // count. diff --git a/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts b/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts index fed805b7f0..583d453981 100644 --- a/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts +++ b/sources/packages/backend/apps/api/src/services/student-appeal/student-appeal.service.ts @@ -31,7 +31,6 @@ import { STUDENT_APPEAL_INVALID_OPERATION, STUDENT_APPEAL_NOT_FOUND, } from "./constants"; -import { StudentAssessmentService } from "../student-assessment/student-assessment.service"; import { NotificationActionsService } from "@sims/services/notifications"; import { NoteSharedService } from "@sims/services"; @@ -43,7 +42,6 @@ export class StudentAppealService extends RecordDataModelService constructor( private readonly dataSource: DataSource, private readonly studentAppealRequestsService: StudentAppealRequestsService, - private readonly studentAssessmentService: StudentAssessmentService, private readonly notificationActionsService: NotificationActionsService, private readonly noteSharedService: NoteSharedService, ) { @@ -352,10 +350,6 @@ export class StudentAppealService extends RecordDataModelService ); } - await this.studentAssessmentService.assertAllAssessmentsCompleted( - appealToUpdate.application.id, - ); - const auditUser = { id: auditUserId } as User; const auditDate = new Date(); diff --git a/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts b/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts index 1886ded5b2..1cf5ed77c3 100644 --- a/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts +++ b/sources/packages/backend/apps/api/src/services/student-assessment/student-assessment.service.ts @@ -11,27 +11,19 @@ import { StudentAssessmentStatus, } from "@sims/sims-db"; import { Brackets, DataSource } from "typeorm"; -import { InjectQueue } from "@nestjs/bull"; -import { Queue } from "bull"; -import { CustomNamedError, QueueNames } from "@sims/utilities"; +import { CustomNamedError } from "@sims/utilities"; import { - ASSESSMENT_ALREADY_IN_PROGRESS, ASSESSMENT_INVALID_OPERATION_IN_THE_CURRENT_STATE, ASSESSMENT_NOT_FOUND, } from "./student-assessment.constants"; import { AssessmentHistory } from "./student-assessment.models"; -import { StartAssessmentQueueInDTO } from "@sims/services/queue"; /** * Manages the student assessment related operations. */ @Injectable() export class StudentAssessmentService extends RecordDataModelService { - constructor( - dataSource: DataSource, - @InjectQueue(QueueNames.StartApplicationAssessment) - private readonly startAssessmentQueue: Queue, - ) { + constructor(dataSource: DataSource) { super(dataSource.getRepository(StudentAssessment)); } @@ -131,58 +123,6 @@ export class StudentAssessmentService extends RecordDataModelService { - const assessment = await this.repo - .createQueryBuilder("assessment") - .select([ - "assessment.id", - "assessment.assessmentWorkflowId", - "assessment.triggerType", - "application.id", - "application.applicationStatus", - "application.data", - ]) - .innerJoin("assessment.application", "application") - .where("assessment.id = :assessmentId", { assessmentId }) - .getOne(); - - if (assessment.assessmentWorkflowId) { - throw new CustomNamedError( - `Student assessment was already started and has a workflow associated with. Assessment id ${assessmentId}`, - ASSESSMENT_INVALID_OPERATION_IN_THE_CURRENT_STATE, - ); - } - - if ( - assessment.triggerType === AssessmentTriggerType.OriginalAssessment && - assessment.application.applicationStatus !== ApplicationStatus.Submitted - ) { - throw new CustomNamedError( - `An assessment with a trigger type '${AssessmentTriggerType.OriginalAssessment}' can only be started with a Student Application in the status '${ApplicationStatus.Submitted}'. Assessment id ${assessmentId}`, - ASSESSMENT_INVALID_OPERATION_IN_THE_CURRENT_STATE, - ); - } - - if ( - assessment.triggerType !== AssessmentTriggerType.OriginalAssessment && - assessment.application.applicationStatus !== ApplicationStatus.Completed - ) { - throw new CustomNamedError( - `An assessment with a trigger type other than '${AssessmentTriggerType.OriginalAssessment}' can only be started with a Student Application in the status '${ApplicationStatus.Completed}'. Assessment id ${assessmentId}`, - ASSESSMENT_INVALID_OPERATION_IN_THE_CURRENT_STATE, - ); - } - - await this.startAssessmentQueue.add({ - workflowName: assessment.application.data.workflowName, - assessmentId: assessment.id, - }); - } - /** * Updates assessment and application statuses when * the student is confirming the NOA (Notice of Assessment). @@ -331,45 +271,4 @@ export class StudentAssessmentService extends RecordDataModelService(queryResult, "status"); } - - /** - * Checks if some student assessment is still being processed. - * Only one student assessment can be processed at a given time because - * any assessment can generate Over Awards and they must be taken into - * the consideration every time. - * * Alongside with the check, the DB has an index to prevent that a new - * * assessment record is created when there is already one with the - * * assessment data not populated (submitted/pending). - * @param application application to have the assessments verified. - * @returns true if there is an assessment that is not finalized yet. - */ - async hasIncompleteAssessment(application: number): Promise { - const queryResult = await this.repo - .createQueryBuilder("assessment") - .select("1") - .innerJoin("assessment.application", "application") - .andWhere("application.id = :application", { application }) - .andWhere("assessment.assessmentData IS NULL") - .limit(1) - .getRawOne(); - return !!queryResult; - } - - /** - * Validate if the student has any student assessment that it is not - * finished yet (submitted/pending). If there is a student assessment - * already being processed, throws an exception. - * @param application application to have the assessments verified. - */ - async assertAllAssessmentsCompleted(application: number) { - const hasIncompleteAssessment = await this.hasIncompleteAssessment( - application, - ); - if (hasIncompleteAssessment) { - throw new CustomNamedError( - "There is already an assessment waiting to be completed. Another assessment cannot be initiated at this time.", - ASSESSMENT_ALREADY_IN_PROGRESS, - ); - } - } } diff --git a/sources/packages/backend/apps/api/src/services/student-scholastic-standings/student-scholastic-standings.service.ts b/sources/packages/backend/apps/api/src/services/student-scholastic-standings/student-scholastic-standings.service.ts index 775670299f..687b68dd57 100644 --- a/sources/packages/backend/apps/api/src/services/student-scholastic-standings/student-scholastic-standings.service.ts +++ b/sources/packages/backend/apps/api/src/services/student-scholastic-standings/student-scholastic-standings.service.ts @@ -20,7 +20,6 @@ import { INVALID_OPERATION_IN_THE_CURRENT_STATUS, } from "../application/application.service"; import { ScholasticStanding } from "./student-scholastic-standings.model"; -import { StudentAssessmentService } from "../student-assessment/student-assessment.service"; import { StudentRestrictionService } from "../restriction/student-restriction.service"; import { APPLICATION_CHANGE_NOT_ELIGIBLE } from "../../constants"; import { SCHOLASTIC_STANDING_MINIMUM_UNSUCCESSFUL_WEEKS } from "../../utilities"; @@ -40,7 +39,6 @@ export class StudentScholasticStandingsService extends RecordDataModelService { + await queryRunner.query( + getSQLFileData("Add-assessment-workflow-enqueuer-queue.sql", "Queue"), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-add-assessment-workflow-enqueuer-queue.sql", + "Queue", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/migrations/1694202849264-UpdateStudentAssessmentStatus.ts b/sources/packages/backend/apps/db-migrations/src/migrations/1694202849264-UpdateStudentAssessmentStatus.ts new file mode 100644 index 0000000000..999fcebbb9 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/migrations/1694202849264-UpdateStudentAssessmentStatus.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { getSQLFileData } from "../utilities/sqlLoader"; + +export class UpdateStudentAssessmentStatus1694202849264 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Update-student-assessment-status.sql", + "StudentAssessments", + ), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + getSQLFileData( + "Rollback-update-student-assessment-status.sql", + "StudentAssessments", + ), + ); + } +} diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-assessment-workflow-enqueuer-queue.sql b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-assessment-workflow-enqueuer-queue.sql new file mode 100644 index 0000000000..f8fa161dbe --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Add-assessment-workflow-enqueuer-queue.sql @@ -0,0 +1,11 @@ +INSERT INTO + sims.queue_configurations(queue_name, queue_configuration) +VALUES + ( + 'assessment-workflow-enqueuer', + '{ + "dashboardReadonly": false, + "cron": "*/30 * * * * *", + "cleanUpPeriod": 3600000 + }' :: json + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-assessment-workflow-enqueuer-queue.sql b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-assessment-workflow-enqueuer-queue.sql new file mode 100644 index 0000000000..60fd48293e --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/Queue/Rollback-add-assessment-workflow-enqueuer-queue.sql @@ -0,0 +1,4 @@ +DELETE FROM + sims.queue_configurations +WHERE + queue_name = 'assessment-workflow-enqueuer'; \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/StudentAssessments/Rollback-update-student-assessment-status.sql b/sources/packages/backend/apps/db-migrations/src/sql/StudentAssessments/Rollback-update-student-assessment-status.sql new file mode 100644 index 0000000000..f67e7cca6c --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/StudentAssessments/Rollback-update-student-assessment-status.sql @@ -0,0 +1,11 @@ +/* + Rollback assessments status updates. + The workflow is already changing the status to 'In progress' and 'Completed', + anything different can be considered as the default column value 'Submitted'. + */ +UPDATE + sims.student_assessments +SET + student_assessment_status = 'Submitted' +WHERE + student_assessment_status NOT IN ('In progress', 'Completed') \ No newline at end of file diff --git a/sources/packages/backend/apps/db-migrations/src/sql/StudentAssessments/Update-student-assessment-status.sql b/sources/packages/backend/apps/db-migrations/src/sql/StudentAssessments/Update-student-assessment-status.sql new file mode 100644 index 0000000000..446036c510 --- /dev/null +++ b/sources/packages/backend/apps/db-migrations/src/sql/StudentAssessments/Update-student-assessment-status.sql @@ -0,0 +1,43 @@ +/** + Updates all assessments associated with active applications. + By default all records were created with 'Submitted' hence there is + no need to have it added to below CASE conditions. + */ +UPDATE + sims.student_assessments sa +SET + student_assessment_status = ( + CASE + WHEN sa.assessment_data IS NOT NULL THEN 'Completed' + WHEN sa.assessment_workflow_id IS NOT NULL + AND sa.assessment_data IS NULL THEN 'In progress' + END + ) :: sims.student_assessment_status +FROM + sims.applications a +WHERE + sa.application_id = a.id + AND a.application_status IN ( + 'Submitted', + 'In Progress', + 'Assessment', + 'Enrolment', + 'Completed' + ); + +-- Update all assessments associated with no longer active applications. +UPDATE + sims.student_assessments +SET + student_assessment_status = 'Cancelled' +FROM + sims.applications +WHERE + application_id = sims.applications.id + AND sims.applications.application_status NOT IN ( + 'Submitted', + 'In Progress', + 'Assessment', + 'Enrolment', + 'Completed' + ); \ No newline at end of file diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/assessment/_tests_/start-application-assessment.processor.e2e-spec.ts b/sources/packages/backend/apps/queue-consumers/src/processors/assessment/_tests_/start-application-assessment.processor.e2e-spec.ts index c821c80627..d2efb36c15 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/assessment/_tests_/start-application-assessment.processor.e2e-spec.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/assessment/_tests_/start-application-assessment.processor.e2e-spec.ts @@ -32,7 +32,12 @@ describe( it("Should throw an error when the workflow createProcessInstance method throws an error.", async () => { // Arrange const dummyException = new Error("Dummy error"); - const job = createMock>(); + const job = createMock>({ + data: { + workflowName: "dummy-workflow-name", + assessmentId: 9999, + }, + }); zbClientMock.createProcessInstance = jest.fn().mockImplementation(() => { throw dummyException; }); diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/assessment/start-application-assessment.processor.ts b/sources/packages/backend/apps/queue-consumers/src/processors/assessment/start-application-assessment.processor.ts index 856db93417..d43b09229a 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/assessment/start-application-assessment.processor.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/assessment/start-application-assessment.processor.ts @@ -3,18 +3,30 @@ import { Job } from "bull"; import { StartAssessmentQueueInDTO } from "@sims/services/queue"; import { WorkflowClientService } from "@sims/services"; import { QueueNames } from "@sims/utilities"; +import { StudentAssessmentService } from "../../services"; /** * Process messages sent to start assessment queue. */ @Processor(QueueNames.StartApplicationAssessment) export class StartApplicationAssessmentProcessor { - constructor(private readonly workflowClientService: WorkflowClientService) {} + constructor( + private readonly workflowClientService: WorkflowClientService, + private readonly studentAssessmentService: StudentAssessmentService, + ) {} @Process() async startAssessment(job: Job) { + let workflowName = job.data.workflowName; + if (!workflowName) { + const applicationData = + await this.studentAssessmentService.getApplicationDynamicData( + job.data.assessmentId, + ); + workflowName = applicationData.workflowName; + } await this.workflowClientService.startApplicationAssessment( - job.data.workflowName, + workflowName, job.data.assessmentId, ); // Todo: add queue history cleaning logic as in schedulers. diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/index.ts b/sources/packages/backend/apps/queue-consumers/src/processors/index.ts index bde2f2f15c..49c4e707a2 100644 --- a/sources/packages/backend/apps/queue-consumers/src/processors/index.ts +++ b/sources/packages/backend/apps/queue-consumers/src/processors/index.ts @@ -25,3 +25,4 @@ export * from "./schedulers/sfas-integration/sfas-integration.scheduler"; export * from "./schedulers/atbc-respone-integration/atbc-response-integration.scheduler"; export * from "./schedulers/application/process-archive-application.scheduler"; export * from "./schedulers/institution-integration/ece-response/ece-response-integration.scheduler"; +export * from "./schedulers/workflow/assessment-workflow-enqueuer.scheduler"; diff --git a/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/workflow/assessment-workflow-enqueuer.scheduler.ts b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/workflow/assessment-workflow-enqueuer.scheduler.ts new file mode 100644 index 0000000000..05631ac6d4 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/processors/schedulers/workflow/assessment-workflow-enqueuer.scheduler.ts @@ -0,0 +1,52 @@ +import { InjectQueue, Process, Processor } from "@nestjs/bull"; +import { Job, Queue } from "bull"; +import { BaseScheduler } from "../base-scheduler"; +import { QueueNames } from "@sims/utilities"; +import { QueueService } from "@sims/services/queue"; +import { QueueProcessSummary } from "../../models/processors.models"; +import { WorkflowEnqueuerService } from "../../../services"; +import { InjectLogger, LoggerService } from "@sims/utilities/logger"; +import { ProcessSummaryResult } from "@sims/integrations/models"; + +/** + * Search for assessments that have some pending operation, for instance, + * initialization or cancellation, and queue them. + */ +@Processor(QueueNames.AssessmentWorkflowEnqueuer) +export class AssessmentWorkflowEnqueuerScheduler extends BaseScheduler { + constructor( + @InjectQueue(QueueNames.AssessmentWorkflowEnqueuer) + schedulerQueue: Queue, + queueService: QueueService, + private readonly workflowEnqueuerService: WorkflowEnqueuerService, + ) { + super(schedulerQueue, queueService); + } + + /** + * Process all applications with pending assessments to be calculated. + * @param job job information. + * @returns processing result. + */ + @Process() + async enqueueAssessmentOperations( + job: Job, + ): Promise { + const summary = new QueueProcessSummary({ + appLogger: this.logger, + jobLogger: job, + }); + await summary.info( + "Checking application assessments to be queued for start.", + ); + const result = + await this.workflowEnqueuerService.enqueueStartAssessmentWorkflows(); + await summary.info("All application assessments queued."); + await this.cleanSchedulerQueueHistory(); + // TODO: logs improvement. + return [summary.getSummary(), result]; + } + + @InjectLogger() + logger: LoggerService; +} 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 faf80c4a1c..043c216a3b 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 @@ -25,6 +25,7 @@ import { ProcessArchiveApplicationsScheduler, ECEProcessIntegrationScheduler, ECEResponseIntegrationScheduler, + AssessmentWorkflowEnqueuerScheduler, } from "./processors"; import { DisbursementScheduleSharedService, @@ -53,7 +54,11 @@ import { SINValidationModule, } from "@sims/integrations/esdc-integration"; import { CRAIntegrationModule } from "@sims/integrations/cra-integration/cra-integration.module"; -import { StudentAssessmentService, ApplicationService } from "./services"; +import { + StudentAssessmentService, + ApplicationService, + WorkflowEnqueuerService, +} from "./services"; import { SFASIntegrationModule } from "@sims/integrations/sfas-integration"; import { ATBCIntegrationModule } from "@sims/integrations/atbc-integration"; import { ECEIntegrationModule } from "@sims/integrations/institution-integration/ece-integration"; @@ -116,6 +121,8 @@ import { ECEIntegrationModule } from "@sims/integrations/institution-integration ApplicationService, ConfirmationOfEnrollmentService, MSFAANumberSharedService, + AssessmentWorkflowEnqueuerScheduler, + WorkflowEnqueuerService, ], }) export class QueueConsumersModule {} diff --git a/sources/packages/backend/apps/queue-consumers/src/services/application/application.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/application/application.service.ts index befd22f930..6a16a0812b 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/application/application.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/application/application.service.ts @@ -1,21 +1,24 @@ import { Injectable } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { Brackets, Repository } from "typeorm"; import { - RecordDataModelService, Application, ApplicationStatus, User, + StudentAssessment, + StudentAssessmentStatus, } from "@sims/sims-db"; import { ConfigService } from "@sims/utilities/config"; +import { InjectRepository } from "@nestjs/typeorm"; @Injectable() -export class ApplicationService extends RecordDataModelService { +export class ApplicationService { constructor( - dataSource: DataSource, private readonly configService: ConfigService, - ) { - super(dataSource.getRepository(Application)); - } + @InjectRepository(Application) + private readonly applicationRepo: Repository, + @InjectRepository(StudentAssessment) + private readonly studentAssessmentRepo: Repository, + ) {} /** * Archives one or more applications when application archive days @@ -27,7 +30,7 @@ export class ApplicationService extends RecordDataModelService { const auditUser = { id: auditUserId } as User; // Build sql statement to get all application ids to archive - const applicationsToArchive = this.repo + const applicationsToArchive = this.applicationRepo .createQueryBuilder("application") .select("application.id") .innerJoin("application.currentAssessment", "currentAssessment") @@ -39,7 +42,7 @@ export class ApplicationService extends RecordDataModelService { .andWhere("application.isArchived <> :isApplicationArchived") .getSql(); - const updateResult = await this.repo + const updateResult = await this.applicationRepo .createQueryBuilder() .update(Application) .set({ isArchived: true, modifier: auditUser }) @@ -54,4 +57,56 @@ export class ApplicationService extends RecordDataModelService { return updateResult.affected; } + + /** + * Finds all applications with some pending student assessment to be + * processed by the workflow, ignoring applications that already have + * some workflows in progress or were already queued for execution. + * All pending student assessments will be returned ordered by its creation + * date to allow the select of the next one to be executed (usually only one + * record would be expected). + * @returns applications with pending assessments to be executed. + */ + async getApplicationsToStartAssessments(): Promise { + // Sub query to validate if an application has assessment already being + // processed by the workflow. + const inProgressStatusesExistsQuery = this.studentAssessmentRepo + .createQueryBuilder("studentAssessment") + .select("1") + .where("studentAssessment.application.id = application.id") + .andWhere( + "studentAssessment.studentAssessmentStatus IN (:...inProgressStatuses)", + ) + .getQuery(); + return ( + this.applicationRepo + .createQueryBuilder("application") + .select(["application.id", "studentAssessment.id"]) + .innerJoin("application.studentAssessments", "studentAssessment") + // currentProcessingAssessment will be null for the original assessment and it must be + // updated every time that a workflow is triggered. + // When currentProcessingAssessment and currentAssessment are different it indicates that + // there is a assessment workflow pending to be executed. + .where( + new Brackets((qb) => { + qb.where("application.currentProcessingAssessment IS NULL").orWhere( + "application.currentProcessingAssessment != application.currentAssessment", + ); + }), + ) + .andWhere( + "studentAssessment.studentAssessmentStatus = :submittedStatus", + { + submittedStatus: StudentAssessmentStatus.Submitted, + }, + ) + .andWhere(`NOT EXISTS (${inProgressStatusesExistsQuery})`) + .setParameter("inProgressStatuses", [ + StudentAssessmentStatus.Queued, + StudentAssessmentStatus.InProgress, + ]) + .orderBy("studentAssessment.createdAt") + .getMany() + ); + } } 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 b724493835..3851c1993e 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/index.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/index.ts @@ -1,2 +1,3 @@ export * from "./student-assessment/student-assessment.service"; export * from "./application/application.service"; +export * from "./workflow/workflow-enqueuer.service"; diff --git a/sources/packages/backend/apps/queue-consumers/src/services/student-assessment/student-assessment.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/student-assessment/student-assessment.service.ts index b5dea54e2f..4835a53b5e 100644 --- a/sources/packages/backend/apps/queue-consumers/src/services/student-assessment/student-assessment.service.ts +++ b/sources/packages/backend/apps/queue-consumers/src/services/student-assessment/student-assessment.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { StudentAssessment } from "@sims/sims-db"; +import { ApplicationData, StudentAssessment } from "@sims/sims-db"; import { Repository } from "typeorm"; import { InjectRepository } from "@nestjs/typeorm"; @@ -36,4 +36,23 @@ export class StudentAssessmentService { }, }); } + + /** + * The the student application dynamic data using the assessment id. + * @param assessmentId assessment id. + * @returns student application dynamic data. + */ + async getApplicationDynamicData( + assessmentId: number, + ): Promise { + const data = await this.studentAssessmentRepo + .createQueryBuilder("studentAssessment") + .select("application.data ->> 'workflowName'", "workflowName") + .innerJoin("studentAssessment.application", "application") + .where("studentAssessment.id = :assessmentId", { assessmentId }) + .getRawOne(); + return { + workflowName: data.workflowName, + }; + } } diff --git a/sources/packages/backend/apps/queue-consumers/src/services/workflow/workflow-enqueuer.service.ts b/sources/packages/backend/apps/queue-consumers/src/services/workflow/workflow-enqueuer.service.ts new file mode 100644 index 0000000000..7a471bb6c3 --- /dev/null +++ b/sources/packages/backend/apps/queue-consumers/src/services/workflow/workflow-enqueuer.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from "@nestjs/common"; +import { ApplicationService } from ".."; +import { Queue } from "bull"; +import { StartAssessmentQueueInDTO } from "@sims/services/queue"; +import { InjectQueue } from "@nestjs/bull"; +import { QueueNames, parseJSONError, processInParallel } from "@sims/utilities"; +import { + Application, + StudentAssessment, + StudentAssessmentStatus, +} from "@sims/sims-db"; +import { DataSource } from "typeorm"; +import { ProcessSummaryResult } from "@sims/integrations/models"; + +/** + * Manages the operations to search assessments that requires some + * workflow operations, for instance, triggering their start or cancellation. + */ +@Injectable() +export class WorkflowEnqueuerService { + constructor( + private readonly dataSource: DataSource, + private readonly applicationService: ApplicationService, + @InjectQueue(QueueNames.StartApplicationAssessment) + private readonly startAssessmentQueue: Queue, + ) {} + + /** + * Search applications with pending assessments to be processed by the assessment workflow. + * If no other assessment is being processed for that application the oldest pending + * assessment will be queue for start. + * @returns process summary log. + */ + async enqueueStartAssessmentWorkflows(): Promise { + const result = new ProcessSummaryResult(); + try { + result.summary.push( + "Checking database for applications with assessments waiting to be triggered.", + ); + const applications = + await this.applicationService.getApplicationsToStartAssessments(); + result.summary.push(`Found ${applications.length} applications.`); + if (!applications.length) { + result.summary.push("No applications found"); + return result; + } + result.children = await processInParallel( + (application: Application) => this.queueNextAssessment(application), + applications, + ); + } catch (error: unknown) { + result.errors.push( + `Error while enqueueing assessment workflows to be processed. ${parseJSONError( + error, + )}`, + ); + } + return result; + } + + /** + * Queue the next pending assessment for an application. + * @param application application with pending assessments. + * @returns process summary. + */ + private async queueNextAssessment( + application: Application, + ): Promise { + const result = new ProcessSummaryResult(); + try { + result.summary.push( + `Queueing next pending assessment for application id ${application.id}.`, + ); + const [nextAssessment] = application.studentAssessments; + result.summary.push( + `Found ${application.studentAssessments.length} pending assessment(s). Queueing assessment ${nextAssessment.id}.`, + ); + // Update application and student assessment. + await this.dataSource.transaction(async (entityManager) => { + result.summary.push( + `Associating application currentProcessingAssessment as assessment id ${nextAssessment.id}.`, + ); + const applicationUpdateResult = await entityManager + .getRepository(Application) + .update(application.id, { + currentProcessingAssessment: { id: nextAssessment.id }, + }); + if (!applicationUpdateResult.affected) { + throw new Error("Application update did not affected any records."); + } + result.summary.push( + `Updating assessment status to ${StudentAssessmentStatus.Queued}.`, + ); + const assessmentUpdateResults = await entityManager + .getRepository(StudentAssessment) + .update(nextAssessment.id, { + studentAssessmentStatus: StudentAssessmentStatus.Queued, + }); + if (!assessmentUpdateResults.affected) { + throw new Error( + "Student assessment update did not affected any records.", + ); + } + }); + result.summary.push( + `Adding assessment to queue ${QueueNames.StartApplicationAssessment}.`, + ); + await this.startAssessmentQueue.add({ assessmentId: nextAssessment.id }); + result.summary.push("Assessment queued for start."); + } catch (error: unknown) { + result.errors.push( + `Error while enqueueing assessment workflow to be processed for application id ${ + application.id + }. ${parseJSONError(error)}`, + ); + } + return result; + } +} diff --git a/sources/packages/backend/libs/integrations/src/models/common.model.ts b/sources/packages/backend/libs/integrations/src/models/common.model.ts index 31a9335167..0f94e9ac5d 100644 --- a/sources/packages/backend/libs/integrations/src/models/common.model.ts +++ b/sources/packages/backend/libs/integrations/src/models/common.model.ts @@ -14,6 +14,10 @@ export class ProcessSummaryResult { * Errors that happened during the file processing. */ errors: string[] = []; + /** + * Summary result for child processes. + */ + children?: ProcessSummaryResult[]; } /** diff --git a/sources/packages/backend/libs/services/src/queue/dto/assessment.dto.ts b/sources/packages/backend/libs/services/src/queue/dto/assessment.dto.ts index d8227c906e..48b2d5d3b4 100644 --- a/sources/packages/backend/libs/services/src/queue/dto/assessment.dto.ts +++ b/sources/packages/backend/libs/services/src/queue/dto/assessment.dto.ts @@ -1,5 +1,5 @@ export interface StartAssessmentQueueInDTO { - workflowName: string; + workflowName?: string; assessmentId: number; } diff --git a/sources/packages/backend/libs/utilities/src/queue.constant.ts b/sources/packages/backend/libs/utilities/src/queue.constant.ts index 3423028b29..51413525aa 100644 --- a/sources/packages/backend/libs/utilities/src/queue.constant.ts +++ b/sources/packages/backend/libs/utilities/src/queue.constant.ts @@ -26,4 +26,5 @@ export enum QueueNames { ATBCResponseIntegration = "atbc-response-integration", ProcessNotifications = "process-notifications", ProcessArchiveApplications = "archive-applications", + AssessmentWorkflowEnqueuer = "assessment-workflow-enqueuer", }