diff --git a/package-lock.json b/package-lock.json index e92b12f9..412694f3 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", @@ -2423,14 +2423,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", @@ -2438,13 +2438,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": { @@ -2454,7 +2454,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": { @@ -2498,14 +2498,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", @@ -2513,9 +2513,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" @@ -2525,16 +2525,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" }, @@ -2592,14 +2592,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": { @@ -2612,15 +2612,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": { @@ -2646,14 +2646,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" }, @@ -2661,72 +2661,18 @@ "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", - "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", @@ -3788,19 +3734,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", @@ -3984,19 +3917,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", @@ -5811,9 +5731,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" @@ -5858,9 +5778,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", @@ -6102,14 +6022,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": { @@ -6378,10 +6324,16 @@ "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.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", @@ -6668,6 +6620,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", @@ -6920,13 +6914,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": { @@ -7052,12 +7045,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", @@ -7167,6 +7154,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", @@ -7432,6 +7447,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", @@ -7441,6 +7509,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", @@ -7537,14 +7619,39 @@ "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/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" @@ -7554,7 +7661,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" @@ -7909,9 +8015,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", @@ -8018,9 +8124,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", @@ -8323,17 +8429,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" @@ -8380,12 +8486,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" @@ -8426,10 +8532,19 @@ "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.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": { @@ -8538,6 +8653,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", @@ -10538,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": { @@ -10956,10 +11102,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" } @@ -11126,6 +11272,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", @@ -11151,9 +11303,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" @@ -11182,6 +11334,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", @@ -11417,7 +11581,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" @@ -11426,6 +11589,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", @@ -11488,18 +11676,18 @@ } }, "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" } }, "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", @@ -11532,6 +11720,28 @@ "node": ">= 14.16" } }, + "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/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -11628,9 +11838,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" @@ -12092,10 +12302,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", @@ -13349,9 +13562,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" @@ -13672,18 +13885,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", @@ -14212,9 +14413,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" @@ -14389,6 +14590,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", @@ -14429,19 +14639,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", @@ -14497,9 +14694,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" @@ -14767,9 +14964,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", @@ -14803,9 +15000,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", @@ -14814,11 +15011,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", @@ -14830,9 +15027,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" @@ -14860,9 +15057,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" @@ -14942,7 +15139,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" @@ -14955,7 +15151,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" @@ -14968,7 +15163,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" @@ -15132,9 +15326,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==", + "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": { @@ -15316,14 +15510,17 @@ "@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", "ioredis": "^5.10.1", "nodemailer": "^8.0.2", + "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", diff --git a/server/.env.example b/server/.env.example index cce67c50..15edc2aa 100644 --- a/server/.env.example +++ b/server/.env.example @@ -15,3 +15,7 @@ FRONTEND_URL=http://localhost:5173 # AI C++ server (ai/docker-compose, port 8088). In Docker on Linux, use host.docker.internal # with extra_hosts in docker-compose.yml, or set e.g. http://172.17.0.1:8088 # AI_SERVER_URL=http://host.docker.internal:8088 + +# 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/package.json b/server/package.json index f9a20687..5cdbf934 100644 --- a/server/package.json +++ b/server/package.json @@ -37,14 +37,17 @@ "@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", "ioredis": "^5.10.1", "nodemailer": "^8.0.2", + "pdf-parse-debugging-disabled": "^1.1.1", "pg": "^8.15.6", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", diff --git a/server/src/common/utils/JobOfferExtraction.spec.ts b/server/src/common/utils/JobOfferExtraction.spec.ts new file mode 100644 index 00000000..99e2b284 --- /dev/null +++ b/server/src/common/utils/JobOfferExtraction.spec.ts @@ -0,0 +1,107 @@ +import axios from "axios"; + +import { scrapeLinkedin, scrapeAxios } from "./JobOfferExtraction"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +// 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(); + 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(""); + }); +}); diff --git a/server/src/common/utils/JobOfferExtraction.ts b/server/src/common/utils/JobOfferExtraction.ts new file mode 100644 index 00000000..e6a5ea98 --- /dev/null +++ b/server/src/common/utils/JobOfferExtraction.ts @@ -0,0 +1,159 @@ +import * as cheerio from "cheerio"; +import { Logger } from "@nestjs/common"; + +import { safeAxiosGet } from "./urlGuard"; + +const logger = new Logger("JobOfferExtraction"); + +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; + logger.debug( + `LinkedIn retry ${attempt}/${maxRetries - 1} - waiting ${delay}ms...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + // 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] ?? jobIdMatch[2]; + const guestApiUrl = `https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/${jobId}`; + const response = await safeAxiosGet(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((_, el) => { + 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; + logger.debug(`Strategy LinkedIn succeeded on attempt ${attempt + 1}`); + } else { + throw new Error("Extracted content too short"); + } + } catch (err) { + logger.debug( + `LinkedIn attempt ${attempt + 1} failed: ${(err as { code?: string })?.code || err}`, + ); + attempt++; + } + } + if (!pageText) { + logger.debug( + "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 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, + }, + 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; + logger.debug("Strategy Axios succeeded"); + } + } catch (err) { + logger.debug( + `Strategy Axios failed: ${(err as { code?: string })?.code || err}`, + ); + } + + return pageText; +}; diff --git a/server/src/common/utils/urlGuard.spec.ts b/server/src/common/utils/urlGuard.spec.ts new file mode 100644 index 00000000..fd670af9 --- /dev/null +++ b/server/src/common/utils/urlGuard.spec.ts @@ -0,0 +1,152 @@ +import axios from "axios"; + +import { isSafeFetchUrl, safeAxiosGet } from "./urlGuard"; + +jest.mock("axios"); +const mockedGet = axios.get as jest.MockedFunction; + +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); + }); +}); + +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 new file mode 100644 index 00000000..dacf2b7a --- /dev/null +++ b/server/src/common/utils/urlGuard.ts @@ -0,0 +1,154 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; + +/** + * 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(host)) { + return false; + } + + 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/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 new file mode 100644 index 00000000..336d94cf --- /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, 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. + // 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: "simple-array", 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(); + } +} 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 39205bac..162acb3d 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,27 @@ describe("UsersController", () => { expect(mockUsersService.deleteAccount).toHaveBeenCalledWith(mockUser); }); }); + + describe("uploadCV", () => { + 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 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 43a5d1ce..ba8c7d68 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -1,16 +1,25 @@ import { Body, Controller, + BadRequestException, + UploadedFile, + UseInterceptors, + UseGuards, + Post, Delete, Get, HttpCode, HttpStatus, Patch, - UseGuards, - UsePipes, } from "@nestjs/common"; +import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator"; +import { Throttle } from "@nestjs/throttler"; +import { FileInterceptor } from "@nestjs/platform-express"; + import { + ApiBadRequestResponse, ApiOkResponse, + ApiOperation, ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; @@ -22,7 +31,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") @@ -55,4 +65,58 @@ export class UsersController { async deleteMe(@CurrentUser() user: user): Promise { 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, + }) + @ApiBadRequestResponse({ + description: + "Invalid request data in body (e.g., missing file or incorrect format)", + }) + @ApiUnauthorizedResponse() + @UsePipes(new PostValidationPipe()) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @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); + } + }, + }), + ) + @UseGuards(AccessTokenGuard) + @Post("uploadCV") + async uploadCV( + @CurrentUser() user: user, + @UploadedFile() file?: UploadedPdf, + ): Promise<{ message: string }> { + 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", + }) + @ApiUnauthorizedResponse() + @UsePipes(new PostValidationPipe()) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @UseGuards(AccessTokenGuard) + @Post("uploadJobOffer") + 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.module.ts b/server/src/modules/users/users.module.ts index 6293b4cf..0649b8e0 100644 --- a/server/src/modules/users/users.module.ts +++ b/server/src/modules/users/users.module.ts @@ -1,16 +1,18 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; - import { AccessTokenGuard } from "@common/guards/accessToken.guard"; import { user, user_email, + user_password, 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 { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; +import { AuthModule } from "../auth/auth.module"; @Module({ imports: [ @@ -19,7 +21,11 @@ import { UsersService } from "./users.service"; user_profile, user_email, user_phone_number, + user_password, + user_cv, + user_job_offer, ]), + AuthModule, ], controllers: [UsersController], providers: [UsersService, AccessTokenGuard], diff --git a/server/src/modules/users/users.service.spec.ts b/server/src/modules/users/users.service.spec.ts index 7f907d28..3069878d 100644 --- a/server/src/modules/users/users.service.spec.ts +++ b/server/src/modules/users/users.service.spec.ts @@ -1,6 +1,11 @@ import { Test, TestingModule } from "@nestjs/testing"; import { getRepositoryToken } from "@nestjs/typeorm"; -import { InternalServerErrorException } from "@nestjs/common"; +import { + BadRequestException, + InternalServerErrorException, +} from "@nestjs/common"; + +import { QueryFailedError } from "typeorm"; import { ProfileVisibility } from "@common/enums/ProfileVisibility"; import { UserStatus } from "@common/enums/UserStatus"; @@ -10,25 +15,98 @@ 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"; +const mockPdfParse = jest.fn(); +// 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", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { create: (...args: unknown[]) => mockGroqCreate(...args) }, + }, + })), +})); + +jest.mock("../../common/utils/JobOfferExtraction", () => ({ + scrapeLinkedin: jest.fn(), + scrapeAxios: jest.fn(), +})); + +import { + scrapeLinkedin, + scrapeAxios, +} from "../../common/utils/JobOfferExtraction"; + +const mockGroqCreate = jest.fn(); +const mockScrapeLinkedin = scrapeLinkedin as jest.Mock; +const mockScrapeAxios = scrapeAxios as jest.Mock; + +const USER_ID = "uid-1"; +const pdfFile = () => ({ buffer: Buffer.from("fake pdf content") }); + +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", + 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", +}); + 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 +154,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,14 +175,18 @@ 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); + + mockPdfParse.mockReset(); + mockGroqCreate.mockReset(); + mockScrapeLinkedin.mockReset(); + mockScrapeAxios.mockReset(); }); it("should be defined", () => { @@ -123,9 +213,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(profileRepo.create).toHaveBeenCalled(); expect(profileRepo.save).toHaveBeenCalled(); expect(v.firstName).toBe("Zed"); @@ -136,9 +224,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"); @@ -183,4 +269,324 @@ describe("UsersService", () => { ); }); }); + + // ─── uploadCV ─────────────────────────────────────────────────────────────── + + describe("uploadCV", () => { + it("throws BadRequest when no file is provided", async () => { + await expect(service.uploadCV(USER_ID, undefined)).rejects.toThrow( + BadRequestException, + ); + }); + + it("throws BadRequest when the PDF text is empty", async () => { + mockPdfParse.mockResolvedValue({ text: "" }); + + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + "The PDF file is empty or could not be parsed.", + ); + }); + + it("throws InternalServerError on an empty AI response", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + InternalServerErrorException, + ); + }); + + it("throws InternalServerError on invalid JSON", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "not valid json }{" } }], + }); + + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + InternalServerErrorException, + ); + }); + + 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 result = await service.uploadCV(USER_ID, pdfFile()); + + expect(cvRepo.findOne).toHaveBeenCalledWith({ + where: { user_id: USER_ID }, + }); + expect(cvRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ desired_job: "Software Engineer" }), + ); + expect(cvRepo.save).toHaveBeenCalled(); + expect(result).toEqual({ message: "CV uploaded successfully" }); + }); + + 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: USER_ID }); + + 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("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({ + choices: [ + { message: { content: "```json\n" + validCvGroqResponse + "\n```" } }, + ], + }); + cvRepo.findOne.mockResolvedValue(null); + + const result = await service.uploadCV(USER_ID, pdfFile()); + + expect(result).toEqual({ message: "CV uploaded successfully" }); + }); + + it("applies defaults when fields are absent", async () => { + mockPdfParse.mockResolvedValue({ text: "some cv text" }); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "{}" } }], + }); + cvRepo.findOne.mockResolvedValue(null); + + await service.uploadCV(USER_ID, pdfFile()); + + expect(cvRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: USER_ID, + desired_job: null, + resume: null, + experiences: [], + education: [], + technical_skills: [], + languages: [], + }), + ); + }); + + 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(USER_ID, pdfFile()); + + const promptSent = mockGroqCreate.mock.calls[0][0].messages[0].content; + expect(promptSent).toContain("a".repeat(8000)); + expect(promptSent).not.toContain("a".repeat(8001)); + expect(promptSent.length).toBeLessThan(12000); + }); + + it("lets unexpected errors bubble up", async () => { + mockPdfParse.mockRejectedValue(new Error("unexpected crash")); + + await expect(service.uploadCV(USER_ID, pdfFile())).rejects.toThrow( + "unexpected crash", + ); + }); + }); + + describe("uploadJobOffer", () => { + it("throws BadRequest on an invalid URL", async () => { + await expect( + service.uploadJobOffer(USER_ID, "not-a-url"), + ).rejects.toThrow("Invalid URL format."); + }); + + 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(mockScrapeLinkedin).not.toHaveBeenCalled(); + expect(mockScrapeAxios).not.toHaveBeenCalled(); + }); + + it("throws BadRequest when no scraper returns content", async () => { + mockScrapeLinkedin.mockResolvedValue(""); + mockScrapeAxios.mockResolvedValue(""); + + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow( + "Could not extract content from this URL. The page may require JavaScript to render or be too protected.", + ); + }); + + it("uses scrapeLinkedin for LinkedIn URLs", async () => { + mockScrapeLinkedin.mockResolvedValue("linkedin job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: validJobOfferGroqResponse } }], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + await service.uploadJobOffer( + USER_ID, + "https://linkedin.com/jobs/view/123", + ); + + expect(mockScrapeLinkedin).toHaveBeenCalledWith( + "https://linkedin.com/jobs/view/123", + ); + }); + + it("throws InternalServerError on an empty AI response", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow(InternalServerErrorException); + }); + + it("throws InternalServerError on invalid JSON", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "}{invalid json" } }], + }); + + await expect( + service.uploadJobOffer(USER_ID, "https://example.com/job/123"), + ).rejects.toThrow(InternalServerErrorException); + }); + + 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 result = await service.uploadJobOffer( + USER_ID, + "https://example.com/job/123", + ); + + expect(jobOfferRepo.create).toHaveBeenCalled(); + expect(jobOfferRepo.save).toHaveBeenCalled(); + expect(result).toEqual({ message: "Job offer parsed successfully" }); + }); + + it("applies defaults when fields are absent", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [{ message: { content: "{}" } }], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + await service.uploadJobOffer(USER_ID, "https://example.com/job/123"); + + expect(jobOfferRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: USER_ID, + job_title: null, + required_skills: [], + missions: [], + offer_url: "https://example.com/job/123", + }), + ); + }); + + 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: USER_ID }); + + const result = await service.uploadJobOffer( + USER_ID, + "https://example.com/job/123", + ); + + expect(jobOfferRepo.update).toHaveBeenCalledWith( + { user_id: USER_ID }, + expect.objectContaining({ job_title: "Backend Developer" }), + ); + expect(result).toEqual({ message: "Job offer updated successfully" }); + }); + + it("strips markdown fences before parsing JSON", async () => { + mockScrapeAxios.mockResolvedValue("some job content"); + mockGroqCreate.mockResolvedValue({ + choices: [ + { + message: { + content: "```json\n" + validJobOfferGroqResponse + "\n```", + }, + }, + ], + }); + jobOfferRepo.findOne.mockResolvedValue(null); + + const result = await service.uploadJobOffer( + USER_ID, + "https://example.com/job/123", + ); + + expect(result).toEqual({ message: "Job offer parsed successfully" }); + }); + + it("lets unexpected errors bubble up", async () => { + mockScrapeAxios.mockRejectedValue(new Error("network crash")); + + 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 b7945807..07515a01 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, QueryFailedError, Repository } from "typeorm"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; import { ProfileVisibility } from "@common/enums/ProfileVisibility"; import { @@ -14,14 +16,74 @@ import { user_phone_number, user_profile, } from "@entities/user.entity"; +import { + scrapeLinkedin, + scrapeAxios, +} from "../../common/utils/JobOfferExtraction"; +import { isSafeFetchUrl } from "../../common/utils/urlGuard"; import { UpdateProfileDto } from "./dto/updateProfile.dto"; import { GetProfileDto } from "./dto/getProfile.dto"; +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; + +/** 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); + // Lazily built so a missing GROQ_API_KEY does not crash app bootstrap — the + // groq-sdk constructor throws on an empty key. Only the upload routes need it; + // they surface the failure as a 500 instead of taking the whole server down. + private _groq?: Groq; + private get groq(): Groq { + if (!this._groq) { + this._groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + } + return this._groq; + } + constructor( @InjectRepository(user) private readonly userRepo: Repository, @@ -31,6 +93,11 @@ export class UsersService { private readonly userEmailRepo: Repository, @InjectRepository(user_phone_number) private readonly phoneRepo: Repository, + + @InjectRepository(user_cv) + private user_cvRepo: Repository, + @InjectRepository(user_job_offer) + private user_job_offerRepo: Repository, ) {} async getProfile(user: user): Promise { @@ -168,4 +235,239 @@ export class UsersService { notificationPrefs: p?.notification_prefs ?? null, }; } + + /** + * 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 { + 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."); + } + } + + /** + * 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, + userId: string, + data: QueryDeepPartialEntity, + ): Promise { + const existing = await repo.findOne({ + where: { user_id: userId } as never, + }); + + if (existing) { + await repo.update({ user_id: userId } as never, data); + return true; + } + + 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( + 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 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", + "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: + ${cvText}`; + + const data = await this.extractWithGroq(prompt); + + 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); + + this.logger.log( + `CV ${existed ? "updated" : "created"} for user ID: ${userId}`, + ); + return { + message: existed ? "CV updated successfully" : "CV uploaded successfully", + }; + } + + async uploadJobOffer( + userId: string, + url: string, + ): Promise<{ message: string }> { + try { + new URL(url); + } catch { + throw new BadRequestException("Invalid URL format."); + } + + // 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."); + } + + let pageText = ""; + const isLinkedIn = url.toLowerCase().includes("linkedin.com/jobs"); + + if (isLinkedIn) pageText = await scrapeLinkedin(url); + if (!pageText) pageText = await scrapeAxios(url); + + if (!pageText) { + throw new BadRequestException( + "Could not extract content from this URL. The page may require JavaScript to render or be too protected.", + ); + } + + 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: + { + "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 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", + }; + } } 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 +}