From 31906fbbd1591d717240d26101828dda894fe467 Mon Sep 17 00:00:00 2001 From: n08i40k Date: Fri, 6 Sep 2024 23:13:44 +0400 Subject: [PATCH] 1.0.0 --- package-lock.json | 752 ++++++++++++++++-- package.json | 12 +- prisma/schema.prisma | 25 + src/app.module.ts | 11 +- src/auth/auth.controller.ts | 86 ++ src/auth/auth.decorator.ts | 8 + src/auth/auth.guard.ts | 43 + src/auth/auth.module.ts | 23 + src/auth/auth.pipe.ts | 30 + src/auth/auth.service.ts | 112 +++ src/contants.ts | 6 + src/dto/auth.dto.ts | 22 + src/dto/schedule.dto.ts | 223 ++++++ src/dto/user.dto.ts | 34 + src/main.ts | 27 + src/prisma/prisma.service.ts | 16 + .../schedule-parser/schedule-parser.ts | 295 +++++++ .../xls-downloader/basic-xls-downloader.ts | 92 +++ .../xls-downloader/xls-downloader.base.ts | 27 + src/schedule/schedule.controller.ts | 36 + src/schedule/schedule.module.ts | 13 + src/schedule/schedule.service.ts | 43 + src/users/users.module.ts | 9 + src/users/users.service.ts | 31 + src/utility/parse-pipe/object-id.pipe.ts | 20 + src/utility/prisma/convert.helper.ts | 9 + src/utility/string.util.ts | 31 + .../validation/class-validator.interceptor.ts | 78 ++ .../validation/partial-validation.pipe.ts | 37 + 29 files changed, 2061 insertions(+), 90 deletions(-) create mode 100644 prisma/schema.prisma create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.decorator.ts create mode 100644 src/auth/auth.guard.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.pipe.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/contants.ts create mode 100644 src/dto/auth.dto.ts create mode 100644 src/dto/schedule.dto.ts create mode 100644 src/dto/user.dto.ts create mode 100644 src/prisma/prisma.service.ts create mode 100644 src/schedule/internal/schedule-parser/schedule-parser.ts create mode 100644 src/schedule/internal/xls-downloader/basic-xls-downloader.ts create mode 100644 src/schedule/internal/xls-downloader/xls-downloader.base.ts create mode 100644 src/schedule/schedule.controller.ts create mode 100644 src/schedule/schedule.module.ts create mode 100644 src/schedule/schedule.service.ts create mode 100644 src/users/users.module.ts create mode 100644 src/users/users.service.ts create mode 100644 src/utility/parse-pipe/object-id.pipe.ts create mode 100644 src/utility/prisma/convert.helper.ts create mode 100644 src/utility/string.util.ts create mode 100644 src/utility/validation/class-validator.interceptor.ts create mode 100644 src/utility/validation/partial-validation.pipe.ts diff --git a/package-lock.json b/package-lock.json index fc284b3..962d98d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "schedule-parser-next", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "schedule-parser-next", - "version": "0.0.1", + "version": "1.0.0", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^10.0.0", @@ -14,14 +14,19 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.4.0", + "@prisma/client": "^5.19.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "dotenv": "^16.4.5", - "js-xlsx": "^0.8.22", + "jsdom": "^25.0.0", + "mongoose": "^8.6.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -31,6 +36,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/jsdom": "^21.1.7", "@types/node": "^20.16.5", "@types/supertest": "^6.0.0", "@types/uuid": "^10.0.0", @@ -1612,6 +1618,14 @@ "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==" }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -1941,17 +1955,34 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@prisma/client": { + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.1.tgz", + "integrity": "sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==", + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, "node_modules/@prisma/debug": { "version": "5.19.1", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.1.tgz", "integrity": "sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==", - "dev": true + "devOptional": true }, "node_modules/@prisma/engines": { "version": "5.19.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.1.tgz", "integrity": "sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/debug": "5.19.1", @@ -1964,13 +1995,13 @@ "version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3.tgz", "integrity": "sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==", - "dev": true + "devOptional": true }, "node_modules/@prisma/fetch-engine": { "version": "5.19.1", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.1.tgz", "integrity": "sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==", - "dev": true, + "devOptional": true, "dependencies": { "@prisma/debug": "5.19.1", "@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", @@ -1981,7 +2012,7 @@ "version": "5.19.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.1.tgz", "integrity": "sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==", - "dev": true, + "devOptional": true, "dependencies": { "@prisma/debug": "5.19.1" } @@ -2198,6 +2229,17 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2293,12 +2335,36 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "dev": true }, + "node_modules/@types/validator": { + "version": "13.12.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.1.tgz", + "integrity": "sha512-w0URwf7BQb0rD/EuiG12KP0bailHKHP5YVviJG9zw3ykAokL0TuxU2TUqMB7EwZ59bDHYdeTIvjI5m0S7qHfOA==" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3243,6 +3309,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz", + "integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -3456,6 +3530,21 @@ "integrity": "sha512-N1NGmowPlGBLsOZLPvm48StN04V4YvQRL0i6b7ctrVY3epjP/ct7hFLOItz6pDIvRjwpfPxi52a2UWV2ziir8g==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3555,17 +3644,9 @@ } }, "node_modules/codepage": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.3.8.tgz", - "integrity": "sha512-cjAoQW5L/TCKWRbzt/xGBvhwJKQFhcIVO0jWQtpKQx4gr9qvXNkpRfq6gSmjjA8dB2Is/DPOb7gNwqQXP7UgTQ==", - "dependencies": { - "commander": "", - "concat-stream": "", - "voc": "" - }, - "bin": { - "codepage": "bin/codepage.njs" - }, + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", "engines": { "node": ">=0.8" } @@ -3600,14 +3681,6 @@ "color-support": "bin.js" } }, - "node_modules/colors": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", - "integrity": "sha512-OsSVtHK8Ir8r3+Fxw/b4jS1ZLPXkV6ZxDRJQzeD7qo0SqMXWrHDM71DgYzPMHY8SFJ0Ao+nNU2p1MmwdzKqPrw==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3623,6 +3696,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "engines": { "node": ">= 6" } @@ -3820,6 +3894,65 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -3836,6 +3969,11 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -4069,6 +4207,17 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4845,9 +4994,9 @@ } }, "node_modules/frac": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/frac/-/frac-0.3.1.tgz", - "integrity": "sha512-1Lzf2jOjhIkRaa013KlxNOn2D9FemmQNeYUDpEIyPeFXmpLvbZXJOlaayMBT6JKXx+afQFgQ1QJ4kaF7Z07QFQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", "engines": { "node": ">=0.8" } @@ -5170,6 +5319,17 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5191,6 +5351,29 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5443,6 +5626,11 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6294,26 +6482,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, - "node_modules/js-xlsx": { - "version": "0.8.22", - "resolved": "https://registry.npmjs.org/js-xlsx/-/js-xlsx-0.8.22.tgz", - "integrity": "sha512-3N4a9RBHTr777rxxlvwJVpC+er/neRC+40sm2M/g3RIpWiCJG0iyaGJa8Za1K3NvjhZcKn9Sz5n36TY9ti5RMQ==", - "dependencies": { - "adler-32": "", - "cfb": ">=0.10.0", - "codepage": "~1.3.6", - "commander": "", - "crc-32": "", - "jszip": "2.4.0", - "ssf": "~0.8.1" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -6325,6 +6493,99 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.0.tgz", + "integrity": "sha512-OhoFVT59T7aEq75TVw9xxEfkXgacpqAhQaYgP9y/fDqWQCMB/b1H66RfmPm/MaeaAIU9nDwMOVTlPN51+ao6CQ==", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -6412,14 +6673,6 @@ "npm": ">=6" } }, - "node_modules/jszip": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-2.4.0.tgz", - "integrity": "sha512-m+yvNmYfRCaf1gr5YFT5e3fnSqLnE9McbNyRd0fNycsT0HltS19NKc18fh3Lvl/AIW/ovL6/MQ1JnfFg4G3o4A==", - "dependencies": { - "pako": "~0.2.5" - } - }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -6439,6 +6692,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6479,6 +6740,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz", + "integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6648,6 +6914,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -6811,6 +7082,131 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mongodb": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.0.tgz", + "integrity": "sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.5", + "bson": "^6.7.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mongoose": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.6.1.tgz", + "integrity": "sha512-dppGcYqvsdg+VcnqXR5b467V4a+iNhmvkfYNpEPi6AjaUxnz6ioEDmrMLOi+sOWjvoHapuwPOigV4f2l7HC6ag==", + "dependencies": { + "bson": "^6.7.0", + "kareem": "2.6.3", + "mongodb": "6.8.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6957,6 +7353,11 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nwsapi": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7104,11 +7505,6 @@ "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", "dev": true }, - "node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7139,6 +7535,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.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", @@ -7381,7 +7788,7 @@ "version": "5.19.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.1.tgz", "integrity": "sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/engines": "5.19.1" @@ -7431,11 +7838,15 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -7470,6 +7881,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7602,6 +8018,11 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -7742,6 +8163,11 @@ "node": "*" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==" + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -7806,6 +8232,17 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -7989,6 +8426,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8044,6 +8486,14 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -8051,16 +8501,11 @@ "dev": true }, "node_modules/ssf": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.8.2.tgz", - "integrity": "sha512-+ZkFDAG+ImJ48DcZvabx6YTrZ67DKkM0kbyOOtH73mbUEvNhQWWgRZrHC8+k7GuGKWQnACYLi7bj0eCt1jmosQ==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", "dependencies": { - "colors": "0.6.2", - "frac": "0.3.1", - "voc": "" - }, - "bin": { - "ssf": "bin/ssf.njs" + "frac": "~1.1.2" }, "engines": { "node": ">=0.8" @@ -8307,6 +8752,11 @@ "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, "node_modules/synckit": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", @@ -8575,6 +9025,28 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -8889,6 +9361,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8934,6 +9415,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8942,15 +9431,15 @@ "node": ">= 0.8" } }, - "node_modules/voc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/voc/-/voc-1.2.0.tgz", - "integrity": "sha512-BOuDjFFYvJdZO6e/N65AlaDItXo2TgyLjeyRYcqgAPkXpp5yTJcvkL2n+syO1r9Qc5g96tfBD2tuiMhYDmaGcA==", - "bin": { - "voc": "voc.njs" + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": { + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=0.8" + "node": ">=18" } }, "node_modules/walker": { @@ -9075,6 +9564,36 @@ "node": ">=4.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/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==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -9107,6 +9626,22 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9172,6 +9707,59 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 2286a51..af8bc6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "schedule-parser-next", - "version": "0.0.1", + "version": "1.0.0", "description": "", "author": "", "private": true, @@ -25,14 +25,19 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.4.0", + "@prisma/client": "^5.19.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "dotenv": "^16.4.5", - "js-xlsx": "^0.8.22", + "jsdom": "^25.0.0", + "mongoose": "^8.6.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -42,6 +47,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/jsdom": "^21.1.7", "@types/node": "^20.16.5", "@types/supertest": "^6.0.0", "@types/uuid": "^10.0.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e821b2c --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,25 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} + +model user { + id String @id @map("_id") @db.ObjectId + // + username String @unique + // + salt String + password String + // + access_token String @unique +} diff --git a/src/app.module.ts b/src/app.module.ts index 6bb615a..6c56c00 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; -import { AppController } from "./app.controller"; -import { AppService } from "./app.service"; +import { AuthModule } from "./auth/auth.module"; +import { UsersModule } from "./users/users.module"; +import { ScheduleModule } from "./schedule/schedule.module"; @Module({ - imports: [], - controllers: [AppController], - providers: [AppService], + imports: [AuthModule, UsersModule, ScheduleModule], + controllers: [], + providers: [], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..8ceac71 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,86 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common"; +import { AuthService } from "./auth.service"; +import { + ApiBody, + ApiConflictResponse, + ApiCreatedResponse, + ApiExtraModels, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiUnauthorizedResponse, + refs, +} from "@nestjs/swagger"; +import { + SignInDto, + SignInResultDto, + SignUpDto, + SignUpResultDto, + UpdateTokenDto, + UpdateTokenResultDto, +} from "../dto/auth.dto"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; + +@Controller("api/v1/auth") +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @ApiExtraModels(SignInDto) + @ApiExtraModels(SignInResultDto) + @ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] }) + @ApiBody({ schema: refs(SignInDto)[0] }) + @ApiOkResponse({ + description: "Авторизация прошла успешно", + schema: refs(SignInResultDto)[0], + }) + @ApiUnauthorizedResponse({ + description: "Некорректное имя пользователя или пароль", + }) + @ResultDto(SignInResultDto) + @HttpCode(HttpStatus.OK) + @Post("signIn") + signIn(@Body() signInDto: SignInDto) { + return this.authService.signIn(signInDto); + } + + @ApiExtraModels(SignUpDto) + @ApiExtraModels(SignUpResultDto) + @ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] }) + @ApiBody({ schema: refs(SignUpDto)[0] }) + @ApiCreatedResponse({ + description: "Регистрация прошла успешно", + schema: refs(SignUpResultDto)[0], + }) + @ApiConflictResponse({ + description: "Такой пользователь уже существует", + }) + @ResultDto(SignUpResultDto) + @HttpCode(HttpStatus.CREATED) + @Post("signUp") + signUp(@Body() signUpDto: SignUpDto) { + return this.authService.signUp(signUpDto); + } + + @ApiExtraModels(UpdateTokenDto) + @ApiExtraModels(UpdateTokenResultDto) + @ApiOperation({ + summary: "Обновление просроченного токена", + tags: ["auth"], + }) + @ApiBody({ schema: refs(UpdateTokenDto)[0] }) + @ApiOkResponse({ + description: "Токен обновлён успешно", + schema: refs(UpdateTokenResultDto)[0], + }) + @ApiNotFoundResponse({ + description: "Передан несуществующий или недействительный токен", + }) + @ResultDto(UpdateTokenResultDto) + @HttpCode(HttpStatus.OK) + @Post("updateToken") + updateToken( + @Body() updateTokenDto: UpdateTokenDto, + ): Promise { + return this.authService.updateToken(updateTokenDto); + } +} diff --git a/src/auth/auth.decorator.ts b/src/auth/auth.decorator.ts new file mode 100644 index 0000000..2faf362 --- /dev/null +++ b/src/auth/auth.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { AuthGuard } from "./auth.guard"; + +export const UserId = createParamDecorator((_, context: ExecutionContext) => { + return AuthGuard.extractTokenFromRequest( + context.switchToHttp().getRequest(), + ); +}); diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts new file mode 100644 index 0000000..8b4e8f6 --- /dev/null +++ b/src/auth/auth.guard.ts @@ -0,0 +1,43 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { Request } from "express"; +import { UsersService } from "../users/users.service"; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + public static extractTokenFromRequest(req: Request): string | null { + const [type, token] = req.headers.authorization?.split(" ") ?? []; + return type === "Bearer" ? token : null; + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = AuthGuard.extractTokenFromRequest(request); + + if (!token) throw new UnauthorizedException("Не указан токен!"); + + try { + if ( + !(await this.jwtService.verifyAsync(token)) || + !(await this.usersService.has({ access_token: token })) + ) { + // noinspection ExceptionCaughtLocallyJS + throw new Error(); + } + } catch { + throw new UnauthorizedException("Указан неверный токен!"); + } + + return true; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..763c5d7 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { jwtConstants } from "../contants"; +import { AuthService } from "./auth.service"; +import { AuthController } from "./auth.controller"; +import { UsersModule } from "../users/users.module"; +import { UsersService } from "../users/users.service"; +import { PrismaService } from "../prisma/prisma.service"; + +@Module({ + imports: [ + UsersModule, + JwtModule.register({ + global: true, + secret: jwtConstants.secret, + signOptions: { expiresIn: "720h" }, + }), + ], + providers: [AuthService, UsersService, PrismaService], + controllers: [AuthController], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.pipe.ts b/src/auth/auth.pipe.ts new file mode 100644 index 0000000..d739b54 --- /dev/null +++ b/src/auth/auth.pipe.ts @@ -0,0 +1,30 @@ +import { + ArgumentMetadata, + Injectable, + PipeTransform, + UnauthorizedException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { user } from "@prisma/client"; +import { UsersService } from "../users/users.service"; + +@Injectable() +export class UserFromTokenPipe implements PipeTransform { + constructor( + private readonly jwtService: JwtService, + private readonly usersService: UsersService, + ) {} + + async transform(token: string): Promise { + const jwt_user: { id: string } = await this.jwtService.decode(token); + + if (!jwt_user) + throw new UnauthorizedException("Передан некорректный токен!"); + + const user = await this.usersService.findUnique({ id: jwt_user.id }); + if (!user) + throw new UnauthorizedException("Передан некорректный токен!"); + + return user; + } +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..60853b6 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,112 @@ +import { + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { + SignInDto, + SignInResultDto, + SignUpDto, + SignUpResultDto, + UpdateTokenDto, + UpdateTokenResultDto, +} from "../dto/auth.dto"; +import { UsersService } from "../users/users.service"; +import { genSalt, hash } from "bcrypt"; +import { Prisma } from "@prisma/client"; +import { Types } from "mongoose"; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + async signUp(signUpDto: SignUpDto): Promise { + if (await this.usersService.has({ username: signUpDto.username })) + throw new ConflictException( + "Пользователь с таким именем уже существует!", + ); + + const salt = await genSalt(8); + const id = new Types.ObjectId().toString("hex"); + + const input: Prisma.userCreateInput = { + id: id, + username: signUpDto.username, + salt: salt, + password: await hash(signUpDto.password, salt), + access_token: await this.jwtService.signAsync({ + id: id, + }), + }; + + return this.usersService.create(input).then((user) => { + return { + id: user.id, + access_token: user.access_token, + }; + }); + } + + async signIn(signInDto: SignInDto): Promise { + const user = await this.usersService.findUnique({ + username: signInDto.username, + }); + + if ( + !user || + user.password !== (await hash(signInDto.password, user.salt)) + ) { + throw new UnauthorizedException( + "Некорректное имя пользователя или пароль!", + ); + } + + const access_token = await this.jwtService.signAsync({ id: user.id }); + + await this.usersService.update({ + where: { id: user.id }, + data: { access_token: access_token }, + }); + + return { id: user.id, access_token: access_token }; + } + + async updateToken( + updateTokenDto: UpdateTokenDto, + ): Promise { + if ( + !(await this.jwtService.verifyAsync(updateTokenDto.access_token, { + ignoreExpiration: true, + })) + ) { + throw new NotFoundException( + "Некорректный или недействительный токен!", + ); + } + + const jwt_user: { id: string } = await this.jwtService.decode( + updateTokenDto.access_token, + ); + + const user = await this.usersService.findUnique({ id: jwt_user.id }); + if (!user || user.access_token !== updateTokenDto.access_token) { + throw new NotFoundException( + "Некорректный или недействительный токен!", + ); + } + + const access_token = await this.jwtService.signAsync({ id: user.id }); + + await this.usersService.update({ + where: { id: user.id }, + data: { access_token: access_token }, + }); + + return { access_token: access_token }; + } +} diff --git a/src/contants.ts b/src/contants.ts new file mode 100644 index 0000000..99e00e6 --- /dev/null +++ b/src/contants.ts @@ -0,0 +1,6 @@ +import { configDotenv } from "dotenv"; +configDotenv(); + +export const jwtConstants = { + secret: process.env.JWT_SECRET!, +}; diff --git a/src/dto/auth.dto.ts b/src/dto/auth.dto.ts new file mode 100644 index 0000000..abf7149 --- /dev/null +++ b/src/dto/auth.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, PickType } from "@nestjs/swagger"; +import { UserDto } from "./user.dto"; +import { IsString } from "class-validator"; + +export class SignInDto extends PickType(UserDto, ["username"]) { + @ApiProperty({ description: "Пароль в исходном виде" }) + @IsString() + password: string; +} + +export class SignInResultDto extends PickType(UserDto, [ + "id", + "access_token", +]) {} + +export class SignUpDto extends SignInDto {} + +export class SignUpResultDto extends SignInResultDto {} + +export class UpdateTokenDto extends PickType(UserDto, ["access_token"]) {} + +export class UpdateTokenResultDto extends UpdateTokenDto {} diff --git a/src/dto/schedule.dto.ts b/src/dto/schedule.dto.ts new file mode 100644 index 0000000..e8d474c --- /dev/null +++ b/src/dto/schedule.dto.ts @@ -0,0 +1,223 @@ +import { + IsArray, + IsDate, + IsEnum, + IsNumber, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; + +export class LessonTimeDto { + @ApiProperty({ + example: 0, + description: "Начало занятия в минутах относительно начала суток", + }) + @IsNumber() + start: number; + @ApiProperty({ + example: 60, + description: "Конец занятия в минутах относительно начала суток", + }) + @IsNumber() + end: number; + + constructor(start: number, end: number) { + this.start = start; + this.end = end; + } + + static fromString(time: string): LessonTimeDto { + time = time.replaceAll(".", ":"); + + const regex = /(\d+:\d+)-(\d+:\d+)/g; + + const parseResult = regex.exec(time); + if (!parseResult) return new LessonTimeDto(0, 0); + + const start = parseResult[1].split(":"); + const end = parseResult[2].split(":"); + + return new LessonTimeDto( + Number.parseInt(start[0]) * 60 + Number.parseInt(start[1]), + Number.parseInt(end[0]) * 60 + Number.parseInt(end[1]), + ); + } +} + +export enum LessonTypeDto { + NONE = 0, + DEFAULT, + CUSTOM, +} + +export class LessonDto { + @ApiProperty({ + example: LessonTypeDto.DEFAULT, + description: "Тип занятия.", + }) + @IsEnum(LessonTypeDto) + type: LessonTypeDto; + + @ApiProperty({ + example: "Элементы высшей математики", + description: "Название занятия", + }) + @IsString() + name: string; + + @ApiProperty({ + example: new LessonTimeDto(0, 60), + description: + "Начало и конец занятия в минутах относительно начала суток", + required: false, + }) + @IsOptional() + @Type(() => LessonTimeDto) + time: LessonTimeDto | null; + + @ApiProperty({ example: ["42", "с\\з"], description: "Кабинеты" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => String) + cabinets: Array; + + @ApiProperty({ + example: ["Хомченко Н.Е."], + description: "ФИО преподавателей", + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => String) + teacherNames: Array; + + constructor( + type: LessonTypeDto, + time: LessonTimeDto, + name: string, + cabinets: Array, + teacherNames: Array, + ) { + this.type = type; + this.name = name; + this.time = time; + this.cabinets = cabinets; + this.teacherNames = teacherNames; + } +} + +export class DayDto { + @ApiProperty({ + example: "Понедельник", + description: "День недели", + }) + @IsString() + name: string; + + @ApiProperty({ example: [0, 1, 3], description: "Индексы занятий" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + nonNullIndices: Array; + + @ApiProperty({ example: [1, 3], description: "Индексы полных пар" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + defaultIndices: Array; + + @ApiProperty({ example: [0], description: "Индексы доп. занятий" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + customIndices: Array; + + @ApiProperty({ example: [], description: "Занятия" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LessonDto) + lessons: Array; + + constructor(name: string) { + this.name = name; + + this.nonNullIndices = []; + this.defaultIndices = []; + this.customIndices = []; + + this.lessons = []; + } + + public fillIndices(): void { + this.nonNullIndices = []; + this.defaultIndices = []; + this.customIndices = []; + + for (const lessonRawIdx in this.lessons) { + const lessonIdx = Number.parseInt(lessonRawIdx); + + const lesson = this.lessons[lessonIdx]; + if (lesson.type === LessonTypeDto.NONE) continue; + + this.nonNullIndices.push(lessonIdx); + + (lesson.type === LessonTypeDto.DEFAULT + ? this.defaultIndices + : this.customIndices + ).push(lessonIdx); + } + } +} + +export class GroupDto { + @ApiProperty({ + example: "ИС-214/23", + description: "Название группы", + }) + @IsString() + name: string; + + @ApiProperty({ example: [], description: "Дни недели" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DayDto) + days: Array; + + constructor(name: string) { + this.name = name; + this.days = []; + } +} + +export class ScheduleDto { + @ApiProperty({ + example: new Date(), + description: + "Дата когда последний раз расписание было скачано с сервера политехникума", + }) + @IsDate() + updatedAt: Date; + + @ApiProperty({ + example: '"66d88751-1b800"', + description: "ETag файла с расписанием на сервере политехникума", + }) + @IsString() + etag: string; + + @ApiProperty({ description: "Расписание группы" }) + @IsObject() + data: GroupDto; + + @ApiProperty({ + example: [5, 6], + description: "Обновлённые дни с последнего изменения расписания", + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + lastChangedDays: Array; +} diff --git a/src/dto/user.dto.ts b/src/dto/user.dto.ts new file mode 100644 index 0000000..24cfaa9 --- /dev/null +++ b/src/dto/user.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty, OmitType } from "@nestjs/swagger"; +import { + IsJWT, + IsMongoId, + IsString, + MaxLength, + MinLength, +} from "class-validator"; + +export class UserDto { + @ApiProperty({ description: "Идентификатор (ObjectId)" }) + @IsMongoId() + id: string; + @ApiProperty({ example: "n08i40k", description: "Имя" }) + @IsString() + @MinLength(4) + @MaxLength(10) + username: string; + @ApiProperty({ description: "Соль пароля" }) + @IsString() + salt: string; + @ApiProperty({ description: "Хеш пароля" }) + @IsString() + password: string; + @ApiProperty({ description: "Последний токен доступа" }) + @IsJWT() + access_token: string; +} + +export class ClientUserDto extends OmitType(UserDto, [ + "password", + "salt", + "access_token", +]) {} diff --git a/src/main.ts b/src/main.ts index f613fc4..71c7493 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,35 @@ import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; +import { ValidatorOptions } from "class-validator"; +import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe"; +import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; async function bootstrap() { const app = await NestFactory.create(AppModule); + const validatorOptions: ValidatorOptions = { + enableDebugMessages: true, + forbidNonWhitelisted: true, + whitelist: true, + }; + app.useGlobalPipes(new PartialValidationPipe(validatorOptions)); + app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions)); + app.enableCors(); + + const swaggerConfig = new DocumentBuilder() + .setTitle("Schedule Parser") + .setDescription("Парсер расписания") + .setVersion("1.0") + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + swaggerDocument.servers = [ + { + url: "http://localhost:3000", + description: "Локальный сервер для разработки", + }, + ]; + SwaggerModule.setup("api-docs", app, swaggerDocument); + await app.listen(3000); } diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..025603c --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/src/schedule/internal/schedule-parser/schedule-parser.ts b/src/schedule/internal/schedule-parser/schedule-parser.ts new file mode 100644 index 0000000..ea9fcd6 --- /dev/null +++ b/src/schedule/internal/schedule-parser/schedule-parser.ts @@ -0,0 +1,295 @@ +import { + XlsDownloaderBase, + XlsDownloaderCacheMode, + XlsDownloaderResult, +} from "../xls-downloader/xls-downloader.base"; + +import * as XLSX from "xlsx"; +import { + DayDto, + GroupDto, + LessonDto, + LessonTimeDto, + LessonTypeDto, +} from "../../../dto/schedule.dto"; +import { trimAll } from "../../../utility/string.util"; + +type InternalId = { row: number; column: number; name: string }; +type InternalDay = InternalId & { lessons: Array }; + +export type ScheduleParseResult = { + etag: string; + group: GroupDto; + affectedDays: Array; +}; + +export class ScheduleParser { + private lastResult: ScheduleParseResult | null = null; + + public constructor( + private readonly xlsDownloader: XlsDownloaderBase, + private readonly group: string, + ) {} + + private static getCellName( + worksheet: XLSX.Sheet, + row: number, + column: number, + ): any | null { + const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: column })]; + return cell ? cell.v : null; + } + + private parseTeacherFullNames(lessonName: string): { + name: string; + teacherFullNames: Array; + } { + const firstRegex = + /(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.[А-ЯЁ]\.(?:\s\([0-9] подгруппа\))?(?:,\s)?)+$/gm; + const secondRegex = + /(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.[А-ЯЁ]\.(?:\s\([0-9] подгруппа\))?)+/gm; + + const fm = firstRegex.exec(lessonName); + if (fm === null) return { name: lessonName, teacherFullNames: [] }; + + const teacherFullNames: Array = []; + + let teacherFullNameMatch: RegExpExecArray; + while ((teacherFullNameMatch = secondRegex.exec(fm[0])) !== null) { + if (teacherFullNameMatch.index === secondRegex.lastIndex) + secondRegex.lastIndex++; + + teacherFullNames.push(teacherFullNameMatch[0].trim()); + } + + if (teacherFullNames.length === 0) + return { name: lessonName, teacherFullNames: [] }; + + return { + name: lessonName.substring(0, fm.index).trim(), + teacherFullNames: teacherFullNames, + }; + } + + parseSkeleton(worksheet: XLSX.Sheet): { + groupSkeleton: InternalId; + daySkeletons: Array; + } { + const range = XLSX.utils.decode_range(worksheet["!ref"] || ""); + let isHeaderParsed: boolean = false; + + let group: InternalId = null; + const days: Array = []; + + for (let row = range.s.r + 1; row <= range.e.r; ++row) { + const dayName = ScheduleParser.getCellName(worksheet, row, 0); + if (!dayName) continue; + + if (!isHeaderParsed) { + isHeaderParsed = true; + + --row; + for ( + let column = range.s.c + 2; + column <= range.e.c; + ++column + ) { + const groupName = ScheduleParser.getCellName( + worksheet, + row, + column, + ); + if (!groupName || this.group !== groupName) continue; + + group = { row: row, column: column, name: groupName }; + break; + } + ++row; + } + + days.push({ row: row, column: 0, name: dayName, lessons: [] }); + + if ( + days.length > 2 && + days[days.length - 2].name.startsWith("Суббота") + ) + break; + } + + return { daySkeletons: days, groupSkeleton: group }; + } + + async getSchedule( + forceCached: boolean = false, + ): Promise { + let downloadData: XlsDownloaderResult; + + if ( + !forceCached || + (downloadData = await this.xlsDownloader.getCachedXLS()) === null + ) { + console.debug("Обновление кеша..."); + downloadData = await this.xlsDownloader.downloadXLS(); + + if ( + !downloadData.new && + this.lastResult && + this.xlsDownloader.getCacheMode() != XlsDownloaderCacheMode.NONE + ) { + console.debug( + "Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...", + ); + console.debug("будет возвращён предыдущий результат."); + + return this.lastResult; + } + } + + console.debug("Чтение кешированного XLS документа..."); + + const workBook = XLSX.read(downloadData.fileData); + const workSheet = workBook.Sheets[workBook.SheetNames[0]]; + + const { groupSkeleton, daySkeletons } = this.parseSkeleton(workSheet); + + const group = new GroupDto(groupSkeleton.name); + + for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) { + const daySkeleton = daySkeletons[dayIdx]; + const day = new DayDto(daySkeleton.name); + + const lessonTimeColumn = daySkeletons[0].column + 1; + const rowDistance = daySkeletons[dayIdx + 1].row - daySkeleton.row; + + for ( + let row = daySkeleton.row; + row < daySkeleton.row + rowDistance; + ++row + ) { + const time = ScheduleParser.getCellName( + workSheet, + row, + lessonTimeColumn, + )?.replaceAll(" ", ""); + if (!time || typeof time !== "string") continue; + + const rawName = ScheduleParser.getCellName( + workSheet, + row, + groupSkeleton.column, + ); + const cabinets: Array = []; + + const rawCabinets = String( + ScheduleParser.getCellName( + workSheet, + row, + groupSkeleton.column + 1, + ), + ); + if (rawCabinets !== "null") { + const rawLessonCabinetParts = rawCabinets.split(/(\n|\s)/g); + + for (const cabinet of rawLessonCabinetParts) { + if ( + cabinet.length === 0 || + cabinet === " " || + cabinet === "\n" + ) + continue; + + cabinets.push(cabinet); + } + } + + const type = + !rawName || rawName.length === 0 + ? LessonTypeDto.NONE + : time?.includes("пара") + ? LessonTypeDto.DEFAULT + : LessonTypeDto.CUSTOM; + + const { name, teacherFullNames } = this.parseTeacherFullNames( + trimAll(rawName?.replace("\n", "") ?? ""), + ); + + day.lessons.push( + new LessonDto( + type, + LessonTimeDto.fromString( + type === LessonTypeDto.DEFAULT + ? time.substring(5) + : time, + ), + name, + cabinets, + teacherFullNames, + ), + ); + } + + day.fillIndices(); + group.days.push(day); + } + + return (this.lastResult = { + etag: downloadData.etag, + group: group, + affectedDays: this.getAffectedDays(this.lastResult?.group, group), + }); + } + + public getLastResult(): ScheduleParseResult | null { + return this.lastResult; + } + + private getAffectedDays( + cachedGroup: GroupDto | null, + group: GroupDto, + ): Array { + const affectedDays: Array = []; + + if (!cachedGroup) return affectedDays; + + // noinspection SpellCheckingInspection + const dayEquals = (lday: DayDto | null, rday: DayDto): boolean => { + if ( + rday === undefined || + rday.lessons.length != lday.lessons.length + ) + return false; + + for (const lessonIdx in lday.lessons) { + // noinspection SpellCheckingInspection + const llesson = lday.lessons[lessonIdx]; + // noinspection SpellCheckingInspection + const rlesson = rday.lessons[lessonIdx]; + if ( + llesson.name.length > 0 && + (llesson.name !== rlesson.name || + llesson.time.start !== rlesson.time.start || + llesson.time.end !== rlesson.time.end || + llesson.cabinets.toString() !== + rlesson.cabinets.toString() || + llesson.teacherNames.toString() !== + rlesson.teacherNames.toString()) + ) + return false; + } + + return true; + }; + + for (const dayIdx in group.days) { + // noinspection SpellCheckingInspection + const lday = group.days[dayIdx]; + // noinspection SpellCheckingInspection + const rday = cachedGroup.days[dayIdx]; + + if (!dayEquals(lday, rday)) + affectedDays.push(Number.parseInt(dayIdx)); + } + + return affectedDays; + } +} diff --git a/src/schedule/internal/xls-downloader/basic-xls-downloader.ts b/src/schedule/internal/xls-downloader/basic-xls-downloader.ts new file mode 100644 index 0000000..3e5384b --- /dev/null +++ b/src/schedule/internal/xls-downloader/basic-xls-downloader.ts @@ -0,0 +1,92 @@ +import { + XlsDownloaderBase, + XlsDownloaderCacheMode, + XlsDownloaderResult, +} from "./xls-downloader.base"; +import axios from "axios"; +import { JSDOM } from "jsdom"; + +export class BasicXlsDownloader extends XlsDownloaderBase { + cache: XlsDownloaderResult | null = null; + + private async getDOM(): Promise { + const response = await axios.get(this.url); + + if (response.status !== 200) { + throw new Error(`Не удалось получить данные с основной страницы! +Статус код: ${response.status} +${response.statusText}`); + } + + return new JSDOM(response.data, { + url: this.url, + contentType: "text/html", + }); + } + + private parseData(dom: JSDOM): { + downloadLink: string; + updateDate: string; + } { + const schedule_block = dom.window.document.getElementById("cont-i"); + if (schedule_block === null) + throw new Error("Не удалось найти блок расписаний!"); + + const schedules = schedule_block.getElementsByTagName("div"); + if (schedules === null || schedules.length === 0) + throw new Error("Не удалось найти строку с расписанием!"); + + const poltavskaya = schedules[0]; + const link = poltavskaya.getElementsByTagName("a")[0]!; + + const spans = poltavskaya.getElementsByTagName("span"); + const update_date = spans[3].textContent!.trimStart(); + + return { + downloadLink: link.href, + updateDate: update_date, + }; + } + + public async getCachedXLS(): Promise { + if (this.cache === null) return null; + + this.cache.new = this.cacheMode === XlsDownloaderCacheMode.HARD; + + return this.cache; + } + + public async downloadXLS(): Promise { + if ( + this.cacheMode === XlsDownloaderCacheMode.HARD && + this.cache !== null + ) + return this.getCachedXLS(); + + const dom = await this.getDOM(); + const parse_data = this.parseData(dom); + + const response = await axios.get(parse_data.downloadLink, { + responseType: "arraybuffer", + }); + if (response.status !== 200) { + throw new Error(`Не удалось получить excel файл! +Статус код: ${response.status} +${response.statusText}`); + } + + const result: XlsDownloaderResult = { + fileData: response.data.buffer, + updateDate: parse_data.updateDate, + etag: response.headers["etag"], + new: + this.cacheMode === XlsDownloaderCacheMode.NONE + ? true + : this.cache?.etag !== response.headers["etag"], + }; + + if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result; + + return result; + } +} diff --git a/src/schedule/internal/xls-downloader/xls-downloader.base.ts b/src/schedule/internal/xls-downloader/xls-downloader.base.ts new file mode 100644 index 0000000..21060a8 --- /dev/null +++ b/src/schedule/internal/xls-downloader/xls-downloader.base.ts @@ -0,0 +1,27 @@ +export type XlsDownloaderResult = { + fileData: ArrayBuffer; + updateDate: string; + etag: string; + new: boolean; +}; + +export enum XlsDownloaderCacheMode { + NONE = 0, + SOFT, // читать кеш только если не был изменён etag. + HARD, // читать кеш всегда, кроме случаев его отсутствия +} + +export abstract class XlsDownloaderBase { + public constructor( + protected readonly url: string, + protected readonly cacheMode = XlsDownloaderCacheMode.NONE, + ) {} + + public abstract downloadXLS(): Promise; + + public abstract getCachedXLS(): Promise; + + public getCacheMode(): XlsDownloaderCacheMode { + return this.cacheMode; + } +} diff --git a/src/schedule/schedule.controller.ts b/src/schedule/schedule.controller.ts new file mode 100644 index 0000000..954b49a --- /dev/null +++ b/src/schedule/schedule.controller.ts @@ -0,0 +1,36 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + UseGuards, +} from "@nestjs/common"; +import { AuthGuard } from "../auth/auth.guard"; +import { ScheduleService } from "./schedule.service"; +import { ScheduleDto } from "../dto/schedule.dto"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + refs, +} from "@nestjs/swagger"; + +@Controller("api/v1/schedule") +@UseGuards(AuthGuard) +export class ScheduleController { + constructor(private scheduleService: ScheduleService) {} + + @ApiExtraModels(ScheduleDto) + @ApiOperation({ summary: "Получение расписания", tags: ["schedule"] }) + @ApiOkResponse({ + description: "Расписание получено успешно", + schema: refs(ScheduleDto)[0], + }) + @ResultDto(ScheduleDto) + @HttpCode(HttpStatus.OK) + @Get("get") + getSchedule(): Promise { + return this.scheduleService.getSchedule(); + } +} diff --git a/src/schedule/schedule.module.ts b/src/schedule/schedule.module.ts new file mode 100644 index 0000000..2f9a752 --- /dev/null +++ b/src/schedule/schedule.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { ScheduleService } from "./schedule.service"; +import { ScheduleController } from "./schedule.controller"; +import { UsersService } from "../users/users.service"; +import { PrismaService } from "../prisma/prisma.service"; + +@Module({ + imports: [], + providers: [ScheduleService, UsersService, PrismaService], + controllers: [ScheduleController], + exports: [ScheduleService], +}) +export class ScheduleModule {} diff --git a/src/schedule/schedule.service.ts b/src/schedule/schedule.service.ts new file mode 100644 index 0000000..2899551 --- /dev/null +++ b/src/schedule/schedule.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from "@nestjs/common"; +import { + ScheduleParser, + ScheduleParseResult, +} from "./internal/schedule-parser/schedule-parser"; +import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader"; +import { XlsDownloaderCacheMode } from "./internal/xls-downloader/xls-downloader.base"; +import { ScheduleDto } from "../dto/schedule.dto"; + +@Injectable() +export class ScheduleService { + private readonly scheduleParser = new ScheduleParser( + new BasicXlsDownloader( + "https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409", + XlsDownloaderCacheMode.SOFT, + ), + "ИС-214/23", + ); + + private lastCacheUpdate: Date = new Date(0); + private lastChangedDays: Array = []; + + constructor() {} + + async getSchedule(): Promise { + const now = new Date(); + const cacheExpired = + (this.lastCacheUpdate.valueOf() - now.valueOf()) / 1000 / 60 > 5; + + if (cacheExpired) this.lastCacheUpdate = now; + + const schedule = await this.scheduleParser.getSchedule(!cacheExpired); + if (schedule.affectedDays.length !== 0) + this.lastChangedDays = schedule.affectedDays; + + return { + updatedAt: this.lastCacheUpdate, + data: schedule.group, + etag: schedule.etag, + lastChangedDays: this.lastChangedDays, + }; + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..1fcc952 --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { UsersService } from "./users.service"; +import { PrismaService } from "../prisma/prisma.service"; + +@Module({ + providers: [PrismaService, UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..463e911 --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { Prisma, user } from "@prisma/client"; + +@Injectable() +export class UsersService { + constructor(private readonly prismaService: PrismaService) {} + + async findUnique(where: Prisma.userWhereUniqueInput): Promise { + return this.prismaService.user.findUnique({ where: where }); + } + + async findOne(where: Prisma.userWhereInput): Promise { + return this.prismaService.user.findFirst({ where: where }); + } + + async update(params: { + where: Prisma.userWhereUniqueInput; + data: Prisma.userUpdateInput; + }): Promise { + return this.prismaService.user.update(params); + } + + async create(data: Prisma.userCreateInput): Promise { + return this.prismaService.user.create({ data }); + } + + async has(where: Prisma.userWhereUniqueInput): Promise { + return (await this.prismaService.user.count({ where })) > 0; + } +} diff --git a/src/utility/parse-pipe/object-id.pipe.ts b/src/utility/parse-pipe/object-id.pipe.ts new file mode 100644 index 0000000..10c5fe9 --- /dev/null +++ b/src/utility/parse-pipe/object-id.pipe.ts @@ -0,0 +1,20 @@ +import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common"; + +@Injectable() +export class ObjectIdPipe implements PipeTransform { + transform(value: any): string { + if ( + value === null || + value === undefined || + typeof value !== "string" || + value.length !== 24 + ) + throw new BadRequestException("Invalid ObjectId"); + + const return_string = value.toLowerCase(); + if (!/^[0-9a-f]{24}$/.test(return_string)) + throw new BadRequestException("Invalid ObjectId"); + + return return_string; + } +} diff --git a/src/utility/prisma/convert.helper.ts b/src/utility/prisma/convert.helper.ts new file mode 100644 index 0000000..a592d48 --- /dev/null +++ b/src/utility/prisma/convert.helper.ts @@ -0,0 +1,9 @@ +type Nullable = { + [P in keyof T]: T[P] | null | Array; +}; + +export function convertToPrismaInput(dto: Nullable): T { + return Object.entries(dto) + .filter((x) => x[1] !== undefined) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) as T; +} diff --git a/src/utility/string.util.ts b/src/utility/string.util.ts new file mode 100644 index 0000000..0f7570d --- /dev/null +++ b/src/utility/string.util.ts @@ -0,0 +1,31 @@ +export function trimAll(str: string): string { + return str.replace(/\s\s+/g, " ").trim(); +} + +const customLessonIdxToTextPresets = [ + "Первое", + "Второе", + "Третье", + "Четвёртое", + "Пятое", + "Шестое", + "Седьмое", +]; + +export function customLessonIdxToText(num: number): string { + return customLessonIdxToTextPresets[num]; +} + +const defaultLessonIdxToTextPresets = [ + "Первая", + "Вторая", + "Третья", + "Четвёртая", + "Пятая", + "Шестая", + "Седьмая", +]; + +export function defaultLessonIdxToText(num: number): string { + return defaultLessonIdxToTextPresets[num]; +} diff --git a/src/utility/validation/class-validator.interceptor.ts b/src/utility/validation/class-validator.interceptor.ts new file mode 100644 index 0000000..04d3f20 --- /dev/null +++ b/src/utility/validation/class-validator.interceptor.ts @@ -0,0 +1,78 @@ +import "reflect-metadata"; + +import { + CallHandler, + ExecutionContext, + HttpStatus, + Injectable, + InternalServerErrorException, + NestInterceptor, + UnprocessableEntityException, +} from "@nestjs/common"; +import { map, Observable } from "rxjs"; +import { instanceToPlain, plainToInstance } from "class-transformer"; +import { validate, ValidationOptions } from "class-validator"; + +@Injectable() +export class ClassValidatorInterceptor implements NestInterceptor { + constructor(private readonly validatorOptions: ValidationOptions) {} + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable | Promise> { + return next.handle().pipe( + map(async (returnValue: any) => { + const handler = context.getHandler(); + const cls = context.getClass(); + + const classDto = Reflect.getMetadata( + "design:result-dto", + cls.prototype, + handler.name, + ); + + if (classDto === undefined) { + console.warn( + `Undefined DTO type for function \"${cls.name}::${handler.name}\"!`, + ); + return returnValue; + } + + const returnValueDto = plainToInstance( + classDto, + instanceToPlain(returnValue), + ); + + if (!(returnValueDto instanceof Object)) + throw new InternalServerErrorException( + returnValueDto, + "Return value is not object!", + ); + + const validationErrors = await validate( + returnValueDto, + this.validatorOptions, + ); + + if (validationErrors.length > 0) { + throw new UnprocessableEntityException({ + message: validationErrors + .map((value) => Object.values(value.constraints)) + .flat(), + object: returnValue, + error: "Response Validation Failed", + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + } + return returnValue; + }), + ); + } +} + +export function ResultDto(type: any) { + return (target: NonNullable, propertyKey: string | symbol) => { + Reflect.defineMetadata("design:result-dto", type, target, propertyKey); + }; +} diff --git a/src/utility/validation/partial-validation.pipe.ts b/src/utility/validation/partial-validation.pipe.ts new file mode 100644 index 0000000..0a548f1 --- /dev/null +++ b/src/utility/validation/partial-validation.pipe.ts @@ -0,0 +1,37 @@ +import { + ArgumentMetadata, + PipeTransform, + Type, + ValidationPipe, +} from "@nestjs/common"; +import { ValidationPipeOptions } from "@nestjs/common/pipes/validation.pipe"; + +export class PartialValidationPipe implements PipeTransform { + private readonly validationPipe: ValidationPipe; + private readonly partialValidationPipe: ValidationPipe; + + constructor(options?: ValidationPipeOptions) { + this.validationPipe = new ValidationPipe(options); + this.partialValidationPipe = new ValidationPipe({ + ...options, + ...{ + skipUndefinedProperties: true, + skipNullValues: false, + }, + }); + } + + canBePartial(metatype?: Type): boolean { + if (metatype === undefined) return false; + return ( + ["Update"].find((kw) => metatype.name.includes(kw)) !== undefined + ); + } + + transform(value: any, metadata: ArgumentMetadata): any { + if (metadata.type == "body" && this.canBePartial(metadata.metatype)) + return this.partialValidationPipe.transform(value, metadata); + + return this.validationPipe.transform(value, metadata); + } +}