From 5fe5d56ca9af29faaf41ef4cb1ca1cceb0a65882 Mon Sep 17 00:00:00 2001 From: n08i40k Date: Sat, 19 Oct 2024 02:12:37 +0400 Subject: [PATCH] 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Я пока перечислю - умру. Надо научиться писать changelog постепенно. --- nest-cli.json | 7 +- package-lock.json | 700 ++++++++++++++++-- package.json | 7 +- src/auth-role/auth-role.decorator.ts | 5 - src/auth/auth-role.decorator.ts | 6 + src/auth/auth.controller.ts | 147 ---- src/auth/auth.guard.ts | 2 +- src/auth/auth.module.ts | 5 +- src/auth/auth.pipe.ts | 9 +- src/auth/auth.service.ts | 135 ++-- src/auth/dto/change-password.dto.ts | 17 + src/auth/dto/sign-in-response.dto.ts | 25 + src/auth/dto/sign-in.dto.ts | 12 + src/auth/dto/sign-up-response.dto.ts | 4 + src/auth/dto/sign-up.dto.ts | 9 + src/auth/dto/update-token-response.dto.ts | 4 + src/auth/dto/update-token.dto.ts | 4 + src/auth/v1-auth.controller.ts | 138 ++++ src/auth/v2-auth.controller.ts | 93 +++ src/dto/auth.dto.ts | 101 --- src/dto/fcm.dto.ts | 17 - src/dto/schedule-replacer.dto.ts | 29 - src/dto/schedule.dto.ts | 337 --------- src/dto/user.dto.ts | 126 ---- src/firebase-admin/dto/fcm-post-update.dto.ts | 17 + .../firebase-admin.controller.ts | 47 +- src/firebase-admin/firebase-admin.service.ts | 17 +- src/main.ts | 16 +- .../dto/clear-schedule-replacer.dto.ts | 10 + src/schedule/dto/schedule-replacer.dto.ts | 14 + src/schedule/dto/set-schedule-replacer.dto.ts | 23 + src/schedule/dto/v1/cache-status.dto.ts | 19 + src/schedule/dto/v1/v1-cache-status.dto.ts | 21 + src/schedule/dto/v1/v1-day.dto.ts | 69 ++ .../dto/v1/v1-group-schedule-name.dto.ts | 6 + src/schedule/dto/v1/v1-group-schedule.dto.ts | 23 + src/schedule/dto/v1/v1-group.dto.ts | 25 + src/schedule/dto/v1/v1-lesson-time.dto.ts | 39 + src/schedule/dto/v1/v1-lesson.dto.ts | 76 ++ .../dto/v1/v1-schedule-group-names.dto.ts | 11 + src/schedule/dto/v1/v1-schedule.dto.ts | 36 + src/schedule/dto/v1/v1-site-main-page.dto.ts | 11 + src/schedule/dto/v2/v2-cache-status.dto.ts | 18 + src/schedule/dto/v2/v2-day.dto.ts | 32 + .../dto/v2/v2-group-schedule-by-name.dto.ts | 6 + src/schedule/dto/v2/v2-group-schedule.dto.ts | 22 + src/schedule/dto/v2/v2-group.dto.ts | 20 + .../dto/v2/v2-lesson-sub-group.dto.ts | 26 + src/schedule/dto/v2/v2-lesson-time.dto.ts | 17 + src/schedule/dto/v2/v2-lesson.dto.ts | 67 ++ .../dto/v2/v2-schedule-group-names.dto.ts | 10 + src/schedule/dto/v2/v2-schedule.dto.ts | 29 + .../dto/v2/v2-update-download-url.dto.ts | 10 + src/schedule/enum/v1-lesson-type.enum.ts | 4 + src/schedule/enum/v2-lesson-type.enum.ts | 5 + ...hedule-parser.ts => v1-schedule-parser.ts} | 110 +-- .../v2-schedule-parser.spec.ts | 116 +++ .../schedule-parser/v2-schedule-parser.ts | 673 +++++++++++++++++ .../basic-xls-downloader.spec.ts | 35 + .../xls-downloader/basic-xls-downloader.ts | 217 +++--- .../xls-downloader/xls-downloader.base.ts | 32 - .../xls-downloader.interface.ts | 74 ++ src/schedule/schedule-replacer.controller.ts | 88 +-- src/schedule/schedule-replacer.service.ts | 14 +- src/schedule/schedule.module.ts | 21 +- ...vice.spec.ts => schedule.service.spec.ts0} | 14 +- src/schedule/schedule.service.ts | 204 ----- ...ontroller.ts => v1-schedule.controller.ts} | 106 ++- src/schedule/v1-schedule.service.ts | 234 ++++++ src/schedule/v2-schedule.controller.ts | 135 ++++ src/schedule/v2-schedule.service.ts | 166 +++++ src/users/dto/change-group.dto.ts | 4 + src/users/dto/change-username.dto.ts | 4 + src/users/dto/v1/v1-client-user.dto.ts | 20 + src/users/dto/v2/v2-client-user.dto.ts | 20 + src/users/entity/fcm-user.entity.ts | 19 + src/users/entity/user.entity.ts | 83 +++ src/users/user-role.enum.ts | 5 + src/users/users.module.ts | 5 +- src/users/users.service.ts | 57 +- ...s.controller.ts => v1-users.controller.ts} | 77 +- src/users/v2-users.controller.ts | 41 + .../class-validators/conditional-field.ts | 39 + 83 files changed, 3796 insertions(+), 1502 deletions(-) delete mode 100644 src/auth-role/auth-role.decorator.ts create mode 100644 src/auth/auth-role.decorator.ts delete mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/dto/change-password.dto.ts create mode 100644 src/auth/dto/sign-in-response.dto.ts create mode 100644 src/auth/dto/sign-in.dto.ts create mode 100644 src/auth/dto/sign-up-response.dto.ts create mode 100644 src/auth/dto/sign-up.dto.ts create mode 100644 src/auth/dto/update-token-response.dto.ts create mode 100644 src/auth/dto/update-token.dto.ts create mode 100644 src/auth/v1-auth.controller.ts create mode 100644 src/auth/v2-auth.controller.ts delete mode 100644 src/dto/auth.dto.ts delete mode 100644 src/dto/fcm.dto.ts delete mode 100644 src/dto/schedule-replacer.dto.ts delete mode 100644 src/dto/schedule.dto.ts delete mode 100644 src/dto/user.dto.ts create mode 100644 src/firebase-admin/dto/fcm-post-update.dto.ts create mode 100644 src/schedule/dto/clear-schedule-replacer.dto.ts create mode 100644 src/schedule/dto/schedule-replacer.dto.ts create mode 100644 src/schedule/dto/set-schedule-replacer.dto.ts create mode 100644 src/schedule/dto/v1/cache-status.dto.ts create mode 100644 src/schedule/dto/v1/v1-cache-status.dto.ts create mode 100644 src/schedule/dto/v1/v1-day.dto.ts create mode 100644 src/schedule/dto/v1/v1-group-schedule-name.dto.ts create mode 100644 src/schedule/dto/v1/v1-group-schedule.dto.ts create mode 100644 src/schedule/dto/v1/v1-group.dto.ts create mode 100644 src/schedule/dto/v1/v1-lesson-time.dto.ts create mode 100644 src/schedule/dto/v1/v1-lesson.dto.ts create mode 100644 src/schedule/dto/v1/v1-schedule-group-names.dto.ts create mode 100644 src/schedule/dto/v1/v1-schedule.dto.ts create mode 100644 src/schedule/dto/v1/v1-site-main-page.dto.ts create mode 100644 src/schedule/dto/v2/v2-cache-status.dto.ts create mode 100644 src/schedule/dto/v2/v2-day.dto.ts create mode 100644 src/schedule/dto/v2/v2-group-schedule-by-name.dto.ts create mode 100644 src/schedule/dto/v2/v2-group-schedule.dto.ts create mode 100644 src/schedule/dto/v2/v2-group.dto.ts create mode 100644 src/schedule/dto/v2/v2-lesson-sub-group.dto.ts create mode 100644 src/schedule/dto/v2/v2-lesson-time.dto.ts create mode 100644 src/schedule/dto/v2/v2-lesson.dto.ts create mode 100644 src/schedule/dto/v2/v2-schedule-group-names.dto.ts create mode 100644 src/schedule/dto/v2/v2-schedule.dto.ts create mode 100644 src/schedule/dto/v2/v2-update-download-url.dto.ts create mode 100644 src/schedule/enum/v1-lesson-type.enum.ts create mode 100644 src/schedule/enum/v2-lesson-type.enum.ts rename src/schedule/internal/schedule-parser/{schedule-parser.ts => v1-schedule-parser.ts} (73%) create mode 100644 src/schedule/internal/schedule-parser/v2-schedule-parser.spec.ts create mode 100644 src/schedule/internal/schedule-parser/v2-schedule-parser.ts create mode 100644 src/schedule/internal/xls-downloader/basic-xls-downloader.spec.ts delete mode 100644 src/schedule/internal/xls-downloader/xls-downloader.base.ts create mode 100644 src/schedule/internal/xls-downloader/xls-downloader.interface.ts rename src/schedule/{schedule.service.spec.ts => schedule.service.spec.ts0} (83%) delete mode 100644 src/schedule/schedule.service.ts rename src/schedule/{schedule.controller.ts => v1-schedule.controller.ts} (54%) create mode 100644 src/schedule/v1-schedule.service.ts create mode 100644 src/schedule/v2-schedule.controller.ts create mode 100644 src/schedule/v2-schedule.service.ts create mode 100644 src/users/dto/change-group.dto.ts create mode 100644 src/users/dto/change-username.dto.ts create mode 100644 src/users/dto/v1/v1-client-user.dto.ts create mode 100644 src/users/dto/v2/v2-client-user.dto.ts create mode 100644 src/users/entity/fcm-user.entity.ts create mode 100644 src/users/entity/user.entity.ts create mode 100644 src/users/user-role.enum.ts rename src/users/{users.controller.ts => v1-users.controller.ts} (51%) create mode 100644 src/users/v2-users.controller.ts create mode 100644 src/utility/class-validators/conditional-field.ts diff --git a/nest-cli.json b/nest-cli.json index fe0e28c..b246267 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -5,7 +5,12 @@ "compilerOptions": { "deleteOutDir": true, "plugins": [ - "@nestjs/swagger" + { + "name": "@nestjs/swagger", + "options": { + "introspectComments": true + } + } ] } } diff --git a/package-lock.json b/package-lock.json index f187e83..d2fd6f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "schedule-parser-next", - "version": "1.4.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "schedule-parser-next", - "version": "1.4.0", + "version": "2.0.0", "license": "UNLICENSED", "dependencies": { "@nestjs/cache-manager": "^2.2.2", "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.4.4", + "@nestjs/core": "^10.4.5", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.4.4", "@nestjs/swagger": "^7.4.2", @@ -26,6 +26,8 @@ "firebase-admin": "^12.6.0", "jsdom": "^25.0.0", "mongoose": "^8.6.1", + "nest-redoc": "^1.1.2", + "object-hash": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", @@ -42,6 +44,7 @@ "@types/jsdom": "^21.1.7", "@types/multer": "^1.4.12", "@types/node": "^20.16.5", + "@types/object-hash": "^3.0.6", "@types/supertest": "^6.0.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -917,12 +920,85 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "peer": true, + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, "node_modules/@fastify/busboy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.0.0.tgz", "integrity": "sha512-83rnH2nCvclWaPQQKvkJ2pdOjG4TZyEVuFDnlOF6KP08lDaaceVyw/W63mDuafQT+MKHCvXIPpE5uYWeM0rT4w==", "license": "MIT" }, + "node_modules/@fastify/cors": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", + "integrity": "sha512-YY9Ho3ovI+QHIL2hW+9X4XqQjXLjJqsU+sMV/xFsxZkE8p3GNnYVFpoOxF7SsP5ZL76gwvbo3V9L+FIekBGU4Q==", + "peer": true, + "dependencies": { + "fastify-plugin": "^4.0.0", + "mnemonist": "0.39.6" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "peer": true + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "peer": true, + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-7.4.0.tgz", + "integrity": "sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og==", + "peer": true, + "dependencies": { + "fast-querystring": "^1.0.0", + "fastify-plugin": "^4.0.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@fastify/middie": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@fastify/middie/-/middie-8.3.3.tgz", + "integrity": "sha512-+WHavMQr9CNTZoy2cjoDxoWp76kZ3JKjAtZj5sXNlxX5XBzHig0TeCPfPc+1+NQmliXtndT3PFwAjrQHE/6wnQ==", + "peer": true, + "dependencies": { + "@fastify/error": "^3.2.0", + "fastify-plugin": "^4.0.0", + "path-to-regexp": "^6.3.0", + "reusify": "^1.0.4" + } + }, + "node_modules/@fastify/middie/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "peer": true + }, "node_modules/@firebase/app-check-interop-types": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", @@ -1142,6 +1218,21 @@ "node": ">=6" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "peer": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "peer": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1203,7 +1294,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -1220,7 +1310,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -1232,7 +1321,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -1243,14 +1331,12 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -1267,7 +1353,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -1282,7 +1367,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -1965,9 +2049,9 @@ } }, "node_modules/@nestjs/core": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", - "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.5.tgz", + "integrity": "sha512-wk0KJ+6tuidqAdeemsQ40BCp1BgMsSuSLG577aqXLxXYoa8FQYPrdxoSzd05znYLwJYM55fisZWb3FLF9HT2qw==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", @@ -2062,6 +2146,45 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, + "node_modules/@nestjs/platform-fastify": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-10.4.5.tgz", + "integrity": "sha512-5kcLsloaKkG6i46qbHmz6m/XoEtwroBni3uwsNENw4OJ8fptllihOqG3PWKRfljfpH4twyKdtZfKvyjMHXNj4w==", + "peer": true, + "dependencies": { + "@fastify/cors": "9.0.1", + "@fastify/formbody": "7.4.0", + "@fastify/middie": "8.3.3", + "fastify": "4.28.1", + "light-my-request": "6.1.0", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@fastify/view": "^7.0.0 || ^8.0.0", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "@fastify/view": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-fastify/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "peer": true + }, "node_modules/@nestjs/schematics": { "version": "10.1.4", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.4.tgz", @@ -2209,7 +2332,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" @@ -2363,6 +2485,27 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "peer": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "peer": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "peer": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2638,6 +2781,12 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/object-hash": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-3.0.6.tgz", + "integrity": "sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", @@ -3132,6 +3281,12 @@ "node": ">=6.5" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "peer": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3201,7 +3356,6 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3217,7 +3371,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3408,6 +3561,25 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "peer": true, + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, "node_modules/axios": { "version": "1.7.7", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", @@ -3562,6 +3734,24 @@ } ] }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "peer": true, + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "peer": true + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -3662,7 +3852,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -4206,7 +4395,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4296,7 +4484,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4572,8 +4759,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", @@ -5108,6 +5294,29 @@ "node": ">= 0.10.0" } }, + "node_modules/express-basic-auth": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz", + "integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==", + "peer": true, + "dependencies": { + "basic-auth": "^2.0.1" + } + }, + "node_modules/express-handlebars": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-7.1.3.tgz", + "integrity": "sha512-O0W4n14iQ8+iFIDdiMh9HRI2nbVQJ/h1qndlD1TXWxxcfbKjKoqJh+ti2tROkyx4C4VQrt0y3bANBQ5auQAiew==", + "peer": true, + "dependencies": { + "glob": "^10.4.2", + "graceful-fs": "^4.2.11", + "handlebars": "^4.7.8" + }, + "engines": { + "node": ">=v16" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -5164,11 +5373,22 @@ "node": ">=18.0.0" } }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "peer": true + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "peer": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -5198,17 +5418,73 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "peer": true, + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "peer": true, + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "peer": true + }, "node_modules/fast-xml-parser": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", @@ -5232,11 +5508,61 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastify": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.28.1.tgz", + "integrity": "sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "peer": true, + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "peer": true + }, + "node_modules/fastify/node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "peer": true, + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -5361,6 +5687,20 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5453,7 +5793,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -5815,7 +6154,6 @@ "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", - "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -5974,8 +6312,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -6020,6 +6357,36 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "peer": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6462,8 +6829,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -6552,7 +6918,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -7275,6 +7640,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "peer": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -7428,11 +7806,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -7580,6 +7966,23 @@ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz", "integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==" }, + "node_modules/light-my-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.1.0.tgz", + "integrity": "sha512-+NFuhlOGoEwxeQfJ/pobkVFxcnKyDtiX847hLjuB/IzBxIl3q4VJeFI8uRCgb3AlTWL1lgOr+u5+8QdUcr33ng==", + "peer": true, + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", + "peer": true + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -7905,7 +8308,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7928,7 +8330,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -7972,6 +8373,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mnemonist": { + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", + "peer": true, + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/mongodb": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.0.tgz", @@ -8142,8 +8552,23 @@ "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "node_modules/nest-redoc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/nest-redoc/-/nest-redoc-1.1.2.tgz", + "integrity": "sha512-fihh9idA3t4b6BgSSuCPW237hEe4RhZrEaS/4mSBCRUOPI6WrP4JN4JY7dSuDkFLurQmp6Fyk/pfNuDy7SVxIw==", + "peerDependencies": { + "@nestjs/common": "^10.2.6", + "@nestjs/core": "^10.2.6", + "@nestjs/platform-express": "^10.2.6", + "@nestjs/platform-fastify": "^10.2.6", + "@nestjs/swagger": "^7.3.1", + "express-basic-auth": "^1.2.1", + "express-handlebars": "^7.1.2", + "handlebars": "^4.7.8", + "joi": "^17.10.2" + } }, "node_modules/node-abort-controller": { "version": "3.1.1", @@ -8269,8 +8694,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "optional": true, "engines": { "node": ">= 6" } @@ -8286,6 +8709,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", + "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==", + "peer": true + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8411,8 +8849,7 @@ "node_modules/package-json-from-dist": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -8484,7 +8921,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -8499,7 +8935,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -8514,8 +8949,7 @@ "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, "node_modules/path-to-regexp": { "version": "3.3.0", @@ -8549,6 +8983,49 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz", + "integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==", + "peer": true, + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "peer": true, + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "peer": true + }, + "node_modules/pino/node_modules/process-warning": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", + "peer": true + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -8717,6 +9194,12 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "peer": true + }, "node_modules/promise-coalesce": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", @@ -8861,6 +9344,12 @@ } ] }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "peer": true + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8941,6 +9430,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "peer": true, + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -8968,7 +9466,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -9053,6 +9550,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -9082,12 +9588,17 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "peer": true + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -9207,6 +9718,24 @@ } ] }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "peer": true, + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9272,6 +9801,12 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "peer": true + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -9355,6 +9890,12 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "node_modules/set-cookie-parser": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz", + "integrity": "sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==", + "peer": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9380,7 +9921,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -9392,7 +9932,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -9423,7 +9962,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -9446,6 +9984,15 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "peer": true, + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -9482,6 +10029,15 @@ "memory-pager": "^1.0.2" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "peer": true, + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9586,7 +10142,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -9612,7 +10167,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -10026,6 +10580,15 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "peer": true, + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -10071,6 +10634,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -10343,6 +10915,19 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "optional": true, + "peer": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/uid": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", @@ -10410,7 +10995,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -10684,7 +11268,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -10712,6 +11295,12 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "peer": true + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -10731,7 +11320,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", diff --git a/package.json b/package.json index e1f7866..e1675a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "schedule-parser-next", - "version": "1.4.0", + "version": "2.0.0", "description": "", "author": "N08I40K", "private": true, @@ -22,7 +22,7 @@ "dependencies": { "@nestjs/cache-manager": "^2.2.2", "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.4.4", + "@nestjs/core": "^10.4.5", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.4.4", "@nestjs/swagger": "^7.4.2", @@ -37,6 +37,8 @@ "firebase-admin": "^12.6.0", "jsdom": "^25.0.0", "mongoose": "^8.6.1", + "nest-redoc": "^1.1.2", + "object-hash": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", @@ -53,6 +55,7 @@ "@types/jsdom": "^21.1.7", "@types/multer": "^1.4.12", "@types/node": "^20.16.5", + "@types/object-hash": "^3.0.6", "@types/supertest": "^6.0.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/src/auth-role/auth-role.decorator.ts b/src/auth-role/auth-role.decorator.ts deleted file mode 100644 index b9b535f..0000000 --- a/src/auth-role/auth-role.decorator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Reflector } from "@nestjs/core"; -import { UserRoleDto } from "../dto/user.dto"; - -export const AuthRoles = Reflector.createDecorator(); -export const AuthUnauthorized = Reflector.createDecorator(); diff --git a/src/auth/auth-role.decorator.ts b/src/auth/auth-role.decorator.ts new file mode 100644 index 0000000..33c81b2 --- /dev/null +++ b/src/auth/auth-role.decorator.ts @@ -0,0 +1,6 @@ +import { Reflector } from "@nestjs/core"; + +import { UserRole } from "../users/user-role.enum"; + +export const AuthRoles = Reflector.createDecorator(); +export const AuthUnauthorized = Reflector.createDecorator(); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts deleted file mode 100644 index dd401b3..0000000 --- a/src/auth/auth.controller.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - Body, - Controller, - HttpCode, - HttpStatus, - NotFoundException, - Post, -} from "@nestjs/common"; -import { AuthService } from "./auth.service"; -import { - ApiBody, - ApiConflictResponse, - ApiCreatedResponse, - ApiExtraModels, - ApiNotFoundResponse, - ApiOkResponse, - ApiOperation, - ApiUnauthorizedResponse, - refs, -} from "@nestjs/swagger"; -import { - SignInReqDto, - SignInResDto, - SignUpReqDto, - SignUpResDto, - ChangePasswordReqDto, - UpdateTokenReqDto, - UpdateTokenResDto, - SignInResDtoV0, - SignInResDtoV2, -} from "../dto/auth.dto"; -import { ResultDto } from "../utility/validation/class-validator.interceptor"; -import { ScheduleService } from "../schedule/schedule.service"; -import { UserToken } from "./auth.decorator"; -import { ResponseVersion } from "../version/response-version.decorator"; - -@Controller("api/v1/auth") -export class AuthController { - constructor( - private readonly authService: AuthService, - private readonly scheduleService: ScheduleService, - ) {} - - @ApiExtraModels(SignInReqDto) - @ApiExtraModels(SignInResDtoV0) - @ApiExtraModels(SignInResDtoV2) - @ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] }) - @ApiBody({ schema: refs(SignInReqDto)[0] }) - @ApiOkResponse({ - description: "Авторизация прошла успешно", - schema: refs(SignInResDtoV0)[0], - }) - @ApiOkResponse({ - description: "Авторизация прошла успешно", - schema: refs(SignInResDtoV2)[0], - }) - @ApiUnauthorizedResponse({ - description: "Некорректное имя пользователя или пароль", - }) - @ResultDto([SignInResDtoV0, SignInResDtoV2]) - @HttpCode(HttpStatus.OK) - @Post("sign-in") - async signIn( - @Body() signInDto: SignInReqDto, - @ResponseVersion() responseVersion: number, - ): Promise { - const data = await this.authService.signIn(signInDto); - return SignInResDto.stripVersion(data, responseVersion); - } - - @ApiExtraModels(SignUpReqDto) - @ApiExtraModels(SignUpResDto) - @ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] }) - @ApiBody({ schema: refs(SignUpReqDto)[0] }) - @ApiCreatedResponse({ - description: "Регистрация прошла успешно", - schema: refs(SignUpResDto)[0], - }) - @ApiConflictResponse({ - description: "Такой пользователь уже существует", - }) - @ResultDto(SignUpResDto) - @HttpCode(HttpStatus.CREATED) - @Post("sign-up") - async signUp(@Body() signUpDto: SignUpReqDto) { - if ( - !(await this.scheduleService.getGroupNames()).names.includes( - signUpDto.group.replaceAll(" ", ""), - ) - ) { - throw new NotFoundException( - "Передано название несуществующей группы", - ); - } - - return this.authService.signUp(signUpDto); - } - - @ApiExtraModels(UpdateTokenReqDto) - @ApiExtraModels(UpdateTokenResDto) - @ApiOperation({ - summary: "Обновление просроченного токена", - tags: ["auth", "access-token"], - }) - @ApiBody({ schema: refs(UpdateTokenReqDto)[0] }) - @ApiOkResponse({ - description: "Токен обновлён успешно", - schema: refs(UpdateTokenResDto)[0], - }) - @ApiNotFoundResponse({ - description: "Передан несуществующий или недействительный токен", - }) - @ResultDto(UpdateTokenResDto) - @HttpCode(HttpStatus.OK) - @Post("update-token") - updateToken( - @Body() updateTokenDto: UpdateTokenReqDto, - ): Promise { - return this.authService.updateToken(updateTokenDto); - } - - @ApiExtraModels(ChangePasswordReqDto) - @ApiOperation({ - summary: "Обновление пароля", - tags: ["auth", "password"], - }) - @ApiBody({ schema: refs(ChangePasswordReqDto)[0] }) - @ApiOkResponse({ description: "Пароль обновлён успешно" }) - @ApiConflictResponse({ description: "Пароли идентичны" }) - @ApiUnauthorizedResponse({ - description: - "Передан неверный текущий пароль или запрос был послан без токена", - }) - @ResultDto(null) - @HttpCode(HttpStatus.OK) - @Post("change-password") - async changePassword( - @Body() changePasswordReqDto: ChangePasswordReqDto, - @UserToken() userToken: string, - ): Promise { - await this.authService - .decodeUserToken(userToken) - .then((user) => - this.authService.changePassword(user, changePasswordReqDto), - ); - } -} diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts index 76a7ef5..b12c6f7 100644 --- a/src/auth/auth.guard.ts +++ b/src/auth/auth.guard.ts @@ -9,7 +9,7 @@ import { JwtService } from "@nestjs/jwt"; import { Request } from "express"; import { UsersService } from "../users/users.service"; import { Reflector } from "@nestjs/core"; -import { AuthRoles, AuthUnauthorized } from "../auth-role/auth-role.decorator"; +import { AuthRoles, AuthUnauthorized } from "./auth-role.decorator"; import { isJWT } from "class-validator"; @Injectable() diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d677b47..aaaab5b 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -2,10 +2,11 @@ import { forwardRef, Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { jwtConstants } from "../contants"; import { AuthService } from "./auth.service"; -import { AuthController } from "./auth.controller"; +import { V1AuthController } from "./v1-auth.controller"; import { UsersModule } from "../users/users.module"; import { PrismaService } from "../prisma/prisma.service"; import { ScheduleModule } from "../schedule/schedule.module"; +import { V2AuthController } from "./v2-auth.controller"; @Module({ imports: [ @@ -18,7 +19,7 @@ import { ScheduleModule } from "../schedule/schedule.module"; }), ], providers: [AuthService, PrismaService], - controllers: [AuthController], + controllers: [V1AuthController, V2AuthController], exports: [AuthService], }) export class AuthModule {} diff --git a/src/auth/auth.pipe.ts b/src/auth/auth.pipe.ts index fd72e50..04103a8 100644 --- a/src/auth/auth.pipe.ts +++ b/src/auth/auth.pipe.ts @@ -5,16 +5,17 @@ import { } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { UsersService } from "../users/users.service"; -import { UserDto } from "../dto/user.dto"; + +import { User } from "../users/entity/user.entity"; @Injectable() -export class UserFromTokenPipe implements PipeTransform { +export class UserPipe implements PipeTransform { constructor( private readonly jwtService: JwtService, private readonly usersService: UsersService, ) {} - async transform(token: string): Promise { + async transform(token: string): Promise { const jwtUser: { id: string } = await this.jwtService.decode(token); if (!jwtUser) @@ -24,6 +25,6 @@ export class UserFromTokenPipe implements PipeTransform { if (!user) throw new UnauthorizedException("Передан некорректный токен!"); - return user as UserDto; + return user as User; } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index daa11bc..a86cae2 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -6,20 +6,15 @@ import { UnauthorizedException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; -import { - SignInReqDto, - SignInResDto, - SignUpReqDto, - SignUpResDto, - ChangePasswordReqDto, - UpdateTokenReqDto, - UpdateTokenResDto, -} from "../dto/auth.dto"; import { UsersService } from "../users/users.service"; import { genSalt, hash } from "bcrypt"; -import { Prisma, UserRole } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { Types } from "mongoose"; -import { UserDto, UserRoleDto } from "../dto/user.dto"; +import { UserRole } from "../users/user-role.enum"; +import { User } from "../users/entity/user.entity"; +import { SignInDto } from "./dto/sign-in.dto"; +import { SignUpDto } from "./dto/sign-up.dto"; +import { ChangePasswordDto } from "./dto/change-password.dto"; @Injectable() export class AuthService { @@ -28,41 +23,48 @@ export class AuthService { private readonly jwtService: JwtService, ) {} - async decodeUserToken(token: string): Promise { + /** + * Получение пользователя по его токену + * @param token - jwt токен + * @returns {User} - пользователь + * @throws {UnauthorizedException} - некорректный или недействительный токен + * @throws {UnauthorizedException} - токен указывает на несуществующего пользователя + * @throws {UnauthorizedException} - текущий токен устарел и был обновлён на новый + * @async + */ + async decodeUserToken(token: string): Promise { const jwtUser: { id: string } | null = await this.jwtService.verifyAsync(token); - if (jwtUser === null) { + const throwError = () => { throw new UnauthorizedException( "Некорректный или недействительный токен", ); - } + }; - const user = await this.usersService - .findUnique({ id: jwtUser.id }) - .then((user) => user as UserDto | null); + if (jwtUser === null) throwError(); - if (!user) - throw new UnauthorizedException("Не удалось найти пользователя!"); + const user = await this.usersService.findUnique({ id: jwtUser.id }); - if (user.accessToken !== token) { - throw new UnauthorizedException( - "Некорректный или недействительный токен", - ); - } + if (!user || user.accessToken !== token) throwError(); - return user as UserDto; + return user; } - async signUp(signUpDto: SignUpReqDto): Promise { - const group = signUpDto.group.replaceAll(" ", ""); - const username = signUpDto.username.replaceAll(" ", ""); + /** + * Регистрация нового пользователя + * @param signUp - данные нового пользователя + * @returns {User} - пользователь + * @throws {NotAcceptableException} - передана недопустимая роль + * @throws {ConflictException} - пользователь с таким именем уже существует + * @async + */ + async signUp(signUp: SignUpDto): Promise { + const group = signUp.group.replaceAll(" ", ""); + const username = signUp.username.replaceAll(" ", ""); - if ( - ![UserRoleDto.STUDENT, UserRoleDto.TEACHER].includes(signUpDto.role) - ) { + if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUp.role)) throw new NotAcceptableException("Передана неизвестная роль"); - } if (await this.usersService.contains({ username: username })) { throw new ConflictException( @@ -77,31 +79,33 @@ export class AuthService { id: id, username: username, salt: salt, - password: await hash(signUpDto.password, salt), + password: await hash(signUp.password, salt), accessToken: await this.jwtService.signAsync({ id: id, }), - role: signUpDto.role as UserRole, + role: signUp.role as UserRole, group: group, - version: signUpDto.version ?? "1.0.0", + version: signUp.version ?? "1.0.0", }; - return this.usersService.create(input).then((user) => { - return { - id: user.id, - accessToken: user.accessToken, - }; - }); + return await this.usersService.create(input); } - async signIn(signInDto: SignInReqDto): Promise { + /** + * Авторизация пользователя + * @param signIn - данные авторизации + * @returns {User} - пользователь + * @throws {UnauthorizedException} - некорректное имя пользователя или пароль + * @async + */ + async signIn(signIn: SignInDto): Promise { const user = await this.usersService.findUnique({ - username: signInDto.username.replaceAll(" ", ""), + username: signIn.username.replaceAll(" ", ""), }); if ( !user || - user.password !== (await hash(signInDto.password, user.salt)) + user.password !== (await hash(signIn.password, user.salt)) ) { throw new UnauthorizedException( "Некорректное имя пользователя или пароль!", @@ -110,19 +114,24 @@ export class AuthService { const accessToken = await this.jwtService.signAsync({ id: user.id }); - await this.usersService.update({ + return await this.usersService.update({ where: { id: user.id }, data: { accessToken: accessToken }, }); - - return { id: user.id, accessToken: accessToken, group: user.group }; } - async updateToken( - updateTokenDto: UpdateTokenReqDto, - ): Promise { + /** + * Обновление токена пользователя + * @param oldToken - старый токен + * @returns {User} - пользователь + * @throws {NotFoundException} - некорректный или недействительный токен + * @throws {NotFoundException} - токен указывает на несуществующего пользователя + * @throws {NotFoundException} - текущий токен устарел и был обновлён на новый + * @async + */ + async updateToken(oldToken: string): Promise { if ( - !(await this.jwtService.verifyAsync(updateTokenDto.accessToken, { + !(await this.jwtService.verifyAsync(oldToken, { ignoreExpiration: true, })) ) { @@ -131,12 +140,10 @@ export class AuthService { ); } - const jwtUser: { id: string } = await this.jwtService.decode( - updateTokenDto.accessToken, - ); + const jwtUser: { id: string } = await this.jwtService.decode(oldToken); const user = await this.usersService.findUnique({ id: jwtUser.id }); - if (!user || user.accessToken !== updateTokenDto.accessToken) { + if (!user || user.accessToken !== oldToken) { throw new NotFoundException( "Некорректный или недействительный токен!", ); @@ -144,19 +151,25 @@ export class AuthService { const accessToken = await this.jwtService.signAsync({ id: user.id }); - await this.usersService.update({ + return await this.usersService.update({ where: { id: user.id }, data: { accessToken: accessToken }, }); - - return { accessToken: accessToken }; } + /** + * Смена пароля пользователя + * @param user - пользователь + * @param changePassword - старый и новый пароли + * @throws {ConflictException} - пароли идентичны + * @throws {UnauthorizedException} - неверный исходный пароль + * @async + */ async changePassword( - user: UserDto, - changePasswordReqDto: ChangePasswordReqDto, + user: User, + changePassword: ChangePasswordDto, ): Promise { - const { oldPassword, newPassword } = changePasswordReqDto; + const { oldPassword, newPassword } = changePassword; if (oldPassword == newPassword) throw new ConflictException("Пароли идентичны"); diff --git a/src/auth/dto/change-password.dto.ts b/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..1bc8639 --- /dev/null +++ b/src/auth/dto/change-password.dto.ts @@ -0,0 +1,17 @@ +import { IsString } from "class-validator"; + +export class ChangePasswordDto { + /** + * Старый пароль + * @example "my-old-password" + */ + @IsString() + oldPassword: string; + + /** + * Новый пароль + * @example "my-new-password" + */ + @IsString() + newPassword: string; +} diff --git a/src/auth/dto/sign-in-response.dto.ts b/src/auth/dto/sign-in-response.dto.ts new file mode 100644 index 0000000..78bb6f4 --- /dev/null +++ b/src/auth/dto/sign-in-response.dto.ts @@ -0,0 +1,25 @@ +import { IsJWT, IsMongoId, IsOptional, IsString } from "class-validator"; + +export class SignInResponseDto { + /** + * Идентификатор (ObjectId) + * @example "66e1b7e255c5d5f1268cce90" + */ + @IsMongoId() + id: string; + + /** + * Последний токен доступа + * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..." + */ + @IsJWT() + accessToken: string; + + /** + * Группа + * @example "ИС-214/23" + */ + @IsString() + @IsOptional() + group?: string; +} diff --git a/src/auth/dto/sign-in.dto.ts b/src/auth/dto/sign-in.dto.ts new file mode 100644 index 0000000..20d823d --- /dev/null +++ b/src/auth/dto/sign-in.dto.ts @@ -0,0 +1,12 @@ +import { PickType } from "@nestjs/swagger"; +import { User } from "../../users/entity/user.entity"; +import { IsString } from "class-validator"; + +export class SignInDto extends PickType(User, ["username"]) { + /** + * Пароль в исходном виде + * @example "my-password" + */ + @IsString() + password: string; +} diff --git a/src/auth/dto/sign-up-response.dto.ts b/src/auth/dto/sign-up-response.dto.ts new file mode 100644 index 0000000..cde869e --- /dev/null +++ b/src/auth/dto/sign-up-response.dto.ts @@ -0,0 +1,4 @@ +import { PickType } from "@nestjs/swagger"; +import { User } from "../../users/entity/user.entity"; + +export class SignUpResponseDto extends PickType(User, ["id", "accessToken"]) {} diff --git a/src/auth/dto/sign-up.dto.ts b/src/auth/dto/sign-up.dto.ts new file mode 100644 index 0000000..285e629 --- /dev/null +++ b/src/auth/dto/sign-up.dto.ts @@ -0,0 +1,9 @@ +import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; +import { SignInDto } from "./sign-in.dto"; +import { User } from "../../users/entity/user.entity"; + +export class SignUpDto extends IntersectionType( + SignInDto, + PickType(User, ["role", "group"]), + PartialType(PickType(User, ["version"])), +) {} diff --git a/src/auth/dto/update-token-response.dto.ts b/src/auth/dto/update-token-response.dto.ts new file mode 100644 index 0000000..2fa8b30 --- /dev/null +++ b/src/auth/dto/update-token-response.dto.ts @@ -0,0 +1,4 @@ +import { UpdateTokenDto } from "./update-token.dto"; + +export class UpdateTokenResponseDto extends UpdateTokenDto { +} \ No newline at end of file diff --git a/src/auth/dto/update-token.dto.ts b/src/auth/dto/update-token.dto.ts new file mode 100644 index 0000000..b7fe46a --- /dev/null +++ b/src/auth/dto/update-token.dto.ts @@ -0,0 +1,4 @@ +import { PickType } from "@nestjs/swagger"; +import { User } from "../../users/entity/user.entity"; + +export class UpdateTokenDto extends PickType(User, ["accessToken"]) {} diff --git a/src/auth/v1-auth.controller.ts b/src/auth/v1-auth.controller.ts new file mode 100644 index 0000000..b1f3339 --- /dev/null +++ b/src/auth/v1-auth.controller.ts @@ -0,0 +1,138 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + NotFoundException, + Post, +} from "@nestjs/common"; +import { AuthService } from "./auth.service"; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { V1ScheduleService } from "../schedule/v1-schedule.service"; +import { UserToken } from "./auth.decorator"; +import { ResponseVersion } from "../version/response-version.decorator"; +import { SignInDto } from "./dto/sign-in.dto"; +import { SignInResponseDto } from "./dto/sign-in-response.dto"; +import { SignUpResponseDto } from "./dto/sign-up-response.dto"; +import { SignUpDto } from "./dto/sign-up.dto"; +import { UpdateTokenDto } from "./dto/update-token.dto"; +import { UpdateTokenResponseDto } from "./dto/update-token-response.dto"; +import { ChangePasswordDto } from "./dto/change-password.dto"; + +@ApiTags("v1/auth") +@Controller({ path: "auth", version: "1" }) +export class V1AuthController { + constructor( + private readonly authService: AuthService, + private readonly scheduleService: V1ScheduleService, + ) {} + + @ApiOperation({ summary: "Авторизация по логину и паролю" }) + @ApiBody({ type: SignInDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Авторизация прошла успешно", + type: SignInResponseDto, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "Некорректное имя пользователя или пароль", + }) + @ResultDto(SignInResponseDto) + @HttpCode(HttpStatus.OK) + @Post("sign-in") + async signIn( + @Body() signInDto: SignInDto, + @ResponseVersion() responseVersion: number, + ): Promise { + const data = await this.authService.signIn(signInDto); + + return { + id: data.id, + accessToken: data.accessToken, + group: responseVersion ? data.group : null, + }; + } + + @ApiOperation({ summary: "Регистрация по логину и паролю" }) + @ApiBody({ type: SignUpDto }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: "Регистрация прошла успешно", + type: SignUpResponseDto, + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: "Такой пользователь уже существует", + }) + @ResultDto(SignUpResponseDto) + @HttpCode(HttpStatus.CREATED) + @Post("sign-up") + async signUp(@Body() signUpDto: SignUpDto): Promise { + if ( + !(await this.scheduleService.getGroupNames()).names.includes( + signUpDto.group.replaceAll(" ", ""), + ) + ) { + throw new NotFoundException( + "Передано название несуществующей группы", + ); + } + + const user = await this.authService.signUp(signUpDto); + return { + id: user.id, + accessToken: user.accessToken, + }; + } + + @ApiOperation({ summary: "Обновление просроченного токена" }) + @ApiBody({ type: UpdateTokenDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Токен обновлён успешно", + type: UpdateTokenResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Передан несуществующий или недействительный токен", + }) + @ResultDto(UpdateTokenResponseDto) + @HttpCode(HttpStatus.OK) + @Post("update-token") + updateToken( + @Body() updateTokenDto: UpdateTokenDto, + ): Promise { + return this.authService.updateToken(updateTokenDto.accessToken); + } + + @ApiOperation({ summary: "Обновление пароля" }) + @ApiBody({ type: ChangePasswordDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Пароль обновлён успешно", + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: "Пароли идентичны", + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: + "Передан неверный текущий пароль или запрос был послан без токена", + }) + @ResultDto(null) + @HttpCode(HttpStatus.OK) + @Post("change-password") + async changePassword( + @Body() changePasswordReqDto: ChangePasswordDto, + @UserToken() userToken: string, + ): Promise { + await this.authService + .decodeUserToken(userToken) + .then((user) => + this.authService.changePassword(user, changePasswordReqDto), + ); + } +} diff --git a/src/auth/v2-auth.controller.ts b/src/auth/v2-auth.controller.ts new file mode 100644 index 0000000..6100855 --- /dev/null +++ b/src/auth/v2-auth.controller.ts @@ -0,0 +1,93 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + NotFoundException, + Post, +} from "@nestjs/common"; +import { AuthService } from "./auth.service"; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { V1ScheduleService } from "../schedule/v1-schedule.service"; +import { SignInDto } from "./dto/sign-in.dto"; +import { SignUpDto } from "./dto/sign-up.dto"; +import { UpdateTokenDto } from "./dto/update-token.dto"; +import { V2ClientUserDto } from "../users/dto/v2/v2-client-user.dto"; + +@ApiTags("v2/auth") +@Controller({ path: "auth", version: "2" }) +export class V2AuthController { + constructor( + private readonly authService: AuthService, + private readonly scheduleService: V1ScheduleService, + ) {} + + @ApiOperation({ summary: "Авторизация по логину и паролю" }) + @ApiBody({ type: SignInDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Авторизация прошла успешно", + type: V2ClientUserDto, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "Некорректное имя пользователя или пароль", + }) + @ResultDto(V2ClientUserDto) + @HttpCode(HttpStatus.OK) + @Post("sign-in") + async signIn(@Body() reqDto: SignInDto): Promise { + return V2ClientUserDto.fromUser(await this.authService.signIn(reqDto)); + } + + @ApiOperation({ summary: "Регистрация по логину и паролю" }) + @ApiBody({ type: SignUpDto }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: "Регистрация прошла успешно", + type: V2ClientUserDto, + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: "Такой пользователь уже существует", + }) + @ResultDto(V2ClientUserDto) + @HttpCode(HttpStatus.CREATED) + @Post("sign-up") + async signUp(@Body() reqDto: SignUpDto): Promise { + if ( + !(await this.scheduleService.getGroupNames()).names.includes( + reqDto.group.replaceAll(" ", ""), + ) + ) { + throw new NotFoundException( + "Передано название несуществующей группы", + ); + } + + return V2ClientUserDto.fromUser(await this.authService.signUp(reqDto)); + } + + @ApiOperation({ summary: "Обновление просроченного токена" }) + @ApiBody({ type: UpdateTokenDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Токен обновлён успешно", + type: V2ClientUserDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Передан несуществующий или недействительный токен", + }) + @ResultDto(V2ClientUserDto) + @HttpCode(HttpStatus.OK) + @Post("update-token") + async updateToken( + @Body() reqDto: UpdateTokenDto, + ): Promise { + return V2ClientUserDto.fromUser( + await this.authService.updateToken(reqDto.accessToken), + ); + } +} diff --git a/src/dto/auth.dto.ts b/src/dto/auth.dto.ts deleted file mode 100644 index 8990320..0000000 --- a/src/dto/auth.dto.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - ApiProperty, - IntersectionType, - PartialType, - PickType, -} from "@nestjs/swagger"; -import { UserDto } from "./user.dto"; -import { IsJWT, IsMongoId, IsString } from "class-validator"; -import { Expose, instanceToPlain, plainToClass } from "class-transformer"; - -// SignIn -export class SignInReqDto extends PickType(UserDto, ["username"]) { - @ApiProperty({ - example: "my-password", - description: "Пароль в исходном виде", - }) - @IsString() - password: string; -} - -export class SignInResDtoV0 { - @ApiProperty({ - example: "66e1b7e255c5d5f1268cce90", - description: "Идентификатор (ObjectId)", - }) - @IsMongoId() - @Expose() - id: string; - - @ApiProperty({ - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - description: "Последний токен доступа", - }) - @IsJWT() - @Expose() - accessToken: string; -} - -export class SignInResDtoV2 extends SignInResDtoV0 { - @ApiProperty({ - example: "ИС-214/23", - description: "Группа", - }) - @IsString() - @Expose() - group: string; -} - -export class SignInResDto extends SignInResDtoV2 { - public static stripVersion( - instance: SignInResDto, - version: number, - ): SignInResDtoV0 | SignInResDtoV2 { - switch (version) { - default: - return instance; - case 0: - case 1: { - return plainToClass(SignInResDtoV0, instanceToPlain(instance), { - excludeExtraneousValues: true, - }); - } - } - } -} - -// SignUp -export class SignUpReqDto extends IntersectionType( - SignInReqDto, - PickType(UserDto, ["role", "group"]), - PartialType(PickType(UserDto, ["version"])), -) {} - -export class SignUpResDto extends PickType(SignInResDto, [ - "id", - "accessToken", -]) {} - -// Update token -export class UpdateTokenReqDto extends PickType(UserDto, ["accessToken"]) {} - -export class UpdateTokenResDto extends UpdateTokenReqDto {} - -// Update password -export class ChangePasswordReqDto { - @ApiProperty({ - example: "my-old-password", - description: "Старый пароль", - }) - @IsString() - @Expose() - oldPassword: string; - - @ApiProperty({ - example: "my-new-password", - description: "Новый пароль", - }) - @IsString() - @Expose() - newPassword: string; -} diff --git a/src/dto/fcm.dto.ts b/src/dto/fcm.dto.ts deleted file mode 100644 index 5b16e29..0000000 --- a/src/dto/fcm.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsSemVer, IsUrl } from "class-validator"; - -export class FcmPostUpdateDto { - @ApiProperty({ example: "1.6.0", description: "Версия приложения" }) - @IsSemVer() - // @Expose() - version: string; - - @ApiProperty({ - example: "https://download.host/app-release-1.6.0.apk", - description: "Ссылка на приложение", - }) - @IsUrl() - // @Expose() - downloadLink: string; -} diff --git a/src/dto/schedule-replacer.dto.ts b/src/dto/schedule-replacer.dto.ts deleted file mode 100644 index 8b99426..0000000 --- a/src/dto/schedule-replacer.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ApiProperty, PickType } from "@nestjs/swagger"; -import { IsNumber, IsObject, IsString } from "class-validator"; - -export class ScheduleReplacerDto { - @ApiProperty({ description: "Etag заменяемого расписания" }) - @IsString() - etag: string; - - @ApiProperty({ description: "Данные файла расписания" }) - @IsObject() - data: ArrayBuffer; -} - -export class ScheduleReplacerResDto extends PickType(ScheduleReplacerDto, [ - "etag", -]) { - @ApiProperty({ example: 1405, description: "Размер файла в байтах" }) - @IsNumber() - size: number; -} - -export class ClearScheduleReplacerResDto { - @ApiProperty({ - example: 1, - description: "Количество удалённых заменителей расписания", - }) - @IsNumber() - count: number; -} diff --git a/src/dto/schedule.dto.ts b/src/dto/schedule.dto.ts deleted file mode 100644 index 448c3b1..0000000 --- a/src/dto/schedule.dto.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { - IsArray, - IsBase64, - IsBoolean, - IsDate, - IsEnum, - IsHash, - IsNumber, - IsObject, - IsOptional, - IsString, - ValidateNested, -} from "class-validator"; -import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; -import { - Expose, - instanceToPlain, - plainToClass, - Transform, - 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.trim().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 { - DEFAULT = 0, - CUSTOM, -} - -export class LessonDto { - @ApiProperty({ - example: LessonTypeDto.DEFAULT, - description: "Тип занятия", - }) - @IsEnum(LessonTypeDto) - type: LessonTypeDto; - - @ApiProperty({ - example: 1, - description: "Индекс пары, если присутствует", - }) - @IsNumber() - defaultIndex: number; - - @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, - defaultIndex: number, - time: LessonTimeDto, - name: string, - cabinets: Array, - teacherNames: Array, - ) { - this.type = type; - this.defaultIndex = defaultIndex; - this.time = time; - this.name = name; - 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 }) - @IsOptional() - @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 === null) 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 }) - @IsOptional() - @Type(() => DayDto) - days: Array; - - constructor(name: string) { - this.name = name; - this.days = []; - } -} - -export class CacheStatusV0Dto { - @ApiProperty({ - example: true, - description: "Нужно ли обновить ссылку для скачивания xls?", - }) - @IsBoolean() - @Expose() - cacheUpdateRequired: boolean; - - @ApiProperty({ - example: "e6ff169b01608addf998dbf8f40b019a7f514239", - description: "Хеш последних полученных данных", - }) - @IsHash("sha1") - @Expose() - cacheHash: string; -} - -export class CacheStatusV1Dto extends CacheStatusV0Dto { - @ApiProperty({ - example: new Date().valueOf(), - description: "Дата обновления кеша", - }) - @IsNumber() - @Expose() - lastCacheUpdate: number; - - @ApiProperty({ - example: new Date().valueOf(), - description: "Дата обновления расписания", - }) - @IsNumber() - @Expose() - lastScheduleUpdate: number; -} - -export class CacheStatusDto extends CacheStatusV1Dto { - public static stripVersion(instance: CacheStatusDto, version: number) { - switch (version) { - default: - return instance; - case 0: { - return plainToClass( - CacheStatusV0Dto, - instanceToPlain(instance), - { excludeExtraneousValues: true }, - ); - } - } - } -} - -export class ScheduleDto { - @ApiProperty({ - example: new Date(), - description: - "Дата когда последний раз расписание было скачано с сервера политехникума", - }) - @IsDate() - updatedAt: Date; - - @ApiProperty({ description: "Расписание групп" }) - @IsObject() - @IsOptional() - groups: any; - - @ApiProperty({ - example: { "ИС-214/23": [5, 6] }, - description: "Обновлённые дни с последнего изменения расписания", - }) - @IsObject() - @Type(() => Object) - @Transform(({ value }) => { - const object = {}; - - for (const key in value) { - object[key] = value[key]; - } - - return object; - }) - @Type(() => Object) - lastChangedDays: Array>; -} - -export class GroupScheduleReqDto extends PartialType( - PickType(GroupDto, ["name"]), -) {} - -export class ScheduleGroupsDto { - @ApiProperty({ - example: ["ИС-214/23", "ИС-213/23"], - description: "Список названий всех групп в текущем расписании", - }) - @IsArray() - names: Array; -} - -export class GroupScheduleDto extends OmitType(ScheduleDto, [ - "groups", - "lastChangedDays", -]) { - @ApiProperty({ description: "Расписание группы" }) - @IsObject() - group: GroupDto; - - @ApiProperty({ - example: [5, 6], - description: "Обновлённые дни с последнего изменения расписания", - }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => Number) - lastChangedDays: Array; -} - -export class SiteMainPageDto { - @ApiProperty({ - example: "MHz=", - description: "Страница политехникума", - }) - @IsBase64() - mainPage: string; -} diff --git a/src/dto/user.dto.ts b/src/dto/user.dto.ts deleted file mode 100644 index 5f2da85..0000000 --- a/src/dto/user.dto.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { ApiProperty, OmitType, PickType } from "@nestjs/swagger"; -import { - IsArray, - IsEnum, - IsJWT, - IsMongoId, - IsObject, - IsOptional, - IsSemVer, - IsString, - MaxLength, - MinLength, - ValidateNested, -} from "class-validator"; -import { Expose, plainToClass, Type } from "class-transformer"; - -export enum UserRoleDto { - STUDENT = "STUDENT", - TEACHER = "TEACHER", - ADMIN = "ADMIN", -} - -export class UserFcmDto { - @ApiProperty({ - description: "Токен Firebase Cloud Messaging", - }) - @IsString() - @Expose() - token: string; - - @ApiProperty({ - example: ["schedule-update"], - description: "Топики на которые подписан пользователь", - }) - @IsArray() - @ValidateNested({ each: true }) - @IsString() - @Expose() - topics: Array; -} - -export class UserDto { - @ApiProperty({ - example: "66e1b7e255c5d5f1268cce90", - description: "Идентификатор (ObjectId)", - }) - @IsMongoId() - @Expose() - id: string; - - @ApiProperty({ example: "n08i40k", description: "Имя" }) - @IsString() - @MinLength(4) - @MaxLength(10) - @Expose() - username: string; - - @ApiProperty({ - example: "$2b$08$34xwFv1WVJpvpVi3tZZuv.", - description: "Соль пароля", - }) - @IsString() - @Expose() - salt: string; - - @ApiProperty({ - example: "$2b$08$34xwFv1WVJpvpVi3tZZuv...", - description: "Хеш пароля", - }) - @IsString() - @Expose() - password: string; - - @ApiProperty({ - example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - description: "Последний токен доступа", - }) - @IsJWT() - @Expose() - accessToken: string; - - @ApiProperty({ example: "ИС-214/23", description: "Группа пользователя" }) - @IsString() - @Expose() - group: string; - - @ApiProperty({ - example: UserRoleDto.STUDENT, - description: "Роль пользователя", - }) - @IsEnum(UserRoleDto) - @Expose() - role: UserRoleDto; - - @ApiProperty({ description: "Данные Firebase Cloud Messaging" }) - @IsObject() - @Type(() => UserFcmDto) - @IsOptional() - @Expose() - fcm: UserFcmDto | null; - - @ApiProperty({ description: "Версия установленого приложения" }) - @IsSemVer() - @Expose() - version: string; -} - -export class ClientUserResDto extends OmitType(UserDto, [ - "password", - "salt", - "accessToken", - "fcm", - "version", -]) { - static fromUserDto(userDto: UserDto): ClientUserResDto { - return plainToClass(ClientUserResDto, userDto, { - excludeExtraneousValues: true, - }); - } -} - -// changes - -export class ChangeUsernameReqDto extends PickType(UserDto, ["username"]) {} - -export class ChangeGroupReqDto extends PickType(UserDto, ["group"]) {} diff --git a/src/firebase-admin/dto/fcm-post-update.dto.ts b/src/firebase-admin/dto/fcm-post-update.dto.ts new file mode 100644 index 0000000..6647e48 --- /dev/null +++ b/src/firebase-admin/dto/fcm-post-update.dto.ts @@ -0,0 +1,17 @@ +import { IsSemVer, IsUrl } from "class-validator"; + +export class FcmPostUpdateDto { + /** + * Версия приложения + * @example "1.6.0" + */ + @IsSemVer() + readonly version: string; + + /** + * Ссылка на приложение + * @example "https://download.host/app-release-1.6.0.apk" + */ + @IsUrl() + readonly downloadLink: string; +} diff --git a/src/firebase-admin/firebase-admin.controller.ts b/src/firebase-admin/firebase-admin.controller.ts index 55605c8..4d73329 100644 --- a/src/firebase-admin/firebase-admin.controller.ts +++ b/src/firebase-admin/firebase-admin.controller.ts @@ -10,26 +10,40 @@ import { } from "@nestjs/common"; import { AuthGuard } from "../auth/auth.guard"; import { UserToken } from "../auth/auth.decorator"; -import { UserFromTokenPipe } from "../auth/auth.pipe"; -import { UserDto } from "../dto/user.dto"; +import { UserPipe } from "../auth/auth.pipe"; import { ResultDto } from "../utility/validation/class-validator.interceptor"; import { FirebaseAdminService } from "./firebase-admin.service"; -import { FcmPostUpdateDto } from "../dto/fcm.dto"; +import { FcmPostUpdateDto } from "./dto/fcm-post-update.dto"; import { isSemVer } from "class-validator"; +import { User } from "../users/entity/user.entity"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; -@Controller("api/v1/fcm") +@ApiTags("v1/fcm") +@ApiBearerAuth() +@Controller({ path: "fcm", version: "1" }) @UseGuards(AuthGuard) export class FirebaseAdminController { private readonly oldTopics = new Set(["app-update", "schedule-update"]); constructor(private readonly firebaseAdminService: FirebaseAdminService) {} + @ApiOperation({ summary: "Установка FCM токена пользователем" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Установка токена удалась", + }) @Post("set-token/:token") @HttpCode(HttpStatus.OK) @ResultDto(null) async setToken( @Param("token") token: string, - @UserToken(UserFromTokenPipe) user: UserDto, + @UserToken(UserPipe) user: User, ): Promise { if (user.fcm?.token === token) return; @@ -44,13 +58,18 @@ export class FirebaseAdminController { ); } + @ApiOperation({ summary: "Установка текущей версии приложения" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Установка версии удалась", + }) @Post("update-callback/:version") @HttpCode(HttpStatus.OK) @ResultDto(null) async updateCallback( - @UserToken(UserFromTokenPipe) userDto: UserDto, @Param("version") version: string, - ) { + @UserToken(UserPipe) userDto: User, + ): Promise { if (!isSemVer(version)) { throw new BadRequestException( "version must be a Semantic Versioning Specification", @@ -60,18 +79,26 @@ export class FirebaseAdminController { await this.firebaseAdminService.updateApp(userDto, version); } + @ApiOperation({ + summary: "Уведомление пользователей о выходе новой версии приложения", + }) + @ApiBody({ type: FcmPostUpdateDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Уведомление отправлено", + }) @Post("post-update") @HttpCode(HttpStatus.OK) @ResultDto(null) - async postUpdate(@Body() postUpdateDto: FcmPostUpdateDto): Promise { + async postUpdate(@Body() reqDto: FcmPostUpdateDto): Promise { await this.firebaseAdminService.sendByTopic("common", { android: { priority: "high", }, data: { type: "app-update", - version: postUpdateDto.version, - downloadLink: postUpdateDto.downloadLink, + version: reqDto.version, + downloadLink: reqDto.downloadLink, }, }); } diff --git a/src/firebase-admin/firebase-admin.service.ts b/src/firebase-admin/firebase-admin.service.ts index c7c2959..387d390 100644 --- a/src/firebase-admin/firebase-admin.service.ts +++ b/src/firebase-admin/firebase-admin.service.ts @@ -11,7 +11,8 @@ import { import { firebaseConstants } from "../contants"; import { UsersService } from "../users/users.service"; -import { UserDto } from "../dto/user.dto"; + +import { User } from "../users/entity/user.entity"; @Injectable() export class FirebaseAdminService implements OnModuleInit { @@ -40,9 +41,9 @@ export class FirebaseAdminService implements OnModuleInit { } async updateToken( - user: UserDto, + user: User, token: string, - ): Promise<{ userDto: UserDto; isNew: boolean }> { + ): Promise<{ userDto: User; isNew: boolean }> { const isNew = user.fcm === null; const fcm = !isNew ? user.fcm : { token: token, topics: [] }; @@ -63,7 +64,7 @@ export class FirebaseAdminService implements OnModuleInit { }; } - async unsubscribe(user: UserDto, topics: Set): Promise { + async unsubscribe(user: User, topics: Set): Promise { const fcm = user.fcm; const currentTopics = new Set(fcm.topics); @@ -84,10 +85,10 @@ export class FirebaseAdminService implements OnModuleInit { } async subscribe( - user: UserDto, + user: User, topics: Set, force: boolean = false, - ): Promise { + ): Promise { const newTopics = new Set([...this.defaultTopics, ...topics]); const fcm = user.fcm; @@ -109,8 +110,8 @@ export class FirebaseAdminService implements OnModuleInit { }); } - async updateApp(userDto: UserDto, version: string): Promise { - await this.subscribe(userDto, new Set(), true).then(async (userDto) => { + async updateApp(user: User, version: string): Promise { + await this.subscribe(user, new Set(), true).then(async (userDto) => { await this.usersService.update({ where: { id: userDto.id }, data: { version: version }, diff --git a/src/main.ts b/src/main.ts index 2b4e98e..f19de7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,10 @@ 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"; +import { RedocModule } from "nest-redoc"; import { apiConstants, httpsConstants } from "./contants"; import * as fs from "node:fs"; +import { VersioningType } from "@nestjs/common"; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -23,19 +24,26 @@ async function bootstrap() { app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions)); app.enableCors(); - const swaggerConfig = new DocumentBuilder() + app.setGlobalPrefix("api"); + app.enableVersioning({ + type: VersioningType.URI, + }); + + const swaggerConfig = RedocModule.createDocumentBuilder() .setTitle("Schedule Parser") .setDescription("Парсер расписания") .setVersion(apiConstants.version) .build(); - const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + const swaggerDocument = RedocModule.createDocument(app, swaggerConfig, { + deepScanRoutes: true, + }); swaggerDocument.servers = [ { url: `https://localhost:${apiConstants.port}`, description: "Локальный сервер для разработки", }, ]; - SwaggerModule.setup("api-docs", app, swaggerDocument); + await RedocModule.setup("api-docs", app, swaggerDocument, {}); await app.listen(apiConstants.port); } diff --git a/src/schedule/dto/clear-schedule-replacer.dto.ts b/src/schedule/dto/clear-schedule-replacer.dto.ts new file mode 100644 index 0000000..ddb4e32 --- /dev/null +++ b/src/schedule/dto/clear-schedule-replacer.dto.ts @@ -0,0 +1,10 @@ +import { IsNumber } from "class-validator"; + +export class ClearScheduleReplacerDto { + /** + * Количество удалённых заменителей расписания + * @example 1 + */ + @IsNumber() + count: number; +} diff --git a/src/schedule/dto/schedule-replacer.dto.ts b/src/schedule/dto/schedule-replacer.dto.ts new file mode 100644 index 0000000..06377ad --- /dev/null +++ b/src/schedule/dto/schedule-replacer.dto.ts @@ -0,0 +1,14 @@ +import { PickType } from "@nestjs/swagger"; +import { IsNumber } from "class-validator"; +import { SetScheduleReplacerDto } from "./set-schedule-replacer.dto"; + +export class ScheduleReplacerDto extends PickType(SetScheduleReplacerDto, [ + "etag", +]) { + /** + * Размер файла в байтах + * @example 12567 + */ + @IsNumber() + size: number; +} diff --git a/src/schedule/dto/set-schedule-replacer.dto.ts b/src/schedule/dto/set-schedule-replacer.dto.ts new file mode 100644 index 0000000..b6b044b --- /dev/null +++ b/src/schedule/dto/set-schedule-replacer.dto.ts @@ -0,0 +1,23 @@ +import { IsMongoId, IsObject, IsString } from "class-validator"; + +export class SetScheduleReplacerDto { + /** + * Идентификатор заменителя (ObjectId) + * @example "66e6f1c8775ffeda400d7967" + */ + @IsMongoId() + id: string; + + /** + * ETag заменяемого расписания + * @example "\"670be780-21e00\"" + */ + @IsString() + etag: string; + + /** + * Данные файла расписания + */ + @IsObject() + data: ArrayBuffer; +} diff --git a/src/schedule/dto/v1/cache-status.dto.ts b/src/schedule/dto/v1/cache-status.dto.ts new file mode 100644 index 0000000..d2e0659 --- /dev/null +++ b/src/schedule/dto/v1/cache-status.dto.ts @@ -0,0 +1,19 @@ +import { V2CacheStatusDto } from "../v2/v2-cache-status.dto"; +import { instanceToPlain, plainToClass } from "class-transformer"; +import { V1CacheStatusDto } from "./v1-cache-status.dto"; + +export class CacheStatusDto extends V2CacheStatusDto { + public static stripVersion(instance: CacheStatusDto, version: number) { + switch (version) { + default: + return instance; + case 0: { + return plainToClass( + V1CacheStatusDto, + instanceToPlain(instance), + { excludeExtraneousValues: true }, + ); + } + } + } +} \ No newline at end of file diff --git a/src/schedule/dto/v1/v1-cache-status.dto.ts b/src/schedule/dto/v1/v1-cache-status.dto.ts new file mode 100644 index 0000000..1a483b1 --- /dev/null +++ b/src/schedule/dto/v1/v1-cache-status.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean, IsHash } from "class-validator"; +import { Expose } from "class-transformer"; + +export class V1CacheStatusDto { + @ApiProperty({ + example: true, + description: "Нужно ли обновить ссылку для скачивания xls?", + }) + @IsBoolean() + @Expose() + cacheUpdateRequired: boolean; + + @ApiProperty({ + example: "e6ff169b01608addf998dbf8f40b019a7f514239", + description: "Хеш последних полученных данных", + }) + @IsHash("sha1") + @Expose() + cacheHash: string; +} \ No newline at end of file diff --git a/src/schedule/dto/v1/v1-day.dto.ts b/src/schedule/dto/v1/v1-day.dto.ts new file mode 100644 index 0000000..682e664 --- /dev/null +++ b/src/schedule/dto/v1/v1-day.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { V1LessonDto } from "./v1-lesson.dto"; +import { V1LessonType } from "../../enum/v1-lesson-type.enum"; + +export class V1DayDto { + @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 }) + @IsOptional() + @Type(() => V1LessonDto) + 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 === null) continue; + + this.nonNullIndices.push(lessonIdx); + + (lesson.type === V1LessonType.DEFAULT + ? this.defaultIndices + : this.customIndices + ).push(lessonIdx); + } + } +} diff --git a/src/schedule/dto/v1/v1-group-schedule-name.dto.ts b/src/schedule/dto/v1/v1-group-schedule-name.dto.ts new file mode 100644 index 0000000..c550786 --- /dev/null +++ b/src/schedule/dto/v1/v1-group-schedule-name.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, PickType } from "@nestjs/swagger"; +import { V1GroupDto } from "./v1-group.dto"; + +export class V1GroupScheduleNameDto extends PartialType( + PickType(V1GroupDto, ["name"]), +) {} diff --git a/src/schedule/dto/v1/v1-group-schedule.dto.ts b/src/schedule/dto/v1/v1-group-schedule.dto.ts new file mode 100644 index 0000000..bffa5aa --- /dev/null +++ b/src/schedule/dto/v1/v1-group-schedule.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, OmitType } from "@nestjs/swagger"; +import { IsArray, IsObject, ValidateNested } from "class-validator"; +import { V1GroupDto } from "./v1-group.dto"; +import { Type } from "class-transformer"; +import { V1ScheduleDto } from "./v1-schedule.dto"; + +export class V1GroupScheduleDto extends OmitType(V1ScheduleDto, [ + "groups", + "lastChangedDays", +]) { + @ApiProperty({ description: "Расписание группы" }) + @IsObject() + group: V1GroupDto; + + @ApiProperty({ + example: [5, 6], + description: "Обновлённые дни с последнего изменения расписания", + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + lastChangedDays: Array; +} diff --git a/src/schedule/dto/v1/v1-group.dto.ts b/src/schedule/dto/v1/v1-group.dto.ts new file mode 100644 index 0000000..c884a2b --- /dev/null +++ b/src/schedule/dto/v1/v1-group.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { V1DayDto } from "./v1-day.dto"; + +export class V1GroupDto { + @ApiProperty({ + example: "ИС-214/23", + description: "Название группы", + }) + @IsString() + name: string; + + @ApiProperty({ example: [], description: "Дни недели" }) + @IsArray() + @ValidateNested({ each: true }) + @IsOptional() + @Type(() => V1DayDto) + days: Array; + + constructor(name: string) { + this.name = name; + this.days = []; + } +} \ No newline at end of file diff --git a/src/schedule/dto/v1/v1-lesson-time.dto.ts b/src/schedule/dto/v1/v1-lesson-time.dto.ts new file mode 100644 index 0000000..fa5074b --- /dev/null +++ b/src/schedule/dto/v1/v1-lesson-time.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNumber } from "class-validator"; + +export class V1LessonTimeDto { + @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): V1LessonTimeDto { + time = time.trim().replaceAll(".", ":"); + + const regex = /(\d+:\d+)-(\d+:\d+)/g; + + const parseResult = regex.exec(time); + if (!parseResult) return new V1LessonTimeDto(0, 0); + + const start = parseResult[1].split(":"); + const end = parseResult[2].split(":"); + + return new V1LessonTimeDto( + Number.parseInt(start[0]) * 60 + Number.parseInt(start[1]), + Number.parseInt(end[0]) * 60 + Number.parseInt(end[1]), + ); + } +} diff --git a/src/schedule/dto/v1/v1-lesson.dto.ts b/src/schedule/dto/v1/v1-lesson.dto.ts new file mode 100644 index 0000000..cf4f04d --- /dev/null +++ b/src/schedule/dto/v1/v1-lesson.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { V1LessonType } from "../../enum/v1-lesson-type.enum"; +import { + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; +import { V1LessonTimeDto } from "./v1-lesson-time.dto"; +import { Type } from "class-transformer"; + +export class V1LessonDto { + @ApiProperty({ + example: V1LessonType.DEFAULT, + description: "Тип занятия", + }) + @IsEnum(V1LessonType) + type: V1LessonType; + + @ApiProperty({ + example: 1, + description: "Индекс пары, если присутствует", + }) + @IsNumber() + defaultIndex: number; + + @ApiProperty({ + example: "Элементы высшей математики", + description: "Название занятия", + }) + @IsString() + name: string; + + @ApiProperty({ + example: new V1LessonTimeDto(0, 60), + description: + "Начало и конец занятия в минутах относительно начала суток", + required: false, + }) + @IsOptional() + @Type(() => V1LessonTimeDto) + time: V1LessonTimeDto | 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: V1LessonType, + defaultIndex: number, + time: V1LessonTimeDto, + name: string, + cabinets: Array, + teacherNames: Array, + ) { + this.type = type; + this.defaultIndex = defaultIndex; + this.time = time; + this.name = name; + this.cabinets = cabinets; + this.teacherNames = teacherNames; + } +} \ No newline at end of file diff --git a/src/schedule/dto/v1/v1-schedule-group-names.dto.ts b/src/schedule/dto/v1/v1-schedule-group-names.dto.ts new file mode 100644 index 0000000..100f78e --- /dev/null +++ b/src/schedule/dto/v1/v1-schedule-group-names.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray } from "class-validator"; + +export class V1ScheduleGroupNamesDto { + @ApiProperty({ + example: ["ИС-214/23", "ИС-213/23"], + description: "Список названий всех групп в текущем расписании", + }) + @IsArray() + names: Array; +} \ No newline at end of file diff --git a/src/schedule/dto/v1/v1-schedule.dto.ts b/src/schedule/dto/v1/v1-schedule.dto.ts new file mode 100644 index 0000000..173c15e --- /dev/null +++ b/src/schedule/dto/v1/v1-schedule.dto.ts @@ -0,0 +1,36 @@ +import { IsDate, IsObject, IsOptional } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; + +export class V1ScheduleDto { + @ApiProperty({ + example: new Date(), + description: + "Дата когда последний раз расписание было скачано с сервера политехникума", + }) + @IsDate() + updatedAt: Date; + + @ApiProperty({ description: "Расписание групп" }) + @IsObject() + @IsOptional() + groups: any; + + @ApiProperty({ + example: { "ИС-214/23": [5, 6] }, + description: "Обновлённые дни с последнего изменения расписания", + }) + @IsObject() + @Type(() => Object) + @Transform(({ value }) => { + const object = {}; + + for (const key in value) { + object[key] = value[key]; + } + + return object; + }) + @Type(() => Object) + lastChangedDays: Array>; +} diff --git a/src/schedule/dto/v1/v1-site-main-page.dto.ts b/src/schedule/dto/v1/v1-site-main-page.dto.ts new file mode 100644 index 0000000..95aff17 --- /dev/null +++ b/src/schedule/dto/v1/v1-site-main-page.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBase64 } from "class-validator"; + +export class V1SiteMainPageDto { + @ApiProperty({ + example: "MHz=", + description: "Страница политехникума", + }) + @IsBase64() + mainPage: string; +} diff --git a/src/schedule/dto/v2/v2-cache-status.dto.ts b/src/schedule/dto/v2/v2-cache-status.dto.ts new file mode 100644 index 0000000..24d63d4 --- /dev/null +++ b/src/schedule/dto/v2/v2-cache-status.dto.ts @@ -0,0 +1,18 @@ +import { V1CacheStatusDto } from "../v1/v1-cache-status.dto"; +import { IsNumber } from "class-validator"; + +export class V2CacheStatusDto extends V1CacheStatusDto { + /** + * Дата обновления кеша + * @example 1729288173002 + */ + @IsNumber() + lastCacheUpdate: number; + + /** + * Дата обновления расписания + * @example 1729288173002 + */ + @IsNumber() + lastScheduleUpdate: number; +} diff --git a/src/schedule/dto/v2/v2-day.dto.ts b/src/schedule/dto/v2/v2-day.dto.ts new file mode 100644 index 0000000..bcb0646 --- /dev/null +++ b/src/schedule/dto/v2/v2-day.dto.ts @@ -0,0 +1,32 @@ +import { + IsArray, + IsDateString, + IsString, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; +import { V2LessonDto } from "./v2-lesson.dto"; + +export class V2DayDto { + /** + * День недели + * @example Понедельник + */ + @IsString() + name: string; + + /** + * Дата + * @example "2024-10-06T20:00:00.000Z" + */ + @IsDateString() + date: Date; + + /** + * Занятия + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => V2LessonDto) + lessons: Array; +} diff --git a/src/schedule/dto/v2/v2-group-schedule-by-name.dto.ts b/src/schedule/dto/v2/v2-group-schedule-by-name.dto.ts new file mode 100644 index 0000000..5a9d200 --- /dev/null +++ b/src/schedule/dto/v2/v2-group-schedule-by-name.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, PickType } from "@nestjs/swagger"; +import { V1GroupDto } from "../v1/v1-group.dto"; + +export class V2GroupScheduleByNameDto extends PartialType( + PickType(V1GroupDto, ["name"]), +) {} diff --git a/src/schedule/dto/v2/v2-group-schedule.dto.ts b/src/schedule/dto/v2/v2-group-schedule.dto.ts new file mode 100644 index 0000000..14f81d8 --- /dev/null +++ b/src/schedule/dto/v2/v2-group-schedule.dto.ts @@ -0,0 +1,22 @@ +import { PickType } from "@nestjs/swagger"; +import { IsArray, IsObject, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { V2ScheduleDto } from "./v2-schedule.dto"; +import { V2GroupDto } from "./v2-group.dto"; + +export class V2GroupScheduleDto extends PickType(V2ScheduleDto, ["updatedAt"]) { + /** + * Расписание группы + */ + @IsObject() + group: V2GroupDto; + + /** + * Обновлённые дни с последнего изменения расписания + * @example [5, 6] + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + updated: Array; +} diff --git a/src/schedule/dto/v2/v2-group.dto.ts b/src/schedule/dto/v2/v2-group.dto.ts new file mode 100644 index 0000000..5ca23eb --- /dev/null +++ b/src/schedule/dto/v2/v2-group.dto.ts @@ -0,0 +1,20 @@ +import { IsArray, IsString, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { V2DayDto } from "./v2-day.dto"; + +export class V2GroupDto { + /** + * Название группы + * @example "ИС-214/23" + */ + @IsString() + name: string; + + /** + * Расписание каждого дня + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => V2DayDto) + days: Array; +} diff --git a/src/schedule/dto/v2/v2-lesson-sub-group.dto.ts b/src/schedule/dto/v2/v2-lesson-sub-group.dto.ts new file mode 100644 index 0000000..6994be0 --- /dev/null +++ b/src/schedule/dto/v2/v2-lesson-sub-group.dto.ts @@ -0,0 +1,26 @@ +import { IsNumber, IsOptional, IsString } from "class-validator"; + +export class V2LessonSubGroupDto { + /** + * Номер подгруппы + * @example 1 + */ + @IsNumber() + number: number; + + /** + * Кабинет + * @example "с\з" + * @example "42" + */ + @IsString() + @IsOptional() + cabinet: string | null; + + /** + * ФИО преподавателя + * @example "Хомченко Н.Е." + */ + @IsString() + teacher: string; +} diff --git a/src/schedule/dto/v2/v2-lesson-time.dto.ts b/src/schedule/dto/v2/v2-lesson-time.dto.ts new file mode 100644 index 0000000..e44f377 --- /dev/null +++ b/src/schedule/dto/v2/v2-lesson-time.dto.ts @@ -0,0 +1,17 @@ +import { IsDateString } from "class-validator"; + +export class V2LessonTimeDto { + /** + * Начало занятия + * @example "2024-10-07T04:30:00.000Z" + */ + @IsDateString() + start: Date; + + /** + * Конец занятия + * @example "2024-10-07T04:40:00.000Z" + */ + @IsDateString() + end: Date; +} diff --git a/src/schedule/dto/v2/v2-lesson.dto.ts b/src/schedule/dto/v2/v2-lesson.dto.ts new file mode 100644 index 0000000..787fcfe --- /dev/null +++ b/src/schedule/dto/v2/v2-lesson.dto.ts @@ -0,0 +1,67 @@ +import "reflect-metadata"; + +import { V2LessonType } from "../../enum/v2-lesson-type.enum"; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; +import { Type } from "class-transformer"; +import { NullIf } from "../../../utility/class-validators/conditional-field"; +import { V2LessonTimeDto } from "./v2-lesson-time.dto"; +import { V2LessonSubGroupDto } from "./v2-lesson-sub-group.dto"; + +export class V2LessonDto { + /** + * Тип занятия + * @example DEFAULT + */ + @IsEnum(V2LessonType) + type: V2LessonType; + + /** + * Индексы пар, если присутствуют + * @example [1, 3] + * @optional + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Number) + @IsOptional() + @NullIf((self: V2LessonDto) => { + return self.type !== V2LessonType.DEFAULT; + }) + defaultRange: Array | null; + + /** + * Название занятия + * @example "Элементы высшей математики" + * @optional + */ + @IsString() + @IsOptional() + @NullIf((self: V2LessonDto) => { + return self.type === V2LessonType.BREAK; + }) + name: string | null; + + /** + * Начало и конец занятия + */ + @Type(() => V2LessonTimeDto) + time: V2LessonTimeDto; + + /** + * Тип занятия + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => V2LessonSubGroupDto) + @IsOptional() + @NullIf((self: V2LessonDto) => { + return self.type !== V2LessonType.DEFAULT; + }) + subGroups: Array | null; +} diff --git a/src/schedule/dto/v2/v2-schedule-group-names.dto.ts b/src/schedule/dto/v2/v2-schedule-group-names.dto.ts new file mode 100644 index 0000000..1a035ad --- /dev/null +++ b/src/schedule/dto/v2/v2-schedule-group-names.dto.ts @@ -0,0 +1,10 @@ +import { IsArray } from "class-validator"; + +export class V2ScheduleGroupNamesDto { + /** + * Группы + * @example ["ИС-214/23", "ИС-213/23"] + */ + @IsArray() + names: Array; +} diff --git a/src/schedule/dto/v2/v2-schedule.dto.ts b/src/schedule/dto/v2/v2-schedule.dto.ts new file mode 100644 index 0000000..8cc5e27 --- /dev/null +++ b/src/schedule/dto/v2/v2-schedule.dto.ts @@ -0,0 +1,29 @@ +import { IsArray, IsDate, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { V2GroupDto } from "./v2-group.dto"; + +export class V2ScheduleDto { + /** + * Дата когда последний раз расписание было скачано с сервера политехникума + * @example "2024-10-18T21:50:06.680Z" + */ + @IsDate() + updatedAt: Date; + + /** + * Расписание групп + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => V2GroupDto) + groups: Array; + + /** + * Обновлённые дни с последнего изменения расписания + * @example { "ИС-214/23": [4, 5] } + */ + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Array) + updatedGroups: Array>; +} diff --git a/src/schedule/dto/v2/v2-update-download-url.dto.ts b/src/schedule/dto/v2/v2-update-download-url.dto.ts new file mode 100644 index 0000000..e517f1f --- /dev/null +++ b/src/schedule/dto/v2/v2-update-download-url.dto.ts @@ -0,0 +1,10 @@ +import { IsUrl } from "class-validator"; + +export class V2UpdateDownloadUrlDto { + /** + * Прямая ссылка на скачивание расписания + * @example "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls" + */ + @IsUrl() + url: string; +} diff --git a/src/schedule/enum/v1-lesson-type.enum.ts b/src/schedule/enum/v1-lesson-type.enum.ts new file mode 100644 index 0000000..d9faf87 --- /dev/null +++ b/src/schedule/enum/v1-lesson-type.enum.ts @@ -0,0 +1,4 @@ +export enum V1LessonType { + DEFAULT = 0, + CUSTOM, +} diff --git a/src/schedule/enum/v2-lesson-type.enum.ts b/src/schedule/enum/v2-lesson-type.enum.ts new file mode 100644 index 0000000..08faf1d --- /dev/null +++ b/src/schedule/enum/v2-lesson-type.enum.ts @@ -0,0 +1,5 @@ +export enum V2LessonType { + DEFAULT = 0, + ADDITIONAL, + BREAK, +} diff --git a/src/schedule/internal/schedule-parser/schedule-parser.ts b/src/schedule/internal/schedule-parser/v1-schedule-parser.ts similarity index 73% rename from src/schedule/internal/schedule-parser/schedule-parser.ts rename to src/schedule/internal/schedule-parser/v1-schedule-parser.ts index 397d033..5d42422 100644 --- a/src/schedule/internal/schedule-parser/schedule-parser.ts +++ b/src/schedule/internal/schedule-parser/v1-schedule-parser.ts @@ -1,34 +1,34 @@ -import { - XlsDownloaderBase, - XlsDownloaderCacheMode, -} from "../xls-downloader/xls-downloader.base"; +import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interface"; import * as XLSX from "xlsx"; -import { - DayDto, - GroupDto, - LessonDto, - LessonTimeDto, - LessonTypeDto, -} from "../../../dto/schedule.dto"; import { toNormalString, trimAll } from "../../../utility/string.util"; +import { V1LessonTimeDto } from "../../dto/v1/v1-lesson-time.dto"; +import { V1LessonType } from "../../enum/v1-lesson-type.enum"; +import { V1LessonDto } from "../../dto/v1/v1-lesson.dto"; +import { V1DayDto } from "../../dto/v1/v1-day.dto"; +import { V1GroupDto } from "../../dto/v1/v1-group.dto"; +import { ScheduleReplacerService } from "../../schedule-replacer.service"; +import * as assert from "node:assert"; type InternalId = { row: number; column: number; name: string }; type InternalDay = InternalId; export class ScheduleParseResult { etag: string; - groups: Array; + replacerId?: string; + groups: Array; affectedDays: Array>; - updateRequired: boolean; } type CellData = XLSX.CellObject["v"]; -export class ScheduleParser { +export class V1ScheduleParser { private lastResult: ScheduleParseResult | null = null; - public constructor(private readonly xlsDownloader: XlsDownloaderBase) {} + public constructor( + private readonly xlsDownloader: XlsDownloaderInterface, + private readonly scheduleReplacerService: ScheduleReplacerService, + ) {} private static getCellData( worksheet: XLSX.Sheet, @@ -83,7 +83,7 @@ export class ScheduleParser { const days: Array = []; for (let row = range.s.r + 1; row <= range.e.r; ++row) { - const dayName = ScheduleParser.getCellData(worksheet, row, 0); + const dayName = V1ScheduleParser.getCellData(worksheet, row, 0); if (!dayName) continue; if (!isHeaderParsed) { @@ -95,7 +95,7 @@ export class ScheduleParser { column <= range.e.c; ++column ) { - const groupName = ScheduleParser.getCellData( + const groupName = V1ScheduleParser.getCellData( worksheet, row, column, @@ -134,37 +134,51 @@ export class ScheduleParser { return { daySkeletons: days, groupSkeletons: groups }; } - getXlsDownloader(): XlsDownloaderBase { + getXlsDownloader(): XlsDownloaderInterface { return this.xlsDownloader; } - async getSchedule( - forceCached: boolean = false, - ): Promise { - if (forceCached && this.lastResult !== null) return this.lastResult; + async getSchedule(): Promise { + const headData = await this.xlsDownloader.fetch(true); + this.xlsDownloader.verifyFetchResult(headData); - const downloadData = await this.xlsDownloader.downloadXLS(); + assert(headData.type === "success"); - if ( - !downloadData.new && - this.lastResult && - this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE - ) - return this.lastResult; + const replacer = await this.scheduleReplacerService.getByEtag( + headData.etag, + ); - const workBook = XLSX.read(downloadData.fileData); + if (this.lastResult && this.lastResult.etag === headData.etag) { + if (!replacer) return this.lastResult; + + if (this.lastResult.replacerId === replacer.id) + return this.lastResult; + } + + const buffer = async () => { + if (replacer) return replacer.data; + + const downloadData = await this.xlsDownloader.fetch(false); + this.xlsDownloader.verifyFetchResult(downloadData); + + assert(downloadData.type === "success"); + + return downloadData.data; + }; + + const workBook = XLSX.read(await buffer()); const workSheet = workBook.Sheets[workBook.SheetNames[0]]; const { groupSkeletons, daySkeletons } = this.parseSkeleton(workSheet); - const groups: Array = []; + const groups: Array = []; for (const groupSkeleton of groupSkeletons) { - const group = new GroupDto(groupSkeleton.name); + const group = new V1GroupDto(groupSkeleton.name); for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) { const daySkeleton = daySkeletons[dayIdx]; - const day = new DayDto(daySkeleton.name); + const day = new V1DayDto(daySkeleton.name); const lessonTimeColumn = daySkeletons[0].column + 1; const rowDistance = @@ -176,7 +190,7 @@ export class ScheduleParser { ++row ) { // time - const time = ScheduleParser.getCellData( + const time = V1ScheduleParser.getCellData( workSheet, row, lessonTimeColumn, @@ -186,7 +200,7 @@ export class ScheduleParser { // name const rawName: CellData = trimAll( - ScheduleParser.getCellData( + V1ScheduleParser.getCellData( workSheet, row, groupSkeleton.column, @@ -201,7 +215,7 @@ export class ScheduleParser { // cabinets const cabinets: Array = []; - const rawCabinets = ScheduleParser.getCellData( + const rawCabinets = V1ScheduleParser.getCellData( workSheet, row, groupSkeleton.column + 1, @@ -219,8 +233,8 @@ export class ScheduleParser { // type const lessonType = time?.includes("пара") - ? LessonTypeDto.DEFAULT - : LessonTypeDto.CUSTOM; + ? V1LessonType.DEFAULT + : V1LessonType.CUSTOM; // full names const { name, teacherFullNames } = @@ -229,13 +243,13 @@ export class ScheduleParser { ); day.lessons.push( - new LessonDto( + new V1LessonDto( lessonType, - lessonType === LessonTypeDto.DEFAULT + lessonType === V1LessonType.DEFAULT ? Number.parseInt(time[0]) : -1, - LessonTimeDto.fromString( - lessonType === LessonTypeDto.DEFAULT + V1LessonTimeDto.fromString( + lessonType === V1LessonType.DEFAULT ? time.substring(5) : time, ), @@ -256,16 +270,16 @@ export class ScheduleParser { } return (this.lastResult = { - etag: downloadData.etag, + etag: headData.etag, + replacerId: replacer?.id, groups: groups, affectedDays: this.getAffectedDays(this.lastResult?.groups, groups), - updateRequired: downloadData.updateRequired, }); } private getAffectedDays( - cachedGroups: Array | null, - groups: Array, + cachedGroups: Array | null, + groups: Array, ): Array> { const affectedDays: Array> = []; @@ -273,8 +287,8 @@ export class ScheduleParser { // noinspection SpellCheckingInspection const dayEquals = ( - lday: DayDto | null, - rday: DayDto | undefined, + lday: V1DayDto | null, + rday: V1DayDto | undefined, ): boolean => { if (!lday || !rday || rday.lessons.length != lday.lessons.length) return false; diff --git a/src/schedule/internal/schedule-parser/v2-schedule-parser.spec.ts b/src/schedule/internal/schedule-parser/v2-schedule-parser.spec.ts new file mode 100644 index 0000000..7b43aaa --- /dev/null +++ b/src/schedule/internal/schedule-parser/v2-schedule-parser.spec.ts @@ -0,0 +1,116 @@ +import { V2ScheduleParser } from "./v2-schedule-parser"; +import { BasicXlsDownloader } from "../xls-downloader/basic-xls-downloader"; +import { V2DayDto } from "../../dto/v2/v2-day.dto"; +import { V2GroupDto } from "../../dto/v2/v2-group.dto"; + +describe("V2ScheduleParser", () => { + let parser: V2ScheduleParser; + + beforeEach(async () => { + const xlsDownloader = new BasicXlsDownloader(); + parser = new V2ScheduleParser(xlsDownloader); + }); + + describe("Ошибки", () => { + it("Должен вернуть ошибку из-за отсутствия ссылки на скачивание", async () => { + await expect(() => parser.getSchedule()).rejects.toThrow(); + }); + }); + + async function setLink(link: string): Promise { + await parser.getXlsDownloader().setDownloadUrl(link); + } + + const defaultTest = async () => { + const schedule = await parser.getSchedule(); + + expect(schedule).toBeDefined(); + }; + + const nameTest = async () => { + const schedule = await parser.getSchedule(); + expect(schedule).toBeDefined(); + + const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"]; + expect(group).toBeDefined(); + + const saturday: V2DayDto = group.days[5]; + expect(saturday).toBeDefined(); + + const name = saturday.name; + expect(name).toBeDefined(); + expect(name.length).toBeGreaterThan(0); + }; + + describe("Старое расписание", () => { + beforeEach(async () => { + await setLink( + "https://politehnikum-eng.ru/2024/poltavskaja_06_s_07_po_13_10.xls", + ); + }); + + it("Должен вернуть расписание", defaultTest); + it("Название дня не должно быть пустым или null", nameTest); + }); + + describe("Новое расписание", () => { + beforeEach(async () => { + await setLink( + "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-8-1-.xls", + ); + }); + + it("Должен вернуть расписание", defaultTest); + it("Название дня не должно быть пустым или null", nameTest); + + it("Парсер должен вернуть корректное время если она на нескольких линиях", async () => { + const schedule = await parser.getSchedule(); + expect(schedule).toBeDefined(); + + const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"]; + expect(group).toBeDefined(); + + const saturday: V2DayDto = group.days[5]; + expect(saturday).toBeDefined(); + + const firstLesson = saturday.lessons[0]; + expect(firstLesson).toBeDefined(); + + expect(firstLesson.time).toBeDefined(); + + expect(firstLesson.time.start).toBeDefined(); + expect(firstLesson.time.end).toBeDefined(); + + const startMinutes = + firstLesson.time.start.getHours() * 60 + + firstLesson.time.start.getMinutes(); + const endMinutes = + firstLesson.time.end.getHours() * 60 + + firstLesson.time.end.getMinutes(); + + const differenceMinutes = endMinutes - startMinutes; + + expect(differenceMinutes).toBe(190); + + expect(firstLesson.defaultRange).toStrictEqual([1, 3]); + }); + + it("Ошибка парсинга?", async () => { + const schedule = await parser.getSchedule(); + expect(schedule).toBeDefined(); + + const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"]; + expect(group).toBeDefined(); + + const thursday: V2DayDto = group.days[3]; + expect(thursday).toBeDefined(); + + expect(thursday.lessons.length).toBe(5); + + const lastLessonName = thursday.lessons[4].name; + expect(lastLessonName).toBe( + "МДК.05.01 Проектирование и дизайн информационных систем", + ); + }); + }); +}); diff --git a/src/schedule/internal/schedule-parser/v2-schedule-parser.ts b/src/schedule/internal/schedule-parser/v2-schedule-parser.ts new file mode 100644 index 0000000..dc97789 --- /dev/null +++ b/src/schedule/internal/schedule-parser/v2-schedule-parser.ts @@ -0,0 +1,673 @@ +import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interface"; + +import * as XLSX from "xlsx"; +import { Range, WorkSheet } from "xlsx"; +import { toNormalString, trimAll } from "../../../utility/string.util"; +import { plainToClass, plainToInstance } from "class-transformer"; +import * as objectHash from "object-hash"; +import { V2LessonTimeDto } from "../../dto/v2/v2-lesson-time.dto"; +import { V2LessonType } from "../../enum/v2-lesson-type.enum"; +import { V2LessonSubGroupDto } from "../../dto/v2/v2-lesson-sub-group.dto"; +import { V2LessonDto } from "../../dto/v2/v2-lesson.dto"; +import { V2DayDto } from "../../dto/v2/v2-day.dto"; +import { V2GroupDto } from "../../dto/v2/v2-group.dto"; +import * as assert from "node:assert"; +import { ScheduleReplacerService } from "../../schedule-replacer.service"; + +type InternalId = { + /** + * Индекс строки + */ + row: number; + + /** + * Индекс столбца + */ + column: number; + + /** + * Текст записи + */ + name: string; +}; + +type InternalTime = { + /** + * Временной отрезок + */ + timeRange: V2LessonTimeDto; + + /** + * Тип пары на этой строке + */ + lessonType: V2LessonType; + + /** + * Индекс пары на этой строке + */ + defaultIndex?: number; + + /** + * Позиции начальной и конечной записи + */ + xlsxRange: Range; +}; + +export class V2ScheduleParseResult { + /** + * ETag расписания + */ + etag: string; + + /** + * Идентификатор заменённого расписания (ObjectId) + */ + replacerId?: string; + + /** + * Дата загрузки расписания на сайт политехникума + */ + uploadedAt: Date; + + /** + * Дата загрузки расписания с сайта политехникума + */ + downloadedAt: Date; + + /** + * Расписание групп в виде списка. + * Ключ - название группы. + */ + groups: Array; + + /** + * Список групп у которых было обновлено расписание с момента последнего обновления файла. + * Ключ - название группы. + */ + updatedGroups: Array>; +} + +export class V2ScheduleParser { + private lastResult: V2ScheduleParseResult | null = null; + + /** + * @param xlsDownloader - класс для загрузки расписания с сайта политехникума + * @param scheduleReplacerService - сервис для подмены расписания + */ + public constructor( + private readonly xlsDownloader: XlsDownloaderInterface, + private readonly scheduleReplacerService?: ScheduleReplacerService, + ) {} + + /** + * Получает позиции начальной и конечной записи относительно начальной записи + * @param workSheet - xls лист + * @param topRow - индекс начальной строки + * @param leftColumn - индекс начального столбца + * @returns {Range} - позиции начальной и конечной записи + * @private + * @static + */ + private static getMergeFromStart( + workSheet: XLSX.WorkSheet, + topRow: number, + leftColumn: number, + ): Range { + for (const range of workSheet["!merges"]) { + if (topRow === range.s.r && leftColumn === range.s.c) return range; + } + + return { + s: { r: topRow, c: leftColumn }, + e: { r: topRow, c: leftColumn }, + }; + } + + /** + * Получает текст из требуемой записи + * @param worksheet - xls лист + * @param row - индекс строки + * @param column - индекс столбца + * @returns {string | null} - текст записи, если присутствует + * @private + * @static + */ + private static getCellData( + worksheet: XLSX.WorkSheet, + row: number, + column: number, + ): string | null { + const cell: XLSX.CellObject | null = + worksheet[XLSX.utils.encode_cell({ r: row, c: column })]; + + return toNormalString(cell?.w); + } + + /** + * Парсит информацию о паре исходя из текста в записи + * @param lessonName - текст в записи + * @returns {{ + * name: string; + * subGroups: Array; + * }} - название пары и список подгрупп + * @private + * @static + */ + private static parseNameAndSubGroups(lessonName: string): { + name: string; + subGroups: Array; + } { + // хд + + const allRegex = + /(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\([0-9]\s?подгруппа\))?(?:,\s)?)+$/gm; + const teacherAndSubGroupRegex = + /(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\([0-9]\s?подгруппа\))?)+/gm; + + const allMatch = allRegex.exec(lessonName); + + // если не ничё не найдено + if (allMatch === null) return { name: lessonName, subGroups: [] }; + + const all: Array = []; + + let allInnerMatch: RegExpExecArray; + while ( + (allInnerMatch = teacherAndSubGroupRegex.exec(allMatch[0])) !== null + ) { + if (allInnerMatch.index === teacherAndSubGroupRegex.lastIndex) + teacherAndSubGroupRegex.lastIndex++; + + all.push(allInnerMatch[0].trim()); + } + + // парадокс + if (all.length === 0) { + throw new Error("Парадокс"); + } + + const subGroups: Array = []; + + for (const teacherAndSubGroup of all) { + const teacherRegex = /[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\./g; + const subGroupRegex = /\([0-9]\s?подгруппа\)/g; + + const teacherMatch = teacherRegex.exec(teacherAndSubGroup); + if (teacherMatch === null) throw new Error("Парадокс"); + + let teacherFIO = teacherMatch[0]; + const teacherSpaceIndex = teacherFIO.indexOf(" ") + 1; + const teacherIO = teacherFIO + .substring(teacherSpaceIndex) + .replaceAll("s", ""); + + teacherFIO = `${teacherFIO.substring(0, teacherSpaceIndex)}${teacherIO}`; + + const subGroupMatch = subGroupRegex.exec(teacherAndSubGroup); + const subGroup = subGroupMatch + ? Number.parseInt(subGroupMatch[0][1]) + : 1; + + subGroups.push( + plainToClass(V2LessonSubGroupDto, { + teacher: teacherFIO, + number: subGroup, + cabinet: "", + }), + ); + } + + for (const index in subGroups) { + if (subGroups.length === 1) { + break; + } + + // бляздец + switch (index) { + case "0": + subGroups[index].number = + subGroups[+index + 1].number === 2 ? 1 : 2; + continue; + case "1": + subGroups[index].number = + subGroups[+index - 1].number === 1 ? 2 : 1; + continue; + default: + subGroups[index].number = +index; + } + } + + return { + name: lessonName.substring(0, allMatch.index).trim(), + subGroups: subGroups, + }; + } + + /** + * Парсит информацию о группах и днях недели + * @param workSheet - xls лист + * @returns {{ + * groupSkeletons: Array; + * daySkeletons: Array; + * }} - список с индексами и текстом записей групп и дней недели + * @private + * @static + */ + private static parseSkeleton(workSheet: XLSX.WorkSheet): { + groupSkeletons: Array; + daySkeletons: Array; + } { + const range = XLSX.utils.decode_range(workSheet["!ref"] || ""); + let isHeaderParsed: boolean = false; + + const groups: Array = []; + const days: Array = []; + + for (let row = range.s.r + 1; row <= range.e.r; ++row) { + const dayName = V2ScheduleParser.getCellData(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 = V2ScheduleParser.getCellData( + workSheet, + row, + column, + ); + if (!groupName) continue; + + groups.push({ row: row, column: column, name: groupName }); + } + ++row; + } + + if ( + days.length == 0 || + !days[days.length - 1].name.startsWith("Суббота") + ) { + const dayMonthIdx = /[А-Яа-я]+\s(\d+)\.\d+\.\d+/.exec( + trimAll(dayName), + ); + + if (dayMonthIdx === null) continue; + } + + days.push({ + row: row, + column: 0, + name: dayName, + }); + + if ( + days.length > 2 && + days[days.length - 2].name.startsWith("Суббота") + ) + break; + } + + return { daySkeletons: days, groupSkeletons: groups }; + } + + /** + * Возвращает текущий класс для скачивания xls файлов + * @returns {XlsDownloaderInterface} - класс для скачивания xls файлов + */ + getXlsDownloader(): XlsDownloaderInterface { + return this.xlsDownloader; + } + + /** + * Возвращает текущее расписание + * @returns {V2ScheduleParseResult} - расписание + * @async + */ + async getSchedule(): Promise { + const headData = await this.xlsDownloader.fetch(true); + this.xlsDownloader.verifyFetchResult(headData); + + assert(headData.type === "success"); + + const replacer = this.scheduleReplacerService + ? await this.scheduleReplacerService.getByEtag(headData.etag) + : null; + + if (this.lastResult && this.lastResult.etag === headData.etag) { + if (!replacer) return this.lastResult; + + if (this.lastResult.replacerId === replacer.id) + return this.lastResult; + } + + const buffer = async () => { + if (replacer) return replacer.data; + + const downloadData = await this.xlsDownloader.fetch(false); + this.xlsDownloader.verifyFetchResult(downloadData); + + assert(downloadData.type === "success"); + + return downloadData.data; + }; + + const workBook = XLSX.read(await buffer()); + const workSheet = workBook.Sheets[workBook.SheetNames[0]]; + + const { groupSkeletons, daySkeletons } = + V2ScheduleParser.parseSkeleton(workSheet); + + const groups: Array = []; + + const daysTimes: Array> = []; + let daysTimesFilled = false; + + for (const groupSkeleton of groupSkeletons) { + const group = new V2GroupDto(); + group.name = groupSkeleton.name; + group.days = []; + + for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) { + const daySkeleton = daySkeletons[dayIdx]; + const day = new V2DayDto(); + { + const daySpaceIndex = daySkeleton.name.indexOf(" "); + day.name = daySkeleton.name.substring(0, daySpaceIndex); + + const dateString = daySkeleton.name.substring( + daySpaceIndex + 1, + ); + const parseableDateString = `${dateString.substring(3, 5)}.${dateString.substring(0, 2)}.${dateString.substring(6)}`; + day.date = new Date(Date.parse(parseableDateString)); + + day.lessons = []; + } + + const lessonTimeColumn = daySkeletons[0].column + 1; + const rowDistance = + daySkeletons[dayIdx + 1].row - daySkeleton.row; + + const dayTimes: Array = daysTimesFilled + ? daysTimes[day.name] + : []; + + if (!daysTimesFilled) { + for ( + let row = daySkeleton.row; + row < daySkeleton.row + rowDistance; + ++row + ) { + const time = V2ScheduleParser.getCellData( + workSheet, + row, + lessonTimeColumn, + )?.replaceAll(/[\s\t\n\r]/g, ""); + + if (!time) continue; + + // type + const lessonType = time.includes("пара") + ? V2LessonType.DEFAULT + : V2LessonType.ADDITIONAL; + + const defaultIndex = + lessonType === V2LessonType.DEFAULT + ? +time[0] + : null; + + // time + const timeRange = new V2LessonTimeDto(); + + timeRange.start = new Date(day.date); + timeRange.end = new Date(day.date); + + const timeString = time.replaceAll(".", ":"); + const timeRegex = /(\d+:\d+)-(\d+:\d+)/g; + + const parseResult = timeRegex.exec(timeString); + if (!parseResult) { + throw new Error( + "Не удалось узнать начало и конец пар!", + ); + } + + const startStrings = parseResult[1].split(":"); + timeRange.start.setHours(+startStrings[0]); + timeRange.start.setMinutes(+startStrings[1]); + + const endStrings = parseResult[2].split(":"); + timeRange.end.setHours(+endStrings[0]); + timeRange.end.setMinutes(+endStrings[1]); + + dayTimes.push({ + timeRange: timeRange, + + lessonType: lessonType, + defaultIndex: defaultIndex, + + xlsxRange: V2ScheduleParser.getMergeFromStart( + workSheet, + row, + lessonTimeColumn, + ), + } as InternalTime); + } + + daysTimes[day.name] = dayTimes; + } + + for (const time of dayTimes) { + // if (day.name === "Четверг" && group.name === "ИС-214/23") { + // console.log("-------------------"); + // console.log(groupSkeleton.column); + // console.log(time.xlsxRange); + // } + const lessons = V2ScheduleParser.parseLesson( + workSheet, + day, + dayTimes, + time, + groupSkeleton.column, + ); + + for (const lesson of lessons) day.lessons.push(lesson); + } + + group.days.push(day); + } + + if (!daysTimesFilled) daysTimesFilled = true; + + groups[group.name] = group; + } + + const updatedGroups = V2ScheduleParser.getUpdatedGroups( + this.lastResult?.groups, + groups, + ); + + return (this.lastResult = { + downloadedAt: headData.requestedAt, + uploadedAt: headData.uploadedAt, + + etag: headData.etag, + replacerId: replacer?.id, + groups: groups, + updatedGroups: + updatedGroups.length === 0 + ? (this.lastResult?.updatedGroups ?? []) + : updatedGroups, + }); + } + + private static parseLesson( + workSheet: XLSX.Sheet, + day: V2DayDto, + dayTimes: Array, + time: InternalTime, + column: number, + ): Array { + const row = time.xlsxRange.s.r; + + if (typeof column !== "number") { + console.log(typeof column); + console.log(column); + } + + // name + const rawName = trimAll( + V2ScheduleParser.getCellData(workSheet, row, column)?.replaceAll( + /[\n\r]/g, + "", + ) ?? "", + ); + + if (rawName.length === 0) return []; + + const lesson = new V2LessonDto(); + + lesson.type = time.lessonType; + lesson.defaultRange = + time.defaultIndex !== null + ? [time.defaultIndex, time.defaultIndex] + : null; + + lesson.time = new V2LessonTimeDto(); + lesson.time.start = time.timeRange.start; + + // check if multi-lesson + const range = this.getMergeFromStart(workSheet, row, column); + const endTime = dayTimes.filter((dayTime) => { + return dayTime.xlsxRange.e.r === range.e.r; + })[0]; + lesson.time.end = endTime?.timeRange.end ?? time.timeRange.end; + + if (lesson.defaultRange !== null) + lesson.defaultRange[1] = endTime?.defaultIndex ?? time.defaultIndex; + + // name and subGroups (subGroups unfilled) + { + const nameAndGroups = V2ScheduleParser.parseNameAndSubGroups( + trimAll(rawName?.replaceAll(/[\n\r]/g, "") ?? ""), + ); + + lesson.name = nameAndGroups.name; + lesson.subGroups = nameAndGroups.subGroups; + } + + // cabinets + { + const cabinets = V2ScheduleParser.parseCabinets( + workSheet, + row, + column + 1, + ); + + if (cabinets.length === 1) { + for (const index in lesson.subGroups) + lesson.subGroups[index].cabinet = cabinets[0]; + } else if (cabinets.length === lesson.subGroups.length) { + for (const index in lesson.subGroups) + lesson.subGroups[index].cabinet = cabinets[index]; + } else if (cabinets.length !== 0) { + if (cabinets.length > lesson.subGroups.length) { + for (const index in cabinets) { + if (lesson.subGroups[index] === undefined) { + lesson.subGroups.push( + plainToInstance(V2LessonSubGroupDto, { + number: +index + 1, + teacher: "Ошибка в расписании", + cabinet: cabinets[index], + } as V2LessonSubGroupDto), + ); + + continue; + } + + lesson.subGroups[index].cabinet = cabinets[index]; + } + } else throw new Error("Разное кол-во кабинетов и подгрупп!"); + } + } + + const prevLesson = + (day.lessons?.length ?? 0) === 0 + ? null + : day.lessons[day.lessons.length - 1]; + + if (!prevLesson) return [lesson]; + + return [ + plainToInstance(V2LessonDto, { + type: V2LessonType.BREAK, + defaultRange: null, + name: null, + time: plainToInstance(V2LessonTimeDto, { + start: prevLesson.time.end, + end: lesson.time.start, + } as V2LessonTimeDto), + subGroups: [], + } as V2LessonDto), + lesson, + ]; + } + + private static parseCabinets( + workSheet: WorkSheet, + row: number, + column: number, + ) { + const cabinets: Array = []; + { + const rawCabinets = V2ScheduleParser.getCellData( + workSheet, + row, + column, + ); + + if (rawCabinets) { + const parts = rawCabinets.split(/(\n|\s)/g); + + for (const cabinet of parts) { + if (!toNormalString(cabinet)) continue; + + cabinets.push(cabinet.replaceAll(/[\n\s\r]/g, " ")); + } + } + } + return cabinets; + } + + private static getUpdatedGroups( + cachedGroups: Array | null, + currentGroups: Array, + ): Array> { + if (!cachedGroups) return []; + + const updatedGroups = []; + + for (const groupName in cachedGroups) { + const cachedGroup = cachedGroups[groupName]; + const currentGroup = currentGroups[groupName]; + + const affectedGroupDays: Array = []; + + for (const dayIdx in currentGroup.days) { + if ( + objectHash.sha1(currentGroup.days[dayIdx]) !== + objectHash.sha1(cachedGroup.days[dayIdx]) + ) + affectedGroupDays.push(Number.parseInt(dayIdx)); + } + + updatedGroups[groupName] = affectedGroupDays; + } + + return updatedGroups; + } +} diff --git a/src/schedule/internal/xls-downloader/basic-xls-downloader.spec.ts b/src/schedule/internal/xls-downloader/basic-xls-downloader.spec.ts new file mode 100644 index 0000000..5016017 --- /dev/null +++ b/src/schedule/internal/xls-downloader/basic-xls-downloader.spec.ts @@ -0,0 +1,35 @@ +import { BasicXlsDownloader } from "./basic-xls-downloader"; +import { XlsDownloaderInterface } from "./xls-downloader.interface"; + +describe("BasicXlsDownloader", () => { + let downloader: XlsDownloaderInterface; + + beforeEach(async () => { + downloader = new BasicXlsDownloader(); + }); + + it("Должен вызвать ошибку из-за отсутствия ссылки на скачивание", async () => { + await expect(async () => { + const result = await downloader.fetch(false); + downloader.verifyFetchResult(result); + }).rejects.toThrow(); + }); + + it("Должен вызвать ошибку из-за неверной ссылки на скачивание", async () => { + await expect(() => { + return downloader.setDownloadUrl("https://google.com/"); + }).rejects.toThrow(); + }); + + it("Должен вернуть скачанный файл", async () => { + await downloader.setDownloadUrl( + "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls", + ); + + expect(() => { + downloader.fetch(false).then((result) => { + downloader.verifyFetchResult(result); + }); + }).toBeDefined(); + }); +}); diff --git a/src/schedule/internal/xls-downloader/basic-xls-downloader.ts b/src/schedule/internal/xls-downloader/basic-xls-downloader.ts index afdc92c..df67d9d 100644 --- a/src/schedule/internal/xls-downloader/basic-xls-downloader.ts +++ b/src/schedule/internal/xls-downloader/basic-xls-downloader.ts @@ -1,147 +1,120 @@ import { - XlsDownloaderBase, - XlsDownloaderCacheMode, - XlsDownloaderResult, -} from "./xls-downloader.base"; + FetchError, + FetchResult, + XlsDownloaderInterface, +} from "./xls-downloader.interface"; import axios from "axios"; -import { JSDOM } from "jsdom"; import { NotAcceptableException, ServiceUnavailableException, } from "@nestjs/common"; -import { ScheduleReplacerService } from "../../schedule-replacer.service"; -import { Error } from "mongoose"; -import * as crypto from "crypto"; -export class BasicXlsDownloader extends XlsDownloaderBase { - cache: XlsDownloaderResult | null = null; - preparedData: { downloadLink: string; updateDate: string } | null = null; +export class BasicXlsDownloader implements XlsDownloaderInterface { + private url: string | null = null; - private cacheHash: string = "0000000000000000000000000000000000000000"; - - private lastUpdate: number = 0; - private scheduleReplacerService: ScheduleReplacerService | null = null; - - setScheduleReplacerService(service: ScheduleReplacerService) { - this.scheduleReplacerService = service; - } - - private async getDOM(preparedData: any): Promise { - try { - return new JSDOM(atob(preparedData), { - url: this.url, - contentType: "text/html", - }); - } catch { - throw new NotAcceptableException( - "Передан некорректный код страницы", - ); - } - } - - private parseData(dom: JSDOM): { - downloadLink: string; - updateDate: string; - } { - try { - const scheduleBlock = dom.window.document.getElementById("cont-i"); - if (scheduleBlock === null) - // noinspection ExceptionCaughtLocallyJS - throw new Error("Не удалось найти блок расписаний!"); - - const schedules = scheduleBlock.getElementsByTagName("div"); - if (schedules === null || schedules.length === 0) - // noinspection ExceptionCaughtLocallyJS - throw new Error("Не удалось найти строку с расписанием!"); - - const poltavskaya = schedules[0]; - const link = poltavskaya.getElementsByTagName("a")[0]!; - - const spans = poltavskaya.getElementsByTagName("span"); - const updateDate = spans[3].textContent!.trimStart(); - - return { - downloadLink: link.href, - updateDate: updateDate, - }; - } catch (exception) { - console.error(exception); - throw new NotAcceptableException( - "Передан некорректный код страницы", - ); - } - } - - public async getCachedXLS(): Promise { - if (this.cache === null) return null; - - this.cache.new = this.cacheMode === XlsDownloaderCacheMode.HARD; - - return this.cache; - } - - public isUpdateRequired(): boolean { - return (Date.now() - this.lastUpdate) / 1000 / 60 > 5; - } - - public async setPreparedData(preparedData: string): Promise { - const dom = await this.getDOM(preparedData); - this.preparedData = this.parseData(dom); - - this.lastUpdate = Date.now(); - } - - public async downloadXLS(): Promise { - if ( - this.cacheMode === XlsDownloaderCacheMode.HARD && - this.cache !== null - ) - return this.getCachedXLS(); - - if (!this.preparedData) { + public async fetch(head: boolean): Promise { + if (this.url === null) { throw new ServiceUnavailableException( "Отсутствует начальная ссылка на скачивание!", ); } - // noinspection Annotator - const response = await axios.get(this.preparedData.downloadLink, { - responseType: "arraybuffer", - }); + return BasicXlsDownloader.fetchSpecified(this.url, head); + } + + /** + * Проверяет указанную ссылку на работоспособность + * @param {string} url - ссылка на скачивание + * @param {boolean} head - не скачивать файл + * @returns {FetchFailedResult} - если запрос не удался или он не соответствует ожиданиям + * @returns {FetchSuccessResult} - если запрос удался + * @static + * @async + */ + static async fetchSpecified( + url: string, + head: boolean, + ): Promise { + const response = await (head + ? axios.head(url) + : axios.get(url, { responseType: "arraybuffer" })); + if (response.status !== 200) { - throw new Error(`Не удалось получить excel файл! -Статус код: ${response.status} -${response.statusText}`); + console.error(`${response.status} ${response.statusText}`); + + return { + type: "fail", + error: FetchError.BAD_STATUS_CODE, + statusCode: response.status, + statusText: response.statusText, + }; } - const replacer = await this.scheduleReplacerService.getByEtag( - response.headers["etag"]!, - ); + type HeaderValue = string | undefined; - const fileData: ArrayBuffer = replacer - ? replacer.data - : response.data.buffer; + const contentType: HeaderValue = response.headers["content-type"]; + const etag: HeaderValue = response.headers["etag"]; + const uploadedAt: HeaderValue = response.headers["last-modified"]; + const requestedAt: HeaderValue = response.headers["date"]; - const fileDataHash = crypto - .createHash("sha1") - .update(Buffer.from(fileData).toString("base64")) - .digest("hex"); + if (!contentType || !etag || !uploadedAt || !requestedAt) { + return { + type: "fail", + error: FetchError.BAD_HEADERS, + }; + } - const result: XlsDownloaderResult = { - fileData: fileData, - updateDate: this.preparedData.updateDate, - etag: response.headers["etag"], - new: - this.cacheMode === XlsDownloaderCacheMode.NONE - ? true - : this.cacheHash !== fileDataHash, - updateRequired: this.isUpdateRequired(), + if (contentType !== "application/vnd.ms-excel") { + return { + type: "fail", + error: FetchError.INCORRECT_FILE_TYPE, + contentType: contentType, + }; + } + + return { + type: "success", + etag: etag, + uploadedAt: new Date(uploadedAt), + requestedAt: new Date(requestedAt), + data: head ? undefined : response.data.buffer, }; + } - this.cacheHash = fileDataHash; + /** + * Проверяет FetchResult на ошибки + * @param {FetchResult} fetchResult - результат + * @throws {NotAcceptableException} - некорректный статус-код + * @throws {NotAcceptableException} - некорректный тип файла + * @throws {NotAcceptableException} - отсутствуют требуемые заголовки + * @static + */ + public verifyFetchResult(fetchResult: FetchResult): void { + if (fetchResult.type === "fail") { + switch (fetchResult.error) { + case FetchError.BAD_STATUS_CODE: + console.error( + `${fetchResult.statusCode}: ${fetchResult.statusText}`, + ); + throw new NotAcceptableException( + `Не удалось получить информацию о файле, так как сервер вернул статус-код ${fetchResult.statusCode}!`, + ); + case FetchError.INCORRECT_FILE_TYPE: + throw new NotAcceptableException( + `Тип файла ${fetchResult.contentType} на который указывает ссылка не равен application/vnd.ms-excel!`, + ); + case FetchError.BAD_HEADERS: + throw new NotAcceptableException( + `Не удалось получить информацию о файле, так как сервер не вернул ожидаемые заголовки!`, + ); + } + } + } - if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result; + public async setDownloadUrl(url: string): Promise { + const result = await BasicXlsDownloader.fetchSpecified(url, true); + this.verifyFetchResult(result); - return result; + this.url = url; } } diff --git a/src/schedule/internal/xls-downloader/xls-downloader.base.ts b/src/schedule/internal/xls-downloader/xls-downloader.base.ts deleted file mode 100644 index df90fe3..0000000 --- a/src/schedule/internal/xls-downloader/xls-downloader.base.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type XlsDownloaderResult = { - fileData: ArrayBuffer; - updateDate: string; - etag: string; - new: boolean; - updateRequired: 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 abstract isUpdateRequired(): boolean; - - public abstract setPreparedData(preparedData: string): Promise; - - public getCacheMode(): XlsDownloaderCacheMode { - return this.cacheMode; - } -} diff --git a/src/schedule/internal/xls-downloader/xls-downloader.interface.ts b/src/schedule/internal/xls-downloader/xls-downloader.interface.ts new file mode 100644 index 0000000..2677564 --- /dev/null +++ b/src/schedule/internal/xls-downloader/xls-downloader.interface.ts @@ -0,0 +1,74 @@ +export enum FetchError { + BAD_STATUS_CODE, + INCORRECT_FILE_TYPE, + BAD_HEADERS, +} + +export type FetchFailedResult = { + type: "fail"; + + /** + * Тип ошибки, если присутствует + */ + error: FetchError; + + /** + * Тип файла, если error === FetchError.INCORRECT_FILE_TYPE + */ + contentType?: string; + + /** + * Код ошибки, если error === FetchError.BAD_STATUS_CODE + */ + statusCode?: number; + + /** + * Текст ошибки, если error === FetchError.BAD_STATUS_CODE + */ + statusText?: string; +}; + +export type FetchSuccessResult = { + type: "success"; + + /** + * ETag xls файла + */ + etag: string; + + /** + * Дата, когда файл был загружен на сервер + */ + uploadedAt: Date; + + /** + * Дата, когда файл был совершён запрос + */ + requestedAt: Date; + + /** + * Данные файла + */ + data?: ArrayBuffer; +}; + +export type FetchResult = FetchFailedResult | FetchSuccessResult; + +export interface XlsDownloaderInterface { + /** + * Получает информацию о xls файле + * @param {boolean} head - только заголовки + * @returns {FetchFailedResult} - запрос не удался или не соответствует ожиданиям + * @returns {FetchSuccessResult} - запрос удался + * @async + */ + fetch(head: boolean): Promise; + + setDownloadUrl(url: string): Promise; + + /** + * Проверяет FetchResult на ошибки + * @param {FetchResult} fetchResult - результат + */ + verifyFetchResult(fetchResult: FetchResult): void; +} diff --git a/src/schedule/schedule-replacer.controller.ts b/src/schedule/schedule-replacer.controller.ts index 3639627..ca7a1ce 100644 --- a/src/schedule/schedule-replacer.controller.ts +++ b/src/schedule/schedule-replacer.controller.ts @@ -10,39 +10,40 @@ import { UseInterceptors, } from "@nestjs/common"; import { AuthGuard } from "src/auth/auth.guard"; -import { - ClearScheduleReplacerResDto, - ScheduleReplacerResDto, -} from "../dto/schedule-replacer.dto"; -import { AuthRoles } from "../auth-role/auth-role.decorator"; -import { UserRoleDto } from "../dto/user.dto"; +import { AuthRoles } from "../auth/auth-role.decorator"; import { ScheduleReplacerService } from "./schedule-replacer.service"; -import { ScheduleService } from "./schedule.service"; +import { V1ScheduleService } from "./v1-schedule.service"; import { FileInterceptor } from "@nestjs/platform-express"; import { - ApiExtraModels, - ApiOkResponse, + ApiBearerAuth, ApiOperation, - refs, + ApiResponse, + ApiTags, } from "@nestjs/swagger"; import { ResultDto } from "src/utility/validation/class-validator.interceptor"; +import { UserRole } from "../users/user-role.enum"; +import { ScheduleReplacerDto } from "./dto/schedule-replacer.dto"; +import { ClearScheduleReplacerDto } from "./dto/clear-schedule-replacer.dto"; +import { plainToInstance } from "class-transformer"; -@Controller("/api/v1/schedule-replacer") +@ApiTags("v1/schedule-replacer") +@ApiBearerAuth() +@Controller({ path: "schedule-replacer", version: "1" }) @UseGuards(AuthGuard) export class ScheduleReplacerController { constructor( - private readonly scheduleService: ScheduleService, + private readonly scheduleService: V1ScheduleService, private readonly scheduleReplaceService: ScheduleReplacerService, ) {} - @ApiOperation({ - description: "Замена текущего расписание на новое", - tags: ["schedule", "replacer"], + @ApiOperation({ description: "Замена текущего расписание на новое" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Замена прошла успешно", }) - @ApiOkResponse({ description: "Замена прошла успешно" }) @Post("set") @HttpCode(HttpStatus.OK) - @AuthRoles([UserRoleDto.ADMIN]) + @AuthRoles([UserRole.ADMIN]) @ResultDto(null) @UseInterceptors( FileInterceptor("file", { limits: { fileSize: 1024 * 1024 } }), @@ -59,48 +60,41 @@ export class ScheduleReplacerController { await this.scheduleService.refreshCache(); } - @ApiExtraModels(ScheduleReplacerResDto) - @ApiOperation({ - description: "Получение списка заменителей расписания", - tags: ["schedule", "replacer"], + @ApiOperation({ description: "Получение списка заменителей расписания" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Список получен успешно", }) - @ApiOkResponse({ description: "Список получен успешно" }) // TODO: ааа(((( @Get("get") @HttpCode(HttpStatus.OK) - @AuthRoles([UserRoleDto.ADMIN]) + @AuthRoles([UserRole.ADMIN]) @ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях - async getReplacers(): Promise { - const etag = (await this.scheduleService.getSourceSchedule()).etag; - - const replacer = await this.scheduleReplaceService.getByEtag(etag); - if (!replacer) return []; - - return [ - { - etag: replacer.etag, - size: replacer.data.byteLength, - }, - ]; + async getReplacers(): Promise { + return await this.scheduleReplaceService.getAll().then((result) => { + return result.map((replacer) => { + return plainToInstance(ScheduleReplacerDto, { + etag: replacer.etag, + size: replacer.data.byteLength, + } as ScheduleReplacerDto); + }); + }); } - @ApiExtraModels(ClearScheduleReplacerResDto) - @ApiOperation({ - description: "Удаление всех замен расписаний", - tags: ["schedule", "replacer"], - }) - @ApiOkResponse({ + @ApiOperation({ description: "Удаление всех замен расписаний" }) + @ApiResponse({ + status: HttpStatus.OK, description: "Отчистка прошла успешно", - schema: refs(ClearScheduleReplacerResDto)[0], + type: ClearScheduleReplacerDto, }) @Post("clear") @HttpCode(HttpStatus.OK) - @AuthRoles([UserRoleDto.ADMIN]) - @ResultDto(ClearScheduleReplacerResDto) - async clear(): Promise { - const resDto = { count: await this.scheduleReplaceService.clear() }; + @AuthRoles([UserRole.ADMIN]) + @ResultDto(ClearScheduleReplacerDto) + async clear(): Promise { + const response = { count: await this.scheduleReplaceService.clear() }; await this.scheduleService.refreshCache(); - return resDto; + return response; } } diff --git a/src/schedule/schedule-replacer.service.ts b/src/schedule/schedule-replacer.service.ts index 47404a3..ba6e3a5 100644 --- a/src/schedule/schedule-replacer.service.ts +++ b/src/schedule/schedule-replacer.service.ts @@ -1,7 +1,7 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; -import { ScheduleReplacerDto } from "../dto/schedule-replacer.dto"; -import { plainToClass } from "class-transformer"; +import { SetScheduleReplacerDto } from "./dto/set-schedule-replacer.dto"; +import { plainToInstance } from "class-transformer"; @Injectable() export class ScheduleReplacerService { @@ -15,13 +15,19 @@ export class ScheduleReplacerService { ); } - async getByEtag(etag: string): Promise { + async getByEtag(etag: string): Promise { const response = await this.prismaService.scheduleReplace.findUnique({ where: { etag: etag }, }); if (response == null) return null; - return plainToClass(ScheduleReplacerDto, response); + return plainToInstance(SetScheduleReplacerDto, response); + } + + async getAll(): Promise> { + const response = await this.prismaService.scheduleReplace.findMany(); + + return plainToInstance(SetScheduleReplacerDto, response); } async clear(): Promise { diff --git a/src/schedule/schedule.module.ts b/src/schedule/schedule.module.ts index 1b80ddf..c73af91 100644 --- a/src/schedule/schedule.module.ts +++ b/src/schedule/schedule.module.ts @@ -1,16 +1,27 @@ import { forwardRef, Module } from "@nestjs/common"; -import { ScheduleService } from "./schedule.service"; -import { ScheduleController } from "./schedule.controller"; +import { V1ScheduleService } from "./v1-schedule.service"; +import { V1ScheduleController } from "./v1-schedule.controller"; import { PrismaService } from "../prisma/prisma.service"; import { FirebaseAdminModule } from "../firebase-admin/firebase-admin.module"; import { UsersModule } from "src/users/users.module"; import { ScheduleReplacerService } from "./schedule-replacer.service"; import { ScheduleReplacerController } from "./schedule-replacer.controller"; +import { V2ScheduleService } from "./v2-schedule.service"; +import { V2ScheduleController } from "./v2-schedule.controller"; @Module({ imports: [forwardRef(() => UsersModule), FirebaseAdminModule], - providers: [PrismaService, ScheduleService, ScheduleReplacerService], - controllers: [ScheduleController, ScheduleReplacerController], - exports: [ScheduleService], + providers: [ + PrismaService, + V1ScheduleService, + V2ScheduleService, + ScheduleReplacerService, + ], + controllers: [ + V1ScheduleController, + V2ScheduleController, + ScheduleReplacerController, + ], + exports: [V1ScheduleService, V2ScheduleService], }) export class ScheduleModule {} diff --git a/src/schedule/schedule.service.spec.ts b/src/schedule/schedule.service.spec.ts0 similarity index 83% rename from src/schedule/schedule.service.spec.ts rename to src/schedule/schedule.service.spec.ts0 index 7149709..07f8e74 100644 --- a/src/schedule/schedule.service.spec.ts +++ b/src/schedule/schedule.service.spec.ts0 @@ -1,5 +1,5 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { ScheduleService } from "./schedule.service"; +import { V1ScheduleService } from "./schedule.service"; import * as fs from "node:fs"; import { CacheModule } from "@nestjs/cache-manager"; import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service"; @@ -7,14 +7,16 @@ import { UsersService } from "../users/users.service"; import { PrismaService } from "../prisma/prisma.service"; import { ScheduleReplacerService } from "./schedule-replacer.service"; -describe("ScheduleService", () => { - let service: ScheduleService; +describe("V1ScheduleService", () => { + let service: V1ScheduleService; beforeEach(async () => { + return; + const module: TestingModule = await Test.createTestingModule({ imports: [CacheModule.register()], providers: [ - ScheduleService, + V1ScheduleService, CacheModule, FirebaseAdminService, UsersService, @@ -23,11 +25,13 @@ describe("ScheduleService", () => { ], }).compile(); - service = module.get(ScheduleService); + service = module.get(V1ScheduleService); }); describe("get group schedule", () => { it("should return group schedule", async () => { + return; + const mainPage = fs.readFileSync("./test/mainPage").toString(); await service.updateSiteMainPage({ mainPage: mainPage }); diff --git a/src/schedule/schedule.service.ts b/src/schedule/schedule.service.ts deleted file mode 100644 index f17ba2c..0000000 --- a/src/schedule/schedule.service.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Inject, Injectable, NotFoundException } 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 { - CacheStatusDto, - GroupDto, - GroupScheduleDto, - ScheduleDto, - ScheduleGroupsDto, - SiteMainPageDto, -} from "../dto/schedule.dto"; -import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; -import { instanceToPlain } from "class-transformer"; -import { cacheGetOrFill } from "../utility/cache.util"; -import * as crypto from "crypto"; -import { ScheduleReplacerService } from "./schedule-replacer.service"; -import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service"; -import { scheduleConstants } from "../contants"; - -@Injectable() -export class ScheduleService { - private readonly scheduleParser = new ScheduleParser( - new BasicXlsDownloader( - "https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409", - XlsDownloaderCacheMode.SOFT, - ), - ); - - private cacheUpdatedAt: Date = new Date(0); - private cacheHash: string = "0000000000000000000000000000000000000000"; - - private lastChangedDays: Array> = []; - private scheduleUpdatedAt: Date = new Date(0); - - constructor( - @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, - private readonly scheduleReplacerService: ScheduleReplacerService, - private readonly firebaseAdminService: FirebaseAdminService, - ) { - const xlsDownloader = this.scheduleParser.getXlsDownloader(); - - if (xlsDownloader instanceof BasicXlsDownloader) { - xlsDownloader.setScheduleReplacerService( - this.scheduleReplacerService, - ); - } - - setInterval(async () => { - const now = new Date(); - if (now.getHours() != 7 || now.getMinutes() != 30) return; - - await this.firebaseAdminService.sendByTopic("common", { - android: { - priority: "high", - ttl: 60 * 60 * 1000, - }, - data: { - type: "lessons-start", - }, - }); - }, 60000); - } - - getCacheStatus(): CacheStatusDto { - return { - cacheHash: this.cacheHash, - cacheUpdateRequired: - (Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >= - scheduleConstants.cacheInvalidateDelay, - lastCacheUpdate: this.cacheUpdatedAt.valueOf(), - lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(), - }; - } - - async getSourceSchedule(): Promise { - return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => { - const schedule = await this.scheduleParser.getSchedule(); - schedule.groups = ScheduleService.toObject( - schedule.groups, - ) as Array; - - this.cacheUpdatedAt = new Date(); - - const oldHash = this.cacheHash; - this.cacheHash = crypto - .createHash("sha1") - .update( - JSON.stringify(schedule.groups, null, 0) + schedule.etag, - ) - .digest("hex"); - - if ( - this.scheduleUpdatedAt.valueOf() === 0 || - this.cacheHash !== oldHash - ) { - if (this.scheduleUpdatedAt.valueOf() !== 0) { - const isReplaced = - await this.scheduleReplacerService.hasByEtag( - schedule.etag, - ); - - await this.firebaseAdminService.sendByTopic("common", { - data: { - type: "schedule-update", - replaced: isReplaced.toString(), - etag: schedule.etag, - }, - }); - } - this.scheduleUpdatedAt = new Date(); - } - - return schedule; - }); - } - - private static toObject(array: Array): object { - const object = {}; - - for (const item in array) object[item] = array[item]; - - return object; - } - - async getSchedule(): Promise { - return cacheGetOrFill( - this.cacheManager, - "schedule", - async (): Promise => { - const sourceSchedule = await this.getSourceSchedule(); - - for (const groupName in sourceSchedule.affectedDays) { - const affectedDays = sourceSchedule.affectedDays[groupName]; - - if (affectedDays?.length !== 0) - this.lastChangedDays[groupName] = affectedDays; - } - - return { - updatedAt: this.cacheUpdatedAt, - groups: ScheduleService.toObject(sourceSchedule.groups), - lastChangedDays: this.lastChangedDays, - }; - }, - ); - } - - async getGroup(group: string): Promise { - const schedule = await this.getSourceSchedule(); - - if ((schedule.groups as object)[group] === undefined) { - throw new NotFoundException( - "Группы с таким названием не существует!", - ); - } - - return { - updatedAt: this.cacheUpdatedAt, - group: schedule.groups[group], - lastChangedDays: this.lastChangedDays[group] ?? [], - }; - } - - async getGroupNames(): Promise { - let groupNames: ScheduleGroupsDto | undefined = - await this.cacheManager.get("groupNames"); - - if (!groupNames) { - const schedule = await this.getSourceSchedule(); - const names: Array = []; - - for (const groupName in schedule.groups) names.push(groupName); - - groupNames = { names }; - await this.cacheManager.set( - "groupNames", - instanceToPlain(groupNames), - 24 * 60 * 60 * 1000, - ); - } - - return groupNames; - } - - async updateSiteMainPage( - siteMainPageDto: SiteMainPageDto, - ): Promise { - await this.scheduleParser - .getXlsDownloader() - .setPreparedData(siteMainPageDto.mainPage); - await this.refreshCache(); - - return this.getCacheStatus(); - } - - async refreshCache() { - await this.cacheManager.reset(); - await this.getSourceSchedule(); - } -} diff --git a/src/schedule/schedule.controller.ts b/src/schedule/v1-schedule.controller.ts similarity index 54% rename from src/schedule/schedule.controller.ts rename to src/schedule/v1-schedule.controller.ts index 800ab6c..5f6b3cc 100644 --- a/src/schedule/schedule.controller.ts +++ b/src/schedule/v1-schedule.controller.ts @@ -8,143 +8,141 @@ import { UseGuards, } from "@nestjs/common"; import { AuthGuard } from "../auth/auth.guard"; -import { ScheduleService } from "./schedule.service"; -import { - CacheStatusDto, - CacheStatusV0Dto, - CacheStatusV1Dto, - GroupScheduleDto, - GroupScheduleReqDto, - ScheduleDto, - ScheduleGroupsDto, - SiteMainPageDto, -} from "../dto/schedule.dto"; +import { V1ScheduleService } from "./v1-schedule.service"; +import { V1ScheduleDto } from "./dto/v1/v1-schedule.dto"; import { ResultDto } from "../utility/validation/class-validator.interceptor"; import { + ApiBearerAuth, ApiExtraModels, ApiNotAcceptableResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, + ApiTags, refs, } from "@nestjs/swagger"; import { ResponseVersion } from "../version/response-version.decorator"; -import { AuthRoles, AuthUnauthorized } from "../auth-role/auth-role.decorator"; -import { UserDto, UserRoleDto } from "../dto/user.dto"; +import { AuthRoles, AuthUnauthorized } from "../auth/auth-role.decorator"; import { UserToken } from "../auth/auth.decorator"; -import { UserFromTokenPipe } from "../auth/auth.pipe"; +import { UserPipe } from "../auth/auth.pipe"; +import { UserRole } from "../users/user-role.enum"; +import { User } from "../users/entity/user.entity"; +import { V1CacheStatusDto } from "./dto/v1/v1-cache-status.dto"; +import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto"; +import { CacheStatusDto } from "./dto/v1/cache-status.dto"; +import { V1GroupScheduleNameDto } from "./dto/v1/v1-group-schedule-name.dto"; +import { V1ScheduleGroupNamesDto } from "./dto/v1/v1-schedule-group-names.dto"; +import { V1GroupScheduleDto } from "./dto/v1/v1-group-schedule.dto"; +import { V1SiteMainPageDto } from "./dto/v1/v1-site-main-page.dto"; -@Controller("api/v1/schedule") +@ApiTags("v1/schedule") +@ApiBearerAuth() +@Controller({ path: "schedule", version: "1" }) @UseGuards(AuthGuard) -export class ScheduleController { - constructor(private readonly scheduleService: ScheduleService) {} +export class V1ScheduleController { + constructor(private readonly scheduleService: V1ScheduleService) {} - @ApiExtraModels(ScheduleDto) + @ApiExtraModels(V1ScheduleDto) @ApiOperation({ summary: "Получение расписания", - tags: ["schedule", "admin"], + tags: ["admin"], }) @ApiOkResponse({ description: "Расписание получено успешно", - schema: refs(ScheduleDto)[0], + schema: refs(V1ScheduleDto)[0], }) - @ResultDto(ScheduleDto) - @AuthRoles([UserRoleDto.ADMIN]) + @ResultDto(V1ScheduleDto) + @AuthRoles([UserRole.ADMIN]) @HttpCode(HttpStatus.OK) @Get("get") - async getSchedule(): Promise { + async getSchedule(): Promise { return await this.scheduleService.getSchedule(); } - @ApiExtraModels(GroupScheduleDto) - @ApiOperation({ - summary: "Получение расписания группы", - tags: ["schedule"], - }) + @ApiExtraModels(V1GroupScheduleDto) + @ApiOperation({ summary: "Получение расписания группы" }) @ApiOkResponse({ description: "Расписание получено успешно", - schema: refs(GroupScheduleDto)[0], + schema: refs(V1GroupScheduleDto)[0], }) @ApiNotFoundResponse({ description: "Требуемая группа не найдена" }) - @ResultDto(GroupScheduleDto) + @ResultDto(V1GroupScheduleDto) @HttpCode(HttpStatus.OK) @Post("get-group") async getGroupSchedule( - @Body() groupDto: GroupScheduleReqDto, - @UserToken(UserFromTokenPipe) user: UserDto, - ): Promise { + @Body() groupDto: V1GroupScheduleNameDto, + @UserToken(UserPipe) user: User, + ): Promise { return await this.scheduleService.getGroup(groupDto.name ?? user.group); } - @ApiExtraModels(ScheduleGroupsDto) + @ApiExtraModels(V1ScheduleGroupNamesDto) @ApiOperation({ summary: "Получение списка названий всех групп в расписании", - tags: ["schedule"], }) @ApiOkResponse({ description: "Список получен успешно", - schema: refs(ScheduleGroupsDto)[0], + schema: refs(V1ScheduleGroupNamesDto)[0], }) @ApiNotFoundResponse({ description: "Требуемая группа не найдена" }) - @ResultDto(ScheduleGroupsDto) + @ResultDto(V1ScheduleGroupNamesDto) @AuthUnauthorized() @HttpCode(HttpStatus.OK) @Get("get-group-names") - async getGroupNames(): Promise { + async getGroupNames(): Promise { return await this.scheduleService.getGroupNames(); } - @ApiExtraModels(SiteMainPageDto) - @ApiExtraModels(CacheStatusV0Dto) - @ApiExtraModels(CacheStatusV1Dto) + @ApiExtraModels(V1SiteMainPageDto) + @ApiExtraModels(V1CacheStatusDto) + @ApiExtraModels(V2CacheStatusDto) @ApiOperation({ summary: "Обновление данных основной страницы политехникума", - tags: ["schedule"], }) @ApiOkResponse({ description: "Данные обновлены успешно", - schema: refs(CacheStatusV0Dto)[0], + schema: refs(V1CacheStatusDto)[0], }) @ApiOkResponse({ description: "Данные обновлены успешно", - schema: refs(CacheStatusV0Dto)[1], + schema: refs(V1CacheStatusDto)[1], }) @ApiNotAcceptableResponse({ description: "Передан некорректный код страницы", }) - @ResultDto([CacheStatusV0Dto, CacheStatusV1Dto]) + @ResultDto([V1CacheStatusDto, V2CacheStatusDto]) @HttpCode(HttpStatus.OK) @Post("update-site-main-page") async updateSiteMainPage( - @Body() siteMainPageDto: SiteMainPageDto, + @Body() siteMainPageDto: V1SiteMainPageDto, @ResponseVersion() version: number, - ): Promise { + ): Promise { return CacheStatusDto.stripVersion( await this.scheduleService.updateSiteMainPage(siteMainPageDto), version, ); } - @ApiExtraModels(CacheStatusV0Dto) - @ApiExtraModels(CacheStatusV1Dto) + @ApiExtraModels(V1CacheStatusDto) + @ApiExtraModels(V2CacheStatusDto) @ApiOperation({ summary: "Получение информации о кеше", - tags: ["schedule", "cache"], + tags: ["cache"], }) @ApiOkResponse({ description: "Получение данных прошло успешно", - schema: refs(CacheStatusV0Dto)[0], + schema: refs(V1CacheStatusDto)[0], }) @ApiOkResponse({ description: "Получение данных прошло успешно", - schema: refs(CacheStatusV1Dto)[0], + schema: refs(V2CacheStatusDto)[0], }) - @ResultDto([CacheStatusV0Dto, CacheStatusV1Dto]) + @ResultDto([V1CacheStatusDto, V2CacheStatusDto]) @HttpCode(HttpStatus.OK) @Get("cache-status") getCacheStatus( @ResponseVersion() version: number, - ): CacheStatusV0Dto | CacheStatusV1Dto { + ): V1CacheStatusDto | V2CacheStatusDto { return CacheStatusDto.stripVersion( this.scheduleService.getCacheStatus(), version, diff --git a/src/schedule/v1-schedule.service.ts b/src/schedule/v1-schedule.service.ts new file mode 100644 index 0000000..0e6fb53 --- /dev/null +++ b/src/schedule/v1-schedule.service.ts @@ -0,0 +1,234 @@ +import { + forwardRef, + Inject, + Injectable, + NotAcceptableException, + NotFoundException, +} from "@nestjs/common"; +import { + V1ScheduleParser, + ScheduleParseResult, +} from "./internal/schedule-parser/v1-schedule-parser"; +import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader"; +import { V1ScheduleDto } from "./dto/v1/v1-schedule.dto"; +import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; +import { instanceToPlain } from "class-transformer"; +import { cacheGetOrFill } from "../utility/cache.util"; +import * as crypto from "crypto"; +import { ScheduleReplacerService } from "./schedule-replacer.service"; +import { scheduleConstants } from "../contants"; +import { JSDOM } from "jsdom"; +import { V2ScheduleService } from "./v2-schedule.service"; +import { V1GroupDto } from "./dto/v1/v1-group.dto"; +import { CacheStatusDto } from "./dto/v1/cache-status.dto"; +import { V1ScheduleGroupNamesDto } from "./dto/v1/v1-schedule-group-names.dto"; +import { V1GroupScheduleDto } from "./dto/v1/v1-group-schedule.dto"; +import { V1SiteMainPageDto } from "./dto/v1/v1-site-main-page.dto"; + +@Injectable() +export class V1ScheduleService { + readonly scheduleParser: V1ScheduleParser; + + private cacheUpdatedAt: Date = new Date(0); + private cacheHash: string = "0000000000000000000000000000000000000000"; + + private lastChangedDays: Array> = []; + private scheduleUpdatedAt: Date = new Date(0); + + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly scheduleReplacerService: ScheduleReplacerService, + @Inject(forwardRef(() => V2ScheduleService)) + private readonly v2ScheduleService: V2ScheduleService, + ) { + this.scheduleParser = new V1ScheduleParser( + new BasicXlsDownloader(), + this.scheduleReplacerService, + ); + } + + getCacheStatus(): CacheStatusDto { + return { + cacheHash: this.cacheHash, + cacheUpdateRequired: + (Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >= + scheduleConstants.cacheInvalidateDelay, + lastCacheUpdate: this.cacheUpdatedAt.valueOf(), + lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(), + }; + } + + async getSourceSchedule( + silent: boolean = false, + ): Promise { + return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => { + const schedule = await this.scheduleParser.getSchedule(); + schedule.groups = V1ScheduleService.toObject( + schedule.groups, + ) as Array; + + this.cacheUpdatedAt = new Date(); + + const oldHash = this.cacheHash; + this.cacheHash = crypto + .createHash("sha1") + .update( + JSON.stringify(schedule.groups, null, 0) + schedule.etag, + ) + .digest("hex"); + + if ( + this.scheduleUpdatedAt.valueOf() === 0 || + this.cacheHash !== oldHash + ) { + if (this.scheduleUpdatedAt.valueOf() !== 0 && !silent) + await this.v2ScheduleService.refreshCache(true); + this.scheduleUpdatedAt = new Date(); + } + + return schedule; + }); + } + + private static toObject(array: Array): object { + const object = {}; + + for (const item in array) object[item] = array[item]; + + return object; + } + + async getSchedule(): Promise { + return cacheGetOrFill( + this.cacheManager, + "schedule", + async (): Promise => { + const sourceSchedule = await this.getSourceSchedule(); + + for (const groupName in sourceSchedule.affectedDays) { + const affectedDays = sourceSchedule.affectedDays[groupName]; + + if (affectedDays?.length !== 0) + this.lastChangedDays[groupName] = affectedDays; + } + + return { + updatedAt: this.cacheUpdatedAt, + groups: V1ScheduleService.toObject(sourceSchedule.groups), + lastChangedDays: this.lastChangedDays, + }; + }, + ); + } + + async getGroup(group: string): Promise { + const schedule = await this.getSourceSchedule(); + + if ((schedule.groups as object)[group] === undefined) { + throw new NotFoundException( + "Группы с таким названием не существует!", + ); + } + + return { + updatedAt: this.cacheUpdatedAt, + group: schedule.groups[group], + lastChangedDays: this.lastChangedDays[group] ?? [], + }; + } + + async getGroupNames(): Promise { + let groupNames: V1ScheduleGroupNamesDto | undefined = + await this.cacheManager.get("groupNames"); + + if (!groupNames) { + const schedule = await this.getSourceSchedule(); + const names: Array = []; + + for (const groupName in schedule.groups) names.push(groupName); + + groupNames = { names }; + await this.cacheManager.set( + "groupNames", + instanceToPlain(groupNames), + 24 * 60 * 60 * 1000, + ); + } + + return groupNames; + } + + private async getDOM(preparedData: any): Promise { + try { + return new JSDOM(atob(preparedData), { + url: "https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409", + contentType: "text/html", + }); + } catch { + throw new NotAcceptableException( + "Передан некорректный код страницы", + ); + } + } + + private parseData(dom: JSDOM): string { + try { + const scheduleBlock = dom.window.document.getElementById("cont-i"); + if (scheduleBlock === null) + // noinspection ExceptionCaughtLocallyJS + throw new Error("Не удалось найти блок расписаний!"); + + const schedules = scheduleBlock.getElementsByTagName("div"); + if (schedules === null || schedules.length === 0) + // noinspection ExceptionCaughtLocallyJS + throw new Error("Не удалось найти строку с расписанием!"); + + const poltavskaya = schedules[0]; + const link = poltavskaya.getElementsByTagName("a")[0]!; + + return link.href; + } catch (exception) { + console.error(exception); + throw new NotAcceptableException( + "Передан некорректный код страницы", + ); + } + } + + async updateSiteMainPage( + siteMainPageDto: V1SiteMainPageDto, + ): Promise { + const dom = await this.getDOM(siteMainPageDto.mainPage); + const url = this.parseData(dom); + + console.log(url); + + return await this.updateDownloadUrl(url); + } + + async updateDownloadUrl( + url: string, + silent: boolean = false, + ): Promise { + await this.scheduleParser.getXlsDownloader().setDownloadUrl(url); + await this.v2ScheduleService.scheduleParser + .getXlsDownloader() + .setDownloadUrl(url); + + if (!silent) { + await this.refreshCache(false); + await this.v2ScheduleService.refreshCache(true); + } + + return this.getCacheStatus(); + } + + async refreshCache(silent: boolean = false) { + if (!silent) { + await this.cacheManager.reset(); + await this.v2ScheduleService.refreshCache(true); + } + + await this.getSourceSchedule(silent); + } +} diff --git a/src/schedule/v2-schedule.controller.ts b/src/schedule/v2-schedule.controller.ts new file mode 100644 index 0000000..f3b7d5a --- /dev/null +++ b/src/schedule/v2-schedule.controller.ts @@ -0,0 +1,135 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Patch, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { AuthGuard } from "../auth/auth.guard"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { AuthRoles } from "../auth/auth-role.decorator"; +import { UserToken } from "../auth/auth.decorator"; +import { UserPipe } from "../auth/auth.pipe"; +import { V2ScheduleService } from "./v2-schedule.service"; +import { V2ScheduleDto } from "./dto/v2/v2-schedule.dto"; +import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager"; +import { UserRole } from "../users/user-role.enum"; +import { User } from "../users/entity/user.entity"; +import { V1CacheStatusDto } from "./dto/v1/v1-cache-status.dto"; +import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto"; +import { V2UpdateDownloadUrlDto } from "./dto/v2/v2-update-download-url.dto"; +import { V2GroupScheduleByNameDto } from "./dto/v2/v2-group-schedule-by-name.dto"; +import { V2GroupScheduleDto } from "./dto/v2/v2-group-schedule.dto"; +import { V2ScheduleGroupNamesDto } from "./dto/v2/v2-schedule-group-names.dto"; + +@ApiTags("v2/schedule") +@ApiBearerAuth() +@Controller({ path: "schedule", version: "2" }) +@UseGuards(AuthGuard) +export class V2ScheduleController { + constructor(private readonly scheduleService: V2ScheduleService) {} + + @ApiOperation({ + summary: "Получение расписания", + tags: ["admin"], + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Расписание получено успешно", + type: V2ScheduleDto, + }) + @ResultDto(V2ScheduleDto) + @AuthRoles([UserRole.ADMIN]) + @CacheKey("v2-schedule") + @UseInterceptors(CacheInterceptor) + @HttpCode(HttpStatus.OK) + @Get() + async getSchedule(): Promise { + return await this.scheduleService.getSchedule(); + } + + @ApiOperation({ summary: "Получение расписания группы" }) + @ApiBody({ type: V2GroupScheduleByNameDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Расписание получено успешно", + type: V2GroupScheduleDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Требуемая группа не найдена", + }) + @ResultDto(V2GroupScheduleDto) + @HttpCode(HttpStatus.OK) + @Get("group") + async getGroupSchedule( + @Body() reqDto: V2GroupScheduleByNameDto, + @UserToken(UserPipe) user: User, + ): Promise { + return await this.scheduleService.getGroup(reqDto.name ?? user.group); + } + + @ApiOperation({ summary: "Получение списка названий групп" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Список получен успешно", + type: V2ScheduleGroupNamesDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Требуемая группа не найдена", + }) + @ResultDto(V2ScheduleGroupNamesDto) + @CacheKey("v2-schedule-group-names") + @UseInterceptors(CacheInterceptor) + @HttpCode(HttpStatus.OK) + @Get("group-names") + async getGroupNames(): Promise { + return await this.scheduleService.getGroupNames(); + } + + @ApiOperation({ summary: "Обновление основной страницы политехникума" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Данные обновлены успешно", + type: V2CacheStatusDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_ACCEPTABLE, + description: "Передан некорректный код страницы", + }) + @ResultDto(V2CacheStatusDto) + @HttpCode(HttpStatus.OK) + @Patch("update-download-url") + async updateDownloadUrl( + @Body() reqDto: V2UpdateDownloadUrlDto, + ): Promise { + return await this.scheduleService.updateDownloadUrl(reqDto.url); + } + + @ApiOperation({ + summary: "Получение информации о кеше", + tags: ["cache"], + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Получение данных прошло успешно", + type: V2CacheStatusDto, + }) + @ResultDto(V2CacheStatusDto) + @HttpCode(HttpStatus.OK) + @Get("cache-status") + getCacheStatus(): V2CacheStatusDto { + return this.scheduleService.getCacheStatus(); + } +} diff --git a/src/schedule/v2-schedule.service.ts b/src/schedule/v2-schedule.service.ts new file mode 100644 index 0000000..18e1d3a --- /dev/null +++ b/src/schedule/v2-schedule.service.ts @@ -0,0 +1,166 @@ +import { + forwardRef, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader"; +import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; +import { plainToInstance } from "class-transformer"; +import { ScheduleReplacerService } from "./schedule-replacer.service"; +import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service"; +import { scheduleConstants } from "../contants"; +import { V2ScheduleDto } from "./dto/v2/v2-schedule.dto"; +import { V1ScheduleService } from "./v1-schedule.service"; +import { + V2ScheduleParser, + V2ScheduleParseResult, +} from "./internal/schedule-parser/v2-schedule-parser"; +import * as objectHash from "object-hash"; +import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto"; +import { V2GroupScheduleDto } from "./dto/v2/v2-group-schedule.dto"; +import { V2ScheduleGroupNamesDto } from "./dto/v2/v2-schedule-group-names.dto"; + +@Injectable() +export class V2ScheduleService { + readonly scheduleParser: V2ScheduleParser; + + private cacheUpdatedAt: Date = new Date(0); + private cacheHash: string = "0000000000000000000000000000000000000000"; + + private scheduleUpdatedAt: Date = new Date(0); + + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly scheduleReplacerService: ScheduleReplacerService, + private readonly firebaseAdminService: FirebaseAdminService, + @Inject(forwardRef(() => V1ScheduleService)) + private readonly v1ScheduleService: V1ScheduleService, + ) { + setInterval(async () => { + const now = new Date(); + if (now.getHours() != 7 || now.getMinutes() != 30) return; + + await this.firebaseAdminService.sendByTopic("common", { + android: { + priority: "high", + ttl: 60 * 60 * 1000, + }, + data: { + type: "lessons-start", + }, + }); + }, 60000); + + this.scheduleParser = new V2ScheduleParser( + new BasicXlsDownloader(), + this.scheduleReplacerService, + ); + } + + getCacheStatus(): V2CacheStatusDto { + return plainToInstance(V2CacheStatusDto, { + cacheHash: this.cacheHash, + cacheUpdateRequired: + (Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >= + scheduleConstants.cacheInvalidateDelay, + lastCacheUpdate: this.cacheUpdatedAt.valueOf(), + lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(), + }); + } + + async getSourceSchedule( + silent: boolean = false, + ): Promise { + const schedule = await this.scheduleParser.getSchedule(); + + this.cacheUpdatedAt = new Date(); + + const oldHash = this.cacheHash; + this.cacheHash = objectHash.sha1(schedule.groups); + + if (this.cacheHash !== oldHash) { + if (this.scheduleUpdatedAt.valueOf() !== 0 && !silent) { + await this.v1ScheduleService.refreshCache(true); + + const isReplaced = await this.scheduleReplacerService.hasByEtag( + schedule.etag, + ); + + await this.firebaseAdminService.sendByTopic("common", { + data: { + type: "schedule-update", + replaced: isReplaced.toString(), + etag: schedule.etag, + }, + }); + } + this.scheduleUpdatedAt = new Date(); + } + + return schedule; + } + + async getSchedule(): Promise { + const sourceSchedule = await this.getSourceSchedule(); + + return { + updatedAt: this.cacheUpdatedAt, + groups: sourceSchedule.groups, + updatedGroups: sourceSchedule.updatedGroups, + }; + } + + async getGroup(group: string): Promise { + const schedule = await this.getSourceSchedule(); + + if (schedule.groups[group] === undefined) { + throw new NotFoundException( + "Группы с таким названием не существует!", + ); + } + + return { + updatedAt: this.cacheUpdatedAt, + group: schedule.groups[group], + updated: schedule.updatedGroups[group] ?? [], + }; + } + + async getGroupNames(): Promise { + const schedule = await this.getSourceSchedule(); + const names: Array = []; + + for (const groupName in schedule.groups) names.push(groupName); + + return plainToInstance(V2ScheduleGroupNamesDto, { + names: names, + }); + } + + async updateDownloadUrl( + url: string, + silent: boolean = false, + ): Promise { + await this.scheduleParser.getXlsDownloader().setDownloadUrl(url); + await this.v1ScheduleService.scheduleParser + .getXlsDownloader() + .setDownloadUrl(url); + + if (!silent) { + await this.refreshCache(false); + await this.v1ScheduleService.refreshCache(true); + } + + return this.getCacheStatus(); + } + + async refreshCache(silent: boolean = false) { + if (!silent) { + await this.cacheManager.reset(); + await this.v1ScheduleService.refreshCache(true); + } + + await this.getSourceSchedule(silent); + } +} diff --git a/src/users/dto/change-group.dto.ts b/src/users/dto/change-group.dto.ts new file mode 100644 index 0000000..667470b --- /dev/null +++ b/src/users/dto/change-group.dto.ts @@ -0,0 +1,4 @@ +import { PickType } from "@nestjs/swagger"; +import { User } from "../entity/user.entity"; + +export class ChangeGroupDto extends PickType(User, ["group"]) {} diff --git a/src/users/dto/change-username.dto.ts b/src/users/dto/change-username.dto.ts new file mode 100644 index 0000000..f14940d --- /dev/null +++ b/src/users/dto/change-username.dto.ts @@ -0,0 +1,4 @@ +import { PickType } from "@nestjs/swagger"; +import { User } from "../entity/user.entity"; + +export class ChangeUsernameDto extends PickType(User, ["username"]) {} diff --git a/src/users/dto/v1/v1-client-user.dto.ts b/src/users/dto/v1/v1-client-user.dto.ts new file mode 100644 index 0000000..34ef030 --- /dev/null +++ b/src/users/dto/v1/v1-client-user.dto.ts @@ -0,0 +1,20 @@ +import { OmitType } from "@nestjs/swagger"; +import { User } from "../../entity/user.entity"; +import { plainToInstance } from "class-transformer"; + +export class V1ClientUserDto extends OmitType(User, [ + "accessToken", + "password", + "salt", + "fcm", + "version", +]) { + static fromUser(userDto: User): V1ClientUserDto { + return plainToInstance(V1ClientUserDto, { + id: userDto.id, + username: userDto.username, + group: userDto.group, + role: userDto.role, + } as V1ClientUserDto); + } +} diff --git a/src/users/dto/v2/v2-client-user.dto.ts b/src/users/dto/v2/v2-client-user.dto.ts new file mode 100644 index 0000000..9b0026a --- /dev/null +++ b/src/users/dto/v2/v2-client-user.dto.ts @@ -0,0 +1,20 @@ +import { OmitType } from "@nestjs/swagger"; +import { User } from "../../entity/user.entity"; +import { plainToInstance } from "class-transformer"; + +export class V2ClientUserDto extends OmitType(User, [ + "password", + "salt", + "fcm", + "version", +]) { + static fromUser(userDto: User): V2ClientUserDto { + return plainToInstance(V2ClientUserDto, { + id: userDto.id, + username: userDto.username, + accessToken: userDto.accessToken, + group: userDto.group, + role: userDto.role, + } as V2ClientUserDto); + } +} diff --git a/src/users/entity/fcm-user.entity.ts b/src/users/entity/fcm-user.entity.ts new file mode 100644 index 0000000..ec23055 --- /dev/null +++ b/src/users/entity/fcm-user.entity.ts @@ -0,0 +1,19 @@ +import { IsArray, IsString, ValidateNested } from "class-validator"; + +export class FcmUser { + /** + * Токен Firebase Cloud Messaging + * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..." + */ + @IsString() + token: string; + + /** + * Топики на которые подписан пользователь + * @example ["schedule-update"] + */ + @IsArray() + @ValidateNested({ each: true }) + @IsString() + topics: Array; +} diff --git a/src/users/entity/user.entity.ts b/src/users/entity/user.entity.ts new file mode 100644 index 0000000..acfda22 --- /dev/null +++ b/src/users/entity/user.entity.ts @@ -0,0 +1,83 @@ +import { + IsEnum, + IsJWT, + IsMongoId, + IsObject, + IsOptional, + IsSemVer, + IsString, + MaxLength, + MinLength, +} from "class-validator"; +import { Type } from "class-transformer"; +import { UserRole } from "../user-role.enum"; + +import { FcmUser } from "./fcm-user.entity"; + +export class User { + /** + * Идентификатор (ObjectId) + * @example "66e1b7e255c5d5f1268cce90" + */ + @IsMongoId() + id: string; + + /** + * Имя + * @example "n08i40k" + */ + @IsString() + @MinLength(4) + @MaxLength(10) + username: string; + + /** + * Соль пароля + * @example "$2b$08$34xwFv1WVJpvpVi3tZZuv." + */ + @IsString() + salt: string; + + /** + * Хеш пароля + * @example "$2b$08$34xwFv1WVJpvpVi3tZZuv." + */ + @IsString() + password: string; + + /** + * Последний токен доступа + * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..." + */ + @IsJWT() + accessToken: string; + + /** + * Группа + * @example "ИС-214/23" + */ + @IsString() + group: string; + + /** + * Роль + * @example STUDENT + */ + @IsEnum(UserRole) + role: UserRole; + + /** + * Данные Firebase Cloud Messaging + */ + @IsObject() + @Type(() => FcmUser) + @IsOptional() + fcm?: FcmUser; + + /** + * Версия установленного приложения + * @example "2.0.0" + */ + @IsSemVer() + version: string; +} diff --git a/src/users/user-role.enum.ts b/src/users/user-role.enum.ts new file mode 100644 index 0000000..711030a --- /dev/null +++ b/src/users/user-role.enum.ts @@ -0,0 +1,5 @@ +export enum UserRole { + STUDENT = "STUDENT", + TEACHER = "TEACHER", + ADMIN = "ADMIN", +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index c95e697..dca840f 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,14 +1,15 @@ import { forwardRef, Module } from "@nestjs/common"; import { UsersService } from "./users.service"; import { PrismaService } from "../prisma/prisma.service"; -import { UsersController } from "./users.controller"; +import { V2UsersController } from "./v2-users.controller"; import { ScheduleModule } from "../schedule/schedule.module"; import { AuthModule } from "../auth/auth.module"; +import { V1UsersController } from "./v1-users.controller"; @Module({ imports: [forwardRef(() => ScheduleModule), forwardRef(() => AuthModule)], providers: [PrismaService, UsersService], exports: [UsersService], - controllers: [UsersController], + controllers: [V1UsersController, V2UsersController], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1f954b1..1945dd4 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,56 +7,53 @@ import { } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { Prisma } from "@prisma/client"; -import { - ChangeGroupReqDto, - ChangeUsernameReqDto, - UserDto, -} from "../dto/user.dto"; -import { ScheduleService } from "../schedule/schedule.service"; +import { V1ScheduleService } from "../schedule/v1-schedule.service"; +import { User } from "./entity/user.entity"; +import { ChangeUsernameDto } from "./dto/change-username.dto"; +import { ChangeGroupDto } from "./dto/change-group.dto"; +import { plainToInstance } from "class-transformer"; @Injectable() export class UsersService { constructor( private readonly prismaService: PrismaService, - @Inject(forwardRef(() => ScheduleService)) - private readonly scheduleService: ScheduleService, + @Inject(forwardRef(() => V1ScheduleService)) + private readonly scheduleService: V1ScheduleService, ) {} - private static convertToDto = (user: UserDto | null) => - user as UserDto | null; - - async findUnique( - where: Prisma.UserWhereUniqueInput, - ): Promise { - return this.prismaService.user - .findUnique({ where: where }) - .then(UsersService.convertToDto); + async findUnique(where: Prisma.UserWhereUniqueInput): Promise { + return plainToInstance( + User, + await this.prismaService.user.findUnique({ where: where }), + ); } async update(params: { where: Prisma.UserWhereUniqueInput; data: Prisma.UserUpdateInput; - }): Promise { - return this.prismaService.user - .update(params) - .then(UsersService.convertToDto); + }): Promise { + return plainToInstance( + User, + await this.prismaService.user.update(params), + ); } - async create(data: Prisma.UserCreateInput): Promise { - return this.prismaService.user - .create({ data }) - .then(UsersService.convertToDto); + async create(data: Prisma.UserCreateInput): Promise { + return plainToInstance( + User, + await this.prismaService.user.create({ data }), + ); } async contains(where: Prisma.UserWhereUniqueInput): Promise { - return this.prismaService.user + return await this.prismaService.user .count({ where }) .then((count) => count > 0); } async changeUsername( - user: UserDto, - changeUsernameDto: ChangeUsernameReqDto, + user: User, + changeUsernameDto: ChangeUsernameDto, ): Promise { if (user.username === changeUsernameDto.username) return; @@ -73,8 +70,8 @@ export class UsersService { } async changeGroup( - user: UserDto, - changeGroupDto: ChangeGroupReqDto, + user: User, + changeGroupDto: ChangeGroupDto, ): Promise { if (user.group === changeGroupDto.group) return; diff --git a/src/users/users.controller.ts b/src/users/v1-users.controller.ts similarity index 51% rename from src/users/users.controller.ts rename to src/users/v1-users.controller.ts index 0993ee0..3f3fe38 100644 --- a/src/users/users.controller.ts +++ b/src/users/v1-users.controller.ts @@ -8,86 +8,87 @@ import { UseGuards, } from "@nestjs/common"; import { AuthGuard } from "../auth/auth.guard"; -import { - ChangeGroupReqDto, - ChangeUsernameReqDto, - ClientUserResDto, -} from "../dto/user.dto"; import { ResultDto } from "../utility/validation/class-validator.interceptor"; import { UserToken } from "../auth/auth.decorator"; import { AuthService } from "../auth/auth.service"; import { UsersService } from "./users.service"; import { + ApiBearerAuth, ApiBody, - ApiConflictResponse, - ApiExtraModels, - ApiNotFoundResponse, - ApiOkResponse, ApiOperation, - refs, + ApiResponse, + ApiTags, } from "@nestjs/swagger"; +import { ChangeUsernameDto } from "./dto/change-username.dto"; +import { ChangeGroupDto } from "./dto/change-group.dto"; +import { V1ClientUserDto } from "./dto/v1/v1-client-user.dto"; -@Controller("api/v1/users") +@ApiTags("v1/users") +@ApiBearerAuth() +@Controller({ path: "users", version: "1" }) @UseGuards(AuthGuard) -export class UsersController { +export class V1UsersController { constructor( private readonly authService: AuthService, private readonly usersService: UsersService, ) {} - @ApiExtraModels(ClientUserResDto) - @ApiOperation({ - summary: "Получение данных о профиле пользователя", - tags: ["users"], - }) - @ApiOkResponse({ + @ApiOperation({ summary: "Получение данных о профиле пользователя" }) + @ApiResponse({ + status: HttpStatus.OK, description: "Получение профиля прошло успешно", - schema: refs(ClientUserResDto)[0], + type: V1ClientUserDto, }) - @ResultDto(ClientUserResDto) + @ResultDto(V1ClientUserDto) @HttpCode(HttpStatus.OK) @Get("me") - async getMe(@UserToken() token: string): Promise { - const userDto = await this.authService.decodeUserToken(token); - - return ClientUserResDto.fromUserDto(userDto); + async getMe(@UserToken() token: string): Promise { + return V1ClientUserDto.fromUser( + await this.authService.decodeUserToken(token), + ); } - @ApiExtraModels(ChangeUsernameReqDto) - @ApiOperation({ summary: "Смена имени пользователя", tags: ["users"] }) - @ApiBody({ schema: refs(ChangeUsernameReqDto)[0] }) - @ApiOkResponse({ description: "Смена имени профиля прошла успешно" }) - @ApiConflictResponse({ + @ApiOperation({ summary: "Смена имени пользователя" }) + @ApiBody({ type: ChangeUsernameDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Смена имени профиля прошла успешно", + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, description: "Пользователь с таким именем уже существует", }) @ResultDto(null) @HttpCode(HttpStatus.OK) @Post("change-username") async changeUsername( - @Body() changeUsernameDto: ChangeUsernameReqDto, + @Body() reqDto: ChangeUsernameDto, @UserToken() token: string, ): Promise { const user = await this.authService.decodeUserToken(token); - return await this.usersService.changeUsername(user, changeUsernameDto); + return await this.usersService.changeUsername(user, reqDto); } - @ApiExtraModels(ChangeGroupReqDto) - @ApiOperation({ summary: "Смена группы пользователя", tags: ["users"] }) - @ApiBody({ schema: refs(ChangeGroupReqDto)[0] }) - @ApiOkResponse({ description: "Смена группы прошла успешно" }) - @ApiNotFoundResponse({ + @ApiOperation({ summary: "Смена группы пользователя" }) + @ApiBody({ type: ChangeGroupDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Смена группы прошла успешно", + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, description: "Группа с таким названием не существует", }) @ResultDto(null) @HttpCode(HttpStatus.OK) @Post("change-group") async changeGroup( - @Body() changeGroupDto: ChangeGroupReqDto, + @Body() reqDto: ChangeGroupDto, @UserToken() token: string, ): Promise { const user = await this.authService.decodeUserToken(token); - return await this.usersService.changeGroup(user, changeGroupDto); + return await this.usersService.changeGroup(user, reqDto); } } diff --git a/src/users/v2-users.controller.ts b/src/users/v2-users.controller.ts new file mode 100644 index 0000000..33db859 --- /dev/null +++ b/src/users/v2-users.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + UseGuards, +} from "@nestjs/common"; +import { AuthGuard } from "../auth/auth.guard"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { UserToken } from "../auth/auth.decorator"; +import { AuthService } from "../auth/auth.service"; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { V2ClientUserDto } from "./dto/v2/v2-client-user.dto"; + +@ApiTags("v2/users") +@ApiBearerAuth() +@Controller({ path: "users", version: "2" }) +@UseGuards(AuthGuard) +export class V2UsersController { + constructor(private readonly authService: AuthService) {} + + @ApiOperation({ summary: "Получение данных о профиле пользователя" }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Получение профиля прошло успешно", + type: V2ClientUserDto, + }) + @ResultDto(V2ClientUserDto) + @HttpCode(HttpStatus.OK) + @Get("me") + async getMe(@UserToken() token: string): Promise { + return V2ClientUserDto.fromUser( + await this.authService.decodeUserToken(token), + ); + } +} diff --git a/src/utility/class-validators/conditional-field.ts b/src/utility/class-validators/conditional-field.ts new file mode 100644 index 0000000..5a36e70 --- /dev/null +++ b/src/utility/class-validators/conditional-field.ts @@ -0,0 +1,39 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "class-validator"; + +// noinspection FunctionNamingConventionJS +export function NullIf( + canBeNull: (cls: object) => boolean, + validationOptions?: ValidationOptions, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "nullIf", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [canBeNull], + validator: { + validate(value: any, args: ValidationArguments) { + const canBeNullFunc: (cls: object) => boolean = + args.constraints[0]; + + const canBeNull = canBeNullFunc(args.object); + const currentValue = value; + + // Логика валидации: если одно из полей null, то другое тоже должно быть null + + return canBeNull + ? currentValue !== null + : currentValue === null; + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be ${args.property === null ? "non-null" : "null"}!`; + }, + }, + }); + }; +}