From 7f0e9aff8e0d522fe4fa1cbd30efcc78a67e1154 Mon Sep 17 00:00:00 2001 From: Mohamed Idrissi <70617264+mhd-hi@users.noreply.github.com> Date: Sat, 5 Oct 2024 20:09:04 -0400 Subject: [PATCH] Add course upsertion job (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add common folder * Add program resource * Fix eslint rule (complexity) & fix dependencies versions * add course, cours-instance * add session resource * Add prisma, scraper, program files (modules and services) * Fix prisma & add scraper files & eslint migration * chore: address sonarlint warning (if statement) * Rollback eslint flat to v8 & remove entity & format imports * add public to classes * cleanup * add ets api hekper files * add ets-program logic & (WIP) upsert programs * Remove unnecessary dtos * (wip 🚔) Add controllers & fix course routes * fix tests * Fix ProgramType relation * Add coursePrerequisite controller & service * Move scraper folder into /common * Move pdf folder into /common * fix http & add prisma to app module * fix tests (wip) & sessions route * fix tests * rename * Add schema image generator * add methods * fix * change "program to programType" relation * fix ci test * miaow miaow * test test * only run eslint on src folder * Rollback to ".test.ts" only * pdf small test * fix logging * fix PR & remove scraper folder * update enum trimester * remove file * fix param * Add swagger * fix program creation parameters & some route parameters * fix axios vulnerability * add queues for programs and courses processing * add bull dashboard * add planification tests * yummy * Extract cheminements.txt file text * (wip) parse programs and courses * (wip) parsing of courses partially done * add cours hors-programme & remove commented lines from parsing * remove console.log * uppercase * add support of alternative courses (choix) * change id to code * Add directory parameter to FileUtil * pdfOutputPath * remove json * program upsert job creating programs and program types to db * fix progress% job * change course code to null * log verbose & fix course data format * a * test etsCourseService * refactor upsertCourses() to use findMany() for better performance * change prerequisite table relations * coverage folder to root * fix prerequisite service * cleanup course service * add program course resource * add type column to ProgramCourse table * cleanup program & courses cheminot helper * (wip) programCourse creation almost done * programCourse upsertion done * Refactor logger initialization to use readonly modifier * Add error handling * fix sonar issues * more fix * add redis env variables * fix type name --- .env.example | 3 + .vscode/settings.json | 2 +- package.json | 2 +- prisma/ERD.svg | 2 +- .../migration.sql | 3 + .../migration.sql | 8 + .../migration.sql | 2 + .../migration.sql | 24 +++ .../migration.sql | 3 + prisma/schema.prisma | 24 ++- src/app.module.ts | 2 + src/common/api-helper/cheminot/Course.ts | 3 +- src/common/api-helper/cheminot/Program.ts | 8 +- .../cheminot/cheminot.controller.ts | 5 +- .../api-helper/cheminot/cheminot.module.ts | 1 + .../api-helper/cheminot/cheminot.service.ts | 7 +- .../ets/course/ets-course.controller.ts | 10 +- .../ets/course/ets-course.service.ts | 66 ++++-- .../ets/program/ets-program.service.ts | 9 +- src/common/utils/pdf/fileUtil.ts | 2 +- src/common/utils/pdf/parser/pdfParserUtil.ts | 2 +- src/common/utils/stringUtil.ts | 5 + .../pdf/pdf-parser/horaire/HoraireCours.ts | 3 +- .../horaire/horaire-cours.service.ts | 4 +- .../planification-cours.service.ts | 4 +- .../website-helper/pdf/pdf.controller.ts | 4 +- .../course-instance.service.ts | 25 ++- .../course-prerequisite.service.ts | 66 +++--- src/course/course.module.ts | 1 + src/course/course.service.ts | 156 ++++++++------ src/jobs/processors/courses.processor.ts | 190 +++++++++++++++++- src/jobs/processors/programs.processor.ts | 5 +- src/jobs/queues.service.ts | 29 +-- .../program-course.controller.ts | 10 + src/program-course/program-course.module.ts | 13 ++ src/program-course/program-course.service.ts | 136 +++++++++++++ src/program/program.service.ts | 64 +++++- src/session/session.service.ts | 2 +- .../ets/course/ets-course.service.test.ts | 164 +++++++++++++++ tsconfig.json | 2 +- 40 files changed, 907 insertions(+), 164 deletions(-) create mode 100644 prisma/migrations/20240830020859_update_course_table_credits_attribute_to_nullable/migration.sql create mode 100644 prisma/migrations/20240830021032_update_course_table_code_attribute_to_not_null/migration.sql create mode 100644 prisma/migrations/20240830022905_update_course_table_add_cycle_attribute/migration.sql create mode 100644 prisma/migrations/20240831045028_course_prerequisite_table_change_relation_to_one_to_many_program_course_table/migration.sql create mode 100644 prisma/migrations/20240902020425_update_program_course_table_add_type_attribute/migration.sql create mode 100644 src/common/utils/stringUtil.ts create mode 100644 src/program-course/program-course.controller.ts create mode 100644 src/program-course/program-course.module.ts create mode 100644 src/program-course/program-course.service.ts create mode 100644 test/common/api-helper/ets/course/ets-course.service.test.ts diff --git a/.env.example b/.env.example index 048c876..029adf4 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ PORT=3000 DATABASE_URL="postgresql://postgres@localhost:5432/planifetsDB?schema=public" LOG_LEVELS="log,error,warn,debug,verbose" + +REDIS_HOST="localhost" +REDIS_PORT=6379 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 67e00b8..31a0eca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "[typescript]": { "editor.tabSize": 2, "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "files.eol": "\n", "typescript.preferences.importModuleSpecifier": "relative", diff --git a/package.json b/package.json index ea22a0a..046356b 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], - "coverageDirectory": "../coverage", + "coverageDirectory": "./coverage", "testEnvironment": "node" } } diff --git a/prisma/ERD.svg b/prisma/ERD.svg index 6607ec2..68719ce 100644 --- a/prisma/ERD.svg +++ b/prisma/ERD.svg @@ -1 +1 @@ -TrimesterAUTOMNEAUTOMNEETEETEHIVERHIVERSessionStringid🗝️TrimestertrimesterIntyearDateTimecreatedAtDateTimeupdatedAtCourseInstanceStringid🗝️DateTimecreatedAtDateTimeupdatedAtCourseIntid🗝️StringcodeStringtitleStringdescriptionIntcreditsDateTimecreatedAtDateTimeupdatedAtCoursePrerequisiteDateTimecreatedAtDateTimeupdatedAtProgramCourseInttypicalSessionIndexDateTimecreatedAtDateTimeupdatedAtProgramIntid🗝️StringcodeStringtitleStringcreditsIntcycleStringurlJsonhoraireCoursPdfJsonJsonplanificationPdfJsonDateTimecreatedAtDateTimeupdatedAtProgramTypeIntid🗝️Stringtitleenum:trimestercourseInstancescoursesessionprogramscourseInstancesprerequisitesprerequisiteOfcourseprerequisitecourseprogramprogramTypescoursesprograms +TrimesterAUTOMNEAUTOMNEETEETEHIVERHIVERSessionStringid🗝️TrimestertrimesterIntyearDateTimecreatedAtDateTimeupdatedAtCourseInstanceStringid🗝️DateTimecreatedAtDateTimeupdatedAtCourseIntid🗝️StringcodeStringtitleStringdescriptionIntcreditsIntcycleDateTimecreatedAtDateTimeupdatedAtCoursePrerequisiteStringunstructuredPrerequisiteDateTimecreatedAtDateTimeupdatedAtProgramCourseStringtypeInttypicalSessionIndexDateTimecreatedAtDateTimeupdatedAtProgramIntid🗝️StringcodeStringtitleStringcreditsIntcycleStringurlJsonhoraireCoursPdfJsonJsonplanificationPdfJsonDateTimecreatedAtDateTimeupdatedAtProgramTypeIntid🗝️Stringtitleenum:trimestercourseInstancescoursesessionprogramscourseInstancesprogramCourseprerequisitecourseprogramprerequisitesprerequisiteToCourseprogramTypescoursesprograms \ No newline at end of file diff --git a/prisma/migrations/20240830020859_update_course_table_credits_attribute_to_nullable/migration.sql b/prisma/migrations/20240830020859_update_course_table_credits_attribute_to_nullable/migration.sql new file mode 100644 index 0000000..87e30a1 --- /dev/null +++ b/prisma/migrations/20240830020859_update_course_table_credits_attribute_to_nullable/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Course" ALTER COLUMN "code" DROP NOT NULL, +ALTER COLUMN "credits" DROP NOT NULL; diff --git a/prisma/migrations/20240830021032_update_course_table_code_attribute_to_not_null/migration.sql b/prisma/migrations/20240830021032_update_course_table_code_attribute_to_not_null/migration.sql new file mode 100644 index 0000000..df07840 --- /dev/null +++ b/prisma/migrations/20240830021032_update_course_table_code_attribute_to_not_null/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `code` on table `Course` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Course" ALTER COLUMN "code" SET NOT NULL; diff --git a/prisma/migrations/20240830022905_update_course_table_add_cycle_attribute/migration.sql b/prisma/migrations/20240830022905_update_course_table_add_cycle_attribute/migration.sql new file mode 100644 index 0000000..d5ff05b --- /dev/null +++ b/prisma/migrations/20240830022905_update_course_table_add_cycle_attribute/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Course" ADD COLUMN "cycle" INTEGER; diff --git a/prisma/migrations/20240831045028_course_prerequisite_table_change_relation_to_one_to_many_program_course_table/migration.sql b/prisma/migrations/20240831045028_course_prerequisite_table_change_relation_to_one_to_many_program_course_table/migration.sql new file mode 100644 index 0000000..d0a3591 --- /dev/null +++ b/prisma/migrations/20240831045028_course_prerequisite_table_change_relation_to_one_to_many_program_course_table/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - The primary key for the `CoursePrerequisite` table will be changed. If it partially fails, the table could be left without primary key constraint. + - Added the required column `programId` to the `CoursePrerequisite` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "CoursePrerequisite" DROP CONSTRAINT "CoursePrerequisite_courseId_fkey"; + +-- DropForeignKey +ALTER TABLE "CoursePrerequisite" DROP CONSTRAINT "CoursePrerequisite_prerequisiteId_fkey"; + +-- AlterTable +ALTER TABLE "CoursePrerequisite" DROP CONSTRAINT "CoursePrerequisite_pkey", +ADD COLUMN "programId" INTEGER NOT NULL, +ADD COLUMN "unstructuredPrerequisite" TEXT, +ADD CONSTRAINT "CoursePrerequisite_pkey" PRIMARY KEY ("courseId", "programId", "prerequisiteId"); + +-- AddForeignKey +ALTER TABLE "CoursePrerequisite" ADD CONSTRAINT "CoursePrerequisite_courseId_programId_fkey" FOREIGN KEY ("courseId", "programId") REFERENCES "ProgramCourse"("courseId", "programId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoursePrerequisite" ADD CONSTRAINT "CoursePrerequisite_prerequisiteId_programId_fkey" FOREIGN KEY ("prerequisiteId", "programId") REFERENCES "ProgramCourse"("courseId", "programId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240902020425_update_program_course_table_add_type_attribute/migration.sql b/prisma/migrations/20240902020425_update_program_course_table_add_type_attribute/migration.sql new file mode 100644 index 0000000..a338cae --- /dev/null +++ b/prisma/migrations/20240902020425_update_program_course_table_add_type_attribute/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "ProgramCourse" ADD COLUMN "type" TEXT, +ALTER COLUMN "typicalSessionIndex" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c67c989..3789d09 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,13 +51,11 @@ model Course { code String @unique title String description String - credits Int + credits Int? + cycle Int? programs ProgramCourse[] courseInstances CourseInstance[] - prerequisites CoursePrerequisite[] @relation("CourseToPrerequisite") - prerequisiteOf CoursePrerequisite[] @relation("PrerequisiteToCourse") - createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -65,26 +63,32 @@ model Course { } model CoursePrerequisite { - courseId Int - prerequisiteId Int + courseId Int + prerequisiteId Int + unstructuredPrerequisite String? + programId Int - course Course @relation("CourseToPrerequisite", fields: [courseId], references: [id]) - prerequisite Course @relation("PrerequisiteToCourse", fields: [prerequisiteId], references: [id]) + programCourse ProgramCourse @relation("CourseToPrerequisites", fields: [courseId, programId], references: [courseId, programId]) + prerequisite ProgramCourse @relation("PrerequisiteToCourse", fields: [prerequisiteId, programId], references: [courseId, programId]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@id([courseId, prerequisiteId]) + @@id([courseId, programId, prerequisiteId]) } model ProgramCourse { courseId Int programId Int - typicalSessionIndex Int + type String? + typicalSessionIndex Int? course Course @relation(fields: [courseId], references: [id]) program Program @relation(fields: [programId], references: [id]) + prerequisites CoursePrerequisite[] @relation("CourseToPrerequisites") + prerequisiteToCourse CoursePrerequisite[] @relation("PrerequisiteToCourse") + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app.module.ts b/src/app.module.ts index 34aa3ff..6a2ba38 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { QueuesEnum } from './jobs/queues.enum'; import { QueuesService } from './jobs/queues.service'; import { PrismaModule } from './prisma/prisma.module'; import { ProgramModule } from './program/program.module'; +import { ProgramCourseModule } from './program-course/program-course.module'; import { SessionModule } from './session/session.module'; @Module({ @@ -47,6 +48,7 @@ import { SessionModule } from './session/session.module'; CoursePrerequisiteModule, SessionModule, ProgramModule, + ProgramCourseModule, ], providers: [ProgramsProcessor, CoursesProcessor, QueuesService], controllers: [AppController], diff --git a/src/common/api-helper/cheminot/Course.ts b/src/common/api-helper/cheminot/Course.ts index aa84822..2d84735 100644 --- a/src/common/api-helper/cheminot/Course.ts +++ b/src/common/api-helper/cheminot/Course.ts @@ -4,7 +4,8 @@ export class Course { public static readonly COURSE_LINE_PARTS_COUNT = 11; public static readonly INTERNSHIP_LINE_PARTS_COUNT = 12; - private static courseCodeValidationPipe = new CourseCodeValidationPipe(); + private static readonly courseCodeValidationPipe = + new CourseCodeValidationPipe(); constructor( public type: string, diff --git a/src/common/api-helper/cheminot/Program.ts b/src/common/api-helper/cheminot/Program.ts index 5e2a049..5846175 100644 --- a/src/common/api-helper/cheminot/Program.ts +++ b/src/common/api-helper/cheminot/Program.ts @@ -1,11 +1,11 @@ import { Course } from './Course'; export class Program { - private horsProgramme: string[] = []; + private readonly horsProgramme: string[] = []; constructor( - private code: number, - private courses: Course[], + public code: string, + public courses: Course[], ) {} public static isProgramLine(line: string): boolean { @@ -20,7 +20,7 @@ export class Program { return null; } - const code = parseInt(parts[1], 10); + const code = parts[1]; return new Program(code, []); } diff --git a/src/common/api-helper/cheminot/cheminot.controller.ts b/src/common/api-helper/cheminot/cheminot.controller.ts index 2893399..dbca58a 100644 --- a/src/common/api-helper/cheminot/cheminot.controller.ts +++ b/src/common/api-helper/cheminot/cheminot.controller.ts @@ -23,9 +23,6 @@ export class CheminotController { summary: 'Parse the programs and courses from the cheminements.txt file', }) public async parseProgramsAndCoursesFromCheminotTxtFile() { - await this.cheminotService.loadPrograms(); - const data = this.cheminotService.getPrograms(); - - return data; + return this.cheminotService.parseProgramsAndCoursesCheminot(); } } diff --git a/src/common/api-helper/cheminot/cheminot.module.ts b/src/common/api-helper/cheminot/cheminot.module.ts index 18c3774..2cd1272 100644 --- a/src/common/api-helper/cheminot/cheminot.module.ts +++ b/src/common/api-helper/cheminot/cheminot.module.ts @@ -7,5 +7,6 @@ import { FileExtractionService } from './file-extraction.service'; @Module({ controllers: [CheminotController], providers: [CheminotService, FileExtractionService], + exports: [CheminotService], }) export class CheminotModule {} diff --git a/src/common/api-helper/cheminot/cheminot.service.ts b/src/common/api-helper/cheminot/cheminot.service.ts index 1338db3..6fe700d 100644 --- a/src/common/api-helper/cheminot/cheminot.service.ts +++ b/src/common/api-helper/cheminot/cheminot.service.ts @@ -6,10 +6,15 @@ import { Program } from './Program'; @Injectable() export class CheminotService { - private programs: Program[] = []; + private readonly programs: Program[] = []; constructor(private readonly fileExtractionService: FileExtractionService) {} + public async parseProgramsAndCoursesCheminot() { + await this.loadPrograms(); + return this.getPrograms(); + } + public async loadPrograms() { const fileContent = await this.fileExtractionService.extractCheminementsFile(); diff --git a/src/common/api-helper/ets/course/ets-course.controller.ts b/src/common/api-helper/ets/course/ets-course.controller.ts index 40a2886..1571fc1 100644 --- a/src/common/api-helper/ets/course/ets-course.controller.ts +++ b/src/common/api-helper/ets/course/ets-course.controller.ts @@ -3,8 +3,8 @@ import { ApiTags } from '@nestjs/swagger'; import { EtsCourseService, - IEtsCourse, - IEtsCoursesData, + ICourseEtsAPI, + ICourseWithCredits, } from './ets-course.service'; @ApiTags('ÉTS API') @@ -13,12 +13,12 @@ export class EtsCourseController { constructor(private readonly etsCourseService: EtsCourseService) {} @Get() - public fetchAllCourses(): Promise { - return this.etsCourseService.fetchAllCourses(); + public fetchAllCourses(): Promise { + return this.etsCourseService.fetchAllCoursesWithCredits(); } @Get(':id') - public fetchCoursesById(@Param('id') id: string): Promise { + public fetchCoursesById(@Param('id') id: string): Promise { if (!id) { throw new Error('The id parameter is required'); } diff --git a/src/common/api-helper/ets/course/ets-course.service.ts b/src/common/api-helper/ets/course/ets-course.service.ts index 5cb3032..0f8915f 100644 --- a/src/common/api-helper/ets/course/ets-course.service.ts +++ b/src/common/api-helper/ets/course/ets-course.service.ts @@ -6,50 +6,92 @@ import { ETS_API_GET_ALL_COURSES, ETS_API_GET_COURSES_BY_IDS, } from '../../../constants/url'; +import { extractNumberFromString } from '../../../utils/stringUtil'; -export interface IEtsCoursesData { +export interface ICoursesEtsAPI { id: number; title: string; + description: string; code: string; cycle: string | null; } -export interface IEtsCourse extends IEtsCoursesData { - credits: string; +export interface ICourseEtsAPI { + id: number; + title: string; + code: string; + credits: number | null; +} + +export interface ICourses { + id: number; + title: string; + description: string; + code: string; + cycle: number | null; +} + +export interface ICourseWithCredits extends ICourses { + credits: number | null; } @Injectable() export class EtsCourseService { constructor(private readonly httpService: HttpService) {} - // Fetches all courses - public async fetchAllCourses(): Promise { + public async fetchAllCoursesWithCredits(): Promise { + const courses = await this.fetchAllCoursesWithoutCredits(); + + // Fetch credits for all courses by their IDs + const courseIds = courses.map((course) => course.id).join(','); + const coursesFetchedById = await this.fetchCoursesById(courseIds); + + // Add credits to the courses + return courses.map((course) => { + const courseCreds = coursesFetchedById.find((cc) => cc.id === course.id); + return { + ...course, + credits: courseCreds?.credits ?? null, + }; + }); + } + + //Fetches all courses without credits + public async fetchAllCoursesWithoutCredits(): Promise { const response = await firstValueFrom( this.httpService.get(ETS_API_GET_ALL_COURSES), ); - const courses = response.data.results; - return courses.map((course: IEtsCoursesData) => ({ + + if (!courses.length) { + throw new Error('No courses fetched.'); + } + + return courses.map((course: ICoursesEtsAPI) => ({ id: course.id, title: course.title, + description: course.description, code: course.code, - cycle: course.cycle, + cycle: course.cycle ? extractNumberFromString(course.cycle) : null, })); } // Fetches one or more courses by their ids // The ids are passed as a string with comma-separated values, ex: "349682,349710" - public async fetchCoursesById(ids: string): Promise { + public async fetchCoursesById(ids: string): Promise { const response = await firstValueFrom( this.httpService.get(`${ETS_API_GET_COURSES_BY_IDS}${ids}`), ); - const courses = response.data; - return courses.map((course: IEtsCourse) => ({ + + if (!courses.length) { + throw new Error('No courses fetched.'); + } + + return courses.map((course: ICourseEtsAPI) => ({ id: course.id, title: course.title, code: course.code, - cycle: course.cycle, credits: course.credits, })); } diff --git a/src/common/api-helper/ets/program/ets-program.service.ts b/src/common/api-helper/ets/program/ets-program.service.ts index 84da21d..3d0cd85 100644 --- a/src/common/api-helper/ets/program/ets-program.service.ts +++ b/src/common/api-helper/ets/program/ets-program.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; import { ETS_API_GET_ALL_PROGRAMS } from '../../../constants/url'; +import { extractNumberFromString } from '../../../utils/stringUtil'; interface IProgramEtsAPI { id: number; @@ -48,7 +49,7 @@ export class EtsProgramService { (program: IProgramEtsAPI) => ({ id: program.id, title: program.title, - cycle: this.extractCycleNumber(program.cycle), + cycle: extractNumberFromString(program.cycle), code: program.code, credits: program.credits, programTypes: { @@ -60,10 +61,4 @@ export class EtsProgramService { return { types, programs }; } - - private extractCycleNumber(cycle: string): number { - const match = RegExp(/\d+/).exec(cycle); - - return match ? parseInt(match[0], 10) : 0; - } } diff --git a/src/common/utils/pdf/fileUtil.ts b/src/common/utils/pdf/fileUtil.ts index 2067c38..de63e50 100644 --- a/src/common/utils/pdf/fileUtil.ts +++ b/src/common/utils/pdf/fileUtil.ts @@ -4,7 +4,7 @@ import path from 'path'; @Injectable() export class FileUtil { - private logger = new Logger(FileUtil.name); + private readonly logger = new Logger(FileUtil.name); public writeDataToFile( data: T, diff --git a/src/common/utils/pdf/parser/pdfParserUtil.ts b/src/common/utils/pdf/parser/pdfParserUtil.ts index df275ed..02fd763 100644 --- a/src/common/utils/pdf/parser/pdfParserUtil.ts +++ b/src/common/utils/pdf/parser/pdfParserUtil.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import PDFParser, { Output } from 'pdf2json'; export class PdfParserUtil { - private static logger = new Logger(PdfParserUtil.name); + private static readonly logger = new Logger(PdfParserUtil.name); public static async parsePdfBuffer( pdfBuffer: Buffer, diff --git a/src/common/utils/stringUtil.ts b/src/common/utils/stringUtil.ts new file mode 100644 index 0000000..717b348 --- /dev/null +++ b/src/common/utils/stringUtil.ts @@ -0,0 +1,5 @@ +export function extractNumberFromString(cycle: string): number { + const match = RegExp(/\d+/).exec(cycle); + + return match ? parseInt(match[0], 10) : 0; +} diff --git a/src/common/website-helper/pdf/pdf-parser/horaire/HoraireCours.ts b/src/common/website-helper/pdf/pdf-parser/horaire/HoraireCours.ts index 8ec2de9..51f80fc 100644 --- a/src/common/website-helper/pdf/pdf-parser/horaire/HoraireCours.ts +++ b/src/common/website-helper/pdf/pdf-parser/horaire/HoraireCours.ts @@ -7,7 +7,8 @@ export class HoraireCours implements IHoraireCours { private static readonly TITLE_FONT_SIZE = 10.998999999999999; private static readonly COURS_X_AXIS = 0.551; - private static courseCodeValidationPipe = new CourseCodeValidationPipe(); + private static readonly courseCodeValidationPipe = + new CourseCodeValidationPipe(); constructor( public code: string = '', diff --git a/src/common/website-helper/pdf/pdf-parser/horaire/horaire-cours.service.ts b/src/common/website-helper/pdf/pdf-parser/horaire/horaire-cours.service.ts index facf404..ba1a489 100644 --- a/src/common/website-helper/pdf/pdf-parser/horaire/horaire-cours.service.ts +++ b/src/common/website-helper/pdf/pdf-parser/horaire/horaire-cours.service.ts @@ -14,9 +14,9 @@ export class HoraireCoursService { private readonly END_PAGE_CONTENT_Y_AXIS = 59; private readonly PREALABLE_X_AXIS = 29.86; - constructor(private httpService: HttpService) {} + constructor(private readonly httpService: HttpService) {} - private logger = new Logger(HoraireCoursService.name); + private readonly logger = new Logger(HoraireCoursService.name); public async parsePdfFromUrl(pdfUrl: string) { try { diff --git a/src/common/website-helper/pdf/pdf-parser/planification/planification-cours.service.ts b/src/common/website-helper/pdf/pdf-parser/planification/planification-cours.service.ts index 78a85df..860d1d7 100644 --- a/src/common/website-helper/pdf/pdf-parser/planification/planification-cours.service.ts +++ b/src/common/website-helper/pdf/pdf-parser/planification/planification-cours.service.ts @@ -13,9 +13,9 @@ import { Row } from './Row'; export class PlanificationCoursService { private readonly BORDER_OFFSET = 0.124; - private courseCodeValidationPipe = new CourseCodeValidationPipe(); + private readonly courseCodeValidationPipe = new CourseCodeValidationPipe(); - constructor(private httpService: HttpService) {} + constructor(private readonly httpService: HttpService) {} public async parsePdfFromUrl( pdfUrl: string, diff --git a/src/common/website-helper/pdf/pdf.controller.ts b/src/common/website-helper/pdf/pdf.controller.ts index c78f7fb..31ef3c4 100644 --- a/src/common/website-helper/pdf/pdf.controller.ts +++ b/src/common/website-helper/pdf/pdf.controller.ts @@ -18,8 +18,8 @@ import { ICoursePlanification } from './pdf-parser/planification/planification-c @Controller('pdf') export class PdfController { constructor( - private horaireCoursService: HoraireCoursService, - private planificationCoursService: PlanificationCoursService, + private readonly horaireCoursService: HoraireCoursService, + private readonly planificationCoursService: PlanificationCoursService, ) {} @Get('horaire-cours') diff --git a/src/course-instance/course-instance.service.ts b/src/course-instance/course-instance.service.ts index ee2b352..1899984 100644 --- a/src/course-instance/course-instance.service.ts +++ b/src/course-instance/course-instance.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { CourseInstance, Prisma } from '@prisma/client'; +import { CourseInstance, Prisma, Session } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -7,7 +7,7 @@ import { PrismaService } from '../prisma/prisma.service'; export class CourseInstanceService { constructor(private readonly prisma: PrismaService) {} - private logger = new Logger(CourseInstanceService.name); + private readonly logger = new Logger(CourseInstanceService.name); public getCourseInstance( courseInstanceWhereUniqueInput: Prisma.CourseInstanceWhereUniqueInput, @@ -40,9 +40,26 @@ export class CourseInstanceService { }); } - // This will be used to get all the infos about a course instance + public async getCourseAvailability( + courseId: number, + ): Promise<{ session: Session; available: boolean }[]> { + this.logger.verbose('getCourseAvailability', courseId); + + const courseInstances = await this.prisma.courseInstance.findMany({ + where: { courseId }, + include: { + session: true, + }, + }); + + return courseInstances.map((ci) => ({ + session: ci.session, + available: true, + })); + } + public async getCourseInstancesByCourse( - courseId: string, + courseId: number, ): Promise { this.logger.log('getCourseInstancesByCourse', courseId); diff --git a/src/course-prerequisite/course-prerequisite.service.ts b/src/course-prerequisite/course-prerequisite.service.ts index e353252..f0a43bc 100644 --- a/src/course-prerequisite/course-prerequisite.service.ts +++ b/src/course-prerequisite/course-prerequisite.service.ts @@ -7,48 +7,59 @@ import { PrismaService } from '../prisma/prisma.service'; export class CoursePrerequisiteService { constructor(private readonly prisma: PrismaService) {} - private logger = new Logger(CoursePrerequisiteService.name); + private readonly logger = new Logger(CoursePrerequisiteService.name); public async getPrerequisites(data: Prisma.CoursePrerequisiteWhereInput) { - this.logger.log('coursePrerequisiteById', data); + this.logger.verbose('Fetching course prerequisites', data); return this.prisma.coursePrerequisite.findMany({ where: data, - include: { prerequisite: true }, + include: { + programCourse: true, + prerequisite: true, + }, }); } public async getAllCoursePrerequisites() { - this.logger.log('getAllCoursePrerequisites'); + this.logger.verbose('Fetching all course prerequisites'); return this.prisma.coursePrerequisite.findMany({ - include: { prerequisite: true }, + include: { + programCourse: true, + prerequisite: true, + }, }); } - private async createCoursePrerequisite( + private async createPrerequisite( data: Prisma.CoursePrerequisiteCreateInput, ): Promise { - this.logger.log('createCoursePrerequisite', data); + this.logger.verbose('createCoursePrerequisite', data); - const courseId = data.course.connect?.id; - const prerequisiteId = data.prerequisite.connect?.id; + const courseId = data.programCourse.connect?.courseId as number; + const programId = data.programCourse.connect?.programId as number; + const prerequisiteId = data.prerequisite.connect?.courseId as number; - if (!courseId || !prerequisiteId) { - throw new Error('courseId and prerequisiteId must be provided.'); + if (!courseId || !programId || !prerequisiteId) { + this.logger.error( + 'courseId, programId, and prerequisiteId must be provided.', + ); } const existingPrerequisite = await this.prisma.coursePrerequisite.findUnique({ where: { - courseId_prerequisiteId: { + courseId_programId_prerequisiteId: { courseId, + programId, prerequisiteId, }, }, }); if (existingPrerequisite) { + this.logger.verbose('Prerequisite already exists', existingPrerequisite); return existingPrerequisite; } @@ -57,27 +68,30 @@ export class CoursePrerequisiteService { }); } - public async createCoursePrerequisites( + public async createProgramCoursePrerequisites( data: Prisma.CoursePrerequisiteCreateInput[], ): Promise { - this.logger.log('ensurePrerequisitesExist', data); + this.logger.verbose('ensurePrerequisitesExist', data); - const ensuredPrerequisites = await Promise.all( - data.map((prerequisiteData) => - this.createCoursePrerequisite(prerequisiteData), - ), + return Promise.all( + data.map((prerequisiteData) => this.createPrerequisite(prerequisiteData)), ); - - return ensuredPrerequisites; } - public async deleteCoursePrerequisite( - where: Prisma.CoursePrerequisiteWhereUniqueInput, - ): Promise { - this.logger.log('deleteCoursePrerequisite', where); + public async deletePrerequisitesForProgramCourse( + programId: number, + courseId: number, + ): Promise { + this.logger.verbose('deletePrerequisitesForProgramCourse', { + programId, + courseId, + }); - return this.prisma.coursePrerequisite.delete({ - where, + return this.prisma.coursePrerequisite.deleteMany({ + where: { + programId: programId, + courseId: courseId, + }, }); } } diff --git a/src/course/course.module.ts b/src/course/course.module.ts index 35c6d1c..666ab35 100644 --- a/src/course/course.module.ts +++ b/src/course/course.module.ts @@ -8,5 +8,6 @@ import { CourseService } from './course.service'; imports: [PrismaModule], controllers: [CourseController], providers: [CourseService], + exports: [CourseService], }) export class CourseModule {} diff --git a/src/course/course.service.ts b/src/course/course.service.ts index 39fdcae..95907c7 100644 --- a/src/course/course.service.ts +++ b/src/course/course.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Course, Prisma, Session } from '@prisma/client'; +import { Course, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -7,28 +7,41 @@ import { PrismaService } from '../prisma/prisma.service'; export class CourseService { constructor(private readonly prisma: PrismaService) {} - private logger = new Logger(CourseService.name); + private readonly logger = new Logger(CourseService.name); public async getCourse( courseWhereUniqueInput: Prisma.CourseWhereUniqueInput, ): Promise { - this.logger.log('courseById', courseWhereUniqueInput); - const course = await this.prisma.course.findUnique({ where: courseWhereUniqueInput, }); + if (!course) { + this.logger.warn( + `Course code "${courseWhereUniqueInput.code}" not found`, + ); + return null; + } return course; } + private async getCoursesByIds( + courseIds: number[], + ): Promise> { + const existingCourses = await this.prisma.course.findMany({ + where: { id: { in: courseIds } }, + }); + + return new Map(existingCourses.map((course) => [course.id, course])); + } public async getAllCourses() { - this.logger.log('getAllCourses'); + this.logger.verbose('getAllCourses'); return this.prisma.course.findMany(); } public async getCoursesByProgram(programId: number): Promise { - this.logger.log('getCoursesByProgram', programId); + this.logger.verbose('getCoursesByProgram', programId); return this.prisma.course.findMany({ where: { @@ -41,86 +54,111 @@ export class CourseService { }); } - public async getCourseAvailability( - courseId: number, - ): Promise<{ session: Session; available: boolean }[]> { - this.logger.log('getCourseAvailability', courseId); - - const courseInstances = await this.prisma.courseInstance.findMany({ - where: { courseId }, - include: { - session: true, - }, - }); - - const sessionAvailability = courseInstances.map((instance) => ({ - session: instance.session, - available: true, - })); - - return sessionAvailability; - } - public async createCourse(data: Prisma.CourseCreateInput): Promise { - this.logger.log('createCourse', data); + this.logger.verbose('Creating new course', data.code); - const course = await this.prisma.course.create({ - data, + return this.prisma.course.create({ + data: { + ...data, + createdAt: new Date(), + }, }); - - return course; } public async updateCourse(params: { where: Prisma.CourseWhereUniqueInput; data: Prisma.CourseUpdateInput; }): Promise { - this.logger.log('updateCourse', params); - const { data, where } = params; + + this.logger.verbose('Updating course', data.code); return this.prisma.course.update({ - data, + data: { + ...data, + updatedAt: new Date(), + }, where, }); } - private async upsertCourse( - courseData: Prisma.CourseCreateInput, - ): Promise { - const existingCourse = await this.prisma.course.findUnique({ - where: { id: courseData.id }, - }); + public async upsertCourses( + data: Prisma.CourseCreateInput[], + ): Promise { + this.logger.verbose('upsertCourses'); - if (existingCourse) { - if (JSON.stringify(existingCourse) !== JSON.stringify(courseData)) { - return this.updateCourse({ - where: { id: courseData.id }, - data: courseData, - }); - } + const existingCourses = await this.getCoursesByIds( + data.map((course) => course.id), + ); + const operations = this.prepareUpsertCourses(data, existingCourses); - return existingCourse; - } - return this.createCourse(courseData); + return Promise.all([...operations.updates, ...operations.creations]); } - public async upsertCourses( + private prepareUpsertCourses( data: Prisma.CourseCreateInput[], - ): Promise { - this.logger.log('upsertCourses', data); + existingCourses: Map, + ): { + updates: Array>; + creations: Array>; + } { + const updates: Array> = []; + const creations: Array> = []; + + data.forEach((courseData) => { + const existingCourse = existingCourses.get(courseData.id); + + if (existingCourse) { + const hasChanges = this.hasCourseChanged(existingCourse, courseData); + if (hasChanges) { + updates.push( + this.updateCourse({ + where: { id: courseData.id }, + data: courseData, + }), + ); + } else { + updates.push(Promise.resolve(existingCourse)); + } + } else { + creations.push(this.createCourse(courseData)); + } + }); - //TODO: Use "findMany" instead of "findUnique". remove upsertCourse function and only use this function only - const upsertedCourses = await Promise.all( - data.map((courseData) => this.upsertCourse(courseData)), - ); + return { updates, creations }; + } - return upsertedCourses; + private hasCourseChanged( + existingCourse: Course, + courseData: Prisma.CourseCreateInput, + ): boolean { + const normalizedExistingCourse = { + id: existingCourse.id, + code: existingCourse.code, + title: existingCourse.title, + description: existingCourse.description, + credits: existingCourse.credits, + cycle: existingCourse.cycle, + }; + + const normalizedCourseData = { + id: courseData.id, + code: courseData.code, + title: courseData.title, + description: courseData.description, + credits: courseData.credits, + cycle: courseData.cycle, + }; + + return ( + JSON.stringify(normalizedExistingCourse) !== + JSON.stringify(normalizedCourseData) + ); } public async deleteCourse( where: Prisma.CourseWhereUniqueInput, ): Promise { - this.logger.log('deleteCourse', where); + this.logger.verbose('deleteCourse', where); return this.prisma.course.delete({ where, diff --git a/src/jobs/processors/courses.processor.ts b/src/jobs/processors/courses.processor.ts index 4758aa1..4708e99 100644 --- a/src/jobs/processors/courses.processor.ts +++ b/src/jobs/processors/courses.processor.ts @@ -1,12 +1,198 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Course } from '@prisma/client'; import { Job } from 'bullmq'; +import { CheminotService } from '../../common/api-helper/cheminot/cheminot.service'; +import { Course as CourseCheminot } from '../../common/api-helper/cheminot/Course'; +import { Program as ProgramCheminot } from '../../common/api-helper/cheminot/Program'; +import { EtsCourseService } from '../../common/api-helper/ets/course/ets-course.service'; +import { CourseService } from '../../course/course.service'; +import { + ProgramIncludeCourseIdsAndPrerequisitesType, + ProgramService, +} from '../../program/program.service'; +import { ProgramCourseService } from '../../program-course/program-course.service'; import { QueuesEnum } from '../queues.enum'; @Processor(QueuesEnum.COURSES) export class CoursesProcessor extends WorkerHost { + private readonly logger = new Logger(CoursesProcessor.name); + + constructor( + private readonly etsCourseService: EtsCourseService, + private readonly courseService: CourseService, + private readonly programCourseService: ProgramCourseService, + private readonly programService: ProgramService, + private readonly cheminotService: CheminotService, + ) { + super(); + } + public async process(job: Job): Promise { - console.log('Processing courses:', job.data); - //TODO: Implement the processing logic here + switch (job.name) { + case 'upsert-courses': + await this.processCourses(job); + break; + case 'courses-details-prerequisites': + await this.syncCourseDetailsWithCheminotData(job); + break; + default: + this.logger.error('Unknown job name: ' + job.name); + } + } + + private async processCourses(job: Job): Promise { + this.logger.log('Processing courses...'); + + try { + const courses = await this.etsCourseService.fetchAllCoursesWithCredits(); + + const coursesLength = courses.length; + + if (!coursesLength) { + this.logger.error('No courses fetched.'); + throw new Error('No courses fetched.'); + } + + this.logger.log(`${coursesLength} courses fetched.`); + + await this.courseService.upsertCourses(courses); + + job.updateProgress(100); + + job.updateData({ + processed: true, + courses: courses.length, + }); + } catch (error: unknown) { + this.logger.error('Error processing courses: ', error); + throw error; + } + } + + private async syncCourseDetailsWithCheminotData(job: Job): Promise { + this.logger.log('Syncing course details with Cheminot data...'); + + try { + const allProgramsDB = + await this.programService.getAllProgramsWithCourses(); + const programsCheminot = + await this.cheminotService.parseProgramsAndCoursesCheminot(); + + console.debug(`Total programs from DB: ${allProgramsDB.length}`); + console.debug(`Total programs from Cheminot: ${programsCheminot.length}`); + + for (const programDB of allProgramsDB) { + if (!programDB) { + this.logger.warn('ProgramDB not found for program: ', programDB); + continue; + } + + await this.processProgram(programDB, programsCheminot); + } + + job.updateProgress(100); + job.updateData({ processed: true, programs: allProgramsDB.length }); + } catch (error: unknown) { + this.logger.error( + 'Error syncing course details with Cheminot data: ', + error, + ); + throw error; + } + } + + private async processProgram( + programDB: ProgramIncludeCourseIdsAndPrerequisitesType, + programsCheminot: ProgramCheminot[], + ): Promise { + console.debug(`Processing program: ${programDB.code}`); + const programCheminot = programsCheminot.find( + (p) => p.code === programDB.code, + ); + + if (!programCheminot) { + this.logger.warn(`Program ${programDB.code} not found in Cheminot data`); + return; + } + + this.logger.log( + `Program in the db: ${programDB.code}\tCourses in DB: ${programDB.courses.length}`, + ); + this.logger.log(`Courses in Cheminot: ${programCheminot.courses.length}`); + + await this.processCheminotCourses(programDB, programCheminot); + } + + private async processCheminotCourses( + programDB: ProgramIncludeCourseIdsAndPrerequisitesType, + programCheminot: ProgramCheminot, + ): Promise { + for (const courseCheminot of programCheminot.courses) { + const existingCourse = await this.courseService.getCourse({ + code: courseCheminot.code, + }); + + if (!existingCourse) { + continue; + } + + await this.handleProgramCourseUpsertion( + programDB, + existingCourse, + courseCheminot, + ); + } + } + + private async handleProgramCourseUpsertion( + programDB: ProgramIncludeCourseIdsAndPrerequisitesType, + existingCourse: Course, + courseCheminot: CourseCheminot, + ): Promise { + const programCourse = programDB.courses.find( + (pc) => pc.course.code === courseCheminot.code, + ); + + if (programCourse) { + const hasChanges = this.programCourseService.hasProgramCourseChanged( + { + typicalSessionIndex: courseCheminot.session, + type: courseCheminot.type, + }, + { + typicalSessionIndex: programCourse.typicalSessionIndex, + type: programCourse.type, + }, + programDB.id, + existingCourse.id, + ); + + if (hasChanges) { + this.logger.verbose( + `Updating ProgramCourse for courseId ${existingCourse.id} and programId ${programDB.id}`, + ); + await this.programCourseService.updateProgramCourse({ + where: { + courseId_programId: { + courseId: existingCourse.id, + programId: programDB.id, + }, + }, + data: { + typicalSessionIndex: courseCheminot.session, + type: courseCheminot.type, + }, + }); + } + } else { + await this.programCourseService.createProgramCourse({ + program: { connect: { id: programDB.id } }, + course: { connect: { id: existingCourse.id } }, + typicalSessionIndex: courseCheminot.session, + type: courseCheminot.type, + }); + } } } diff --git a/src/jobs/processors/programs.processor.ts b/src/jobs/processors/programs.processor.ts index 6b6f23e..ab9f3c6 100644 --- a/src/jobs/processors/programs.processor.ts +++ b/src/jobs/processors/programs.processor.ts @@ -8,7 +8,7 @@ import { QueuesEnum } from '../queues.enum'; @Processor(QueuesEnum.PROGRAMS) export class ProgramsProcessor extends WorkerHost { - private logger = new Logger(ProgramsProcessor.name); + private readonly logger = new Logger(ProgramsProcessor.name); constructor( private readonly etsProgramService: EtsProgramService, @@ -22,6 +22,9 @@ export class ProgramsProcessor extends WorkerHost { case 'upsert-programs': await this.processPrograms(job); break; + case 'courses-availability': + //TOOD: Implement + break; default: this.logger.error('Unknown job name: ' + job.name); } diff --git a/src/jobs/queues.service.ts b/src/jobs/queues.service.ts index b80c2e0..f83eb00 100644 --- a/src/jobs/queues.service.ts +++ b/src/jobs/queues.service.ts @@ -12,7 +12,7 @@ import { QueuesEnum } from './queues.enum'; @Injectable() export class QueuesService implements OnModuleInit, OnModuleDestroy { - private logger = new Logger(QueuesService.name); + private readonly logger = new Logger(QueuesService.name); private programsQueueEvents!: QueueEvents; private coursesQueueEvents!: QueueEvents; @@ -49,18 +49,21 @@ export class QueuesService implements OnModuleInit, OnModuleDestroy { } private async processCourses() { - const job = await this.coursesQueue.add('upsert-courses', {}); - this.logger.log( - 'Courses job added to queue: ' + job.id + ' (' + job.name + ')', - ); + try { + const upsertJob = await this.coursesQueue.add('upsert-courses', {}); + await upsertJob.waitUntilFinished(this.coursesQueueEvents); + this.logger.log('Upsert courses job completed'); + + const detailsJob = await this.coursesQueue.add( + 'courses-details-prerequisites', + {}, + ); + await detailsJob.waitUntilFinished(this.coursesQueueEvents); + this.logger.log('Courses details and prerequisites job completed'); - job - .waitUntilFinished(this.coursesQueueEvents) - .then(() => { - this.logger.log('Courses job finished processing.'); - }) - .catch((err) => { - this.logger.error('Error processing courses job:', err); - }); + this.logger.log('All course jobs completed successfully!'); + } catch (error) { + this.logger.error('Error processing course jobs:', error); + } } } diff --git a/src/program-course/program-course.controller.ts b/src/program-course/program-course.controller.ts new file mode 100644 index 0000000..1b2c1fc --- /dev/null +++ b/src/program-course/program-course.controller.ts @@ -0,0 +1,10 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { ProgramCourseService } from './program-course.service'; + +@ApiTags('Programs courses') +@Controller('program-courses') +export class ProgramCourseController { + constructor(private readonly programCourseService: ProgramCourseService) {} +} diff --git a/src/program-course/program-course.module.ts b/src/program-course/program-course.module.ts new file mode 100644 index 0000000..aba40a9 --- /dev/null +++ b/src/program-course/program-course.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { PrismaModule } from '../prisma/prisma.module'; +import { ProgramCourseController } from './program-course.controller'; +import { ProgramCourseService } from './program-course.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ProgramCourseController], + providers: [ProgramCourseService], + exports: [ProgramCourseService], +}) +export class ProgramCourseModule {} diff --git a/src/program-course/program-course.service.ts b/src/program-course/program-course.service.ts new file mode 100644 index 0000000..7ee6bf4 --- /dev/null +++ b/src/program-course/program-course.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma, ProgramCourse } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class ProgramCourseService { + constructor(private readonly prisma: PrismaService) {} + + private readonly logger = new Logger(ProgramCourseService.name); + + public async getProgramCourse( + programCourseWhereUniqueInput: Prisma.ProgramCourseWhereUniqueInput, + ): Promise { + this.logger.verbose('getProgramCourse', programCourseWhereUniqueInput); + + return this.prisma.programCourse.findUnique({ + where: programCourseWhereUniqueInput, + }); + } + + public async getProgramCoursesByProgram( + programId: number, + ): Promise { + this.logger.verbose('getProgramCoursesByProgram', programId); + + return this.prisma.programCourse.findMany({ + where: { + programId, + }, + }); + } + + public async getAllProgramCourses(): Promise { + this.logger.verbose('getAllProgramCourses'); + + return this.prisma.programCourse.findMany(); + } + + public async createProgramCourse( + data: Prisma.ProgramCourseCreateInput, + ): Promise { + const existingProgramCourse = await this.prisma.programCourse.findFirst({ + where: { + programId: data.program.connect?.id, + courseId: data.course.connect?.id, + }, + }); + + if (existingProgramCourse) { + this.logger.error('ProgramCourse already exists', existingProgramCourse); + return undefined; + } + + this.logger.verbose('createProgramCourse', data); + + return this.prisma.programCourse.create({ + data, + }); + } + + public async updateProgramCourse(params: { + where: Prisma.ProgramCourseWhereUniqueInput; + data: Prisma.ProgramCourseUpdateInput; + }): Promise { + const { data, where } = params; + const existingProgramCourse = await this.prisma.programCourse.findUnique({ + where, + }); + + if (!existingProgramCourse) { + this.logger.error( + 'ProgramCourse not found!', + 'Where: ', + where, + 'Data: ', + data, + ); + return undefined; + } + + this.logger.verbose('Updating existing ProgramCourse', { data, where }); + + return this.prisma.programCourse.update({ + data, + where, + }); + } + + public async deleteProgramCourse( + where: Prisma.ProgramCourseWhereUniqueInput, + ): Promise { + this.logger.verbose('deleteProgramCourse', JSON.stringify(where)); + return this.prisma.programCourse.delete({ + where, + }); + } + + public hasProgramCourseChanged( + newCourseData: { + typicalSessionIndex: number | null; + type: string | null; + }, + existingProgramCourse: { + typicalSessionIndex: number | null; + type: string | null; + }, + programId: number, + courseId: number, + ): boolean { + const hasTypicalSessionIndexChanged = + newCourseData.typicalSessionIndex !== + existingProgramCourse.typicalSessionIndex; + + const hasTypeChanged = newCourseData.type !== existingProgramCourse.type; + + const hasChanged = hasTypicalSessionIndexChanged || hasTypeChanged; + + if (hasChanged) { + this.logger.verbose('ProgramCourse has changed', { + existingData: existingProgramCourse, + newData: newCourseData, + changes: { + typicalSessionIndex: hasTypicalSessionIndexChanged + ? 'has changed' + : 'no changes', + type: hasTypeChanged ? 'has changed' : 'no changes', + }, + programId, + courseId, + }); + } + + return hasChanged; + } +} diff --git a/src/program/program.service.ts b/src/program/program.service.ts index 3ca6b92..8c85d6c 100644 --- a/src/program/program.service.ts +++ b/src/program/program.service.ts @@ -3,11 +3,32 @@ import { Prisma, Program, ProgramType } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +export type ProgramIncludeCourseIdsAndPrerequisitesType = { + id: number; + code: string | null; + courses: { + course: { + id: number; + code: string; + }; + typicalSessionIndex: number | null; + type: string | null; + prerequisites: { + prerequisite: { + course: { + id: number; + code: string; + }; + }; + }[]; + }[]; +}; + @Injectable() export class ProgramService { constructor(private readonly prisma: PrismaService) {} - private logger = new Logger(ProgramService.name); + private readonly logger = new Logger(ProgramService.name); public async getProgram( programWhereUniqueInput: Prisma.ProgramWhereUniqueInput, @@ -25,6 +46,47 @@ export class ProgramService { return this.prisma.program.findMany(); } + public async getAllProgramsWithCourses(): Promise< + ProgramIncludeCourseIdsAndPrerequisitesType[] + > { + const data = await this.prisma.program.findMany({ + select: { + id: true, + code: true, + courses: { + select: { + course: { + select: { + id: true, + code: true, + }, + }, + typicalSessionIndex: true, + type: true, + prerequisites: { + select: { + prerequisite: { + select: { + course: { + select: { + id: true, + code: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + this.logger.verbose('getAllProgramsWithCourses', JSON.stringify(data)); + + return data; + } + public async createProgram( data: Prisma.ProgramCreateInput, ): Promise { diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 74abd42..76f8ff2 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -7,7 +7,7 @@ import { PrismaService } from '../prisma/prisma.service'; export class SessionService { constructor(private readonly prisma: PrismaService) {} - private logger = new Logger(SessionService.name); + private readonly logger = new Logger(SessionService.name); public async getSession(id: string): Promise { this.logger.log('getSession', id); diff --git a/test/common/api-helper/ets/course/ets-course.service.test.ts b/test/common/api-helper/ets/course/ets-course.service.test.ts new file mode 100644 index 0000000..bb0450c --- /dev/null +++ b/test/common/api-helper/ets/course/ets-course.service.test.ts @@ -0,0 +1,164 @@ +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosHeaders, AxiosResponse } from 'axios'; +import { of } from 'rxjs'; + +import { EtsCourseService } from '../../../../../src/common/api-helper/ets/course/ets-course.service'; +import { + ETS_API_GET_ALL_COURSES, + ETS_API_GET_COURSES_BY_IDS, +} from '../../../../../src/common/constants/url'; +import { extractNumberFromString } from '../../../../../src/common/utils/stringUtil'; + +describe('EtsCourseService', () => { + let service: EtsCourseService; + let httpService: HttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EtsCourseService, + { + provide: HttpService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(EtsCourseService); + httpService = module.get(HttpService); + }); + + it('should fetch all courses without credits', async () => { + const mockResponse: AxiosResponse = { + data: { + results: [ + { + id: 1, + title: 'Petit chaton', + description: 'Description 1', + code: 'Miaow321', + cycle: null, + }, + ], + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: new AxiosHeaders(), + }, + }; + + jest.spyOn(httpService, 'get').mockReturnValueOnce(of(mockResponse)); + + const courses = await service.fetchAllCoursesWithoutCredits(); + + expect(courses).toEqual([ + { + id: 1, + title: 'Petit chaton', + description: 'Description 1', + code: 'Miaow321', + cycle: null, + }, + ]); + }); + + it('should fetch courses by ids', async () => { + const mockResponse: AxiosResponse = { + data: [ + { + id: 1, + title: 'Prrrrrrrrrr', + code: 'LOG100', + credits: 4, + }, + ], + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: new AxiosHeaders(), + }, + }; + + jest.spyOn(httpService, 'get').mockReturnValueOnce(of(mockResponse)); + + const courses = await service.fetchCoursesById('1'); + + expect(courses).toEqual([ + { + id: 1, + title: 'Prrrrrrrrrr', + code: 'LOG100', + credits: 4, + }, + ]); + }); + + it('should fetch all courses with credits', async () => { + const mockCoursesResponse: AxiosResponse = { + data: { + results: [ + { + id: 1, + title: 'League of Legends 123', + description: 'Description 1', + code: 'LOL123', + cycle: '2e cycle', + }, + ], + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: new AxiosHeaders(), + }, + }; + + const mockCoursesByIdResponse: AxiosResponse = { + data: [ + { + id: 1, + title: 'League of Legends 123', + code: 'LOL123', + credits: 3, + }, + ], + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: new AxiosHeaders(), + }, + }; + + jest.spyOn(httpService, 'get').mockImplementation((url) => { + if (url.includes(ETS_API_GET_ALL_COURSES)) { + return of(mockCoursesResponse); + } else if (url.includes(ETS_API_GET_COURSES_BY_IDS)) { + return of(mockCoursesByIdResponse); + } + + // Return an empty observable as a fallback to avoid returning undefined + return of({} as AxiosResponse); + }); + + const coursesWithCredits = await service.fetchAllCoursesWithCredits(); + + expect(coursesWithCredits).toEqual([ + { + id: 1, + title: 'League of Legends 123', + description: 'Description 1', + code: 'LOL123', + cycle: extractNumberFromString('2e cycle'), + credits: 3, + }, + ]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index bf17a17..8a7ef3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,4 +18,4 @@ ], "esModuleInterop": true, } -} \ No newline at end of file +}