From 6a7ad052c1508bb92c9b5f326971f91b7b970d6f Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:06:15 +0800 Subject: [PATCH 01/18] Add the data base for the user_cv by creating a new entity --- server/src/entities/userCV.entity.ts | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 server/src/entities/userCV.entity.ts diff --git a/server/src/entities/userCV.entity.ts b/server/src/entities/userCV.entity.ts new file mode 100644 index 00000000..41c0739e --- /dev/null +++ b/server/src/entities/userCV.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToOne, + JoinColumn, + UpdateDateColumn, + BeforeInsert, +} from "typeorm"; +import { user } from "./user.entity"; +import { uuidv7 } from "uuidv7"; + +@Entity() +export class user_cv { + @PrimaryGeneratedColumn("uuid") + cv_id: string; + + @Column({ nullable: false }) + user_id: string; + +// This part will establish a one-to-one relationship between the user_cv and user entities, allowing us to easily access the user associated with a given CV. +// The onDelete: "CASCADE" option ensures that if a user is deleted, their associated CV will also be automatically removed from the database. + @OneToOne(() => user, { onDelete: "CASCADE" }) + @JoinColumn({ name: "user_id" }) + user: user; + + @Column({ nullable: true }) + desired_job: string; + + @Column({ type: "text", nullable: true }) + resume: string; + + @Column({ type: "json", nullable: true }) + experiences: { + company: string; + title: string; + description: string; + duration: string; + }[]; + + @Column({ type: "json", nullable: true }) + education: { + degree: string; + school_name: string; + duration: string; + }[]; + + @Column({ type: "simple-array", nullable: true }) + technical_skills: string[]; + + @Column({ type: "json", nullable: true }) + languages: { + language: string; + level: string; + }[]; + + @UpdateDateColumn() + updated_at: Date; + + @BeforeInsert() + generateUUIDv7() { + if (!this.cv_id) this.cv_id = uuidv7(); + } +} \ No newline at end of file From 3edd8890ca631d3b4b4d12586c8629a95dc61dfc Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:10:36 +0800 Subject: [PATCH 02/18] Modify some files to respect prettier style --- server/src/entities/userCV.entity.ts | 6 +++--- server/tsconfig.build.json | 9 ++++++++- server/tsconfig.json | 9 ++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/server/src/entities/userCV.entity.ts b/server/src/entities/userCV.entity.ts index 41c0739e..2d42e989 100644 --- a/server/src/entities/userCV.entity.ts +++ b/server/src/entities/userCV.entity.ts @@ -18,8 +18,8 @@ export class user_cv { @Column({ nullable: false }) user_id: string; -// This part will establish a one-to-one relationship between the user_cv and user entities, allowing us to easily access the user associated with a given CV. -// The onDelete: "CASCADE" option ensures that if a user is deleted, their associated CV will also be automatically removed from the database. + // This part will establish a one-to-one relationship between the user_cv and user entities, allowing us to easily access the user associated with a given CV. + // The onDelete: "CASCADE" option ensures that if a user is deleted, their associated CV will also be automatically removed from the database. @OneToOne(() => user, { onDelete: "CASCADE" }) @JoinColumn({ name: "user_id" }) user: user; @@ -61,4 +61,4 @@ export class user_cv { generateUUIDv7() { if (!this.cv_id) this.cv_id = uuidv7(); } -} \ No newline at end of file +} diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json index 4e131458..fde98c1d 100644 --- a/server/tsconfig.build.json +++ b/server/tsconfig.build.json @@ -1,4 +1,11 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*test.ts", "src/test/**"] + "exclude": [ + "node_modules", + "test", + "dist", + "**/*spec.ts", + "**/*test.ts", + "src/test/**" + ] } diff --git a/server/tsconfig.json b/server/tsconfig.json index abe6bdd9..948a30f6 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -24,5 +24,12 @@ "@src/*": ["./src/*"] } }, - "exclude": ["node_modules", "dist", "test", "**/*spec.ts", "**/*test.ts", "src/test"] + "exclude": [ + "node_modules", + "dist", + "test", + "**/*spec.ts", + "**/*test.ts", + "src/test" + ] } From aa5925dee5551502465f3b70d2130e415b566a2f Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:36:41 +0800 Subject: [PATCH 03/18] Add the route to upload the user's cv from yhe frontend --- server/package.json | 6 ++ server/src/common/middleware/multer.config.ts | 25 +++++++ server/src/modules/users/users.controller.ts | 39 +++++++++- server/src/modules/users/users.service.ts | 74 +++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 server/src/common/middleware/multer.config.ts diff --git a/server/package.json b/server/package.json index fdcae84a..93804cf9 100644 --- a/server/package.json +++ b/server/package.json @@ -22,6 +22,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@nestjs/axios": "^4.0.0", "@nestjs/cli": "^11.0.7", "@nestjs/common": "^11.1.0", @@ -40,6 +41,9 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", + "multer": "^2.1.1", + "pdf-parse": "^2.4.5", + "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -50,7 +54,9 @@ "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.9", "@types/jest": "^29.5.2", + "@types/multer": "^2.1.0", "@types/node": "^20.3.1", + "@types/pdf-parse": "^1.1.5", "@types/supertest": "^6.0.0", "jest": "^29.5.0", "oxlint": "^1.11.2", diff --git a/server/src/common/middleware/multer.config.ts b/server/src/common/middleware/multer.config.ts new file mode 100644 index 00000000..d4ffd9b5 --- /dev/null +++ b/server/src/common/middleware/multer.config.ts @@ -0,0 +1,25 @@ +import multer from "multer"; +import path from "path"; + +// use memory storage to store the file in memory as a buffer +const storage = multer.memoryStorage(); + +export const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024, // Limite à 5 Mo + }, + fileFilter: (req, file, cb) => { + // ckeck file type is pdf + const filetypes = /pdf/; + const mimetype = filetypes.test(file.mimetype); + const extname = filetypes.test( + path.extname(file.originalname).toLowerCase(), + ); + + if (mimetype && extname) { + return cb(null, true); + } + cb(new Error("Only PDF files are allowed!")); + }, +}); diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 8c73f8ee..5b015633 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -1,5 +1,16 @@ -import { Body, Controller, Put } from "@nestjs/common"; +import { + Body, + Controller, + Req, + Res, + BadRequestException, + UseInterceptors, + Put, + Post, +} from "@nestjs/common"; import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator"; +import type { Request, Response } from "express"; +import { FileInterceptor } from "@nestjs/platform-express"; import { ApiTags, @@ -32,4 +43,30 @@ export class UsersController { async updatePassword(@Body() body: UpdatePasswordDto) { return this.usersService.changeUserPassword(body.email, body.newPassword); } + + @ApiOkResponse({ + description: "The CV has successfully uploaded", + type: String, + }) + @ApiBadRequestResponse({ + description: + "Invalid request data in body (e.g., missing file or incorrect format)", + }) + @UsePipes(new PostValidationPipe()) + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (file.mimetype === "application/pdf") { + cb(null, true); + } else { + cb(new BadRequestException("Only PDF files are accepted"), false); + } + }, + }), + ) + @Post("uploadCV") + async uploadCV(@Req() req: Request, @Res() res: Response) { + return this.usersService.uploadCV(req, res); + } } diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index f49c6c0e..d88b0207 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -8,6 +8,8 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { user_password, user_email } from "@entities/user.entity"; import { hashPassword } from "@common/utils/passwordHasher"; +import * as pdf from "pdf-parse"; +import type { Request, Response } from "express"; @Injectable() export class UsersService { @@ -77,4 +79,76 @@ export class UsersService { ); } } + + async uploadCV(req: Request, res: Response) { + try { + if (!req.file) { + return res.status(400).json({ message: "Upload a PDF file." }); + } + + const pdf = require("pdf-parse-debugging-disabled"); + const pdfData = await pdf(req.file.buffer); + const rawText = pdfData.text; + + if (rawText.length === 0) { + return res + .status(400) + .json({ message: "The PDF file is empty or could not be parsed." }); + } else { + console.log("Raw text extracted from PDF:", rawText); + res.status(200).json({ + message: "CV analysé avec succès", + }); + } + } catch (error) { + console.error("Parsing error:", error); + res.status(500).json({ message: "Error processing the CV file." }); + } + } } + +// router.post('/upload-cv', upload.single('cv'), async (req, res) => { +// try { +// if (!req.file) { +// return res.status(400).json({ error: "Aucun fichier téléchargé" }); +// } + +// // ÉTAPE A : Extraire le texte brut du PDF +// const pdfData = await pdf(req.file.buffer); +// const rawText = pdfData.text; + +// // ÉTAPE B : Envoyer le texte à Claude pour analyse +// const msg = await anthropic.messages.create({ +// model: "claude-3-5-sonnet-20240620", +// max_tokens: 1500, +// temperature: 0, // 0 pour une réponse constante et précise +// system: "Tu es un parseur de CV expert. Ton rôle est d'extraire les données au format JSON strict.", +// messages: [ +// { +// role: "user", +// content: `Extrais les informations suivantes de ce texte de CV : +// nom, poste_actuel, experiences (liste avec dates, poste, entreprise), +// competences_techniques (liste), et diplomes. + +// Réponds uniquement avec le JSON, sans texte avant ou après. + +// Texte du CV : ${rawText}` +// } +// ], +// }); + +// // ÉTAPE C : Parser la réponse de Claude +// const textResponse = msg.content[0].text; +// const extractedData = JSON.parse(textResponse); + +// // ÉTAPE D : Réponse au front +// res.status(200).json({ +// message: "CV analysé avec succès", +// data: extractedData +// }); + +// } catch (error) { +// console.error("Erreur parsing CV:", error); +// res.status(500).json({ error: "Erreur lors du traitement du CV" }); +// } +// }); From 1a7c960596064e037a046617077f435ccf17923e Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:51:13 +0800 Subject: [PATCH 04/18] Add the extraction of the user's information on the cv and send it to the claude AI --- package-lock.json | 1270 ++++++++++-------- server/src/modules/auth/auth.service.ts | 1 - server/src/modules/users/users.controller.ts | 4 + server/src/modules/users/users.module.ts | 4 +- server/src/modules/users/users.service.ts | 159 ++- 5 files changed, 819 insertions(+), 619 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50916fa2..504367b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,9 +38,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.15.tgz", - "integrity": "sha512-pU2RZYX6vhd7uLSdLwPnuBcr0mXJSjp3EgOXKsrlQFQZevc+Qs+2JdXgIElnOT/aDqtRtriDmLlSbtdE8n3ZbA==", + "version": "19.2.19", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", + "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", "license": "MIT", "dependencies": { "ajv": "8.17.1", @@ -74,12 +74,12 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.15.tgz", - "integrity": "sha512-kNOJ+3vekJJCQKWihNmxBkarJzNW09kP5a9E1SRNiQVNOUEeSwcRR0qYotM65nx821gNzjjhJXnAZ8OazWldrg==", + "version": "19.2.19", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.19.tgz", + "integrity": "sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.15", + "@angular-devkit/core": "19.2.19", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -92,13 +92,13 @@ } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.15.tgz", - "integrity": "sha512-1ESFmFGMpGQmalDB3t2EtmWDGv6gOFYBMxmHO2f1KI/UDl8UmZnCGL4mD3EWo8Hv0YIsZ9wOH9Q7ZHNYjeSpzg==", + "version": "19.2.19", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.19.tgz", + "integrity": "sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.15", - "@angular-devkit/schematics": "19.2.15", + "@angular-devkit/core": "19.2.19", + "@angular-devkit/schematics": "19.2.19", "@inquirer/prompts": "7.3.2", "ansi-colors": "4.1.3", "symbol-observable": "4.0.0", @@ -151,6 +151,26 @@ "tslib": "^2.1.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", + "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -862,9 +882,9 @@ } }, "node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "license": "MIT", "funding": { "type": "github", @@ -1465,25 +1485,25 @@ "optional": true }, "node_modules/@inquirer/ansi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz", - "integrity": "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@inquirer/checkbox": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.0.tgz", - "integrity": "sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.1", - "@inquirer/core": "^10.3.0", - "@inquirer/figures": "^1.0.14", - "@inquirer/type": "^3.0.9", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1498,13 +1518,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.19", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.19.tgz", - "integrity": "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==", + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { "node": ">=18" @@ -1519,19 +1539,19 @@ } }, "node_modules/@inquirer/core": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.0.tgz", - "integrity": "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==", + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.1", - "@inquirer/figures": "^1.0.14", - "@inquirer/type": "^3.0.9", + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1546,14 +1566,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.21", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.21.tgz", - "integrity": "sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ==", + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/external-editor": "^1.0.2", - "@inquirer/type": "^3.0.9" + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" }, "engines": { "node": ">=18" @@ -1568,14 +1588,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.21", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.21.tgz", - "integrity": "sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA==", + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1590,12 +1610,12 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", - "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", "license": "MIT", "dependencies": { - "chardet": "^2.1.0", + "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "engines": { @@ -1611,22 +1631,22 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.14.tgz", - "integrity": "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@inquirer/input": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.5.tgz", - "integrity": "sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { "node": ">=18" @@ -1641,13 +1661,13 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.21.tgz", - "integrity": "sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { "node": ">=18" @@ -1662,14 +1682,14 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.21", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.21.tgz", - "integrity": "sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA==", + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.1", - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { "node": ">=18" @@ -1684,21 +1704,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.8.0.tgz", - "integrity": "sha512-JHwGbQ6wjf1dxxnalDYpZwZxUEosT+6CPGD9Zh4sm9WXdtUp9XODCQD3NjSTmu+0OAyxWXNOqf0spjIymJa2Tw==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^4.2.0", - "@inquirer/confirm": "^5.1.14", - "@inquirer/editor": "^4.2.15", - "@inquirer/expand": "^4.0.17", - "@inquirer/input": "^4.2.1", - "@inquirer/number": "^3.0.17", - "@inquirer/password": "^4.0.17", - "@inquirer/rawlist": "^4.1.5", - "@inquirer/search": "^3.1.0", - "@inquirer/select": "^4.3.1" + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" }, "engines": { "node": ">=18" @@ -1713,14 +1733,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.9.tgz", - "integrity": "sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/type": "^3.0.9", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1735,15 +1755,15 @@ } }, "node_modules/@inquirer/search": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.0.tgz", - "integrity": "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.0", - "@inquirer/figures": "^1.0.14", - "@inquirer/type": "^3.0.9", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1758,16 +1778,16 @@ } }, "node_modules/@inquirer/select": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.0.tgz", - "integrity": "sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.1", - "@inquirer/core": "^10.3.0", - "@inquirer/figures": "^1.0.14", - "@inquirer/type": "^3.0.9", - "yoctocolors-cjs": "^2.1.2" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1782,9 +1802,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.9.tgz", - "integrity": "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", "license": "MIT", "engines": { "node": ">=18" @@ -1798,27 +1818,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1916,9 +1915,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -2421,11 +2420,195 @@ } }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nestjs/axios": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", @@ -2438,29 +2621,28 @@ } }, "node_modules/@nestjs/cli": { - "version": "11.0.10", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.10.tgz", - "integrity": "sha512-4waDT0yGWANg0pKz4E47+nUrqIJv/UqrZ5wLPkCqc7oMGRMWKAaw1NDZ9rKsaqhqvxb2LfI5+uXOWr4yi94DOQ==", + "version": "11.0.16", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", + "integrity": "sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.15", - "@angular-devkit/schematics": "19.2.15", - "@angular-devkit/schematics-cli": "19.2.15", - "@inquirer/prompts": "7.8.0", + "@angular-devkit/core": "19.2.19", + "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/schematics-cli": "19.2.19", + "@inquirer/prompts": "7.10.1", "@nestjs/schematics": "^11.0.1", - "ansis": "4.1.0", + "ansis": "4.2.0", "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.1.0", - "glob": "11.0.3", + "glob": "13.0.0", "node-emoji": "1.11.0", "ora": "5.4.1", - "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", - "typescript": "5.8.3", - "webpack": "5.100.2", + "typescript": "5.9.3", + "webpack": "5.104.1", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2482,131 +2664,13 @@ } } }, - "node_modules/@nestjs/cli/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@nestjs/cli/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/cli/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@nestjs/cli/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@nestjs/cli/node_modules/webpack": { - "version": "5.100.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.2.tgz", - "integrity": "sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==", - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.2", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@nestjs/common": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.8.tgz", - "integrity": "sha512-bbsOqwld/GdBfiRNc4nnjyWWENDEicq4SH+R5AuYatvf++vf1x5JIsHB1i1KtfZMD3eRte0D4K9WXuAYil6XAg==", + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", + "integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==", "license": "MIT", "dependencies": { - "file-type": "21.0.0", + "file-type": "21.3.2", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", @@ -2632,14 +2696,14 @@ } }, "node_modules/@nestjs/config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz", - "integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz", + "integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==", "license": "MIT", "dependencies": { - "dotenv": "16.4.7", - "dotenv-expand": "12.0.1", - "lodash": "4.17.21" + "dotenv": "17.2.3", + "dotenv-expand": "12.0.3", + "lodash": "4.17.23" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", @@ -2647,9 +2711,9 @@ } }, "node_modules/@nestjs/config/node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2733,14 +2797,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.8.tgz", - "integrity": "sha512-rL6pZH9BW7BnL5X2eWbJMtt86uloAKjFgyY5+L2UkizgfEp7rgAs0+Z1z0BcW2Pgu5+q8O7RKPNyHJ/9ZNz/ZQ==", + "version": "11.1.16", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.16.tgz", + "integrity": "sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==", "license": "MIT", "dependencies": { - "cors": "2.8.5", - "express": "5.1.0", - "multer": "2.0.2", + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", "path-to-regexp": "8.3.0", "tslib": "2.8.1" }, @@ -2824,20 +2888,20 @@ } }, "node_modules/@nestjs/swagger": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.1.tgz", - "integrity": "sha512-1MS7xf0pzc1mofG53xrrtrurnziafPUHkqzRm4YUVPA/egeiMaSerQBD/feiAeQ2BnX0WiLsTX4HQFO0icvOjQ==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", + "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.15.1", + "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "2.1.0", - "js-yaml": "4.1.0", - "lodash": "4.17.21", + "js-yaml": "4.1.1", + "lodash": "4.17.23", "path-to-regexp": "8.3.0", - "swagger-ui-dist": "5.29.4" + "swagger-ui-dist": "5.31.0" }, "peerDependencies": { - "@fastify/static": "^8.0.0", + "@fastify/static": "^8.0.0 || ^9.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "class-transformer": "*", @@ -4975,14 +5039,13 @@ } }, "node_modules/@tokenizer/inflate": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", - "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "fflate": "^0.8.2", - "token-types": "^6.0.0" + "debug": "^4.4.3", + "token-types": "^6.1.1" }, "engines": { "node": ">=18" @@ -5393,6 +5456,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", @@ -5402,6 +5475,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pdf-parse": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", + "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -6034,9 +6117,9 @@ } }, "node_modules/ansis": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", - "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", "license": "ISC", "engines": { "node": ">=14" @@ -6184,13 +6267,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -6387,12 +6470,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", - "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bcrypt": { @@ -6434,35 +6520,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -6489,9 +6567,9 @@ } }, "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -6508,11 +6586,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -6677,9 +6755,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001754", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", - "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "funding": [ { "type": "opencollective", @@ -7052,15 +7130,16 @@ } }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -7136,9 +7215,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -7146,6 +7225,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { @@ -7501,9 +7584,9 @@ } }, "node_modules/dotenv-expand": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz", - "integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==", + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", "license": "BSD-2-Clause", "dependencies": { "dotenv": "^16.4.5" @@ -7551,9 +7634,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.245", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.245.tgz", - "integrity": "sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "license": "ISC" }, "node_modules/emittery": { @@ -7641,6 +7724,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -7888,18 +7972,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -7990,21 +8075,15 @@ } } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, "node_modules/file-type": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz", - "integrity": "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==", + "version": "21.3.2", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", + "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", "license": "MIT", "dependencies": { - "@tokenizer/inflate": "^0.2.7", - "strtok3": "^10.2.2", - "token-types": "^6.0.0", + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" }, "engines": { @@ -8028,9 +8107,9 @@ } }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -8041,7 +8120,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -8149,9 +8232,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8364,21 +8447,15 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -8405,16 +8482,37 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8565,28 +8663,23 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -8628,9 +8721,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -9030,21 +9123,6 @@ "node": ">=6" } }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/javascript-natural-sort": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", @@ -10057,9 +10135,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10127,6 +10205,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -10209,12 +10300,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -10560,9 +10651,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { @@ -10901,15 +10992,19 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -10932,9 +11027,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10961,18 +11056,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -11001,21 +11084,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -11134,6 +11218,12 @@ "lodash": "^4.17.21" } }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -11471,26 +11561,26 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -11531,6 +11621,60 @@ "node": ">= 14.16" } }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdf-parse-debugging-disabled": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pdf-parse-debugging-disabled/-/pdf-parse-debugging-disabled-1.1.1.tgz", + "integrity": "sha512-pTPktRvyvLSj9zIS9DcOKYKcR7qeTqqyMkWjgYV7YglBmA9yrDJ1/rATOpXQoT5QcXKDYjEt4BSmZkJBcP1JDw==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + } + }, + "node_modules/pdf-parse-debugging-disabled/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -12132,9 +12276,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -12146,15 +12290,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -12165,15 +12300,15 @@ } }, "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" @@ -12640,9 +12775,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -12681,34 +12816,29 @@ } }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/seroval": { @@ -12733,9 +12863,9 @@ } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -12745,6 +12875,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -13317,9 +13451,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.29.4", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.4.tgz", - "integrity": "sha512-gJFDz/gyLOCQtWwAgqs6Rk78z9ONnqTnlW11gimG9nLap8drKa3AJBKpzIQMIjl5PD2Ix+Tn+mc/tfoT2tgsng==", + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -13393,15 +13527,14 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -13747,12 +13880,12 @@ } }, "node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "license": "MIT", "dependencies": { - "@borewit/text-codec": "^0.1.0", + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, @@ -13790,14 +13923,11 @@ "node": ">=18" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" }, "node_modules/ts-jest": { "version": "29.4.5", @@ -13957,9 +14087,9 @@ } }, "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "devOptional": true, "license": "BSD-3-Clause", "engines": { @@ -14232,9 +14362,10 @@ } }, "node_modules/typeorm/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -14273,12 +14404,12 @@ "license": "ISC" }, "node_modules/typeorm/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -14429,9 +14560,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -14527,9 +14658,9 @@ } }, "node_modules/validator": { - "version": "13.15.20", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", - "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -14789,11 +14920,10 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -14803,21 +14933,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, @@ -14867,7 +14997,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -14880,12 +15009,17 @@ } } }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -14895,7 +15029,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -14908,7 +15041,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -15246,7 +15378,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -15286,6 +15418,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", "@nestjs/axios": "^4.0.0", "@nestjs/cli": "^11.0.7", "@nestjs/common": "^11.1.0", @@ -15304,6 +15437,9 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", + "multer": "^2.1.1", + "pdf-parse": "^2.4.5", + "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -15314,7 +15450,9 @@ "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.9", "@types/jest": "^29.5.2", + "@types/multer": "^2.1.0", "@types/node": "^20.3.1", + "@types/pdf-parse": "^1.1.5", "@types/supertest": "^6.0.0", "jest": "^29.5.0", "oxlint": "^1.11.2", diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts index 4f9f571b..6d34d250 100644 --- a/server/src/modules/auth/auth.service.ts +++ b/server/src/modules/auth/auth.service.ts @@ -54,7 +54,6 @@ export class AuthService { const emailExists = await this.userEmailRepository.findOne({ where: { email: createUserDto.email }, }); - if (emailExists) { throw new ConflictException("An account with this email already exists"); } diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 5b015633..4ffb006a 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -5,12 +5,15 @@ import { Res, BadRequestException, UseInterceptors, + UseGuards, Put, Post, } from "@nestjs/common"; import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator"; import type { Request, Response } from "express"; import { FileInterceptor } from "@nestjs/platform-express"; +import { AccessTokenGuard } from "@common/guards/accessToken.guard"; + import { ApiTags, @@ -65,6 +68,7 @@ export class UsersController { }, }), ) + @UseGuards(AccessTokenGuard) @Post("uploadCV") async uploadCV(@Req() req: Request, @Res() res: Response) { return this.usersService.uploadCV(req, res); diff --git a/server/src/modules/users/users.module.ts b/server/src/modules/users/users.module.ts index a8351976..10e0b67c 100644 --- a/server/src/modules/users/users.module.ts +++ b/server/src/modules/users/users.module.ts @@ -1,11 +1,13 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { user_password, user_email } from "@entities/user.entity"; +import { user_cv } from "@entities/userCV.entity"; import { UsersService } from "./users.service"; import { UsersController } from "./users.controller"; +import { AuthModule } from "../auth/auth.module"; @Module({ - imports: [TypeOrmModule.forFeature([user_email, user_password])], + imports: [TypeOrmModule.forFeature([user_email, user_password, user_cv]), AuthModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index d88b0207..3eb6623f 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -8,8 +8,8 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { user_password, user_email } from "@entities/user.entity"; import { hashPassword } from "@common/utils/passwordHasher"; -import * as pdf from "pdf-parse"; -import type { Request, Response } from "express"; +import { type Request, type Response } from "express"; +import {user_cv} from "@entities/userCV.entity"; @Injectable() export class UsersService { @@ -21,6 +21,9 @@ export class UsersService { @InjectRepository(user_password) private passwordRepo: Repository, + + @InjectRepository(user_cv) + private user_cvRepo: Repository, ) { this.logger = new Logger(UsersService.name); } @@ -86,18 +89,117 @@ export class UsersService { return res.status(400).json({ message: "Upload a PDF file." }); } + const userId = (req as any).userId; const pdf = require("pdf-parse-debugging-disabled"); const pdfData = await pdf(req.file.buffer); const rawText = pdfData.text; - if (rawText.length === 0) { + if (rawText.length === 0 || !rawText) { + console.log("The file is empty!!!"); return res .status(400) .json({ message: "The PDF file is empty or could not be parsed." }); + } + console.log("Raw text extracted from PDF:", rawText); + + const Anthropic = require("@anthropic-ai/sdk"); + const client = new Anthropic(); + const prompt = await client.messages.create({ + model: "claude-sonnet-4-20250514", + max_tokens: 2048, + messages: [ + { + role: "user", + content: `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: + { + "desired_job": "string or null", + "resume": "string or null - candidate profile/summary", + "experiences": [ + { + "company": "string", + "title": "string", + "description": "string", + "duration": "string" + } + ], + "education": [ + { + "degree": "string", + "school_name": "string", + "duration": "string" + } + ], + "technical_skills": ["string"], + "languages": [ + { + "language": "string", + "level": "string" + } + ] + } + + Rules: + - Always return valid JSON, even if the CV is incomplete or poorly formatted + - Use null for missing fields + - Use an empty array [] if no entries are found for a list field + - Extract all experiences, education, skills and languages you can find + - For durations, keep the original format from the CV (e.g. "Jan 2022 - Mar 2024") + + CV text: + ${rawText}`, + }, + ], + }); + + const responseText = prompt.content + .filter((block: { type: string }) => block.type === "text") + .map((block: { type: string; text?: string }) => + block.type === "text" ? block.text : "" + ) + .join(""); + let extractedData; + try { + const cleaned = responseText.replace(/```json|```/g, "").trim(); + extractedData = JSON.parse(cleaned); + } catch (parseError) { + console.error("JSON parse error:", parseError); + return res + .status(500) + .json({ message: "Failed to parse extracted CV data." }); + } + + const existingCV = await this.user_cvRepo.findOne({ where: { user_id: userId } }); + + if (existingCV) { + await this.user_cvRepo.update( + { user_id: userId }, + { + desired_job: extractedData.desired_job ?? null, + resume: extractedData.resume ?? null, + experiences: extractedData.experiences ?? [], + education: extractedData.education ?? [], + technical_skills: extractedData.technical_skills ?? [], + languages: extractedData.languages ?? [], + } + ); + console.log("CV updated for user ID:", userId); + return res.status(200).json({ + message: "CV uploaded successfully", + }); } else { - console.log("Raw text extracted from PDF:", rawText); - res.status(200).json({ - message: "CV analysé avec succès", + const newCV = this.user_cvRepo.create({ + user_id: userId, + desired_job: extractedData.desired_job ?? null, + resume: extractedData.resume ?? null, + experiences: extractedData.experiences ?? [], + education: extractedData.education ?? [], + technical_skills: extractedData.technical_skills ?? [], + languages: extractedData.languages ?? [], + }); + console.log("CV created for user ID:", userId); + await this.user_cvRepo.save(newCV); + return res.status(200).json({ + message: "CV uploaded successfully", }); } } catch (error) { @@ -107,48 +209,3 @@ export class UsersService { } } -// router.post('/upload-cv', upload.single('cv'), async (req, res) => { -// try { -// if (!req.file) { -// return res.status(400).json({ error: "Aucun fichier téléchargé" }); -// } - -// // ÉTAPE A : Extraire le texte brut du PDF -// const pdfData = await pdf(req.file.buffer); -// const rawText = pdfData.text; - -// // ÉTAPE B : Envoyer le texte à Claude pour analyse -// const msg = await anthropic.messages.create({ -// model: "claude-3-5-sonnet-20240620", -// max_tokens: 1500, -// temperature: 0, // 0 pour une réponse constante et précise -// system: "Tu es un parseur de CV expert. Ton rôle est d'extraire les données au format JSON strict.", -// messages: [ -// { -// role: "user", -// content: `Extrais les informations suivantes de ce texte de CV : -// nom, poste_actuel, experiences (liste avec dates, poste, entreprise), -// competences_techniques (liste), et diplomes. - -// Réponds uniquement avec le JSON, sans texte avant ou après. - -// Texte du CV : ${rawText}` -// } -// ], -// }); - -// // ÉTAPE C : Parser la réponse de Claude -// const textResponse = msg.content[0].text; -// const extractedData = JSON.parse(textResponse); - -// // ÉTAPE D : Réponse au front -// res.status(200).json({ -// message: "CV analysé avec succès", -// data: extractedData -// }); - -// } catch (error) { -// console.error("Erreur parsing CV:", error); -// res.status(500).json({ error: "Erreur lors du traitement du CV" }); -// } -// }); From 049c6301df2970ebc35eb7a8049b41616c447b58 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:29:43 +0800 Subject: [PATCH 05/18] Add the start of changing the IA for sorting the user CV --- server/package.json | 3 +- server/src/modules/users/users.controller.ts | 2 +- server/src/modules/users/users.module.ts | 4 +- server/src/modules/users/users.service.ts | 177 ++++++++++++++++--- 4 files changed, 156 insertions(+), 30 deletions(-) diff --git a/server/package.json b/server/package.json index aee257fc..e9d2743d 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", + "@google/generative-ai": "^0.24.1", "@nestjs/axios": "^4.0.0", "@nestjs/cli": "^11.0.7", "@nestjs/common": "^11.1.0", @@ -44,8 +45,8 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", - "nodemailer": "^8.0.2", "multer": "^2.1.1", + "nodemailer": "^8.0.2", "pdf-parse": "^2.4.5", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 4ffb006a..81a49f3f 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -68,7 +68,7 @@ export class UsersController { }, }), ) - @UseGuards(AccessTokenGuard) + // @UseGuards(AccessTokenGuard) @Post("uploadCV") async uploadCV(@Req() req: Request, @Res() res: Response) { return this.usersService.uploadCV(req, res); diff --git a/server/src/modules/users/users.module.ts b/server/src/modules/users/users.module.ts index 10e0b67c..74908a0d 100644 --- a/server/src/modules/users/users.module.ts +++ b/server/src/modules/users/users.module.ts @@ -1,13 +1,13 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { user_password, user_email } from "@entities/user.entity"; +import { user_password, user_email, user } from "@entities/user.entity"; import { user_cv } from "@entities/userCV.entity"; import { UsersService } from "./users.service"; import { UsersController } from "./users.controller"; import { AuthModule } from "../auth/auth.module"; @Module({ - imports: [TypeOrmModule.forFeature([user_email, user_password, user_cv]), AuthModule], + imports: [TypeOrmModule.forFeature([user_email, user_password, user_cv, user]), AuthModule,], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 3eb6623f..a023053c 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -82,7 +82,6 @@ export class UsersService { ); } } - async uploadCV(req: Request, res: Response) { try { if (!req.file) { @@ -102,15 +101,11 @@ export class UsersService { } console.log("Raw text extracted from PDF:", rawText); - const Anthropic = require("@anthropic-ai/sdk"); - const client = new Anthropic(); - const prompt = await client.messages.create({ - model: "claude-sonnet-4-20250514", - max_tokens: 2048, - messages: [ - { - role: "user", - content: `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: + // const { GoogleGenerativeAI } = require("@google/generative-ai"); + // const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + // const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" }); + + const prompt = `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: { "desired_job": "string or null", "resume": "string or null - candidate profile/summary", @@ -146,17 +141,26 @@ export class UsersService { - For durations, keep the original format from the CV (e.g. "Jan 2022 - Mar 2024") CV text: - ${rawText}`, - }, - ], - }); + ${rawText}`; + + // const result = await model.generateContent(prompt); + // const responseText = result.response.text(); + + const apiKey = process.env.GEMINI_API_KEY; + const geminiRes = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + }), + } + ); + + const geminiData = await geminiRes.json(); + const responseText = geminiData.candidates[0].content.parts[0].text; - const responseText = prompt.content - .filter((block: { type: string }) => block.type === "text") - .map((block: { type: string; text?: string }) => - block.type === "text" ? block.text : "" - ) - .join(""); let extractedData; try { const cleaned = responseText.replace(/```json|```/g, "").trim(); @@ -183,9 +187,7 @@ export class UsersService { } ); console.log("CV updated for user ID:", userId); - return res.status(200).json({ - message: "CV uploaded successfully", - }); + return res.status(200).json({ message: "CV uploaded successfully" }); } else { const newCV = this.user_cvRepo.create({ user_id: userId, @@ -198,14 +200,137 @@ export class UsersService { }); console.log("CV created for user ID:", userId); await this.user_cvRepo.save(newCV); - return res.status(200).json({ - message: "CV uploaded successfully", - }); + return res.status(200).json({ message: "CV uploaded successfully" }); } } catch (error) { console.error("Parsing error:", error); res.status(500).json({ message: "Error processing the CV file." }); } } + + // async uploadCV(req: Request, res: Response) { + // try { + // if (!req.file) { + // return res.status(400).json({ message: "Upload a PDF file." }); + // } + + // const userId = (req as any).userId; + // const pdf = require("pdf-parse-debugging-disabled"); + // const pdfData = await pdf(req.file.buffer); + // const rawText = pdfData.text; + + // if (rawText.length === 0 || !rawText) { + // console.log("The file is empty!!!"); + // return res + // .status(400) + // .json({ message: "The PDF file is empty or could not be parsed." }); + // } + // console.log("Raw text extracted from PDF:", rawText); + + // const Anthropic = require("@anthropic-ai/sdk"); + // const client = new Anthropic(); + // const prompt = await client.messages.create({ + // model: "claude-sonnet-4-20250514", + // max_tokens: 2048, + // messages: [ + // { + // role: "user", + // content: `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: + // { + // "desired_job": "string or null", + // "resume": "string or null - candidate profile/summary", + // "experiences": [ + // { + // "company": "string", + // "title": "string", + // "description": "string", + // "duration": "string" + // } + // ], + // "education": [ + // { + // "degree": "string", + // "school_name": "string", + // "duration": "string" + // } + // ], + // "technical_skills": ["string"], + // "languages": [ + // { + // "language": "string", + // "level": "string" + // } + // ] + // } + + // Rules: + // - Always return valid JSON, even if the CV is incomplete or poorly formatted + // - Use null for missing fields + // - Use an empty array [] if no entries are found for a list field + // - Extract all experiences, education, skills and languages you can find + // - For durations, keep the original format from the CV (e.g. "Jan 2022 - Mar 2024") + + // CV text: + // ${rawText}`, + // }, + // ], + // }); + + // const responseText = prompt.content + // .filter((block: { type: string }) => block.type === "text") + // .map((block: { type: string; text?: string }) => + // block.type === "text" ? block.text : "" + // ) + // .join(""); + // let extractedData; + // try { + // const cleaned = responseText.replace(/```json|```/g, "").trim(); + // extractedData = JSON.parse(cleaned); + // } catch (parseError) { + // console.error("JSON parse error:", parseError); + // return res + // .status(500) + // .json({ message: "Failed to parse extracted CV data." }); + // } + + // const existingCV = await this.user_cvRepo.findOne({ where: { user_id: userId } }); + + // if (existingCV) { + // await this.user_cvRepo.update( + // { user_id: userId }, + // { + // desired_job: extractedData.desired_job ?? null, + // resume: extractedData.resume ?? null, + // experiences: extractedData.experiences ?? [], + // education: extractedData.education ?? [], + // technical_skills: extractedData.technical_skills ?? [], + // languages: extractedData.languages ?? [], + // } + // ); + // console.log("CV updated for user ID:", userId); + // return res.status(200).json({ + // message: "CV uploaded successfully", + // }); + // } else { + // const newCV = this.user_cvRepo.create({ + // user_id: userId, + // desired_job: extractedData.desired_job ?? null, + // resume: extractedData.resume ?? null, + // experiences: extractedData.experiences ?? [], + // education: extractedData.education ?? [], + // technical_skills: extractedData.technical_skills ?? [], + // languages: extractedData.languages ?? [], + // }); + // console.log("CV created for user ID:", userId); + // await this.user_cvRepo.save(newCV); + // return res.status(200).json({ + // message: "CV uploaded successfully", + // }); + // } + // } catch (error) { + // console.error("Parsing error:", error); + // res.status(500).json({ message: "Error processing the CV file." }); + // } + // } } From 9f4639a150e2695c8cda4e5fde8a73f5c4c740a5 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:37:20 +0800 Subject: [PATCH 06/18] add the package-lock of all the project --- package-lock.json | 351 +++++++++++++++++++--------------------------- 1 file changed, 142 insertions(+), 209 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3202d948..9ad27cd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,15 +38,15 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", + "version": "19.2.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.23.tgz", + "integrity": "sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==", "license": "MIT", "dependencies": { - "ajv": "8.17.1", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", + "picomatch": "4.0.4", "rxjs": "7.8.1", "source-map": "0.7.4" }, @@ -74,12 +74,12 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.19.tgz", - "integrity": "sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==", + "version": "19.2.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.23.tgz", + "integrity": "sha512-Jzs7YM4X6azmHU7Mw5tQSPMuvaqYS8SLnZOJbtiXCy1JyuW9bm/WBBecNHMiuZ8LHXKhvQ6AVX1tKrzF6uiDmw==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", + "@angular-devkit/core": "19.2.23", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -92,13 +92,13 @@ } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.19.tgz", - "integrity": "sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==", + "version": "19.2.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.23.tgz", + "integrity": "sha512-M8g7Gu3Lc5bbzijd2QLcQhfdpfMVE32YXQ6FIkA8x91Kmd2gb8aVvGYPLYUN5619P+ABWhN5Dn2PKuk01zz3vg==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/core": "19.2.23", + "@angular-devkit/schematics": "19.2.23", "@inquirer/prompts": "7.3.2", "ansi-colors": "4.1.3", "symbol-observable": "4.0.0", @@ -1484,6 +1484,15 @@ "license": "MIT", "optional": true }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -2621,14 +2630,14 @@ } }, "node_modules/@nestjs/cli": { - "version": "11.0.16", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", - "integrity": "sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==", + "version": "11.0.18", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.18.tgz", + "integrity": "sha512-z72OS+sFrDgIkNu/e/vUhbnjHZwAYQS8fBJKXLiFyz8059IVuY2FKebV2YMxyhY+920d4LX1hBIAGL5qQNdR7g==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", - "@angular-devkit/schematics-cli": "19.2.19", + "@angular-devkit/core": "19.2.23", + "@angular-devkit/schematics": "19.2.23", + "@angular-devkit/schematics-cli": "19.2.23", "@inquirer/prompts": "7.10.1", "@nestjs/schematics": "^11.0.1", "ansis": "4.2.0", @@ -2636,13 +2645,13 @@ "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.1.0", - "glob": "13.0.0", + "glob": "13.0.6", "node-emoji": "1.11.0", "ora": "5.4.1", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", "typescript": "5.9.3", - "webpack": "5.104.1", + "webpack": "5.105.4", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2652,7 +2661,7 @@ "node": ">= 20.11" }, "peerDependencies": { - "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0", + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", "@swc/core": "^1.3.62" }, "peerDependenciesMeta": { @@ -2723,16 +2732,16 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.8.tgz", - "integrity": "sha512-7riWfmTmMhCJHZ5ZiaG+crj4t85IPCq/wLRuOUSigBYyFT2JZj0lVHtAdf4Davp9ouNI8GINBDt9h9b5Gz9nTw==", + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.18.tgz", + "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -2763,6 +2772,16 @@ } } }, + "node_modules/@nestjs/core/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nestjs/event-emitter": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", @@ -2810,15 +2829,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.16", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.16.tgz", - "integrity": "sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==", + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz", + "integrity": "sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.1.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -2830,6 +2849,16 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", @@ -2844,14 +2873,14 @@ } }, "node_modules/@nestjs/schematics": { - "version": "11.0.9", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", - "integrity": "sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==", + "version": "11.0.10", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz", + "integrity": "sha512-q9lr0wGwgBHLarD4uno3XiW4JX60WPlg2VTgbqPHl/6bT4u1IEEzj+q9Tad3bVnqL5mlDF3vrZ2tj+x13CJpmw==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.17", - "@angular-devkit/schematics": "19.2.17", - "comment-json": "4.4.1", + "@angular-devkit/core": "19.2.23", + "@angular-devkit/schematics": "19.2.23", + "comment-json": "4.6.2", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, @@ -2859,60 +2888,6 @@ "typescript": ">=4.8.2" } }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.17.tgz", - "integrity": "sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==", - "license": "MIT", - "dependencies": { - "ajv": "8.17.1", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.17.tgz", - "integrity": "sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==", - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.17", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@nestjs/schematics/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/@nestjs/swagger": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", @@ -3986,19 +3961,6 @@ "react-dom": ">=18.0.0 || >=19.0.0" } }, - "node_modules/@tanstack/react-router-devtools/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@tanstack/react-router-devtools/node_modules/vite": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.1.tgz", @@ -4182,19 +4144,6 @@ } } }, - "node_modules/@tanstack/router-devtools-core/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@tanstack/router-devtools-core/node_modules/vite": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.1.tgz", @@ -5528,6 +5477,16 @@ "@types/node": "*" } }, + "node_modules/@types/pdf-parse": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", + "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -6019,9 +5978,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -6066,9 +6025,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6587,9 +6546,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7119,13 +7078,12 @@ } }, "node_modules/comment-json": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", - "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -7251,12 +7209,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -7728,13 +7680,13 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -8513,17 +8465,17 @@ } }, "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8570,12 +8522,12 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -8617,9 +8569,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11114,10 +11066,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -11315,9 +11267,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz", - "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -11652,9 +11604,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.2.tgz", + "integrity": "sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -11846,9 +11798,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -13863,18 +13815,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -14403,9 +14343,9 @@ } }, "node_modules/typeorm/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -14620,19 +14560,6 @@ "node": ">=18.12.0" } }, - "node_modules/unplugin/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -14958,9 +14885,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -14994,9 +14921,9 @@ } }, "node_modules/webpack": { - "version": "5.104.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", - "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", @@ -15005,11 +14932,11 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.4", + "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -15021,9 +14948,9 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -15051,9 +14978,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", "engines": { "node": ">=10.13.0" @@ -15493,6 +15420,7 @@ "license": "UNLICENSED", "dependencies": { "@anthropic-ai/sdk": "^0.78.0", + "@google/generative-ai": "^0.24.1", "@nestjs/axios": "^4.0.0", "@nestjs/cli": "^11.0.7", "@nestjs/common": "^11.1.0", @@ -15514,7 +15442,10 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", + "multer": "^2.1.1", "nodemailer": "^8.0.2", + "pdf-parse": "^2.4.5", + "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -15525,8 +15456,10 @@ "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.9", "@types/jest": "^29.5.14", + "@types/multer": "^2.1.0", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.17", + "@types/pdf-parse": "^1.1.5", "@types/supertest": "^6.0.0", "jest": "^29.5.0", "oxlint": "^1.11.2", From 7bdb0612366eb97c246d8bd768b3b092e8eb4203 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Fri, 15 May 2026 16:30:34 +0800 Subject: [PATCH 07/18] Add the end of the upload user's cv feature --- package-lock.json | 151 +++++++++-------- server/package.json | 1 + server/src/modules/users/users.controller.ts | 2 +- server/src/modules/users/users.service.ts | 162 ++----------------- 4 files changed, 102 insertions(+), 214 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ad27cd8..86dac094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2705,14 +2705,14 @@ } }, "node_modules/@nestjs/config": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz", - "integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", "license": "MIT", "dependencies": { - "dotenv": "17.2.3", + "dotenv": "17.4.1", "dotenv-expand": "12.0.3", - "lodash": "4.17.23" + "lodash": "4.18.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", @@ -2720,9 +2720,9 @@ } }, "node_modules/@nestjs/config/node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2772,16 +2772,6 @@ } } }, - "node_modules/@nestjs/core/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@nestjs/event-emitter": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", @@ -2809,14 +2799,14 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", - "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", "license": "MIT", "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.0 || ^0.14.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "peerDependenciesMeta": { @@ -2849,16 +2839,6 @@ "@nestjs/core": "^11.0.0" } }, - "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@nestjs/schedule": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", @@ -2889,17 +2869,17 @@ } }, "node_modules/@nestjs/swagger": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", - "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", + "version": "11.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.3.tgz", + "integrity": "sha512-LR4BuOj+iBFzhGRnNP0OHjmrPXliDEjrmniXtLsfLDIELjkuUXYCTGjZMqgDdOY+QSabeF59LndaDzOOe+vMmw==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.16.0", - "@nestjs/mapped-types": "2.1.0", + "@nestjs/mapped-types": "2.1.1", "js-yaml": "4.1.1", - "lodash": "4.17.23", - "path-to-regexp": "8.3.0", - "swagger-ui-dist": "5.31.0" + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.6" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0", @@ -6269,14 +6249,40 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/babel-dead-code-elimination": { @@ -8051,9 +8057,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -8160,9 +8166,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -8568,6 +8574,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/groq-sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-1.2.0.tgz", + "integrity": "sha512-pMhSYXWcjiqvbOeKdv2zjci1BYjGdp04a40hnmQmJpKJyB6oa+gRndAqE128gwR0NLgYO+Wt1fIF46KNHcplsw==", + "license": "Apache-2.0", + "bin": { + "groq-sdk": "bin/cli" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -10669,9 +10684,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { @@ -11613,9 +11628,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -12262,10 +12277,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -13492,9 +13510,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", + "integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -14615,9 +14633,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -15442,6 +15460,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", + "groq-sdk": "^1.2.0", "multer": "^2.1.1", "nodemailer": "^8.0.2", "pdf-parse": "^2.4.5", diff --git a/server/package.json b/server/package.json index e9d2743d..7a5a0fd1 100644 --- a/server/package.json +++ b/server/package.json @@ -45,6 +45,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", + "groq-sdk": "^1.2.0", "multer": "^2.1.1", "nodemailer": "^8.0.2", "pdf-parse": "^2.4.5", diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 81a49f3f..4ffb006a 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -68,7 +68,7 @@ export class UsersController { }, }), ) - // @UseGuards(AccessTokenGuard) + @UseGuards(AccessTokenGuard) @Post("uploadCV") async uploadCV(@Req() req: Request, @Res() res: Response) { return this.usersService.uploadCV(req, res); diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index a023053c..5765517a 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -82,6 +82,7 @@ export class UsersService { ); } } + async uploadCV(req: Request, res: Response) { try { if (!req.file) { @@ -99,11 +100,6 @@ export class UsersService { .status(400) .json({ message: "The PDF file is empty or could not be parsed." }); } - console.log("Raw text extracted from PDF:", rawText); - - // const { GoogleGenerativeAI } = require("@google/generative-ai"); - // const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - // const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" }); const prompt = `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: { @@ -143,23 +139,21 @@ export class UsersService { CV text: ${rawText}`; - // const result = await model.generateContent(prompt); - // const responseText = result.response.text(); + const Groq = require("groq-sdk"); + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); - const apiKey = process.env.GEMINI_API_KEY; - const geminiRes = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contents: [{ parts: [{ text: prompt }] }], - }), - } - ); + const completion = await groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [{ role: "user", content: prompt }], + temperature: 0, + }); - const geminiData = await geminiRes.json(); - const responseText = geminiData.candidates[0].content.parts[0].text; + const responseText = completion.choices[0]?.message?.content; + + if (!responseText) { + console.error("Empty response from Groq"); + return res.status(500).json({ message: "Empty response from AI." }); + } let extractedData; try { @@ -187,7 +181,7 @@ export class UsersService { } ); console.log("CV updated for user ID:", userId); - return res.status(200).json({ message: "CV uploaded successfully" }); + return res.status(200).json({ message: "CV updated successfully" }); } else { const newCV = this.user_cvRepo.create({ user_id: userId, @@ -207,130 +201,4 @@ export class UsersService { res.status(500).json({ message: "Error processing the CV file." }); } } - - // async uploadCV(req: Request, res: Response) { - // try { - // if (!req.file) { - // return res.status(400).json({ message: "Upload a PDF file." }); - // } - - // const userId = (req as any).userId; - // const pdf = require("pdf-parse-debugging-disabled"); - // const pdfData = await pdf(req.file.buffer); - // const rawText = pdfData.text; - - // if (rawText.length === 0 || !rawText) { - // console.log("The file is empty!!!"); - // return res - // .status(400) - // .json({ message: "The PDF file is empty or could not be parsed." }); - // } - // console.log("Raw text extracted from PDF:", rawText); - - // const Anthropic = require("@anthropic-ai/sdk"); - // const client = new Anthropic(); - // const prompt = await client.messages.create({ - // model: "claude-sonnet-4-20250514", - // max_tokens: 2048, - // messages: [ - // { - // role: "user", - // content: `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: - // { - // "desired_job": "string or null", - // "resume": "string or null - candidate profile/summary", - // "experiences": [ - // { - // "company": "string", - // "title": "string", - // "description": "string", - // "duration": "string" - // } - // ], - // "education": [ - // { - // "degree": "string", - // "school_name": "string", - // "duration": "string" - // } - // ], - // "technical_skills": ["string"], - // "languages": [ - // { - // "language": "string", - // "level": "string" - // } - // ] - // } - - // Rules: - // - Always return valid JSON, even if the CV is incomplete or poorly formatted - // - Use null for missing fields - // - Use an empty array [] if no entries are found for a list field - // - Extract all experiences, education, skills and languages you can find - // - For durations, keep the original format from the CV (e.g. "Jan 2022 - Mar 2024") - - // CV text: - // ${rawText}`, - // }, - // ], - // }); - - // const responseText = prompt.content - // .filter((block: { type: string }) => block.type === "text") - // .map((block: { type: string; text?: string }) => - // block.type === "text" ? block.text : "" - // ) - // .join(""); - // let extractedData; - // try { - // const cleaned = responseText.replace(/```json|```/g, "").trim(); - // extractedData = JSON.parse(cleaned); - // } catch (parseError) { - // console.error("JSON parse error:", parseError); - // return res - // .status(500) - // .json({ message: "Failed to parse extracted CV data." }); - // } - - // const existingCV = await this.user_cvRepo.findOne({ where: { user_id: userId } }); - - // if (existingCV) { - // await this.user_cvRepo.update( - // { user_id: userId }, - // { - // desired_job: extractedData.desired_job ?? null, - // resume: extractedData.resume ?? null, - // experiences: extractedData.experiences ?? [], - // education: extractedData.education ?? [], - // technical_skills: extractedData.technical_skills ?? [], - // languages: extractedData.languages ?? [], - // } - // ); - // console.log("CV updated for user ID:", userId); - // return res.status(200).json({ - // message: "CV uploaded successfully", - // }); - // } else { - // const newCV = this.user_cvRepo.create({ - // user_id: userId, - // desired_job: extractedData.desired_job ?? null, - // resume: extractedData.resume ?? null, - // experiences: extractedData.experiences ?? [], - // education: extractedData.education ?? [], - // technical_skills: extractedData.technical_skills ?? [], - // languages: extractedData.languages ?? [], - // }); - // console.log("CV created for user ID:", userId); - // await this.user_cvRepo.save(newCV); - // return res.status(200).json({ - // message: "CV uploaded successfully", - // }); - // } - // } catch (error) { - // console.error("Parsing error:", error); - // res.status(500).json({ message: "Error processing the CV file." }); - // } - // } } - From 5d51a3497e7e7b3df98474d9e8e8f2c1236864f3 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Fri, 15 May 2026 17:09:48 +0800 Subject: [PATCH 08/18] Add some change for the coding style --- server/src/modules/users/users.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index a2574ae7..5057fa28 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -18,7 +18,7 @@ import { import { UpdateProfileDto } from "./dto/updateProfile.dto"; import { GetProfileDto } from "./dto/getProfile.dto"; import { type Request, type Response } from "express"; -import {user_cv} from "@entities/userCV.entity"; +import { user_cv } from "@entities/userCV.entity"; @Injectable() export class UsersService { @@ -257,7 +257,9 @@ export class UsersService { .json({ message: "Failed to parse extracted CV data." }); } - const existingCV = await this.user_cvRepo.findOne({ where: { user_id: userId } }); + const existingCV = await this.user_cvRepo.findOne({ + where: { user_id: userId }, + }); if (existingCV) { await this.user_cvRepo.update( @@ -269,7 +271,7 @@ export class UsersService { education: extractedData.education ?? [], technical_skills: extractedData.technical_skills ?? [], languages: extractedData.languages ?? [], - } + }, ); console.log("CV updated for user ID:", userId); return res.status(200).json({ message: "CV updated successfully" }); From b797ea023a9f887a19bf526b1080aa850b76f25b Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Fri, 15 May 2026 20:03:47 +0800 Subject: [PATCH 09/18] Add the extration of the job offer from linkedin, using axios and pepeer --- server/package.json | 5 +- server/src/entities/userJobOffer.entity.ts | 85 +++++ server/src/modules/users/users.controller.ts | 6 + server/src/modules/users/users.module.ts | 2 + server/src/modules/users/users.service.ts | 310 +++++++++++++++++++ 5 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 server/src/entities/userJobOffer.entity.ts diff --git a/server/package.json b/server/package.json index 48f07657..274cd036 100644 --- a/server/package.json +++ b/server/package.json @@ -39,15 +39,16 @@ "@types/bcrypt": "^5.0.2", "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.17", - "axios": "^1.9.0", + "axios": "^1.16.1", "bcrypt": "^6.0.0", + "cheerio": "^1.2.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", "groq-sdk": "^1.2.0", - "multer": "^2.1.1", "ioredis": "^5.10.1", + "multer": "^2.1.1", "nodemailer": "^8.0.2", "pdf-parse": "^2.4.5", "pdf-parse-debugging-disabled": "^1.1.1", diff --git a/server/src/entities/userJobOffer.entity.ts b/server/src/entities/userJobOffer.entity.ts new file mode 100644 index 00000000..4bda25b4 --- /dev/null +++ b/server/src/entities/userJobOffer.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + OneToOne, + JoinColumn, + UpdateDateColumn, + BeforeInsert, +} from "typeorm"; +import { user } from "./user.entity"; +import { uuidv7 } from "uuidv7"; + +@Entity() +export class user_job_offer { + @PrimaryGeneratedColumn("uuid") + job_offer_id: string; + + @Column({ nullable: false }) + user_id: string; + + // This part will establish a one-to-one relationship between the user_job_offer and user entities, allowing us to easily access the user associated with a given job offer. + // The onDelete: "CASCADE" option ensures that if a user is deleted, their associated job offer will also be automatically removed from the database. + @OneToOne(() => user, { onDelete: "CASCADE" }) + @JoinColumn({ name: "user_id" }) + user: user; + + @Column({ nullable: true }) + job_title: string; + + @Column({ nullable: true }) + company_name: string; + + @Column({ type: "text", nullable: true }) + company_description: string; + + @Column({ nullable: true }) + sector: string; + + @Column({ nullable: true }) + contract_type: string; + + @Column({ nullable: true }) + location: string; + + @Column({ type: "simple-array", nullable: true }) + required_skills: string[]; + + @Column({ type: "simple-array", nullable: true }) + preferred_skills: string[]; + + @Column({ nullable: true }) + required_experience: string; + + @Column({ nullable: true }) + required_education: string; + + @Column({ type: "json", nullable: true }) + missions: string[]; + + @Column({ type: "simple-array", nullable: true }) + soft_skills: string[]; + + @Column({ type: "simple-array", nullable: true }) + languages_required: string[]; + + @Column({ nullable: true }) + salary_range: string; + + @Column({ type: "simple-array", nullable: true }) + company_values: string[]; + + @Column({ type: "text", nullable: true }) + team_description: string; + + @Column({ nullable: true }) + offer_url: string; + + @UpdateDateColumn() + updated_at: Date; + + @BeforeInsert() + generateUUIDv7() { + if (!this.job_offer_id) this.job_offer_id = uuidv7(); + } +} \ No newline at end of file diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 63e9ed9e..efb7bf1a 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -92,4 +92,10 @@ export class UsersController { async uploadCV(@Req() req: Request, @Res() res: Response) { return this.usersService.uploadCV(req, res); } + + @UseGuards(AccessTokenGuard) + @Post("uploadJobOffer") + async uploadJobOffer(@Req() req: Request, @Res() res: Response) { + return this.usersService.uploadJobOffer(req, res); + } } diff --git a/server/src/modules/users/users.module.ts b/server/src/modules/users/users.module.ts index 8f765155..0649b8e0 100644 --- a/server/src/modules/users/users.module.ts +++ b/server/src/modules/users/users.module.ts @@ -9,6 +9,7 @@ import { user_profile, } from "@entities/user.entity"; import { user_cv } from "@entities/userCV.entity"; +import { user_job_offer } from "@entities/userJobOffer.entity"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; import { AuthModule } from "../auth/auth.module"; @@ -22,6 +23,7 @@ import { AuthModule } from "../auth/auth.module"; user_phone_number, user_password, user_cv, + user_job_offer, ]), AuthModule, ], diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 5057fa28..22a321b1 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -19,6 +19,7 @@ import { UpdateProfileDto } from "./dto/updateProfile.dto"; import { GetProfileDto } from "./dto/getProfile.dto"; import { type Request, type Response } from "express"; import { user_cv } from "@entities/userCV.entity"; +import { user_job_offer } from "@entities/userJobOffer.entity"; @Injectable() export class UsersService { @@ -36,6 +37,8 @@ export class UsersService { @InjectRepository(user_cv) private user_cvRepo: Repository, + @InjectRepository(user_job_offer) + private user_job_offerRepo: Repository, ) {} async getProfile(user: user): Promise { @@ -294,4 +297,311 @@ export class UsersService { res.status(500).json({ message: "Error processing the CV file." }); } } + + async uploadJobOffer(req: Request, res: Response) { + try { + const userId = (req as any).userId; + const { url } = req.body; + + if (!url) { + return res.status(400).json({ message: "Please provide a job offer URL." }); + } + + try { + new URL(url); + } catch { + return res.status(400).json({ message: "Invalid URL format." }); + } + + console.log("Fetching job offer from URL:", url); + + let pageText: string = ""; + const axios = require("axios"); + const cheerio = require("cheerio"); + + const isWTTJ = url.includes("welcometothejungle.com"); + const isLinkedIn = url.includes("linkedin.com/jobs"); + + // ─── STRATÉGIE LINKEDIN (si URL contient linkedin.com/jobs) ────────────────── + if (isLinkedIn && !pageText) { + try { + // Extraire le job ID depuis l'URL + // Formats possibles : + // https://www.linkedin.com/jobs/view/3812345678 + // https://www.linkedin.com/jobs/view/titre-du-poste-3812345678 + const jobIdMatch = url.match(/(\d{8,})/); + + if (!jobIdMatch) { + throw new Error("Could not extract LinkedIn job ID from URL"); + } + + const jobId = jobIdMatch[1]; + console.log(`LinkedIn detected - job ID: ${jobId}`); + + const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; + + const response = await axios.get(guestApiUrl, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8", + "Referer": "https://www.linkedin.com/", + }, + timeout: 10000, + }); + + const cheerio = require("cheerio"); + const $ = cheerio.load(response.data); + + // Extraire les champs structurés exposés par LinkedIn guest API + const jobTitle = $("h2.top-card-layout__title, h1.top-card-layout__title").text().trim(); + const companyName = $("a.topcard__org-name-link, span.topcard__org-name-link").text().trim(); + const location = $("span.topcard__flavor--bullet").first().text().trim(); + const description = $("div.show-more-less-html__markup").text().replace(/\s+/g, " ").trim(); + + // Critères structurés (type de contrat, niveau d'expérience, etc.) + const criteria: Record = {}; + $("li.description__job-criteria-item").each((_: number, el: any) => { + const label = $(el).find("h3").text().trim(); + const value = $(el).find("span").text().trim(); + if (label && value) criteria[label] = value; + }); + + pageText = ` + Job Title: ${jobTitle} + Company: ${companyName} + Location: ${location} + Contract Type: ${criteria["Type de poste"] || criteria["Employment type"] || ""} + Seniority Level: ${criteria["Niveau hiérarchique"] || criteria["Seniority level"] || ""} + Industry: ${criteria["Secteur"] || criteria["Industries"] || ""} + Job Function: ${criteria["Fonction"] || criteria["Job function"] || ""} + Description: ${description} + `.replace(/\s+/g, " ").trim(); + + if (pageText.length >= 100) { + console.log("Strategy LinkedIn (guest API) succeeded"); + } else { + throw new Error("Extracted content too short"); + } + + } catch (err) { + console.log("Strategy LinkedIn failed:", err); + } + } + + if (!pageText) { + // STRATÉGIE axios + try { + const response = await axios.get(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Cache-Control": "max-age=0", + }, + timeout: 10000, + maxRedirects: 5, + }); + + const temp = cheerio.load(response.data); + temp("script, style, nav, footer, header, iframe, noscript, [aria-hidden='true']").remove(); + const extracted = temp("body").text().replace(/\s+/g, " ").trim(); + + if (extracted.length >= 300) { + pageText = extracted; + console.log("Strategy 1 (axios) succeeded"); + } + } catch (err) { + console.log("Strategy 1 (axios) failed, trying next..."); + } + } + + // STRATÉGIE Puppeteer + if (!pageText) { + try { + const puppeteer = require("puppeteer"); + + const browser = await puppeteer.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-blink-features=AutomationControlled", + "--disable-infobars", + "--window-size=1920,1080", + ], + }); + + const page = await browser.newPage(); + + await page.evaluateOnNewDocument(() => { + Object.defineProperty(navigator, "webdriver", { get: () => false }); + (window as any).chrome = { runtime: {} }; + }); + + await page.setUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ); + + await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2)); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const html = await page.content(); + await browser.close(); + + const temp = cheerio.load(html); + temp("script, style, nav, footer, header, iframe, noscript").remove(); + const extracted = temp("body").text().replace(/\s+/g, " ").trim(); + + if (extracted.length >= 300) { + pageText = extracted; + console.log("Strategy 2 (puppeteer) succeeded"); + } + } catch (err) { + console.log("Strategy 2 (puppeteer) failed, trying next..."); + } + } + + if (!pageText) { + return res.status(400).json({ + message: "Could not extract content from this URL. The website may be too protected.", + }); + } + + pageText = pageText.substring(0, 8000); + + console.log("Final extracted page text length:", pageText.length); + + const prompt = `You are a specialized job offer analysis assistant. Analyze the following text extracted from a job offer page and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: + { + "job_title": "string or null", + "company_name": "string or null", + "company_description": "string or null", + "sector": "string or null", + "contract_type": "string or null", + "location": "string or null", + "required_skills": ["string"], + "preferred_skills": ["string"], + "required_experience": "string or null", + "required_education": "string or null", + "missions": ["string"], + "soft_skills": ["string"], + "languages_required": ["string"], + "salary_range": "string or null", + "company_values": ["string"], + "team_description": "string or null" + } + + Rules: + - Always return valid JSON, even if the job offer is incomplete or poorly formatted + - Use null for missing string fields + - Use an empty array [] if no entries are found for a list field + - Extract all relevant information you can find + - For missions and skills, extract each item as a separate string in the array + + Job offer text: + ${pageText}`; + + const Groq = require("groq-sdk"); + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + + console.log("Sending job offer analysis prompt to Groq..."); + + const completion = await groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [{ role: "user", content: prompt }], + temperature: 0, + }); + + const responseText = completion.choices[0]?.message?.content; + + console.log("Raw response from Groq:", responseText); + + if (!responseText) { + console.error("Empty response from Groq"); + return res.status(500).json({ message: "Empty response from AI." }); + } + + let extractedData; + try { + const cleaned = responseText.replace(/```json|```/g, "").trim(); + extractedData = JSON.parse(cleaned); + } catch (parseError) { + console.error("JSON parse error:", parseError); + return res.status(500).json({ message: "Failed to parse extracted job offer data." }); + } + + const existingJobOffer = await this.user_job_offerRepo.findOne({ + where: { user_id: userId }, + }); + + if (existingJobOffer) { + await this.user_job_offerRepo.update( + { user_id: userId }, + { + job_title: extractedData.job_title ?? null, + company_name: extractedData.company_name ?? null, + company_description: extractedData.company_description ?? null, + sector: extractedData.sector ?? null, + contract_type: extractedData.contract_type ?? null, + location: extractedData.location ?? null, + required_skills: extractedData.required_skills ?? [], + preferred_skills: extractedData.preferred_skills ?? [], + required_experience: extractedData.required_experience ?? null, + required_education: extractedData.required_education ?? null, + missions: extractedData.missions ?? [], + soft_skills: extractedData.soft_skills ?? [], + languages_required: extractedData.languages_required ?? [], + salary_range: extractedData.salary_range ?? null, + company_values: extractedData.company_values ?? [], + team_description: extractedData.team_description ?? null, + offer_url: url, + } + ); + console.log("Job offer updated for user ID:", userId); + return res.status(200).json({ + message: "Job offer updated successfully", + }); + } else { + const newJobOffer = this.user_job_offerRepo.create({ + user_id: userId, + job_title: extractedData.job_title ?? null, + company_name: extractedData.company_name ?? null, + company_description: extractedData.company_description ?? null, + sector: extractedData.sector ?? null, + contract_type: extractedData.contract_type ?? null, + location: extractedData.location ?? null, + required_skills: extractedData.required_skills ?? [], + preferred_skills: extractedData.preferred_skills ?? [], + required_experience: extractedData.required_experience ?? null, + required_education: extractedData.required_education ?? null, + missions: extractedData.missions ?? [], + soft_skills: extractedData.soft_skills ?? [], + languages_required: extractedData.languages_required ?? [], + salary_range: extractedData.salary_range ?? null, + company_values: extractedData.company_values ?? [], + team_description: extractedData.team_description ?? null, + offer_url: url, + }); + await this.user_job_offerRepo.save(newJobOffer); + console.log("Job offer created for user ID:", userId); + return res.status(200).json({ + message: "Job offer parsed successfully", + }); + } + + } catch (error) { + console.error("Error processing job offer:", error); + res.status(500).json({ message: "Error processing the job offer." }); + } + } } From 921da14fc988f205e18b58009a81d49c9d388974 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Sat, 16 May 2026 20:31:09 +0800 Subject: [PATCH 10/18] Add more structure to the code for the job offer scrapping --- package-lock.json | 926 +++++++++++++++++- server/package.json | 1 + server/src/common/utils/JobOfferExtraction.ts | 170 ++++ server/src/modules/users/users.service.ts | 163 +-- 4 files changed, 1091 insertions(+), 169 deletions(-) create mode 100644 server/src/common/utils/JobOfferExtraction.ts diff --git a/package-lock.json b/package-lock.json index 78f6d963..c1b72738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3146,6 +3146,39 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -5033,6 +5066,12 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz", @@ -5622,6 +5661,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -6004,7 +6053,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -6291,6 +6339,20 @@ "node": ">= 6" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.10.tgz", @@ -6463,6 +6525,97 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", + "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6495,6 +6648,15 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -6557,6 +6719,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -6660,6 +6828,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -6847,6 +7024,48 @@ "node": ">= 16" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -6871,6 +7090,19 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -7339,6 +7571,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -7366,6 +7626,15 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7494,6 +7763,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/degenerator/node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7555,6 +7850,12 @@ "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -7604,6 +7905,59 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", @@ -7613,6 +7967,20 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -7709,6 +8077,40 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -7726,7 +8128,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -7735,6 +8136,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -7862,6 +8272,46 @@ "node": ">=8" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -7928,6 +8378,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -7952,6 +8411,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8062,12 +8530,53 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8106,6 +8615,15 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8494,6 +9012,20 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -8719,6 +9251,37 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8743,7 +9306,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -8757,7 +9319,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -8920,6 +9481,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11158,6 +11728,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -11296,6 +11872,15 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -11382,6 +11967,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nwsapi": { "version": "2.2.22", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", @@ -11577,6 +12174,38 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -11617,7 +12246,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -11626,6 +12254,31 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -11786,6 +12439,12 @@ "@napi-rs/canvas": "^0.1.80" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -12094,6 +12753,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -12345,6 +13013,40 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -12354,6 +13056,16 @@ "node": ">=10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12372,6 +13084,71 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.1.tgz", + "integrity": "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1608973", + "puppeteer-core": "24.43.1", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -13202,6 +13979,44 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/solid-js": { "version": "1.9.10", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", @@ -13334,6 +14149,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13663,6 +14489,41 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -13887,6 +14748,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -14348,6 +15218,12 @@ "node": ">= 0.4" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -14634,6 +15510,15 @@ "react": ">=15.0.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -15024,6 +15909,12 @@ "resolved": "web", "link": true }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -15174,7 +16065,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -15187,7 +16077,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -15200,7 +16089,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -15364,10 +16252,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -15454,6 +16341,16 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -15493,7 +16390,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -15550,8 +16446,9 @@ "@types/bcrypt": "^5.0.2", "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.17", - "axios": "^1.9.0", + "axios": "^1.16.1", "bcrypt": "^6.0.0", + "cheerio": "^1.2.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", @@ -15563,6 +16460,7 @@ "pdf-parse": "^2.4.5", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", + "puppeteer": "^24.43.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "typeorm": "^0.3.22", diff --git a/server/package.json b/server/package.json index 274cd036..63f467e4 100644 --- a/server/package.json +++ b/server/package.json @@ -53,6 +53,7 @@ "pdf-parse": "^2.4.5", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", + "puppeteer": "^24.43.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "typeorm": "^0.3.22", diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts new file mode 100644 index 00000000..faeef4c2 --- /dev/null +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -0,0 +1,170 @@ +import axios from "axios"; +import * as cheerio from "cheerio"; + +export const scrapeLinkedin = async (url: string): Promise => { + const maxRetries = 3; + let attempt = 0; + let pageText = ""; + + const userAgents = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", + ]; + + while (attempt < maxRetries && !pageText) { + try { + // Délai croissant entre chaque tentative : 0ms, 2000ms, 4000ms + if (attempt > 0) { + const delay = attempt * 2000; + console.log(`LinkedIn retry ${attempt}/${maxRetries - 1} - waiting ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + const jobIdMatch = url.match(/(\d{8,})/); + if (!jobIdMatch) throw new Error("Could not extract LinkedIn job ID from URL"); + + const jobId = jobIdMatch[1]; + const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; + const response = await axios.get(guestApiUrl, { + headers: { + "User-Agent": userAgents[attempt % userAgents.length], + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8", + "Referer": "https://www.linkedin.com/", + }, + timeout: 15000, + }); + + const temp = cheerio.load(response.data); + + const jobTitle = temp("h2.top-card-layout__title, h1.top-card-layout__title").text().trim(); + const companyName = temp("a.topcard__org-name-link, span.topcard__org-name-link").text().trim(); + const location = temp("span.topcard__flavor--bullet").first().text().trim(); + const description = temp("div.show-more-less-html__markup").text().replace(/\s+/g, " ").trim(); + + const criteria: Record = {}; + temp("li.description__job-criteria-item").each((_: number, el: any) => { + const label = temp(el).find("h3").text().trim(); + const value = temp(el).find("span").text().trim(); + if (label && value) criteria[label] = value; + }); + + const extracted = ` + Job Title: ${jobTitle} + Company: ${companyName} + Location: ${location} + Contract Type: ${criteria["Type de poste"] || criteria["Employment type"] || ""} + Seniority Level: ${criteria["Niveau hiérarchique"] || criteria["Seniority level"] || ""} + Industry: ${criteria["Secteur"] || criteria["Industries"] || ""} + Job Function: ${criteria["Fonction"] || criteria["Job function"] || ""} + Description: ${description} + `.replace(/\s+/g, " ").trim(); + + if (extracted.length >= 100) { + pageText = extracted; + console.log(`Strategy LinkedIn succeeded on attempt ${attempt + 1}`); + } else { + throw new Error("Extracted content too short"); + } + } catch (err) { + console.log(`LinkedIn attempt ${attempt + 1} failed:`, (err as any)?.code || err); + attempt++; + } + } + if (!pageText) { + console.log("All LinkedIn attempts failed, falling through to next strategy..."); + } + return pageText; +} + +// ─── AXIOS (sites simples) ─────────────────────────────────────────────────── +export const scrapeAxios = async (url: string): Promise => { + let pageText = ""; + + try { + const response = await axios.get(url, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Cache-Control": "max-age=0", + }, + timeout: 10000, + maxRedirects: 5, + }); + + const temp = cheerio.load(response.data); + temp("script, style, nav, footer, header, iframe, noscript, [aria-hidden='true']").remove(); + const extracted = temp("body").text().replace(/\s+/g, " ").trim(); + + if (extracted.length >= 300) { + pageText = extracted; + console.log("Strategy Axios succeeded"); + } + } catch (err) { + console.log("Strategy Axios failed:", (err as any)?.code || err); + } + + return pageText; +}; + +// ─── PUPPETEER GÉNÉRIQUE ───────────────────────────────────────────────────── +export const scrapePuppeteer = async (url: string): Promise => { + let pageText = ""; + + try { + const puppeteer = require("puppeteer"); + + const browser = await puppeteer.launch({ + headless: true, + executablePath: "/usr/bin/google-chrome", + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-blink-features=AutomationControlled", + "--disable-infobars", + "--window-size=1920,1080", + ], + }); + + const page = await browser.newPage(); + + await page.evaluateOnNewDocument(() => { + Object.defineProperty(navigator, "webdriver", { get: () => false }); + (window as any).chrome = { runtime: {} }; + }); + + await page.setUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ); + + await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2)); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const html = await page.content(); + await browser.close(); + + const temp = cheerio.load(html); + temp("script, style, nav, footer, header, iframe, noscript").remove(); + const extracted = temp("body").text().replace(/\s+/g, " ").trim(); + + if (extracted.length >= 300) { + pageText = extracted; + console.log("Strategy Puppeteer generic succeeded"); + } + } catch (err) { + console.log("Strategy Puppeteer generic failed:", (err as any)?.message || err); + } + + return pageText; +}; \ No newline at end of file diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 22a321b1..83b85a36 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -14,6 +14,11 @@ import { user_phone_number, user_profile, } from "@entities/user.entity"; +import { + scrapeLinkedin, + scrapeAxios, + scrapePuppeteer, +} from "../../common/utils/JobOfferExtraction"; import { UpdateProfileDto } from "./dto/updateProfile.dto"; import { GetProfileDto } from "./dto/getProfile.dto"; @@ -313,163 +318,13 @@ export class UsersService { return res.status(400).json({ message: "Invalid URL format." }); } - console.log("Fetching job offer from URL:", url); - let pageText: string = ""; - const axios = require("axios"); - const cheerio = require("cheerio"); - const isWTTJ = url.includes("welcometothejungle.com"); const isLinkedIn = url.includes("linkedin.com/jobs"); - // ─── STRATÉGIE LINKEDIN (si URL contient linkedin.com/jobs) ────────────────── - if (isLinkedIn && !pageText) { - try { - // Extraire le job ID depuis l'URL - // Formats possibles : - // https://www.linkedin.com/jobs/view/3812345678 - // https://www.linkedin.com/jobs/view/titre-du-poste-3812345678 - const jobIdMatch = url.match(/(\d{8,})/); - - if (!jobIdMatch) { - throw new Error("Could not extract LinkedIn job ID from URL"); - } - - const jobId = jobIdMatch[1]; - console.log(`LinkedIn detected - job ID: ${jobId}`); - - const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; - - const response = await axios.get(guestApiUrl, { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8", - "Referer": "https://www.linkedin.com/", - }, - timeout: 10000, - }); - - const cheerio = require("cheerio"); - const $ = cheerio.load(response.data); - - // Extraire les champs structurés exposés par LinkedIn guest API - const jobTitle = $("h2.top-card-layout__title, h1.top-card-layout__title").text().trim(); - const companyName = $("a.topcard__org-name-link, span.topcard__org-name-link").text().trim(); - const location = $("span.topcard__flavor--bullet").first().text().trim(); - const description = $("div.show-more-less-html__markup").text().replace(/\s+/g, " ").trim(); - - // Critères structurés (type de contrat, niveau d'expérience, etc.) - const criteria: Record = {}; - $("li.description__job-criteria-item").each((_: number, el: any) => { - const label = $(el).find("h3").text().trim(); - const value = $(el).find("span").text().trim(); - if (label && value) criteria[label] = value; - }); - - pageText = ` - Job Title: ${jobTitle} - Company: ${companyName} - Location: ${location} - Contract Type: ${criteria["Type de poste"] || criteria["Employment type"] || ""} - Seniority Level: ${criteria["Niveau hiérarchique"] || criteria["Seniority level"] || ""} - Industry: ${criteria["Secteur"] || criteria["Industries"] || ""} - Job Function: ${criteria["Fonction"] || criteria["Job function"] || ""} - Description: ${description} - `.replace(/\s+/g, " ").trim(); - - if (pageText.length >= 100) { - console.log("Strategy LinkedIn (guest API) succeeded"); - } else { - throw new Error("Extracted content too short"); - } - - } catch (err) { - console.log("Strategy LinkedIn failed:", err); - } - } - - if (!pageText) { - // STRATÉGIE axios - try { - const response = await axios.get(url, { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", - "Accept-Encoding": "gzip, deflate, br", - "Connection": "keep-alive", - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Cache-Control": "max-age=0", - }, - timeout: 10000, - maxRedirects: 5, - }); - - const temp = cheerio.load(response.data); - temp("script, style, nav, footer, header, iframe, noscript, [aria-hidden='true']").remove(); - const extracted = temp("body").text().replace(/\s+/g, " ").trim(); - - if (extracted.length >= 300) { - pageText = extracted; - console.log("Strategy 1 (axios) succeeded"); - } - } catch (err) { - console.log("Strategy 1 (axios) failed, trying next..."); - } - } - - // STRATÉGIE Puppeteer - if (!pageText) { - try { - const puppeteer = require("puppeteer"); - - const browser = await puppeteer.launch({ - headless: true, - args: [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-blink-features=AutomationControlled", - "--disable-infobars", - "--window-size=1920,1080", - ], - }); - - const page = await browser.newPage(); - - await page.evaluateOnNewDocument(() => { - Object.defineProperty(navigator, "webdriver", { get: () => false }); - (window as any).chrome = { runtime: {} }; - }); - - await page.setUserAgent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - ); - - await page.setViewport({ width: 1920, height: 1080 }); - await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2)); - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const html = await page.content(); - await browser.close(); - - const temp = cheerio.load(html); - temp("script, style, nav, footer, header, iframe, noscript").remove(); - const extracted = temp("body").text().replace(/\s+/g, " ").trim(); - - if (extracted.length >= 300) { - pageText = extracted; - console.log("Strategy 2 (puppeteer) succeeded"); - } - } catch (err) { - console.log("Strategy 2 (puppeteer) failed, trying next..."); - } - } + if (isLinkedIn) pageText = await scrapeLinkedin(url); + if (!pageText) pageText = await scrapeAxios(url); + if (!pageText) pageText = await scrapePuppeteer(url); if (!pageText) { return res.status(400).json({ @@ -524,8 +379,6 @@ export class UsersService { const responseText = completion.choices[0]?.message?.content; - console.log("Raw response from Groq:", responseText); - if (!responseText) { console.error("Empty response from Groq"); return res.status(500).json({ message: "Empty response from AI." }); From b56d8ae9eafa69e3aecee1eb871da1782ed13536 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Sat, 16 May 2026 20:32:43 +0800 Subject: [PATCH 11/18] Cleaning the code by pretttier --- server/src/common/utils/JobOfferExtraction.ts | 182 +++++++++++------- server/src/entities/userJobOffer.entity.ts | 2 +- server/src/modules/users/users.service.ts | 14 +- 3 files changed, 118 insertions(+), 80 deletions(-) diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts index faeef4c2..86e2ec59 100644 --- a/server/src/common/utils/JobOfferExtraction.ts +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -2,55 +2,73 @@ import axios from "axios"; import * as cheerio from "cheerio"; export const scrapeLinkedin = async (url: string): Promise => { - const maxRetries = 3; - let attempt = 0; - let pageText = ""; - - const userAgents = [ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", - ]; - - while (attempt < maxRetries && !pageText) { - try { - // Délai croissant entre chaque tentative : 0ms, 2000ms, 4000ms - if (attempt > 0) { - const delay = attempt * 2000; - console.log(`LinkedIn retry ${attempt}/${maxRetries - 1} - waiting ${delay}ms...`); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - - const jobIdMatch = url.match(/(\d{8,})/); - if (!jobIdMatch) throw new Error("Could not extract LinkedIn job ID from URL"); - - const jobId = jobIdMatch[1]; - const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; - const response = await axios.get(guestApiUrl, { - headers: { - "User-Agent": userAgents[attempt % userAgents.length], - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8", - "Referer": "https://www.linkedin.com/", - }, - timeout: 15000, - }); - - const temp = cheerio.load(response.data); - - const jobTitle = temp("h2.top-card-layout__title, h1.top-card-layout__title").text().trim(); - const companyName = temp("a.topcard__org-name-link, span.topcard__org-name-link").text().trim(); - const location = temp("span.topcard__flavor--bullet").first().text().trim(); - const description = temp("div.show-more-less-html__markup").text().replace(/\s+/g, " ").trim(); - - const criteria: Record = {}; - temp("li.description__job-criteria-item").each((_: number, el: any) => { - const label = temp(el).find("h3").text().trim(); - const value = temp(el).find("span").text().trim(); - if (label && value) criteria[label] = value; - }); - - const extracted = ` + const maxRetries = 3; + let attempt = 0; + let pageText = ""; + + const userAgents = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", + ]; + + while (attempt < maxRetries && !pageText) { + try { + // Délai croissant entre chaque tentative : 0ms, 2000ms, 4000ms + if (attempt > 0) { + const delay = attempt * 2000; + console.log( + `LinkedIn retry ${attempt}/${maxRetries - 1} - waiting ${delay}ms...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + const jobIdMatch = url.match(/(\d{8,})/); + if (!jobIdMatch) + throw new Error("Could not extract LinkedIn job ID from URL"); + + const jobId = jobIdMatch[1]; + const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; + const response = await axios.get(guestApiUrl, { + headers: { + "User-Agent": userAgents[attempt % userAgents.length], + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8", + Referer: "https://www.linkedin.com/", + }, + timeout: 15000, + }); + + const temp = cheerio.load(response.data); + + const jobTitle = temp( + "h2.top-card-layout__title, h1.top-card-layout__title", + ) + .text() + .trim(); + const companyName = temp( + "a.topcard__org-name-link, span.topcard__org-name-link", + ) + .text() + .trim(); + const location = temp("span.topcard__flavor--bullet") + .first() + .text() + .trim(); + const description = temp("div.show-more-less-html__markup") + .text() + .replace(/\s+/g, " ") + .trim(); + + const criteria: Record = {}; + temp("li.description__job-criteria-item").each((_: number, el: any) => { + const label = temp(el).find("h3").text().trim(); + const value = temp(el).find("span").text().trim(); + if (label && value) criteria[label] = value; + }); + + const extracted = ` Job Title: ${jobTitle} Company: ${companyName} Location: ${location} @@ -59,24 +77,31 @@ export const scrapeLinkedin = async (url: string): Promise => { Industry: ${criteria["Secteur"] || criteria["Industries"] || ""} Job Function: ${criteria["Fonction"] || criteria["Job function"] || ""} Description: ${description} - `.replace(/\s+/g, " ").trim(); - - if (extracted.length >= 100) { - pageText = extracted; - console.log(`Strategy LinkedIn succeeded on attempt ${attempt + 1}`); - } else { - throw new Error("Extracted content too short"); - } - } catch (err) { - console.log(`LinkedIn attempt ${attempt + 1} failed:`, (err as any)?.code || err); - attempt++; - } - } - if (!pageText) { - console.log("All LinkedIn attempts failed, falling through to next strategy..."); + ` + .replace(/\s+/g, " ") + .trim(); + + if (extracted.length >= 100) { + pageText = extracted; + console.log(`Strategy LinkedIn succeeded on attempt ${attempt + 1}`); + } else { + throw new Error("Extracted content too short"); + } + } catch (err) { + console.log( + `LinkedIn attempt ${attempt + 1} failed:`, + (err as any)?.code || err, + ); + attempt++; } - return pageText; -} + } + if (!pageText) { + console.log( + "All LinkedIn attempts failed, falling through to next strategy...", + ); + } + return pageText; +}; // ─── AXIOS (sites simples) ─────────────────────────────────────────────────── export const scrapeAxios = async (url: string): Promise => { @@ -85,11 +110,13 @@ export const scrapeAxios = async (url: string): Promise => { try { const response = await axios.get(url, { headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", - "Connection": "keep-alive", + Connection: "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", @@ -101,7 +128,9 @@ export const scrapeAxios = async (url: string): Promise => { }); const temp = cheerio.load(response.data); - temp("script, style, nav, footer, header, iframe, noscript, [aria-hidden='true']").remove(); + temp( + "script, style, nav, footer, header, iframe, noscript, [aria-hidden='true']", + ).remove(); const extracted = temp("body").text().replace(/\s+/g, " ").trim(); if (extracted.length >= 300) { @@ -142,13 +171,15 @@ export const scrapePuppeteer = async (url: string): Promise => { }); await page.setUserAgent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", ); await page.setViewport({ width: 1920, height: 1080 }); await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 }); await new Promise((resolve) => setTimeout(resolve, 2000)); - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2)); + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight / 2), + ); await new Promise((resolve) => setTimeout(resolve, 1000)); const html = await page.content(); @@ -163,8 +194,11 @@ export const scrapePuppeteer = async (url: string): Promise => { console.log("Strategy Puppeteer generic succeeded"); } } catch (err) { - console.log("Strategy Puppeteer generic failed:", (err as any)?.message || err); + console.log( + "Strategy Puppeteer generic failed:", + (err as any)?.message || err, + ); } return pageText; -}; \ No newline at end of file +}; diff --git a/server/src/entities/userJobOffer.entity.ts b/server/src/entities/userJobOffer.entity.ts index 4bda25b4..2bfbd533 100644 --- a/server/src/entities/userJobOffer.entity.ts +++ b/server/src/entities/userJobOffer.entity.ts @@ -82,4 +82,4 @@ export class user_job_offer { generateUUIDv7() { if (!this.job_offer_id) this.job_offer_id = uuidv7(); } -} \ No newline at end of file +} diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 83b85a36..693f67ae 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -309,7 +309,9 @@ export class UsersService { const { url } = req.body; if (!url) { - return res.status(400).json({ message: "Please provide a job offer URL." }); + return res + .status(400) + .json({ message: "Please provide a job offer URL." }); } try { @@ -328,7 +330,8 @@ export class UsersService { if (!pageText) { return res.status(400).json({ - message: "Could not extract content from this URL. The website may be too protected.", + message: + "Could not extract content from this URL. The website may be too protected.", }); } @@ -390,7 +393,9 @@ export class UsersService { extractedData = JSON.parse(cleaned); } catch (parseError) { console.error("JSON parse error:", parseError); - return res.status(500).json({ message: "Failed to parse extracted job offer data." }); + return res + .status(500) + .json({ message: "Failed to parse extracted job offer data." }); } const existingJobOffer = await this.user_job_offerRepo.findOne({ @@ -418,7 +423,7 @@ export class UsersService { company_values: extractedData.company_values ?? [], team_description: extractedData.team_description ?? null, offer_url: url, - } + }, ); console.log("Job offer updated for user ID:", userId); return res.status(200).json({ @@ -451,7 +456,6 @@ export class UsersService { message: "Job offer parsed successfully", }); } - } catch (error) { console.error("Error processing job offer:", error); res.status(500).json({ message: "Error processing the job offer." }); From b76af565ed46a561c3a7aaa2549d9d30eaffb332 Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Sun, 31 May 2026 15:36:18 +0800 Subject: [PATCH 12/18] Add new unity test in the server for the job offer --- .../src/modules/users/users.service.spec.ts | 300 ++++++++++++++++-- server/src/modules/users/users.service.ts | 2 +- 2 files changed, 280 insertions(+), 22 deletions(-) diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 7f907d28..2914cdcd 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -10,25 +10,92 @@ import { user_phone_number, user_profile, } from "@entities/user.entity"; +import { user_cv } from "@entities/userCV.entity"; +import { user_job_offer } from "@entities/userJobOffer.entity"; import { UsersService } from "./users.service"; +// ─── Mocks modules externes ─────────────────────────────────────────────────── + +jest.mock("pdf-parse-debugging-disabled", () => jest.fn()); + +jest.mock("groq-sdk", () => { + return jest.fn().mockImplementation(() => ({ + chat: { completions: { create: mockGroqCreate } }, + })); +}); + +jest.mock("../../common/utils/JobOfferExtraction", () => ({ + scrapeLinkedin: jest.fn(), + scrapeAxios: jest.fn(), + scrapePuppeteer: jest.fn(), +})); + +import { + scrapeLinkedin, + scrapeAxios, + scrapePuppeteer, +} from "../../common/utils/JobOfferExtraction"; + +const mockGroqCreate = jest.fn(); +const mockScrapeLinkedin = scrapeLinkedin as jest.Mock; +const mockScrapeAxios = scrapeAxios as jest.Mock; +const mockScrapePuppeteer = scrapePuppeteer as jest.Mock; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const mockRes = () => { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +const mockReq = (overrides: Record = {}): any => ({ + userId: "uid-1", + body: {}, + ...overrides, +}); + +const validJobOfferGroqResponse = JSON.stringify({ + job_title: "Backend Developer", + company_name: "TechCorp", + company_description: "A tech company", + sector: "IT", + contract_type: "CDI", + location: "Paris", + required_skills: ["Node.js"], + preferred_skills: ["Docker"], + required_experience: "3 years", + required_education: "BSc", + missions: ["Build APIs"], + soft_skills: ["Teamwork"], + languages_required: ["English"], + salary_range: "50k-60k", + company_values: ["Innovation"], + team_description: "Small agile team", +}); + +// ─── Suite principale ───────────────────────────────────────────────────────── + describe("UsersService", () => { let service: UsersService; - let userRepo: { - save: jest.Mock; - delete: jest.Mock; - }; - let profileRepo: { + + let userRepo: { save: jest.Mock; delete: jest.Mock }; + let profileRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock }; + let emailRepo: { findOne: jest.Mock }; + let phoneRepo: { findOne: jest.Mock }; + let cvRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock; + update: jest.Mock; }; - let emailRepo: { - findOne: jest.Mock; - }; - let phoneRepo: { + let jobOfferRepo: { findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; }; const baseUser = { @@ -76,11 +143,19 @@ describe("UsersService", () => { create: jest.fn((p: Partial) => p as user_profile), save: jest.fn((p: user_profile) => Promise.resolve(p)), }; - emailRepo = { + emailRepo = { findOne: jest.fn() }; + phoneRepo = { findOne: jest.fn() }; + cvRepo = { findOne: jest.fn(), + create: jest.fn((cv) => cv), + save: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }; - phoneRepo = { + jobOfferRepo = { findOne: jest.fn(), + create: jest.fn((jo) => jo), + save: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }; const module: TestingModule = await Test.createTestingModule({ @@ -89,20 +164,26 @@ describe("UsersService", () => { { provide: getRepositoryToken(user), useValue: userRepo }, { provide: getRepositoryToken(user_profile), useValue: profileRepo }, { provide: getRepositoryToken(user_email), useValue: emailRepo }, - { - provide: getRepositoryToken(user_phone_number), - useValue: phoneRepo, - }, + { provide: getRepositoryToken(user_phone_number), useValue: phoneRepo }, + { provide: getRepositoryToken(user_cv), useValue: cvRepo }, + { provide: getRepositoryToken(user_job_offer), useValue: jobOfferRepo }, ], }).compile(); service = module.get(UsersService); + + mockGroqCreate.mockReset(); + mockScrapeLinkedin.mockReset(); + mockScrapeAxios.mockReset(); + mockScrapePuppeteer.mockReset(); }); it("should be defined", () => { expect(service).toBeDefined(); }); + // ─── getProfile ───────────────────────────────────────────────────────────── + describe("getProfile", () => { it("returns assembled camelCase view", async () => { profileRepo.findOne.mockResolvedValue({ ...baseProfile }); @@ -117,15 +198,15 @@ describe("UsersService", () => { }); }); + // ─── updateProfile ────────────────────────────────────────────────────────── + describe("updateProfile", () => { it("creates profile when missing and updates first name", async () => { profileRepo.findOne.mockResolvedValueOnce(null); emailRepo.findOne.mockResolvedValue(emailRow); phoneRepo.findOne.mockResolvedValue(null); - const v = await service.updateProfile(baseUser, { - firstName: "Zed", - }); + const v = await service.updateProfile(baseUser, { firstName: "Zed" }); expect(profileRepo.create).toHaveBeenCalled(); expect(profileRepo.save).toHaveBeenCalled(); expect(v.firstName).toBe("Zed"); @@ -136,9 +217,7 @@ describe("UsersService", () => { emailRepo.findOne.mockResolvedValue(emailRow); phoneRepo.findOne.mockResolvedValue(null); - const v = await service.updateProfile(baseUser, { - firstName: "Zed", - }); + const v = await service.updateProfile(baseUser, { firstName: "Zed" }); expect(userRepo.save).toHaveBeenCalled(); expect(profileRepo.save).toHaveBeenCalled(); expect(v.firstName).toBe("Zed"); @@ -167,6 +246,8 @@ describe("UsersService", () => { }); }); + // ─── deleteAccount ────────────────────────────────────────────────────────── + describe("deleteAccount", () => { it("deletes existing account", async () => { userRepo.delete.mockResolvedValue({ affected: 1 }); @@ -183,4 +264,181 @@ describe("UsersService", () => { ); }); }); + + // ─── uploadJobOffer ───────────────────────────────────────────────────────── + + describe("uploadJobOffer", () => { + it("retourne 400 si aucune URL n'est fournie", async () => { + const req = mockReq({ body: {} }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "Please provide a job offer URL.", + }); + }); + + it("retourne 400 si l'URL est invalide", async () => { + const req = mockReq({ body: { url: "not-a-url" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: "Invalid URL format." }); + }); + + it("retourne 400 si aucun scraper ne retourne du contenu", async () => { + mockScrapeLinkedin.mockResolvedValue(""); + mockScrapeAxios.mockResolvedValue(""); + mockScrapePuppeteer.mockResolvedValue(""); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: + "Could not extract content from this URL. The website may be too protected.", + }); + }); + + it("utilise scrapeLinkedin pour les URLs LinkedIn", async () => { + mockScrapeLinkedin.mockResolvedValue("linkedin job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validJobOfferGroqResponse } }], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + const req = mockReq({ + body: { url: "https://linkedin.com/jobs/view/123" }, + }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(mockScrapeLinkedin).toHaveBeenCalledWith( + "https://linkedin.com/jobs/view/123", + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("retourne 500 si Groq retourne une réponse vide", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: "Empty response from AI.", + }); + }); + + it("retourne 500 si Groq retourne un JSON invalide", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "}{invalid json" } }], + }); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: "Failed to parse extracted job offer data.", + }); + }); + + it("crée une nouvelle offre et retourne 200 si aucune n'existe", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validJobOfferGroqResponse } }], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(jobOfferRepo.create).toHaveBeenCalled(); + expect(jobOfferRepo.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: "Job offer parsed successfully", + }); + }); + + it("met à jour l'offre existante et retourne 200", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validJobOfferGroqResponse } }], + }); + jobOfferRepo.findOne.mockResolvedValue({ + user_id: "uid-1", + job_title: "old job", + }); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(jobOfferRepo.update).toHaveBeenCalledWith( + { user_id: "uid-1" }, + expect.objectContaining({ job_title: "Backend Developer" }), + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: "Job offer updated successfully", + }); + }); + + it("nettoie les backticks markdown avant de parser le JSON", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [ + { + message: { + content: "```json\n" + validJobOfferGroqResponse + "\n```", + }, + }, + ], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("retourne 500 en cas d'erreur inattendue", async () => { + mockScrapeAxios.mockRejectedValue(new Error("network crash")); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: "Error processing the job offer.", + }); + }); + }); }); diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 693f67ae..59a85d82 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -182,7 +182,7 @@ export class UsersService { }; } - async uploadCV(req: Request, res: Response) { + async uploadCV(req: Request & { file?: any }, res: Response) { try { if (!req.file) { return res.status(400).json({ message: "Upload a PDF file." }); From 618669abce47e6e5990d4d21b25353b88256ad4c Mon Sep 17 00:00:00 2001 From: eregine <114678670+eregine@users.noreply.github.com> Date: Sun, 31 May 2026 16:54:16 +0800 Subject: [PATCH 13/18] Add some unity test for the upload cv part --- .../src/modules/users/users.service.spec.ts | 189 ++++++++++++++++-- 1 file changed, 175 insertions(+), 14 deletions(-) diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 2914cdcd..576987ad 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -15,9 +15,8 @@ import { user_job_offer } from "@entities/userJobOffer.entity"; import { UsersService } from "./users.service"; -// ─── Mocks modules externes ─────────────────────────────────────────────────── - -jest.mock("pdf-parse-debugging-disabled", () => jest.fn()); +const mockPdfParse = jest.fn(); +jest.mock("pdf-parse-debugging-disabled", () => mockPdfParse); jest.mock("groq-sdk", () => { return jest.fn().mockImplementation(() => ({ @@ -42,8 +41,6 @@ const mockScrapeLinkedin = scrapeLinkedin as jest.Mock; const mockScrapeAxios = scrapeAxios as jest.Mock; const mockScrapePuppeteer = scrapePuppeteer as jest.Mock; -// ─── Helpers ────────────────────────────────────────────────────────────────── - const mockRes = () => { const res: any = {}; res.status = jest.fn().mockReturnValue(res); @@ -57,6 +54,32 @@ const mockReq = (overrides: Record = {}): any => ({ ...overrides, }); +const mockReqWithFile = (overrides: Record = {}): any => ({ + userId: "uid-1", + file: { + buffer: Buffer.from("fake pdf content"), + mimetype: "application/pdf", + originalname: "cv.pdf", + }, + ...overrides, +}); + +const validCvGroqResponse = JSON.stringify({ + desired_job: "Software Engineer", + resume: "Experienced developer", + experiences: [ + { + company: "Acme", + title: "Dev", + description: "stuff", + duration: "2020-2022", + }, + ], + education: [{ degree: "BSc", school_name: "MIT", duration: "2016-2020" }], + technical_skills: ["TypeScript", "Node.js"], + languages: [{ language: "English", level: "C2" }], +}); + const validJobOfferGroqResponse = JSON.stringify({ job_title: "Backend Developer", company_name: "TechCorp", @@ -76,8 +99,6 @@ const validJobOfferGroqResponse = JSON.stringify({ team_description: "Small agile team", }); -// ─── Suite principale ───────────────────────────────────────────────────────── - describe("UsersService", () => { let service: UsersService; @@ -172,6 +193,7 @@ describe("UsersService", () => { service = module.get(UsersService); + mockPdfParse.mockReset(); mockGroqCreate.mockReset(); mockScrapeLinkedin.mockReset(); mockScrapeAxios.mockReset(); @@ -182,8 +204,6 @@ describe("UsersService", () => { expect(service).toBeDefined(); }); - // ─── getProfile ───────────────────────────────────────────────────────────── - describe("getProfile", () => { it("returns assembled camelCase view", async () => { profileRepo.findOne.mockResolvedValue({ ...baseProfile }); @@ -198,8 +218,6 @@ describe("UsersService", () => { }); }); - // ─── updateProfile ────────────────────────────────────────────────────────── - describe("updateProfile", () => { it("creates profile when missing and updates first name", async () => { profileRepo.findOne.mockResolvedValueOnce(null); @@ -246,8 +264,6 @@ describe("UsersService", () => { }); }); - // ─── deleteAccount ────────────────────────────────────────────────────────── - describe("deleteAccount", () => { it("deletes existing account", async () => { userRepo.delete.mockResolvedValue({ affected: 1 }); @@ -265,7 +281,152 @@ describe("UsersService", () => { }); }); - // ─── uploadJobOffer ───────────────────────────────────────────────────────── + // ─── uploadCV ─────────────────────────────────────────────────────────────── + + describe("uploadCV", () => { + it("retourne 400 si aucun fichier n'est fourni", async () => { + const req = mockReqWithFile({ file: undefined }); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: "Upload a PDF file." }); + }); + + it("retourne 400 si le texte extrait du PDF est vide", async () => { + mockPdfParse.mockResolvedValue({ text: "" }); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "The PDF file is empty or could not be parsed.", + }); + }); + + it("retourne 500 si Groq retourne une réponse vide", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: "Empty response from AI.", + }); + }); + + it("retourne 500 si Groq retourne un JSON invalide", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "not valid json }{" } }], + }); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: "Failed to parse extracted CV data.", + }); + }); + + it("crée un nouveau CV et retourne 200 si aucun CV n'existe", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validCvGroqResponse } }], + }); + cvRepo.findOne.mockResolvedValue(null); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(cvRepo.findOne).toHaveBeenCalledWith({ + where: { user_id: "uid-1" }, + }); + expect(cvRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ desired_job: "Software Engineer" }), + ); + expect(cvRepo.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: "CV uploaded successfully", + }); + }); + + it("met à jour le CV existant et retourne 200", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validCvGroqResponse } }], + }); + cvRepo.findOne.mockResolvedValue({ + user_id: "uid-1", + desired_job: "old job", + }); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(cvRepo.update).toHaveBeenCalledWith( + { user_id: "uid-1" }, + expect.objectContaining({ desired_job: "Software Engineer" }), + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: "CV updated successfully", + }); + }); + + it("nettoie les backticks markdown avant de parser le JSON", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [ + { + message: { + content: "```json\n" + validCvGroqResponse + "\n```", + }, + }, + ], + }); + cvRepo.findOne.mockResolvedValue(null); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("retourne 500 en cas d'erreur inattendue", async () => { + mockPdfParse.mockRejectedValue(new Error("unexpected crash")); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: "Error processing the CV file.", + }); + }); + }); describe("uploadJobOffer", () => { it("retourne 400 si aucune URL n'est fournie", async () => { From bdfd100d64b65306c9594331bc2aff446b59512d Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Sun, 21 Jun 2026 13:35:49 +0800 Subject: [PATCH 14/18] fix: guard SSRF, drop dead multer, restore coverage gate on cv/job-offer upload - add isSafeFetchUrl guard (scheme + private/loopback/link-local/ipv4-mapped-ipv6) before job-offer scraping - delete unused multer.config.ts (controller uses FileInterceptor) - top-level groq-sdk/pdf-parse imports + singleton groq client + type shim - throttle upload routes, truncate cv text to 8000 chars, fix empty-check null-deref - nest Logger over console, drop puppeteer hardcoded chrome path, missions simple-array - add bruno files + GROQ_API_KEY to .env.example - add JobOfferExtraction/urlGuard specs + sparse-field/ssrf cases (branch cov 81.45%) --- server/.env.example | 4 + server/src/common/middleware/multer.config.ts | 25 --- .../common/utils/JobOfferExtraction.spec.ts | 165 ++++++++++++++++++ server/src/common/utils/JobOfferExtraction.ts | 31 ++-- server/src/common/utils/urlGuard.spec.ts | 70 ++++++++ server/src/common/utils/urlGuard.ts | 107 ++++++++++++ server/src/entities/userJobOffer.entity.ts | 2 +- .../modules/users/users.controller.spec.ts | 20 +++ server/src/modules/users/users.controller.ts | 9 +- .../src/modules/users/users.service.spec.ts | 106 ++++++++++- server/src/modules/users/users.service.ts | 61 ++++--- .../types/pdf-parse-debugging-disabled.d.ts | 22 +++ server/talkup-backend-bruno/users/folder.bru | 8 + .../talkup-backend-bruno/users/uploadCV.bru | 20 +++ .../users/uploadJobOffer.bru | 22 +++ 15 files changed, 596 insertions(+), 76 deletions(-) delete mode 100644 server/src/common/middleware/multer.config.ts create mode 100644 server/src/common/utils/JobOfferExtraction.spec.ts create mode 100644 server/src/common/utils/urlGuard.spec.ts create mode 100644 server/src/common/utils/urlGuard.ts create mode 100644 server/src/types/pdf-parse-debugging-disabled.d.ts create mode 100644 server/talkup-backend-bruno/users/folder.bru create mode 100644 server/talkup-backend-bruno/users/uploadCV.bru create mode 100644 server/talkup-backend-bruno/users/uploadJobOffer.bru diff --git a/server/.env.example b/server/.env.example index 3e5b320b..072a8a36 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,3 +11,7 @@ FRONTEND_URL=http://localhost:5173 # Same Compose stack with the API container (hostname `redis` on the compose network): # REDIS_URL=redis://redis:6379 # REDIS_PORT=6379 + +# Required — Groq API key for CV / job-offer extraction (users uploadCV & uploadJobOffer). +# Without it those routes fail at runtime. Get one at https://console.groq.com/keys +GROQ_API_KEY= diff --git a/server/src/common/middleware/multer.config.ts b/server/src/common/middleware/multer.config.ts deleted file mode 100644 index d4ffd9b5..00000000 --- a/server/src/common/middleware/multer.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import multer from "multer"; -import path from "path"; - -// use memory storage to store the file in memory as a buffer -const storage = multer.memoryStorage(); - -export const upload = multer({ - storage: storage, - limits: { - fileSize: 5 * 1024 * 1024, // Limite à 5 Mo - }, - fileFilter: (req, file, cb) => { - // ckeck file type is pdf - const filetypes = /pdf/; - const mimetype = filetypes.test(file.mimetype); - const extname = filetypes.test( - path.extname(file.originalname).toLowerCase(), - ); - - if (mimetype && extname) { - return cb(null, true); - } - cb(new Error("Only PDF files are allowed!")); - }, -}); diff --git a/server/src/common/utils/JobOfferExtraction.spec.ts b/server/src/common/utils/JobOfferExtraction.spec.ts new file mode 100644 index 00000000..f117e61c --- /dev/null +++ b/server/src/common/utils/JobOfferExtraction.spec.ts @@ -0,0 +1,165 @@ +import axios from "axios"; +import puppeteer from "puppeteer"; + +import { + scrapeLinkedin, + scrapeAxios, + scrapePuppeteer, +} from "./JobOfferExtraction"; + +jest.mock("axios"); +jest.mock("puppeteer"); + +const mockedAxios = axios as jest.Mocked; +const mockedPuppeteer = puppeteer as jest.Mocked; + +// Fake timers so retry backoff (attempt * 2000ms) and puppeteer's sequential +// waits resolve instantly. Run a scraper through to completion by draining all +// queued timers while its async work settles. +jest.useFakeTimers(); +const runScraper = async (start: () => Promise): Promise => { + const promise = start(); + await jest.runAllTimersAsync(); + return promise; +}; + +const LINKEDIN_HTML = ` +

Senior Backend Engineer

+ TechCorp + Paris, France +
We build distributed systems and we are hiring engineers to grow the platform team across Europe.
+
    +
  • Employment type

    Full-time
  • +
  • Seniority level

    Senior
  • +
+`; + +const longBody = "Job description ".repeat(40); // > 300 chars +const GENERIC_HTML = `

${longBody}

`; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("scrapeLinkedin", () => { + it("extracts structured text from the guest API on first try", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: LINKEDIN_HTML }); + + const text = await runScraper(() => + scrapeLinkedin("https://www.linkedin.com/jobs/view/1234567890"), + ); + + expect(text).toContain("Senior Backend Engineer"); + expect(text).toContain("TechCorp"); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it("returns empty when the URL has no extractable job id", async () => { + // No 8+ digit id → throws inside, retries, ends empty. + const text = await runScraper(() => + scrapeLinkedin("https://www.linkedin.com/jobs/view/abc"), + ); + expect(text).toBe(""); + }); + + it("retries on failure then succeeds", async () => { + mockedAxios.get + .mockRejectedValueOnce({ code: "ETIMEDOUT" }) + .mockResolvedValueOnce({ data: LINKEDIN_HTML }); + + const text = await runScraper(() => + scrapeLinkedin("https://www.linkedin.com/jobs/view/1234567890"), + ); + + expect(text).toContain("Senior Backend Engineer"); + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + }); + + it("returns empty when content is too short", async () => { + mockedAxios.get.mockResolvedValue({ data: "

" }); + + const text = await runScraper(() => + scrapeLinkedin("https://www.linkedin.com/jobs/view/1234567890"), + ); + + expect(text).toBe(""); + }); +}); + +describe("scrapeAxios", () => { + it("extracts body text from a simple page", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: GENERIC_HTML }); + + const text = await scrapeAxios("https://example.com/job/1"); + + expect(text).toContain("Job description"); + expect(text).not.toContain("nav"); + }); + + it("returns empty when the page text is too short", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: "hi" }); + + const text = await scrapeAxios("https://example.com/job/1"); + + expect(text).toBe(""); + }); + + it("returns empty on request error", async () => { + mockedAxios.get.mockRejectedValueOnce({ code: "ECONNREFUSED" }); + + const text = await scrapeAxios("https://example.com/job/1"); + + expect(text).toBe(""); + }); +}); + +describe("scrapePuppeteer", () => { + const makeBrowser = (html: string) => { + const page = { + evaluateOnNewDocument: jest.fn(), + setUserAgent: jest.fn(), + setViewport: jest.fn(), + goto: jest.fn(), + evaluate: jest.fn(), + content: jest.fn().mockResolvedValue(html), + }; + return { + newPage: jest.fn().mockResolvedValue(page), + close: jest.fn(), + }; + }; + + it("extracts body text via a headless browser", async () => { + mockedPuppeteer.launch.mockResolvedValueOnce( + makeBrowser(GENERIC_HTML) as never, + ); + + const text = await runScraper(() => + scrapePuppeteer("https://example.com/job/1"), + ); + + expect(text).toContain("Job description"); + }); + + it("returns empty when launch fails", async () => { + mockedPuppeteer.launch.mockRejectedValueOnce(new Error("no chrome")); + + const text = await runScraper(() => + scrapePuppeteer("https://example.com/job/1"), + ); + + expect(text).toBe(""); + }); + + it("returns empty when rendered content is too short", async () => { + mockedPuppeteer.launch.mockResolvedValueOnce( + makeBrowser("tiny") as never, + ); + + const text = await runScraper(() => + scrapePuppeteer("https://example.com/job/1"), + ); + + expect(text).toBe(""); + }); +}); diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts index 86e2ec59..8c2df5c2 100644 --- a/server/src/common/utils/JobOfferExtraction.ts +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -1,5 +1,9 @@ import axios from "axios"; import * as cheerio from "cheerio"; +import puppeteer from "puppeteer"; +import { Logger } from "@nestjs/common"; + +const logger = new Logger("JobOfferExtraction"); export const scrapeLinkedin = async (url: string): Promise => { const maxRetries = 3; @@ -17,7 +21,7 @@ export const scrapeLinkedin = async (url: string): Promise => { // Délai croissant entre chaque tentative : 0ms, 2000ms, 4000ms if (attempt > 0) { const delay = attempt * 2000; - console.log( + logger.debug( `LinkedIn retry ${attempt}/${maxRetries - 1} - waiting ${delay}ms...`, ); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -83,20 +87,19 @@ export const scrapeLinkedin = async (url: string): Promise => { if (extracted.length >= 100) { pageText = extracted; - console.log(`Strategy LinkedIn succeeded on attempt ${attempt + 1}`); + logger.debug(`Strategy LinkedIn succeeded on attempt ${attempt + 1}`); } else { throw new Error("Extracted content too short"); } } catch (err) { - console.log( - `LinkedIn attempt ${attempt + 1} failed:`, - (err as any)?.code || err, + logger.debug( + `LinkedIn attempt ${attempt + 1} failed: ${(err as any)?.code || err}`, ); attempt++; } } if (!pageText) { - console.log( + logger.debug( "All LinkedIn attempts failed, falling through to next strategy...", ); } @@ -135,10 +138,10 @@ export const scrapeAxios = async (url: string): Promise => { if (extracted.length >= 300) { pageText = extracted; - console.log("Strategy Axios succeeded"); + logger.debug("Strategy Axios succeeded"); } } catch (err) { - console.log("Strategy Axios failed:", (err as any)?.code || err); + logger.debug(`Strategy Axios failed: ${(err as any)?.code || err}`); } return pageText; @@ -149,11 +152,10 @@ export const scrapePuppeteer = async (url: string): Promise => { let pageText = ""; try { - const puppeteer = require("puppeteer"); - + // Use puppeteer's bundled Chromium. Override only via PUPPETEER_EXECUTABLE_PATH + // (respected by puppeteer natively) when a system Chrome is required. const browser = await puppeteer.launch({ headless: true, - executablePath: "/usr/bin/google-chrome", args: [ "--no-sandbox", "--disable-setuid-sandbox", @@ -191,12 +193,11 @@ export const scrapePuppeteer = async (url: string): Promise => { if (extracted.length >= 300) { pageText = extracted; - console.log("Strategy Puppeteer generic succeeded"); + logger.debug("Strategy Puppeteer generic succeeded"); } } catch (err) { - console.log( - "Strategy Puppeteer generic failed:", - (err as any)?.message || err, + logger.debug( + `Strategy Puppeteer generic failed: ${(err as any)?.message || err}`, ); } diff --git a/server/src/common/utils/urlGuard.spec.ts b/server/src/common/utils/urlGuard.spec.ts new file mode 100644 index 00000000..21bc6c4f --- /dev/null +++ b/server/src/common/utils/urlGuard.spec.ts @@ -0,0 +1,70 @@ +import { isSafeFetchUrl } from "./urlGuard"; + +describe("isSafeFetchUrl", () => { + describe("allows", () => { + it.each([ + "https://www.linkedin.com/jobs/view/1234567890", + "http://example.com/job/123", + "https://careers.acme.io/offers/42", + "https://203.0.113.10/job", // public IP literal + ])("accepts public http(s) URL %s", (url) => { + expect(isSafeFetchUrl(url)).toBe(true); + }); + }); + + describe("rejects malformed / non-http(s)", () => { + it.each([ + "not-a-url", + "", + "ftp://example.com/file", + "file:///etc/passwd", + "gopher://example.com", + "javascript:alert(1)", + ])("rejects %s", (url) => { + expect(isSafeFetchUrl(url)).toBe(false); + }); + }); + + describe("rejects SSRF targets (loopback / private / link-local)", () => { + it.each([ + "http://localhost/admin", + "http://localhost:8080/internal", + "http://api.localhost/x", + "http://127.0.0.1/", + "http://127.1.2.3/", + "http://0.0.0.0/", + "http://10.0.0.5/", + "http://172.16.0.1/", + "http://172.31.255.255/", + "http://192.168.1.1/", + "http://169.254.169.254/latest/meta-data/", // cloud metadata + "http://100.64.0.1/", // CGNAT + "http://[::1]/", // IPv6 loopback + "http://[::]/", + "http://[fc00::1]/", // IPv6 unique-local + "http://[fe80::1]/", // IPv6 link-local + "http://[::ffff:127.0.0.1]/", // IPv4-mapped loopback + "http://[::ffff:10.0.0.1]/", // IPv4-mapped private + ])("blocks %s", (url) => { + expect(isSafeFetchUrl(url)).toBe(false); + }); + }); + + describe("public ranges adjacent to private blocks pass", () => { + it.each([ + "http://172.15.0.1/", // just below 172.16/12 + "http://172.32.0.1/", // just above 172.16/12 + "http://11.0.0.1/", // not 10/8 + "http://100.63.0.1/", // just below CGNAT + "http://100.128.0.1/", // just above CGNAT + "http://[2606:4700::1]/", // public IPv6 + ])("accepts %s", (url) => { + expect(isSafeFetchUrl(url)).toBe(true); + }); + }); + + it("rejects a hostname new URL() cannot parse", () => { + // new URL("http://999.1.1.1/") throws (invalid IPv4 literal) → not safe. + expect(isSafeFetchUrl("http://999.1.1.1/")).toBe(false); + }); +}); diff --git a/server/src/common/utils/urlGuard.ts b/server/src/common/utils/urlGuard.ts new file mode 100644 index 00000000..827d9c8e --- /dev/null +++ b/server/src/common/utils/urlGuard.ts @@ -0,0 +1,107 @@ +/** + * SSRF guard for user-supplied URLs that the server will fetch (job-offer scraping). + * + * Blocks the obvious attack surface without doing DNS resolution (which would add + * network I/O and flakiness): scheme must be http(s), and the host must not be a + * loopback / private / link-local / reserved IP literal or a local hostname. + * + * Residual risk: a public hostname that resolves to a private IP (DNS rebinding) + * is NOT caught here — that needs resolve-then-pin-IP at fetch time. Tracked as a + * follow-up; this guard stops metadata-endpoint and localhost/internal-IP probing. + */ + +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "ip6-localhost", + "ip6-loopback", +]); + +const isPrivateIPv4 = (host: string): boolean => { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) { + return false; + } + const octets = m.slice(1, 5).map(Number); + if (octets.some((o) => o > 255)) { + return false; + } + const [a, b] = octets; + + // 0.0.0.0/8, 10/8, 127/8 loopback, 169.254/16 link-local (incl. cloud metadata) + if (a === 0 || a === 10 || a === 127) { + return true; + } + if (a === 169 && b === 254) { + return true; + } + // 172.16/12 + if (a === 172 && b >= 16 && b <= 31) { + return true; + } + // 192.168/16 + if (a === 192 && b === 168) { + return true; + } + // 100.64/10 carrier-grade NAT + if (a === 100 && b >= 64 && b <= 127) { + return true; + } + return false; +}; + +const isBlockedIPv6 = (host: string): boolean => { + // host from new URL() keeps brackets, e.g. "[::1]" + const inner = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (inner === "::1" || inner === "::") { + return true; + } + // unique-local fc00::/7 and link-local fe80::/10 + if (/^f[cd][0-9a-f]{2}:/.test(inner) || /^fe[89ab][0-9a-f]:/.test(inner)) { + return true; + } + // IPv4-mapped ::ffff:a.b.c.d. new URL() may keep the dotted form or normalize + // it to hex (::ffff:7f00:1). Handle both, decoding hex back to dotted-quad. + const dotted = inner.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) { + return isPrivateIPv4(dotted[1]); + } + const hex = inner.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const hi = parseInt(hex[1], 16); + const lo = parseInt(hex[2], 16); + const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; + return isPrivateIPv4(ipv4); + } + return false; +}; + +/** + * Returns true when the URL is safe to fetch server-side. + * Rejects non-http(s) schemes and private/loopback/link-local/reserved targets. + */ +export const isSafeFetchUrl = (raw: string): boolean => { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + + const host = parsed.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(host) || host.endsWith(".localhost")) { + return false; + } + if (isPrivateIPv4(host)) { + return false; + } + if (host.includes(":") && isBlockedIPv6(parsed.host)) { + return false; + } + + return true; +}; diff --git a/server/src/entities/userJobOffer.entity.ts b/server/src/entities/userJobOffer.entity.ts index 2bfbd533..575d67ca 100644 --- a/server/src/entities/userJobOffer.entity.ts +++ b/server/src/entities/userJobOffer.entity.ts @@ -54,7 +54,7 @@ export class user_job_offer { @Column({ nullable: true }) required_education: string; - @Column({ type: "json", nullable: true }) + @Column({ type: "simple-array", nullable: true }) missions: string[]; @Column({ type: "simple-array", nullable: true }) diff --git a/server/src/modules/users/users.controller.spec.ts b/server/src/modules/users/users.controller.spec.ts index 39205bac..49b91d68 100644 --- a/server/src/modules/users/users.controller.spec.ts +++ b/server/src/modules/users/users.controller.spec.ts @@ -42,6 +42,8 @@ describe("UsersController", () => { getProfile: jest.fn().mockResolvedValue(profile), updateProfile: jest.fn().mockResolvedValue(profile), deleteAccount: jest.fn().mockResolvedValue(undefined), + uploadCV: jest.fn().mockResolvedValue(undefined), + uploadJobOffer: jest.fn().mockResolvedValue(undefined), }; const moduleBuilder = Test.createTestingModule({ @@ -83,4 +85,22 @@ describe("UsersController", () => { expect(mockUsersService.deleteAccount).toHaveBeenCalledWith(mockUser); }); }); + + describe("uploadCV", () => { + it("delegates to uploadCV with req/res", async () => { + const req = {} as never; + const res = {} as never; + await controller.uploadCV(req, res); + expect(mockUsersService.uploadCV).toHaveBeenCalledWith(req, res); + }); + }); + + describe("uploadJobOffer", () => { + it("delegates to uploadJobOffer with req/res", async () => { + const req = {} as never; + const res = {} as never; + await controller.uploadJobOffer(req, res); + expect(mockUsersService.uploadJobOffer).toHaveBeenCalledWith(req, res); + }); + }); }); diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index efb7bf1a..5700c103 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -6,7 +6,6 @@ import { BadRequestException, UseInterceptors, UseGuards, - Put, Post, Delete, Get, @@ -15,6 +14,7 @@ import { Patch, } from "@nestjs/common"; import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator"; +import { Throttle } from "@nestjs/throttler"; import type { Request, Response } from "express"; import { FileInterceptor } from "@nestjs/platform-express"; @@ -75,6 +75,7 @@ export class UsersController { "Invalid request data in body (e.g., missing file or incorrect format)", }) @UsePipes(new PostValidationPipe()) + @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseInterceptors( FileInterceptor("file", { limits: { fileSize: 10 * 1024 * 1024 }, @@ -93,6 +94,12 @@ export class UsersController { return this.usersService.uploadCV(req, res); } + @ApiOkResponse({ description: "The job offer was successfully parsed" }) + @ApiBadRequestResponse({ + description: "Missing/invalid URL, blocked target, or unscrapable page", + }) + @ApiUnauthorizedResponse() + @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(AccessTokenGuard) @Post("uploadJobOffer") async uploadJobOffer(@Req() req: Request, @Res() res: Response) { diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 576987ad..05df2b49 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -16,13 +16,21 @@ import { user_job_offer } from "@entities/userJobOffer.entity"; import { UsersService } from "./users.service"; const mockPdfParse = jest.fn(); -jest.mock("pdf-parse-debugging-disabled", () => mockPdfParse); +// Lazy wrappers: the service now imports these at module-load time, so the mock +// factory must not touch the `mock*` consts until call time (avoids TDZ). +jest.mock("pdf-parse-debugging-disabled", () => ({ + __esModule: true, + default: (...args: unknown[]) => mockPdfParse(...args), +})); -jest.mock("groq-sdk", () => { - return jest.fn().mockImplementation(() => ({ - chat: { completions: { create: mockGroqCreate } }, - })); -}); +jest.mock("groq-sdk", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { create: (...args: unknown[]) => mockGroqCreate(...args) }, + }, + })), +})); jest.mock("../../common/utils/JobOfferExtraction", () => ({ scrapeLinkedin: jest.fn(), @@ -413,6 +421,50 @@ describe("UsersService", () => { expect(res.status).toHaveBeenCalledWith(200); }); + it("applique les valeurs par défaut quand des champs sont absents", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + // Empty object → every field falls back to its `?? null` / `?? []` default. + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "{}" } }], + }); + cvRepo.findOne.mockResolvedValue(null); + + const req = mockReqWithFile(); + const res = mockRes(); + + await service.uploadCV(req, res); + + expect(cvRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + desired_job: null, + resume: null, + experiences: [], + education: [], + technical_skills: [], + languages: [], + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("tronque le texte du CV avant l'envoi au LLM", async () => { + const longText = "a".repeat(20000); + mockPdfParse.mockResolvedValue({ text: longText }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validCvGroqResponse } }], + }); + cvRepo.findOne.mockResolvedValue(null); + + await service.uploadCV(mockReqWithFile(), mockRes()); + + const promptSent = mockGroqCreate.mock.calls[0][0].messages[0].content; + // Raw CV text is capped at 8000 chars: prompt = boilerplate + <=8000, + // far below the un-truncated 20000-char input. + expect(promptSent).not.toContain("a".repeat(8001)); + expect(promptSent).toContain("a".repeat(8000)); + expect(promptSent.length).toBeLessThan(12000); + }); + it("retourne 500 en cas d'erreur inattendue", async () => { mockPdfParse.mockRejectedValue(new Error("unexpected crash")); @@ -451,6 +503,22 @@ describe("UsersService", () => { expect(res.json).toHaveBeenCalledWith({ message: "Invalid URL format." }); }); + it("retourne 400 et ne scrape pas une cible SSRF (loopback/privée)", async () => { + const req = mockReq({ body: { url: "http://169.254.169.254/latest/" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: "This URL target is not allowed.", + }); + // Guard runs before any scraping is attempted. + expect(mockScrapeLinkedin).not.toHaveBeenCalled(); + expect(mockScrapeAxios).not.toHaveBeenCalled(); + expect(mockScrapePuppeteer).not.toHaveBeenCalled(); + }); + it("retourne 400 si aucun scraper ne retourne du contenu", async () => { mockScrapeLinkedin.mockResolvedValue(""); mockScrapeAxios.mockResolvedValue(""); @@ -542,6 +610,32 @@ describe("UsersService", () => { }); }); + it("applique les valeurs par défaut quand des champs sont absents", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + // Empty object → every field falls back to its `?? null` / `?? []` default. + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "{}" } }], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + const req = mockReq({ body: { url: "https://example.com/job/123" } }); + const res = mockRes(); + + await service.uploadJobOffer(req, res); + + expect(jobOfferRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + job_title: null, + company_name: null, + required_skills: [], + missions: [], + soft_skills: [], + offer_url: "https://example.com/job/123", + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + it("met à jour l'offre existante et retourne 200", async () => { mockScrapeAxios.mockResolvedValue("some job content"); mockGroqCreate.mockResolvedValue({ diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 59a85d82..2230a553 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -19,16 +19,23 @@ import { scrapeAxios, scrapePuppeteer, } from "../../common/utils/JobOfferExtraction"; +import { isSafeFetchUrl } from "../../common/utils/urlGuard"; import { UpdateProfileDto } from "./dto/updateProfile.dto"; import { GetProfileDto } from "./dto/getProfile.dto"; import { type Request, type Response } from "express"; import { user_cv } from "@entities/userCV.entity"; import { user_job_offer } from "@entities/userJobOffer.entity"; +import Groq from "groq-sdk"; +import pdfParse from "pdf-parse-debugging-disabled"; + +// Cap raw text sent to the LLM to bound token cost on large documents. +const MAX_LLM_INPUT_CHARS = 8000; @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); + private readonly groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); constructor( @InjectRepository(user) @@ -189,17 +196,17 @@ export class UsersService { } const userId = (req as any).userId; - const pdf = require("pdf-parse-debugging-disabled"); - const pdfData = await pdf(req.file.buffer); + const pdfData = await pdfParse(req.file.buffer); const rawText = pdfData.text; - if (rawText.length === 0 || !rawText) { - console.log("The file is empty!!!"); + if (!rawText || rawText.length === 0) { return res .status(400) .json({ message: "The PDF file is empty or could not be parsed." }); } + const cvText = rawText.substring(0, MAX_LLM_INPUT_CHARS); + const prompt = `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: { "desired_job": "string or null", @@ -236,12 +243,9 @@ export class UsersService { - For durations, keep the original format from the CV (e.g. "Jan 2022 - Mar 2024") CV text: - ${rawText}`; - - const Groq = require("groq-sdk"); - const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + ${cvText}`; - const completion = await groq.chat.completions.create({ + const completion = await this.groq.chat.completions.create({ model: "llama-3.3-70b-versatile", messages: [{ role: "user", content: prompt }], temperature: 0, @@ -250,7 +254,7 @@ export class UsersService { const responseText = completion.choices[0]?.message?.content; if (!responseText) { - console.error("Empty response from Groq"); + this.logger.error("Empty response from Groq"); return res.status(500).json({ message: "Empty response from AI." }); } @@ -259,7 +263,7 @@ export class UsersService { const cleaned = responseText.replace(/```json|```/g, "").trim(); extractedData = JSON.parse(cleaned); } catch (parseError) { - console.error("JSON parse error:", parseError); + this.logger.error(`JSON parse error: ${parseError}`); return res .status(500) .json({ message: "Failed to parse extracted CV data." }); @@ -281,7 +285,7 @@ export class UsersService { languages: extractedData.languages ?? [], }, ); - console.log("CV updated for user ID:", userId); + this.logger.log(`CV updated for user ID: ${userId}`); return res.status(200).json({ message: "CV updated successfully" }); } else { const newCV = this.user_cvRepo.create({ @@ -293,12 +297,12 @@ export class UsersService { technical_skills: extractedData.technical_skills ?? [], languages: extractedData.languages ?? [], }); - console.log("CV created for user ID:", userId); await this.user_cvRepo.save(newCV); + this.logger.log(`CV created for user ID: ${userId}`); return res.status(200).json({ message: "CV uploaded successfully" }); } } catch (error) { - console.error("Parsing error:", error); + this.logger.error(`uploadCV failed: ${error}`); res.status(500).json({ message: "Error processing the CV file." }); } } @@ -320,6 +324,14 @@ export class UsersService { return res.status(400).json({ message: "Invalid URL format." }); } + // SSRF guard: reject non-http(s) schemes and private/loopback/link-local + // targets (cloud metadata, localhost, internal services). + if (!isSafeFetchUrl(url)) { + return res + .status(400) + .json({ message: "This URL target is not allowed." }); + } + let pageText: string = ""; const isLinkedIn = url.includes("linkedin.com/jobs"); @@ -335,9 +347,7 @@ export class UsersService { }); } - pageText = pageText.substring(0, 8000); - - console.log("Final extracted page text length:", pageText.length); + pageText = pageText.substring(0, MAX_LLM_INPUT_CHARS); const prompt = `You are a specialized job offer analysis assistant. Analyze the following text extracted from a job offer page and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: { @@ -369,12 +379,7 @@ export class UsersService { Job offer text: ${pageText}`; - const Groq = require("groq-sdk"); - const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); - - console.log("Sending job offer analysis prompt to Groq..."); - - const completion = await groq.chat.completions.create({ + const completion = await this.groq.chat.completions.create({ model: "llama-3.3-70b-versatile", messages: [{ role: "user", content: prompt }], temperature: 0, @@ -383,7 +388,7 @@ export class UsersService { const responseText = completion.choices[0]?.message?.content; if (!responseText) { - console.error("Empty response from Groq"); + this.logger.error("Empty response from Groq"); return res.status(500).json({ message: "Empty response from AI." }); } @@ -392,7 +397,7 @@ export class UsersService { const cleaned = responseText.replace(/```json|```/g, "").trim(); extractedData = JSON.parse(cleaned); } catch (parseError) { - console.error("JSON parse error:", parseError); + this.logger.error(`JSON parse error: ${parseError}`); return res .status(500) .json({ message: "Failed to parse extracted job offer data." }); @@ -425,7 +430,7 @@ export class UsersService { offer_url: url, }, ); - console.log("Job offer updated for user ID:", userId); + this.logger.log(`Job offer updated for user ID: ${userId}`); return res.status(200).json({ message: "Job offer updated successfully", }); @@ -451,13 +456,13 @@ export class UsersService { offer_url: url, }); await this.user_job_offerRepo.save(newJobOffer); - console.log("Job offer created for user ID:", userId); + this.logger.log(`Job offer created for user ID: ${userId}`); return res.status(200).json({ message: "Job offer parsed successfully", }); } } catch (error) { - console.error("Error processing job offer:", error); + this.logger.error(`uploadJobOffer failed: ${error}`); res.status(500).json({ message: "Error processing the job offer." }); } } diff --git a/server/src/types/pdf-parse-debugging-disabled.d.ts b/server/src/types/pdf-parse-debugging-disabled.d.ts new file mode 100644 index 00000000..40837feb --- /dev/null +++ b/server/src/types/pdf-parse-debugging-disabled.d.ts @@ -0,0 +1,22 @@ +/** + * Type shim for `pdf-parse-debugging-disabled` — a drop-in fork of `pdf-parse` + * that ships no declaration file. Mirrors the subset we use (text extraction). + * Without this, the default import trips `noImplicitAny` (TS7016). + */ +declare module "pdf-parse-debugging-disabled" { + interface PdfParseResult { + text: string; + numpages: number; + numrender: number; + info: unknown; + metadata: unknown; + version: string; + } + + function pdfParse( + dataBuffer: Buffer | Uint8Array, + options?: Record, + ): Promise; + + export default pdfParse; +} diff --git a/server/talkup-backend-bruno/users/folder.bru b/server/talkup-backend-bruno/users/folder.bru new file mode 100644 index 00000000..b09cd064 --- /dev/null +++ b/server/talkup-backend-bruno/users/folder.bru @@ -0,0 +1,8 @@ +meta { + name: users + seq: 4 +} + +auth { + mode: inherit +} diff --git a/server/talkup-backend-bruno/users/uploadCV.bru b/server/talkup-backend-bruno/users/uploadCV.bru new file mode 100644 index 00000000..6b6b4794 --- /dev/null +++ b/server/talkup-backend-bruno/users/uploadCV.bru @@ -0,0 +1,20 @@ +meta { + name: uploadCV + type: http + seq: 1 +} + +post { + url: {{host}}/users/uploadCV + body: multipartForm + auth: inherit +} + +body:multipart-form { + file: @file() +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/server/talkup-backend-bruno/users/uploadJobOffer.bru b/server/talkup-backend-bruno/users/uploadJobOffer.bru new file mode 100644 index 00000000..b2f02dca --- /dev/null +++ b/server/talkup-backend-bruno/users/uploadJobOffer.bru @@ -0,0 +1,22 @@ +meta { + name: uploadJobOffer + type: http + seq: 2 +} + +post { + url: {{host}}/users/uploadJobOffer + body: json + auth: inherit +} + +body:json { + { + "url": "https://www.linkedin.com/jobs/view/1234567890" + } +} + +settings { + encodeUrl: true + timeout: 0 +} From 926afe8f4fe6a6c30d6d7257cc492b777fc02daf Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Sun, 21 Jun 2026 14:15:19 +0800 Subject: [PATCH 15/18] refactor: align cv/job-offer upload with controller conventions Follow-up to the review of the initial upload implementation. - controller uses @CurrentUser/@UploadedFile/@Body(UploadJobOfferDto) and returns a value instead of raw @Req/@Res; drops the (req as any).userId casts - service throws BadRequest/InternalServerError instead of res.status().json() - extract shared extractWithGroq() + generic upsertByUser() helpers, collapsing the ~95% duplication between uploadCV and uploadJobOffer - service methods take (userId, file|url) and return { message } - rewrite specs to the new signatures; branch coverage 84% --- .../modules/users/dto/uploadJobOffer.dto.ts | 17 + .../modules/users/users.controller.spec.ts | 25 +- server/src/modules/users/users.controller.ts | 22 +- .../src/modules/users/users.service.spec.ts | 337 +++++----------- server/src/modules/users/users.service.ts | 372 +++++++++--------- 5 files changed, 322 insertions(+), 451 deletions(-) create mode 100644 server/src/modules/users/dto/uploadJobOffer.dto.ts diff --git a/server/src/modules/users/dto/uploadJobOffer.dto.ts b/server/src/modules/users/dto/uploadJobOffer.dto.ts new file mode 100644 index 00000000..f73d6ef1 --- /dev/null +++ b/server/src/modules/users/dto/uploadJobOffer.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiSchema } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, MaxLength } from "class-validator"; + +@ApiSchema({ + name: "UploadJobOfferRequest", + description: "Public URL of a job-offer page to extract structured data from", +}) +export class UploadJobOfferDto { + @IsString() + @IsNotEmpty() + @MaxLength(2048) + @ApiProperty({ + description: "URL of the job offer to scrape and analyze", + example: "https://www.linkedin.com/jobs/view/1234567890", + }) + url: string; +} diff --git a/server/src/modules/users/users.controller.spec.ts b/server/src/modules/users/users.controller.spec.ts index 49b91d68..162acb3d 100644 --- a/server/src/modules/users/users.controller.spec.ts +++ b/server/src/modules/users/users.controller.spec.ts @@ -87,20 +87,25 @@ describe("UsersController", () => { }); describe("uploadCV", () => { - it("delegates to uploadCV with req/res", async () => { - const req = {} as never; - const res = {} as never; - await controller.uploadCV(req, res); - expect(mockUsersService.uploadCV).toHaveBeenCalledWith(req, res); + it("delegates to uploadCV with the user id and file", async () => { + const file = { buffer: Buffer.from("pdf") } as never; + await controller.uploadCV(mockUser, file); + expect(mockUsersService.uploadCV).toHaveBeenCalledWith( + mockUser.user_id, + file, + ); }); }); describe("uploadJobOffer", () => { - it("delegates to uploadJobOffer with req/res", async () => { - const req = {} as never; - const res = {} as never; - await controller.uploadJobOffer(req, res); - expect(mockUsersService.uploadJobOffer).toHaveBeenCalledWith(req, res); + it("delegates to uploadJobOffer with the user id and url", async () => { + await controller.uploadJobOffer(mockUser, { + url: "https://example.com/job/1", + }); + expect(mockUsersService.uploadJobOffer).toHaveBeenCalledWith( + mockUser.user_id, + "https://example.com/job/1", + ); }); }); }); diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 5700c103..f8324f63 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -1,9 +1,8 @@ import { Body, Controller, - Req, - Res, BadRequestException, + UploadedFile, UseInterceptors, UseGuards, Post, @@ -15,7 +14,6 @@ import { } from "@nestjs/common"; import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator"; import { Throttle } from "@nestjs/throttler"; -import type { Request, Response } from "express"; import { FileInterceptor } from "@nestjs/platform-express"; import { @@ -32,7 +30,8 @@ import { CurrentUser } from "@common/decorators/currentUser.decorator"; import { user } from "@entities/user.entity"; import { UpdateProfileDto } from "./dto/updateProfile.dto"; -import { UsersService } from "./users.service"; +import { UploadJobOfferDto } from "./dto/uploadJobOffer.dto"; +import { UploadedPdf, UsersService } from "./users.service"; @ApiTags("Users") @Controller("users") @@ -90,8 +89,11 @@ export class UsersController { ) @UseGuards(AccessTokenGuard) @Post("uploadCV") - async uploadCV(@Req() req: Request, @Res() res: Response) { - return this.usersService.uploadCV(req, res); + async uploadCV( + @CurrentUser() user: user, + @UploadedFile() file?: UploadedPdf, + ): Promise<{ message: string }> { + return this.usersService.uploadCV(user.user_id, file); } @ApiOkResponse({ description: "The job offer was successfully parsed" }) @@ -99,10 +101,14 @@ export class UsersController { description: "Missing/invalid URL, blocked target, or unscrapable page", }) @ApiUnauthorizedResponse() + @UsePipes(new PostValidationPipe()) @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(AccessTokenGuard) @Post("uploadJobOffer") - async uploadJobOffer(@Req() req: Request, @Res() res: Response) { - return this.usersService.uploadJobOffer(req, res); + async uploadJobOffer( + @CurrentUser() user: user, + @Body() dto: UploadJobOfferDto, + ): Promise<{ message: string }> { + return this.usersService.uploadJobOffer(user.user_id, dto.url); } } diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 05df2b49..2d5d77bf 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -1,6 +1,9 @@ import { Test, TestingModule } from "@nestjs/testing"; import { getRepositoryToken } from "@nestjs/typeorm"; -import { InternalServerErrorException } from "@nestjs/common"; +import { + BadRequestException, + InternalServerErrorException, +} from "@nestjs/common"; import { ProfileVisibility } from "@common/enums/ProfileVisibility"; import { UserStatus } from "@common/enums/UserStatus"; @@ -49,28 +52,8 @@ const mockScrapeLinkedin = scrapeLinkedin as jest.Mock; const mockScrapeAxios = scrapeAxios as jest.Mock; const mockScrapePuppeteer = scrapePuppeteer as jest.Mock; -const mockRes = () => { - const res: any = {}; - res.status = jest.fn().mockReturnValue(res); - res.json = jest.fn().mockReturnValue(res); - return res; -}; - -const mockReq = (overrides: Record = {}): any => ({ - userId: "uid-1", - body: {}, - ...overrides, -}); - -const mockReqWithFile = (overrides: Record = {}): any => ({ - userId: "uid-1", - file: { - buffer: Buffer.from("fake pdf content"), - mimetype: "application/pdf", - originalname: "cv.pdf", - }, - ...overrides, -}); +const USER_ID = "uid-1"; +const pdfFile = () => ({ buffer: Buffer.from("fake pdf content") }); const validCvGroqResponse = JSON.stringify({ desired_job: "Software Engineer", @@ -292,150 +275,103 @@ describe("UsersService", () => { // ─── uploadCV ─────────────────────────────────────────────────────────────── describe("uploadCV", () => { - it("retourne 400 si aucun fichier n'est fourni", async () => { - const req = mockReqWithFile({ file: undefined }); - const res = mockRes(); - - await service.uploadCV(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ message: "Upload a PDF file." }); + it("throws BadRequest when no file is provided", async () => { + await expect(service.uploadCV(USER_ID, undefined)).rejects.toThrow( + BadRequestException, + ); }); - it("retourne 400 si le texte extrait du PDF est vide", async () => { + it("throws BadRequest when the PDF text is empty", async () => { mockPdfParse.mockResolvedValue({ text: "" }); - const req = mockReqWithFile(); - const res = mockRes(); - - await service.uploadCV(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - message: "The PDF file is empty or could not be parsed.", - }); + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + "The PDF file is empty or could not be parsed.", + ); }); - it("retourne 500 si Groq retourne une réponse vide", async () => { + it("throws InternalServerError on an empty AI response", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: null } }], }); - const req = mockReqWithFile(); - const res = mockRes(); - - await service.uploadCV(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: "Empty response from AI.", - }); + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + InternalServerErrorException, + ); }); - it("retourne 500 si Groq retourne un JSON invalide", async () => { + it("throws InternalServerError on invalid JSON", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: "not valid json }{" } }], }); - const req = mockReqWithFile(); - const res = mockRes(); - - await service.uploadCV(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: "Failed to parse extracted CV data.", - }); + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + InternalServerErrorException, + ); }); - it("crée un nouveau CV et retourne 200 si aucun CV n'existe", async () => { + it("creates a new CV and returns the created message", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: validCvGroqResponse } }], }); cvRepo.findOne.mockResolvedValue(null); - const req = mockReqWithFile(); - const res = mockRes(); - - await service.uploadCV(req, res); + const result = await service.uploadCV(USER_ID, pdfFile()); expect(cvRepo.findOne).toHaveBeenCalledWith({ - where: { user_id: "uid-1" }, + where: { user_id: USER_ID }, }); expect(cvRepo.create).toHaveBeenCalledWith( expect.objectContaining({ desired_job: "Software Engineer" }), ); expect(cvRepo.save).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - message: "CV uploaded successfully", - }); + expect(result).toEqual({ message: "CV uploaded successfully" }); }); - it("met à jour le CV existant et retourne 200", async () => { + it("updates an existing CV and returns the updated message", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: validCvGroqResponse } }], }); - cvRepo.findOne.mockResolvedValue({ - user_id: "uid-1", - desired_job: "old job", - }); - - const req = mockReqWithFile(); - const res = mockRes(); + cvRepo.findOne.mockResolvedValue({ user_id: USER_ID }); - await service.uploadCV(req, res); + const result = await service.uploadCV(USER_ID, pdfFile()); expect(cvRepo.update).toHaveBeenCalledWith( - { user_id: "uid-1" }, + { user_id: USER_ID }, expect.objectContaining({ desired_job: "Software Engineer" }), ); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - message: "CV updated successfully", - }); + expect(result).toEqual({ message: "CV updated successfully" }); }); - it("nettoie les backticks markdown avant de parser le JSON", async () => { + it("strips markdown fences before parsing JSON", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); mockGroqCreate.mockResolvedValue({ choices: [ - { - message: { - content: "```json\n" + validCvGroqResponse + "\n```", - }, - }, + { message: { content: "```json\n" + validCvGroqResponse + "\n```" } }, ], }); cvRepo.findOne.mockResolvedValue(null); - const req = mockReqWithFile(); - const res = mockRes(); + const result = await service.uploadCV(USER_ID, pdfFile()); - await service.uploadCV(req, res); - - expect(res.status).toHaveBeenCalledWith(200); + expect(result).toEqual({ message: "CV uploaded successfully" }); }); - it("applique les valeurs par défaut quand des champs sont absents", async () => { + it("applies defaults when fields are absent", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); - // Empty object → every field falls back to its `?? null` / `?? []` default. mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: "{}" } }], }); cvRepo.findOne.mockResolvedValue(null); - const req = mockReqWithFile(); - const res = mockRes(); - - await service.uploadCV(req, res); + await service.uploadCV(USER_ID, pdfFile()); expect(cvRepo.create).toHaveBeenCalledWith( expect.objectContaining({ + user_id: USER_ID, desired_job: null, resume: null, experiences: [], @@ -444,224 +380,157 @@ describe("UsersService", () => { languages: [], }), ); - expect(res.status).toHaveBeenCalledWith(200); }); - it("tronque le texte du CV avant l'envoi au LLM", async () => { - const longText = "a".repeat(20000); - mockPdfParse.mockResolvedValue({ text: longText }); + it("truncates the CV text before sending it to the LLM", async () => { + mockPdfParse.mockResolvedValue({ text: "a".repeat(20000) }); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: validCvGroqResponse } }], }); cvRepo.findOne.mockResolvedValue(null); - await service.uploadCV(mockReqWithFile(), mockRes()); + await service.uploadCV(USER_ID, pdfFile()); const promptSent = mockGroqCreate.mock.calls[0][0].messages[0].content; - // Raw CV text is capped at 8000 chars: prompt = boilerplate + <=8000, - // far below the un-truncated 20000-char input. - expect(promptSent).not.toContain("a".repeat(8001)); expect(promptSent).toContain("a".repeat(8000)); + expect(promptSent).not.toContain("a".repeat(8001)); expect(promptSent.length).toBeLessThan(12000); }); - it("retourne 500 en cas d'erreur inattendue", async () => { + it("lets unexpected errors bubble up", async () => { mockPdfParse.mockRejectedValue(new Error("unexpected crash")); - const req = mockReqWithFile(); - const res = mockRes(); - - await service.uploadCV(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: "Error processing the CV file.", - }); + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + "unexpected crash", + ); }); }); describe("uploadJobOffer", () => { - it("retourne 400 si aucune URL n'est fournie", async () => { - const req = mockReq({ body: {} }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - message: "Please provide a job offer URL.", - }); - }); - - it("retourne 400 si l'URL est invalide", async () => { - const req = mockReq({ body: { url: "not-a-url" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ message: "Invalid URL format." }); + it("throws BadRequest on an invalid URL", async () => { + await expect( + service.uploadJobOffer(USER_ID, "not-a-url"), + ).rejects.toThrow("Invalid URL format."); }); - it("retourne 400 et ne scrape pas une cible SSRF (loopback/privée)", async () => { - const req = mockReq({ body: { url: "http://169.254.169.254/latest/" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); + it("throws BadRequest and does not scrape an SSRF target", async () => { + await expect( + service.uploadJobOffer(USER_ID, "http://169.254.169.254/latest/"), + ).rejects.toThrow("This URL target is not allowed."); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - message: "This URL target is not allowed.", - }); - // Guard runs before any scraping is attempted. expect(mockScrapeLinkedin).not.toHaveBeenCalled(); expect(mockScrapeAxios).not.toHaveBeenCalled(); expect(mockScrapePuppeteer).not.toHaveBeenCalled(); }); - it("retourne 400 si aucun scraper ne retourne du contenu", async () => { + it("throws BadRequest when no scraper returns content", async () => { mockScrapeLinkedin.mockResolvedValue(""); mockScrapeAxios.mockResolvedValue(""); mockScrapePuppeteer.mockResolvedValue(""); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); - - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - message: - "Could not extract content from this URL. The website may be too protected.", - }); + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow( + "Could not extract content from this URL. The website may be too protected.", + ); }); - it("utilise scrapeLinkedin pour les URLs LinkedIn", async () => { + it("uses scrapeLinkedin for LinkedIn URLs", async () => { mockScrapeLinkedin.mockResolvedValue("linkedin job content"); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: validJobOfferGroqResponse } }], }); jobOfferRepo.findOne.mockResolvedValue(null); - const req = mockReq({ - body: { url: "https://linkedin.com/jobs/view/123" }, - }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); + await service.uploadJobOffer( + USER_ID, + "https://linkedin.com/jobs/view/123", + ); expect(mockScrapeLinkedin).toHaveBeenCalledWith( "https://linkedin.com/jobs/view/123", ); - expect(res.status).toHaveBeenCalledWith(200); }); - it("retourne 500 si Groq retourne une réponse vide", async () => { + it("throws InternalServerError on an empty AI response", async () => { mockScrapeAxios.mockResolvedValue("some job content"); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: null } }], }); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: "Empty response from AI.", - }); + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow(InternalServerErrorException); }); - it("retourne 500 si Groq retourne un JSON invalide", async () => { + it("throws InternalServerError on invalid JSON", async () => { mockScrapeAxios.mockResolvedValue("some job content"); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: "}{invalid json" } }], }); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: "Failed to parse extracted job offer data.", - }); + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow(InternalServerErrorException); }); - it("crée une nouvelle offre et retourne 200 si aucune n'existe", async () => { + it("creates a new job offer and returns the parsed message", async () => { mockScrapeAxios.mockResolvedValue("some job content"); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: validJobOfferGroqResponse } }], }); jobOfferRepo.findOne.mockResolvedValue(null); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); + const result = await service.uploadJobOffer( + USER_ID, + "https://example.com/job/123", + ); expect(jobOfferRepo.create).toHaveBeenCalled(); expect(jobOfferRepo.save).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - message: "Job offer parsed successfully", - }); + expect(result).toEqual({ message: "Job offer parsed successfully" }); }); - it("applique les valeurs par défaut quand des champs sont absents", async () => { + it("applies defaults when fields are absent", async () => { mockScrapeAxios.mockResolvedValue("some job content"); - // Empty object → every field falls back to its `?? null` / `?? []` default. mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: "{}" } }], }); jobOfferRepo.findOne.mockResolvedValue(null); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); + await service.uploadJobOffer(USER_ID, "https://example.com/job/123"); expect(jobOfferRepo.create).toHaveBeenCalledWith( expect.objectContaining({ + user_id: USER_ID, job_title: null, - company_name: null, required_skills: [], missions: [], - soft_skills: [], offer_url: "https://example.com/job/123", }), ); - expect(res.status).toHaveBeenCalledWith(200); }); - it("met à jour l'offre existante et retourne 200", async () => { + it("updates an existing job offer and returns the updated message", async () => { mockScrapeAxios.mockResolvedValue("some job content"); mockGroqCreate.mockResolvedValue({ choices: [{ message: { content: validJobOfferGroqResponse } }], }); - jobOfferRepo.findOne.mockResolvedValue({ - user_id: "uid-1", - job_title: "old job", - }); - - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); + jobOfferRepo.findOne.mockResolvedValue({ user_id: USER_ID }); - await service.uploadJobOffer(req, res); + const result = await service.uploadJobOffer( + USER_ID, + "https://example.com/job/123", + ); expect(jobOfferRepo.update).toHaveBeenCalledWith( - { user_id: "uid-1" }, + { user_id: USER_ID }, expect.objectContaining({ job_title: "Backend Developer" }), ); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - message: "Job offer updated successfully", - }); + expect(result).toEqual({ message: "Job offer updated successfully" }); }); - it("nettoie les backticks markdown avant de parser le JSON", async () => { + it("strips markdown fences before parsing JSON", async () => { mockScrapeAxios.mockResolvedValue("some job content"); mockGroqCreate.mockResolvedValue({ choices: [ @@ -674,26 +543,20 @@ describe("UsersService", () => { }); jobOfferRepo.findOne.mockResolvedValue(null); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); + const result = await service.uploadJobOffer( + USER_ID, + "https://example.com/job/123", + ); - expect(res.status).toHaveBeenCalledWith(200); + expect(result).toEqual({ message: "Job offer parsed successfully" }); }); - it("retourne 500 en cas d'erreur inattendue", async () => { + it("lets unexpected errors bubble up", async () => { mockScrapeAxios.mockRejectedValue(new Error("network crash")); - const req = mockReq({ body: { url: "https://example.com/job/123" } }); - const res = mockRes(); - - await service.uploadJobOffer(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - message: "Error processing the job offer.", - }); + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow("network crash"); }); }); }); diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 2230a553..899f7cec 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -1,11 +1,13 @@ import { + BadRequestException, Injectable, InternalServerErrorException, Logger, NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { ObjectLiteral, Repository } from "typeorm"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { ProfileVisibility } from "@common/enums/ProfileVisibility"; import { @@ -23,7 +25,6 @@ import { isSafeFetchUrl } from "../../common/utils/urlGuard"; import { UpdateProfileDto } from "./dto/updateProfile.dto"; import { GetProfileDto } from "./dto/getProfile.dto"; -import { type Request, type Response } from "express"; import { user_cv } from "@entities/userCV.entity"; import { user_job_offer } from "@entities/userJobOffer.entity"; import Groq from "groq-sdk"; @@ -32,6 +33,43 @@ import pdfParse from "pdf-parse-debugging-disabled"; // Cap raw text sent to the LLM to bound token cost on large documents. const MAX_LLM_INPUT_CHARS = 8000; +/** Minimal shape of the multer file we consume (avoids depending on the global + * Express.Multer namespace, which is not in this project's tsconfig `types`). */ +export interface UploadedPdf { + buffer: Buffer; +} + +/** Shape returned by the CV extraction prompt. All fields optional — the model + * may omit any of them; defaults are applied at persistence time. */ +interface CvExtraction { + desired_job?: string | null; + resume?: string | null; + experiences?: unknown[]; + education?: unknown[]; + technical_skills?: string[]; + languages?: unknown[]; +} + +/** Shape returned by the job-offer extraction prompt. */ +interface JobOfferExtraction { + job_title?: string | null; + company_name?: string | null; + company_description?: string | null; + sector?: string | null; + contract_type?: string | null; + location?: string | null; + required_skills?: string[]; + preferred_skills?: string[]; + required_experience?: string | null; + required_education?: string | null; + missions?: string[]; + soft_skills?: string[]; + languages_required?: string[]; + salary_range?: string | null; + company_values?: string[]; + team_description?: string | null; +} + @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); @@ -189,25 +227,77 @@ export class UsersService { }; } - async uploadCV(req: Request & { file?: any }, res: Response) { + /** + * Sends a prompt to Groq, strips any markdown fences from the reply, and parses + * it as JSON. Throws InternalServerErrorException on empty or unparsable output. + */ + private async extractWithGroq(prompt: string): Promise { + const completion = await this.groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [{ role: "user", content: prompt }], + temperature: 0, + }); + + const responseText = completion.choices[0]?.message?.content; + + if (!responseText) { + this.logger.error("Empty response from Groq"); + throw new InternalServerErrorException("Empty response from AI."); + } + try { - if (!req.file) { - return res.status(400).json({ message: "Upload a PDF file." }); - } + const cleaned = responseText.replace(/```json|```/g, "").trim(); + return JSON.parse(cleaned) as T; + } catch (parseError) { + this.logger.error(`JSON parse error: ${parseError}`); + throw new InternalServerErrorException("Failed to parse extracted data."); + } + } - const userId = (req as any).userId; - const pdfData = await pdfParse(req.file.buffer); - const rawText = pdfData.text; + /** + * Find-or-update a single row keyed by user_id: updates when one exists, + * otherwise creates and saves. Used for the one-per-user CV / job-offer rows. + * Returns true when an existing row was updated, false when one was created. + */ + private async upsertByUser( + repo: Repository, + userId: string, + data: QueryDeepPartialEntity, + ): Promise { + const existing = await repo.findOne({ + where: { user_id: userId } as never, + }); - if (!rawText || rawText.length === 0) { - return res - .status(400) - .json({ message: "The PDF file is empty or could not be parsed." }); - } + if (existing) { + await repo.update({ user_id: userId } as never, data); + return true; + } + + const row = repo.create({ user_id: userId, ...data } as never); + await repo.save(row); + return false; + } + + async uploadCV( + userId: string, + file?: UploadedPdf, + ): Promise<{ message: string }> { + if (!file) { + throw new BadRequestException("Upload a PDF file."); + } + + const pdfData = await pdfParse(file.buffer); + const rawText = pdfData.text; + + if (!rawText || rawText.length === 0) { + throw new BadRequestException( + "The PDF file is empty or could not be parsed.", + ); + } - const cvText = rawText.substring(0, MAX_LLM_INPUT_CHARS); + const cvText = rawText.substring(0, MAX_LLM_INPUT_CHARS); - const prompt = `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: + const prompt = `You are a specialized CV analysis assistant. Analyze the following text extracted from a CV and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: { "desired_job": "string or null", "resume": "string or null - candidate profile/summary", @@ -245,111 +335,57 @@ export class UsersService { CV text: ${cvText}`; - const completion = await this.groq.chat.completions.create({ - model: "llama-3.3-70b-versatile", - messages: [{ role: "user", content: prompt }], - temperature: 0, - }); - - const responseText = completion.choices[0]?.message?.content; - - if (!responseText) { - this.logger.error("Empty response from Groq"); - return res.status(500).json({ message: "Empty response from AI." }); - } - - let extractedData; - try { - const cleaned = responseText.replace(/```json|```/g, "").trim(); - extractedData = JSON.parse(cleaned); - } catch (parseError) { - this.logger.error(`JSON parse error: ${parseError}`); - return res - .status(500) - .json({ message: "Failed to parse extracted CV data." }); - } + const data = await this.extractWithGroq(prompt); - const existingCV = await this.user_cvRepo.findOne({ - where: { user_id: userId }, - }); + const existed = await this.upsertByUser(this.user_cvRepo, userId, { + desired_job: data.desired_job ?? null, + resume: data.resume ?? null, + experiences: data.experiences ?? [], + education: data.education ?? [], + technical_skills: data.technical_skills ?? [], + languages: data.languages ?? [], + } as QueryDeepPartialEntity); - if (existingCV) { - await this.user_cvRepo.update( - { user_id: userId }, - { - desired_job: extractedData.desired_job ?? null, - resume: extractedData.resume ?? null, - experiences: extractedData.experiences ?? [], - education: extractedData.education ?? [], - technical_skills: extractedData.technical_skills ?? [], - languages: extractedData.languages ?? [], - }, - ); - this.logger.log(`CV updated for user ID: ${userId}`); - return res.status(200).json({ message: "CV updated successfully" }); - } else { - const newCV = this.user_cvRepo.create({ - user_id: userId, - desired_job: extractedData.desired_job ?? null, - resume: extractedData.resume ?? null, - experiences: extractedData.experiences ?? [], - education: extractedData.education ?? [], - technical_skills: extractedData.technical_skills ?? [], - languages: extractedData.languages ?? [], - }); - await this.user_cvRepo.save(newCV); - this.logger.log(`CV created for user ID: ${userId}`); - return res.status(200).json({ message: "CV uploaded successfully" }); - } - } catch (error) { - this.logger.error(`uploadCV failed: ${error}`); - res.status(500).json({ message: "Error processing the CV file." }); - } + this.logger.log( + `CV ${existed ? "updated" : "created"} for user ID: ${userId}`, + ); + return { + message: existed ? "CV updated successfully" : "CV uploaded successfully", + }; } - async uploadJobOffer(req: Request, res: Response) { + async uploadJobOffer( + userId: string, + url: string, + ): Promise<{ message: string }> { try { - const userId = (req as any).userId; - const { url } = req.body; - - if (!url) { - return res - .status(400) - .json({ message: "Please provide a job offer URL." }); - } - - try { - new URL(url); - } catch { - return res.status(400).json({ message: "Invalid URL format." }); - } - - // SSRF guard: reject non-http(s) schemes and private/loopback/link-local - // targets (cloud metadata, localhost, internal services). - if (!isSafeFetchUrl(url)) { - return res - .status(400) - .json({ message: "This URL target is not allowed." }); - } + new URL(url); + } catch { + throw new BadRequestException("Invalid URL format."); + } - let pageText: string = ""; + // SSRF guard: reject non-http(s) schemes and private/loopback/link-local + // targets (cloud metadata, localhost, internal services). + if (!isSafeFetchUrl(url)) { + throw new BadRequestException("This URL target is not allowed."); + } - const isLinkedIn = url.includes("linkedin.com/jobs"); + let pageText = ""; + const isLinkedIn = url.includes("linkedin.com/jobs"); - if (isLinkedIn) pageText = await scrapeLinkedin(url); - if (!pageText) pageText = await scrapeAxios(url); - if (!pageText) pageText = await scrapePuppeteer(url); + if (isLinkedIn) pageText = await scrapeLinkedin(url); + if (!pageText) pageText = await scrapeAxios(url); + if (!pageText) pageText = await scrapePuppeteer(url); - if (!pageText) { - return res.status(400).json({ - message: - "Could not extract content from this URL. The website may be too protected.", - }); - } + if (!pageText) { + throw new BadRequestException( + "Could not extract content from this URL. The website may be too protected.", + ); + } - pageText = pageText.substring(0, MAX_LLM_INPUT_CHARS); + pageText = pageText.substring(0, MAX_LLM_INPUT_CHARS); - const prompt = `You are a specialized job offer analysis assistant. Analyze the following text extracted from a job offer page and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: + const prompt = `You are a specialized job offer analysis assistant. Analyze the following text extracted from a job offer page and return ONLY a valid JSON object (no markdown, no backticks, no comments) with exactly this structure: { "job_title": "string or null", "company_name": "string or null", @@ -379,91 +415,35 @@ export class UsersService { Job offer text: ${pageText}`; - const completion = await this.groq.chat.completions.create({ - model: "llama-3.3-70b-versatile", - messages: [{ role: "user", content: prompt }], - temperature: 0, - }); - - const responseText = completion.choices[0]?.message?.content; - - if (!responseText) { - this.logger.error("Empty response from Groq"); - return res.status(500).json({ message: "Empty response from AI." }); - } - - let extractedData; - try { - const cleaned = responseText.replace(/```json|```/g, "").trim(); - extractedData = JSON.parse(cleaned); - } catch (parseError) { - this.logger.error(`JSON parse error: ${parseError}`); - return res - .status(500) - .json({ message: "Failed to parse extracted job offer data." }); - } - - const existingJobOffer = await this.user_job_offerRepo.findOne({ - where: { user_id: userId }, - }); - - if (existingJobOffer) { - await this.user_job_offerRepo.update( - { user_id: userId }, - { - job_title: extractedData.job_title ?? null, - company_name: extractedData.company_name ?? null, - company_description: extractedData.company_description ?? null, - sector: extractedData.sector ?? null, - contract_type: extractedData.contract_type ?? null, - location: extractedData.location ?? null, - required_skills: extractedData.required_skills ?? [], - preferred_skills: extractedData.preferred_skills ?? [], - required_experience: extractedData.required_experience ?? null, - required_education: extractedData.required_education ?? null, - missions: extractedData.missions ?? [], - soft_skills: extractedData.soft_skills ?? [], - languages_required: extractedData.languages_required ?? [], - salary_range: extractedData.salary_range ?? null, - company_values: extractedData.company_values ?? [], - team_description: extractedData.team_description ?? null, - offer_url: url, - }, - ); - this.logger.log(`Job offer updated for user ID: ${userId}`); - return res.status(200).json({ - message: "Job offer updated successfully", - }); - } else { - const newJobOffer = this.user_job_offerRepo.create({ - user_id: userId, - job_title: extractedData.job_title ?? null, - company_name: extractedData.company_name ?? null, - company_description: extractedData.company_description ?? null, - sector: extractedData.sector ?? null, - contract_type: extractedData.contract_type ?? null, - location: extractedData.location ?? null, - required_skills: extractedData.required_skills ?? [], - preferred_skills: extractedData.preferred_skills ?? [], - required_experience: extractedData.required_experience ?? null, - required_education: extractedData.required_education ?? null, - missions: extractedData.missions ?? [], - soft_skills: extractedData.soft_skills ?? [], - languages_required: extractedData.languages_required ?? [], - salary_range: extractedData.salary_range ?? null, - company_values: extractedData.company_values ?? [], - team_description: extractedData.team_description ?? null, - offer_url: url, - }); - await this.user_job_offerRepo.save(newJobOffer); - this.logger.log(`Job offer created for user ID: ${userId}`); - return res.status(200).json({ - message: "Job offer parsed successfully", - }); - } - } catch (error) { - this.logger.error(`uploadJobOffer failed: ${error}`); - res.status(500).json({ message: "Error processing the job offer." }); - } + const data = await this.extractWithGroq(prompt); + + const existed = await this.upsertByUser(this.user_job_offerRepo, userId, { + job_title: data.job_title ?? null, + company_name: data.company_name ?? null, + company_description: data.company_description ?? null, + sector: data.sector ?? null, + contract_type: data.contract_type ?? null, + location: data.location ?? null, + required_skills: data.required_skills ?? [], + preferred_skills: data.preferred_skills ?? [], + required_experience: data.required_experience ?? null, + required_education: data.required_education ?? null, + missions: data.missions ?? [], + soft_skills: data.soft_skills ?? [], + languages_required: data.languages_required ?? [], + salary_range: data.salary_range ?? null, + company_values: data.company_values ?? [], + team_description: data.team_description ?? null, + offer_url: url, + } as QueryDeepPartialEntity); + + this.logger.log( + `Job offer ${existed ? "updated" : "created"} for user ID: ${userId}`, + ); + return { + message: existed + ? "Job offer updated successfully" + : "Job offer parsed successfully", + }; } } From 122c14d508288117ccc94a33d2af35bd87b6a6ae Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Tue, 23 Jun 2026 21:48:17 +0800 Subject: [PATCH 16/18] fix: close SSRF redirect bypass and drop dead deps on cv/job-offer upload --- package-lock.json | 284 ------------------ server/package.json | 6 - .../common/utils/JobOfferExtraction.spec.ts | 30 ++ server/src/common/utils/JobOfferExtraction.ts | 60 ++-- server/src/common/utils/urlGuard.spec.ts | 84 +++++- server/src/common/utils/urlGuard.ts | 47 +++ server/src/modules/users/users.controller.ts | 1 + 7 files changed, 200 insertions(+), 312 deletions(-) diff --git a/package-lock.json b/package-lock.json index c1b72738..55453410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,26 +151,6 @@ "tslib": "^2.1.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", - "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1484,15 +1464,6 @@ "license": "MIT", "optional": true }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -2440,190 +2411,6 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", - "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", - "license": "MIT", - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.80", - "@napi-rs/canvas-darwin-arm64": "0.1.80", - "@napi-rs/canvas-darwin-x64": "0.1.80", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", - "@napi-rs/canvas-linux-arm64-musl": "0.1.80", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-musl": "0.1.80", - "@napi-rs/canvas-win32-x64-msvc": "0.1.80" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", - "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", - "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", - "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", - "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", - "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", - "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", - "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", - "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", - "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", - "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nestjs/axios": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", @@ -5473,16 +5260,6 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, - "node_modules/@types/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/node": { "version": "20.19.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", @@ -5502,16 +5279,6 @@ "@types/node": "*" } }, - "node_modules/@types/pdf-parse": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", - "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -10856,19 +10623,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -12385,26 +12139,6 @@ "node": ">= 14.16" } }, - "node_modules/pdf-parse": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", - "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", - "license": "Apache-2.0", - "dependencies": { - "@napi-rs/canvas": "0.1.80", - "pdfjs-dist": "5.4.296" - }, - "bin": { - "pdf-parse": "bin/cli.mjs" - }, - "engines": { - "node": ">=20.16.0 <21 || >=22.3.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/mehmet-kozan" - } - }, "node_modules/pdf-parse-debugging-disabled": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pdf-parse-debugging-disabled/-/pdf-parse-debugging-disabled-1.1.1.tgz", @@ -12427,18 +12161,6 @@ "ms": "^2.1.1" } }, - "node_modules/pdfjs-dist": { - "version": "5.4.296", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", - "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.80" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -16429,8 +16151,6 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", - "@google/generative-ai": "^0.24.1", "@nestjs/axios": "^4.0.0", "@nestjs/cli": "^11.0.7", "@nestjs/common": "^11.1.0", @@ -16455,9 +16175,7 @@ "dotenv": "^16.5.0", "groq-sdk": "^1.2.0", "ioredis": "^5.10.1", - "multer": "^2.1.1", "nodemailer": "^8.0.2", - "pdf-parse": "^2.4.5", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "puppeteer": "^24.43.1", @@ -16470,10 +16188,8 @@ "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.9", "@types/jest": "^29.5.14", - "@types/multer": "^2.1.0", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.17", - "@types/pdf-parse": "^1.1.5", "@types/supertest": "^6.0.0", "jest": "^29.5.0", "oxlint": "^1.11.2", diff --git a/server/package.json b/server/package.json index 63f467e4..5ca54f93 100644 --- a/server/package.json +++ b/server/package.json @@ -22,8 +22,6 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", - "@google/generative-ai": "^0.24.1", "@nestjs/axios": "^4.0.0", "@nestjs/cli": "^11.0.7", "@nestjs/common": "^11.1.0", @@ -48,9 +46,7 @@ "dotenv": "^16.5.0", "groq-sdk": "^1.2.0", "ioredis": "^5.10.1", - "multer": "^2.1.1", "nodemailer": "^8.0.2", - "pdf-parse": "^2.4.5", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "puppeteer": "^24.43.1", @@ -63,10 +59,8 @@ "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.9", "@types/jest": "^29.5.14", - "@types/multer": "^2.1.0", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.17", - "@types/pdf-parse": "^1.1.5", "@types/supertest": "^6.0.0", "jest": "^29.5.0", "oxlint": "^1.11.2", diff --git a/server/src/common/utils/JobOfferExtraction.spec.ts b/server/src/common/utils/JobOfferExtraction.spec.ts index f117e61c..0f25e7c7 100644 --- a/server/src/common/utils/JobOfferExtraction.spec.ts +++ b/server/src/common/utils/JobOfferExtraction.spec.ts @@ -116,6 +116,8 @@ describe("scrapeAxios", () => { describe("scrapePuppeteer", () => { const makeBrowser = (html: string) => { const page = { + setRequestInterception: jest.fn(), + on: jest.fn(), evaluateOnNewDocument: jest.fn(), setUserAgent: jest.fn(), setViewport: jest.fn(), @@ -141,6 +143,34 @@ describe("scrapePuppeteer", () => { expect(text).toContain("Job description"); }); + it("blocks page requests to unsafe (private/metadata) targets", async () => { + const browser = makeBrowser(GENERIC_HTML); + mockedPuppeteer.launch.mockResolvedValueOnce(browser as never); + + await runScraper(() => scrapePuppeteer("https://example.com/job/1")); + + const page = await browser.newPage.mock.results[0].value; + const onRequest = page.on.mock.calls.find( + (c: unknown[]) => c[0] === "request", + )?.[1] as (req: unknown) => void; + expect(onRequest).toBeDefined(); + + const safeReq = { + url: () => "https://example.com/a", + continue: jest.fn(), + abort: jest.fn(), + }; + const evilReq = { + url: () => "http://169.254.169.254/", + continue: jest.fn(), + abort: jest.fn(), + }; + onRequest(safeReq); + onRequest(evilReq); + expect(safeReq.continue).toHaveBeenCalled(); + expect(evilReq.abort).toHaveBeenCalled(); + }); + it("returns empty when launch fails", async () => { mockedPuppeteer.launch.mockRejectedValueOnce(new Error("no chrome")); diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts index 8c2df5c2..ee79144a 100644 --- a/server/src/common/utils/JobOfferExtraction.ts +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -1,8 +1,9 @@ -import axios from "axios"; import * as cheerio from "cheerio"; import puppeteer from "puppeteer"; import { Logger } from "@nestjs/common"; +import { isSafeFetchUrl, safeAxiosGet } from "./urlGuard"; + const logger = new Logger("JobOfferExtraction"); export const scrapeLinkedin = async (url: string): Promise => { @@ -33,7 +34,7 @@ export const scrapeLinkedin = async (url: string): Promise => { const jobId = jobIdMatch[1]; const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; - const response = await axios.get(guestApiUrl, { + const response = await safeAxiosGet(guestApiUrl, { headers: { "User-Agent": userAgents[attempt % userAgents.length], Accept: @@ -66,7 +67,7 @@ export const scrapeLinkedin = async (url: string): Promise => { .trim(); const criteria: Record = {}; - temp("li.description__job-criteria-item").each((_: number, el: any) => { + temp("li.description__job-criteria-item").each((_, el) => { const label = temp(el).find("h3").text().trim(); const value = temp(el).find("span").text().trim(); if (label && value) criteria[label] = value; @@ -111,24 +112,27 @@ export const scrapeAxios = async (url: string): Promise => { let pageText = ""; try { - const response = await axios.get(url, { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - Accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", - "Accept-Encoding": "gzip, deflate, br", - Connection: "keep-alive", - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Cache-Control": "max-age=0", + const response = await safeAxiosGet( + url, + { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "gzip, deflate, br", + Connection: "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Cache-Control": "max-age=0", + }, + timeout: 10000, }, - timeout: 10000, - maxRedirects: 5, - }); + 5, + ); const temp = cheerio.load(response.data); temp( @@ -167,9 +171,23 @@ export const scrapePuppeteer = async (url: string): Promise => { const page = await browser.newPage(); + // SSRF guard for the headless browser: re-validate every request the page + // makes (the initial nav AND any redirect/sub-resource) against the same + // allowlist used for axios. Without this, a 30x redirect or an embedded + // resource pointing at 169.254.169.254 / an internal host would be fetched. + await page.setRequestInterception(true); + page.on("request", (req) => { + if (isSafeFetchUrl(req.url())) { + void req.continue(); + } else { + logger.debug(`Puppeteer blocked unsafe request: ${req.url()}`); + void req.abort(); + } + }); + await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, "webdriver", { get: () => false }); - (window as any).chrome = { runtime: {} }; + (window as { chrome?: unknown }).chrome = { runtime: {} }; }); await page.setUserAgent( diff --git a/server/src/common/utils/urlGuard.spec.ts b/server/src/common/utils/urlGuard.spec.ts index 21bc6c4f..fd670af9 100644 --- a/server/src/common/utils/urlGuard.spec.ts +++ b/server/src/common/utils/urlGuard.spec.ts @@ -1,4 +1,9 @@ -import { isSafeFetchUrl } from "./urlGuard"; +import axios from "axios"; + +import { isSafeFetchUrl, safeAxiosGet } from "./urlGuard"; + +jest.mock("axios"); +const mockedGet = axios.get as jest.MockedFunction; describe("isSafeFetchUrl", () => { describe("allows", () => { @@ -68,3 +73,80 @@ describe("isSafeFetchUrl", () => { expect(isSafeFetchUrl("http://999.1.1.1/")).toBe(false); }); }); + +describe("safeAxiosGet", () => { + beforeEach(() => mockedGet.mockReset()); + + it("returns the response for a direct 200 with no redirect", async () => { + mockedGet.mockResolvedValueOnce({ status: 200, headers: {}, data: "ok" }); + + const res = await safeAxiosGet("https://example.com/job"); + + expect(res.data).toBe("ok"); + expect(mockedGet).toHaveBeenCalledTimes(1); + // redirects must be disabled so we follow them ourselves + expect(mockedGet.mock.calls[0][1]).toMatchObject({ maxRedirects: 0 }); + }); + + it("follows a redirect to another public host", async () => { + mockedGet + .mockResolvedValueOnce({ + status: 302, + headers: { location: "https://careers.acme.io/real" }, + data: "", + }) + .mockResolvedValueOnce({ status: 200, headers: {}, data: "final" }); + + const res = await safeAxiosGet("https://example.com/job"); + + expect(res.data).toBe("final"); + expect(mockedGet).toHaveBeenCalledTimes(2); + }); + + it("blocks a redirect to a private/metadata target (SSRF)", async () => { + mockedGet.mockResolvedValueOnce({ + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data/" }, + data: "", + }); + + await expect(safeAxiosGet("https://example.com/job")).rejects.toThrow( + /Blocked redirect target/, + ); + // the second (unsafe) hop must never be fetched + expect(mockedGet).toHaveBeenCalledTimes(1); + }); + + it("blocks a redirect to localhost", async () => { + mockedGet.mockResolvedValueOnce({ + status: 301, + headers: { location: "http://localhost:8080/internal" }, + data: "", + }); + + await expect(safeAxiosGet("https://example.com/job")).rejects.toThrow( + /Blocked redirect target/, + ); + }); + + it("rejects an unsafe initial URL without fetching", async () => { + await expect(safeAxiosGet("http://127.0.0.1/")).rejects.toThrow( + /Blocked redirect target/, + ); + expect(mockedGet).not.toHaveBeenCalled(); + }); + + it("throws when the redirect budget is exhausted", async () => { + mockedGet.mockResolvedValue({ + status: 302, + headers: { location: "https://example.com/loop" }, + data: "", + }); + + await expect( + safeAxiosGet("https://example.com/job", {}, 2), + ).rejects.toThrow(/Too many redirects/); + // initial + 2 redirect hops = 3 fetches + expect(mockedGet).toHaveBeenCalledTimes(3); + }); +}); diff --git a/server/src/common/utils/urlGuard.ts b/server/src/common/utils/urlGuard.ts index 827d9c8e..27d1d9ae 100644 --- a/server/src/common/utils/urlGuard.ts +++ b/server/src/common/utils/urlGuard.ts @@ -1,3 +1,5 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; + /** * SSRF guard for user-supplied URLs that the server will fetch (job-offer scraping). * @@ -105,3 +107,48 @@ export const isSafeFetchUrl = (raw: string): boolean => { return true; }; + +/** + * GET a user-supplied URL while re-validating EVERY redirect hop against + * {@link isSafeFetchUrl}. axios' built-in `maxRedirects` only checks the first + * URL, so a public host that 30x-redirects to 169.254.169.254 (cloud metadata) + * or an internal service would otherwise bypass the SSRF guard. We disable + * automatic redirects and follow them manually so each Location is checked. + * + * Throws if a hop targets a blocked host or the redirect budget is exhausted. + */ +export const safeAxiosGet = async ( + url: string, + config: AxiosRequestConfig = {}, + maxRedirects = 5, +): Promise => { + let currentUrl = url; + + for (let hop = 0; hop <= maxRedirects; hop++) { + if (!isSafeFetchUrl(currentUrl)) { + throw new Error(`Blocked redirect target: ${currentUrl}`); + } + + const response = await axios.get(currentUrl, { + ...config, + maxRedirects: 0, + // Treat 3xx as a resolved response instead of an axios error so we can + // inspect the Location ourselves; everything >=400 still throws. + validateStatus: (status) => status < 400, + }); + + const status = response.status ?? 200; + if (status < 300 || status >= 400) { + return response; + } + + const location = response.headers?.["location"] as string | undefined; + if (!location) { + return response; + } + // Location may be relative; resolve against the current URL. + currentUrl = new URL(location, currentUrl).toString(); + } + + throw new Error(`Too many redirects while fetching ${url}`); +}; diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index f8324f63..eedc63fe 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -73,6 +73,7 @@ export class UsersController { description: "Invalid request data in body (e.g., missing file or incorrect format)", }) + @ApiUnauthorizedResponse() @UsePipes(new PostValidationPipe()) @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseInterceptors( From 0efbdc7d9146ae956e8cda8fcf620ab7cb1be8db Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Tue, 23 Jun 2026 23:58:50 +0800 Subject: [PATCH 17/18] refactor: drop puppeteer from job-offer scraping --- package-lock.json | 667 +----------------- server/package.json | 1 - .../common/utils/JobOfferExtraction.spec.ts | 96 +-- server/src/common/utils/JobOfferExtraction.ts | 74 +- .../src/modules/users/users.service.spec.ts | 8 +- server/src/modules/users/users.service.ts | 4 +- 6 files changed, 12 insertions(+), 838 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55453410..412694f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2933,39 +2933,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@puppeteer/browsers": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", - "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -4853,12 +4820,6 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "license": "MIT" - }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz", @@ -5428,16 +5389,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -5820,6 +5771,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -6106,20 +6058,6 @@ "node": ">= 6" } }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/babel-dead-code-elimination": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.10.tgz", @@ -6292,97 +6230,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", - "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", - "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", - "license": "Apache-2.0", - "dependencies": { - "streamx": "^2.25.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-abort-controller": "*", - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - }, - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", - "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6415,15 +6262,6 @@ "node": ">=6.0.0" } }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -6595,15 +6433,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -6857,19 +6686,6 @@ "node": ">=6.0" } }, - "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -7393,15 +7209,6 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7530,32 +7337,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/degenerator/node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7617,12 +7398,6 @@ "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", "license": "MIT" }, - "node_modules/devtools-protocol": { - "version": "0.0.1608973", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", - "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", - "license": "BSD-3-Clause" - }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -7869,15 +7644,6 @@ "node": ">=0.10.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -7903,15 +7669,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -8039,46 +7796,6 @@ "node": ">=8" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -8145,15 +7862,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -8178,15 +7886,6 @@ "node": ">=0.8.x" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8297,53 +7996,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8382,15 +8040,6 @@ "bser": "2.1.1" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8779,20 +8428,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -9073,6 +8708,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -9086,6 +8722,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -9248,15 +8885,6 @@ "url": "https://opencollective.com/ioredis" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11482,12 +11110,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -11626,15 +11248,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, - "node_modules/netmask": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", - "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -11928,38 +11541,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -12161,12 +11742,6 @@ "ms": "^2.1.1" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -12475,15 +12050,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -12735,40 +12301,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-agent/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -12778,16 +12310,6 @@ "node": ">=10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12806,71 +12328,6 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "24.43.1", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.1.tgz", - "integrity": "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.13.2", - "chromium-bidi": "14.0.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1608973", - "puppeteer-core": "24.43.1", - "typed-query-selector": "^2.12.2" - }, - "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "24.43.1", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", - "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.13.2", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1608973", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.20.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer/node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -13701,44 +13158,6 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/solid-js": { "version": "1.9.10", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", @@ -13871,17 +13290,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -14211,41 +13619,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar-fs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", - "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - } - }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -14470,15 +13843,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -14940,12 +14304,6 @@ "node": ">= 0.4" } }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "license": "MIT" - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -15631,12 +14989,6 @@ "resolved": "web", "link": true }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -15977,6 +15329,7 @@ "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16063,16 +15416,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -16112,6 +15455,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -16178,7 +15522,6 @@ "nodemailer": "^8.0.2", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", - "puppeteer": "^24.43.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "typeorm": "^0.3.22", diff --git a/server/package.json b/server/package.json index 5ca54f93..5cdbf934 100644 --- a/server/package.json +++ b/server/package.json @@ -49,7 +49,6 @@ "nodemailer": "^8.0.2", "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", - "puppeteer": "^24.43.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "typeorm": "^0.3.22", diff --git a/server/src/common/utils/JobOfferExtraction.spec.ts b/server/src/common/utils/JobOfferExtraction.spec.ts index 0f25e7c7..99e2b284 100644 --- a/server/src/common/utils/JobOfferExtraction.spec.ts +++ b/server/src/common/utils/JobOfferExtraction.spec.ts @@ -1,21 +1,14 @@ import axios from "axios"; -import puppeteer from "puppeteer"; -import { - scrapeLinkedin, - scrapeAxios, - scrapePuppeteer, -} from "./JobOfferExtraction"; +import { scrapeLinkedin, scrapeAxios } from "./JobOfferExtraction"; jest.mock("axios"); -jest.mock("puppeteer"); const mockedAxios = axios as jest.Mocked; -const mockedPuppeteer = puppeteer as jest.Mocked; -// Fake timers so retry backoff (attempt * 2000ms) and puppeteer's sequential -// waits resolve instantly. Run a scraper through to completion by draining all -// queued timers while its async work settles. +// Fake timers so the linkedin retry backoff (attempt * 2000ms) resolves +// instantly. Run a scraper through to completion by draining all queued +// timers while its async work settles. jest.useFakeTimers(); const runScraper = async (start: () => Promise): Promise => { const promise = start(); @@ -112,84 +105,3 @@ describe("scrapeAxios", () => { expect(text).toBe(""); }); }); - -describe("scrapePuppeteer", () => { - const makeBrowser = (html: string) => { - const page = { - setRequestInterception: jest.fn(), - on: jest.fn(), - evaluateOnNewDocument: jest.fn(), - setUserAgent: jest.fn(), - setViewport: jest.fn(), - goto: jest.fn(), - evaluate: jest.fn(), - content: jest.fn().mockResolvedValue(html), - }; - return { - newPage: jest.fn().mockResolvedValue(page), - close: jest.fn(), - }; - }; - - it("extracts body text via a headless browser", async () => { - mockedPuppeteer.launch.mockResolvedValueOnce( - makeBrowser(GENERIC_HTML) as never, - ); - - const text = await runScraper(() => - scrapePuppeteer("https://example.com/job/1"), - ); - - expect(text).toContain("Job description"); - }); - - it("blocks page requests to unsafe (private/metadata) targets", async () => { - const browser = makeBrowser(GENERIC_HTML); - mockedPuppeteer.launch.mockResolvedValueOnce(browser as never); - - await runScraper(() => scrapePuppeteer("https://example.com/job/1")); - - const page = await browser.newPage.mock.results[0].value; - const onRequest = page.on.mock.calls.find( - (c: unknown[]) => c[0] === "request", - )?.[1] as (req: unknown) => void; - expect(onRequest).toBeDefined(); - - const safeReq = { - url: () => "https://example.com/a", - continue: jest.fn(), - abort: jest.fn(), - }; - const evilReq = { - url: () => "http://169.254.169.254/", - continue: jest.fn(), - abort: jest.fn(), - }; - onRequest(safeReq); - onRequest(evilReq); - expect(safeReq.continue).toHaveBeenCalled(); - expect(evilReq.abort).toHaveBeenCalled(); - }); - - it("returns empty when launch fails", async () => { - mockedPuppeteer.launch.mockRejectedValueOnce(new Error("no chrome")); - - const text = await runScraper(() => - scrapePuppeteer("https://example.com/job/1"), - ); - - expect(text).toBe(""); - }); - - it("returns empty when rendered content is too short", async () => { - mockedPuppeteer.launch.mockResolvedValueOnce( - makeBrowser("tiny") as never, - ); - - const text = await runScraper(() => - scrapePuppeteer("https://example.com/job/1"), - ); - - expect(text).toBe(""); - }); -}); diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts index ee79144a..9d6d81d8 100644 --- a/server/src/common/utils/JobOfferExtraction.ts +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -1,8 +1,7 @@ import * as cheerio from "cheerio"; -import puppeteer from "puppeteer"; import { Logger } from "@nestjs/common"; -import { isSafeFetchUrl, safeAxiosGet } from "./urlGuard"; +import { safeAxiosGet } from "./urlGuard"; const logger = new Logger("JobOfferExtraction"); @@ -150,74 +149,3 @@ export const scrapeAxios = async (url: string): Promise => { return pageText; }; - -// ─── PUPPETEER GÉNÉRIQUE ───────────────────────────────────────────────────── -export const scrapePuppeteer = async (url: string): Promise => { - let pageText = ""; - - try { - // Use puppeteer's bundled Chromium. Override only via PUPPETEER_EXECUTABLE_PATH - // (respected by puppeteer natively) when a system Chrome is required. - const browser = await puppeteer.launch({ - headless: true, - args: [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-blink-features=AutomationControlled", - "--disable-infobars", - "--window-size=1920,1080", - ], - }); - - const page = await browser.newPage(); - - // SSRF guard for the headless browser: re-validate every request the page - // makes (the initial nav AND any redirect/sub-resource) against the same - // allowlist used for axios. Without this, a 30x redirect or an embedded - // resource pointing at 169.254.169.254 / an internal host would be fetched. - await page.setRequestInterception(true); - page.on("request", (req) => { - if (isSafeFetchUrl(req.url())) { - void req.continue(); - } else { - logger.debug(`Puppeteer blocked unsafe request: ${req.url()}`); - void req.abort(); - } - }); - - await page.evaluateOnNewDocument(() => { - Object.defineProperty(navigator, "webdriver", { get: () => false }); - (window as { chrome?: unknown }).chrome = { runtime: {} }; - }); - - await page.setUserAgent( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - ); - - await page.setViewport({ width: 1920, height: 1080 }); - await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - await page.evaluate(() => - window.scrollTo(0, document.body.scrollHeight / 2), - ); - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const html = await page.content(); - await browser.close(); - - const temp = cheerio.load(html); - temp("script, style, nav, footer, header, iframe, noscript").remove(); - const extracted = temp("body").text().replace(/\s+/g, " ").trim(); - - if (extracted.length >= 300) { - pageText = extracted; - logger.debug("Strategy Puppeteer generic succeeded"); - } - } catch (err) { - logger.debug( - `Strategy Puppeteer generic failed: ${(err as any)?.message || err}`, - ); - } - - return pageText; -}; diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 2d5d77bf..6704be91 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -38,19 +38,16 @@ jest.mock("groq-sdk", () => ({ jest.mock("../../common/utils/JobOfferExtraction", () => ({ scrapeLinkedin: jest.fn(), scrapeAxios: jest.fn(), - scrapePuppeteer: jest.fn(), })); import { scrapeLinkedin, scrapeAxios, - scrapePuppeteer, } from "../../common/utils/JobOfferExtraction"; const mockGroqCreate = jest.fn(); const mockScrapeLinkedin = scrapeLinkedin as jest.Mock; const mockScrapeAxios = scrapeAxios as jest.Mock; -const mockScrapePuppeteer = scrapePuppeteer as jest.Mock; const USER_ID = "uid-1"; const pdfFile = () => ({ buffer: Buffer.from("fake pdf content") }); @@ -188,7 +185,6 @@ describe("UsersService", () => { mockGroqCreate.mockReset(); mockScrapeLinkedin.mockReset(); mockScrapeAxios.mockReset(); - mockScrapePuppeteer.mockReset(); }); it("should be defined", () => { @@ -420,18 +416,16 @@ describe("UsersService", () => { expect(mockScrapeLinkedin).not.toHaveBeenCalled(); expect(mockScrapeAxios).not.toHaveBeenCalled(); - expect(mockScrapePuppeteer).not.toHaveBeenCalled(); }); it("throws BadRequest when no scraper returns content", async () => { mockScrapeLinkedin.mockResolvedValue(""); mockScrapeAxios.mockResolvedValue(""); - mockScrapePuppeteer.mockResolvedValue(""); await expect( service.uploadJobOffer(USER_ID, "https://example.com/job/123"), ).rejects.toThrow( - "Could not extract content from this URL. The website may be too protected.", + "Could not extract content from this URL. The page may require JavaScript to render or be too protected.", ); }); diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index 07fac809..d8aa078d 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -19,7 +19,6 @@ import { import { scrapeLinkedin, scrapeAxios, - scrapePuppeteer, } from "../../common/utils/JobOfferExtraction"; import { isSafeFetchUrl } from "../../common/utils/urlGuard"; @@ -385,11 +384,10 @@ export class UsersService { if (isLinkedIn) pageText = await scrapeLinkedin(url); if (!pageText) pageText = await scrapeAxios(url); - if (!pageText) pageText = await scrapePuppeteer(url); if (!pageText) { throw new BadRequestException( - "Could not extract content from this URL. The website may be too protected.", + "Could not extract content from this URL. The page may require JavaScript to render or be too protected.", ); } From 1a3f1b347e79beb8dc1c3a2511b624b37e10b998 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 24 Jun 2026 00:46:43 +0800 Subject: [PATCH 18/18] fix: close IPv6 SSRF bypass and enforce one-per-user cv/job-offer rows --- server/src/common/utils/JobOfferExtraction.ts | 16 ++++++--- server/src/common/utils/urlGuard.ts | 2 +- server/src/entities/userCV.entity.ts | 2 +- server/src/entities/userJobOffer.entity.ts | 2 +- server/src/modules/users/users.controller.ts | 7 ++++ .../src/modules/users/users.service.spec.ts | 36 +++++++++++++++++++ server/src/modules/users/users.service.ts | 26 +++++++++++--- 7 files changed, 79 insertions(+), 12 deletions(-) diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts index 9d6d81d8..e6a5ea98 100644 --- a/server/src/common/utils/JobOfferExtraction.ts +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -27,11 +27,17 @@ export const scrapeLinkedin = async (url: string): Promise => { await new Promise((resolve) => setTimeout(resolve, delay)); } - const jobIdMatch = url.match(/(\d{8,})/); + // Anchor to where LinkedIn actually puts the job id: /jobs/view/, + // ?currentJobId=, or the trailing - of a view slug. A bare + // /(\d{8,})/ would grab the first long digit run anywhere — a tracking + // param or timestamp could win over the real id. + const jobIdMatch = url.match( + /(?:jobs\/view\/|currentJobId=)(\d+)|-(\d{8,})(?:[/?#]|$)/, + ); if (!jobIdMatch) throw new Error("Could not extract LinkedIn job ID from URL"); - const jobId = jobIdMatch[1]; + const jobId = jobIdMatch[1] ?? jobIdMatch[2]; const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; const response = await safeAxiosGet(guestApiUrl, { headers: { @@ -93,7 +99,7 @@ export const scrapeLinkedin = async (url: string): Promise => { } } catch (err) { logger.debug( - `LinkedIn attempt ${attempt + 1} failed: ${(err as any)?.code || err}`, + `LinkedIn attempt ${attempt + 1} failed: ${(err as { code?: string })?.code || err}`, ); attempt++; } @@ -144,7 +150,9 @@ export const scrapeAxios = async (url: string): Promise => { logger.debug("Strategy Axios succeeded"); } } catch (err) { - logger.debug(`Strategy Axios failed: ${(err as any)?.code || err}`); + logger.debug( + `Strategy Axios failed: ${(err as { code?: string })?.code || err}`, + ); } return pageText; diff --git a/server/src/common/utils/urlGuard.ts b/server/src/common/utils/urlGuard.ts index 27d1d9ae..dacf2b7a 100644 --- a/server/src/common/utils/urlGuard.ts +++ b/server/src/common/utils/urlGuard.ts @@ -101,7 +101,7 @@ export const isSafeFetchUrl = (raw: string): boolean => { if (isPrivateIPv4(host)) { return false; } - if (host.includes(":") && isBlockedIPv6(parsed.host)) { + if (host.includes(":") && isBlockedIPv6(host)) { return false; } diff --git a/server/src/entities/userCV.entity.ts b/server/src/entities/userCV.entity.ts index 2d42e989..fd827015 100644 --- a/server/src/entities/userCV.entity.ts +++ b/server/src/entities/userCV.entity.ts @@ -15,7 +15,7 @@ export class user_cv { @PrimaryGeneratedColumn("uuid") cv_id: string; - @Column({ nullable: false }) + @Column({ nullable: false, unique: true }) user_id: string; // This part will establish a one-to-one relationship between the user_cv and user entities, allowing us to easily access the user associated with a given CV. diff --git a/server/src/entities/userJobOffer.entity.ts b/server/src/entities/userJobOffer.entity.ts index 575d67ca..336d94cf 100644 --- a/server/src/entities/userJobOffer.entity.ts +++ b/server/src/entities/userJobOffer.entity.ts @@ -15,7 +15,7 @@ export class user_job_offer { @PrimaryGeneratedColumn("uuid") job_offer_id: string; - @Column({ nullable: false }) + @Column({ nullable: false, unique: true }) user_id: string; // This part will establish a one-to-one relationship between the user_job_offer and user entities, allowing us to easily access the user associated with a given job offer. diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index eedc63fe..ba8c7d68 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -19,6 +19,7 @@ import { FileInterceptor } from "@nestjs/platform-express"; import { ApiBadRequestResponse, ApiOkResponse, + ApiOperation, ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; @@ -65,6 +66,9 @@ export class UsersController { await this.usersService.deleteAccount(user); } + @ApiOperation({ + summary: "Upload a CV PDF and extract structured profile info", + }) @ApiOkResponse({ description: "The CV has successfully uploaded", type: String, @@ -97,6 +101,9 @@ export class UsersController { return this.usersService.uploadCV(user.user_id, file); } + @ApiOperation({ + summary: "Scrape a job-offer URL and extract structured offer info", + }) @ApiOkResponse({ description: "The job offer was successfully parsed" }) @ApiBadRequestResponse({ description: "Missing/invalid URL, blocked target, or unscrapable page", diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 6704be91..3069878d 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -5,6 +5,8 @@ import { InternalServerErrorException, } from "@nestjs/common"; +import { QueryFailedError } from "typeorm"; + import { ProfileVisibility } from "@common/enums/ProfileVisibility"; import { UserStatus } from "@common/enums/UserStatus"; import { @@ -342,6 +344,40 @@ describe("UsersService", () => { expect(result).toEqual({ message: "CV updated successfully" }); }); + it("falls back to update when a concurrent insert wins the unique race", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validCvGroqResponse } }], + }); + // No row at findOne time, but the concurrent upload's insert lands first, + // so our save hits the user_id unique violation (Postgres 23505). + cvRepo.findOne.mockResolvedValue(null); + const uniqueViolation = new QueryFailedError("insert", [], new Error()); + ( + uniqueViolation as unknown as { driverError: { code: string } } + ).driverError = { code: "23505" }; + cvRepo.save.mockRejectedValueOnce(uniqueViolation); + + const result = await service.uploadCV(USER_ID, pdfFile()); + + expect(cvRepo.update).toHaveBeenCalledWith( + { user_id: USER_ID }, + expect.objectContaining({ desired_job: "Software Engineer" }), + ); + expect(result).toEqual({ message: "CV updated successfully" }); + }); + + it("rethrows non-unique-violation save errors", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validCvGroqResponse } }], + }); + cvRepo.findOne.mockResolvedValue(null); + cvRepo.save.mockRejectedValueOnce(new Error("db down")); + + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow(); + }); + it("strips markdown fences before parsing JSON", async () => { mockPdfParse.mockResolvedValue({ text: "some cv text" }); mockGroqCreate.mockResolvedValue({ diff --git a/server/src/modules/users/users.service.ts b/server/src/modules/users/users.service.ts index d8aa078d..07515a01 100644 --- a/server/src/modules/users/users.service.ts +++ b/server/src/modules/users/users.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { ObjectLiteral, Repository } from "typeorm"; +import { ObjectLiteral, QueryFailedError, Repository } from "typeorm"; import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { ProfileVisibility } from "@common/enums/ProfileVisibility"; @@ -267,6 +267,11 @@ export class UsersService { * Find-or-update a single row keyed by user_id: updates when one exists, * otherwise creates and saves. Used for the one-per-user CV / job-offer rows. * Returns true when an existing row was updated, false when one was created. + * + * The user_id column carries a unique constraint, so two concurrent uploads + * can both miss the findOne and race the insert. The loser hits a unique + * violation (Postgres 23505); we swallow it and fall back to an update so the + * row stays one-per-user instead of silently duplicating. */ private async upsertByUser( repo: Repository, @@ -282,9 +287,20 @@ export class UsersService { return true; } - const row = repo.create({ user_id: userId, ...data } as never); - await repo.save(row); - return false; + try { + const row = repo.create({ user_id: userId, ...data } as never); + await repo.save(row); + return false; + } catch (error) { + if ( + error instanceof QueryFailedError && + (error.driverError as { code?: string })?.code === "23505" + ) { + await repo.update({ user_id: userId } as never, data); + return true; + } + throw error; + } } async uploadCV( @@ -380,7 +396,7 @@ export class UsersService { } let pageText = ""; - const isLinkedIn = url.includes("linkedin.com/jobs"); + const isLinkedIn = url.toLowerCase().includes("linkedin.com/jobs"); if (isLinkedIn) pageText = await scrapeLinkedin(url); if (!pageText) pageText = await scrapeAxios(url);