From aa76657ea9602b453e07ace05c0122eb7b23bab8 Mon Sep 17 00:00:00 2001 From: Sergey Bolshakov Date: Tue, 10 Mar 2026 23:20:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20=D0=B1=D0=B4=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20prisma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 391 +++++++++- package.json | 13 +- .../20260310190727_init/migration.sql | 143 ++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 127 ++++ prisma/seed.ts | 346 +++++++++ src/app.module.ts | 2 + src/auth/auth.service.ts | 30 +- src/auth/refresh-token-store.service.ts | 39 +- src/auth/verification-store.service.ts | 169 +++-- src/catalog/catalog.module.ts | 6 +- src/catalog/catalog.service.ts | 678 ++++-------------- src/prisma/prisma.module.ts | 9 + src/prisma/prisma.service.ts | 16 + src/reservations/reservations.service.ts | 129 ++-- src/reviews/reviews.service.ts | 125 ++-- src/users/users.service.ts | 160 ++--- src/yachts/yachts.service.ts | 126 ++-- 18 files changed, 1577 insertions(+), 935 deletions(-) create mode 100644 prisma/migrations/20260310190727_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 src/prisma/prisma.module.ts create mode 100644 src/prisma/prisma.service.ts diff --git a/package-lock.json b/package-lock.json index ce2ae21..81ba0f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.9", "@nestjs/swagger": "^11.2.3", + "@prisma/client": "^6.9.0", "@types/multer": "^2.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", @@ -46,6 +47,7 @@ "globals": "^16.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "prisma": "^6.9.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -3192,6 +3194,91 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3226,6 +3313,13 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tokenizer/inflate": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.3.1.tgz", @@ -4941,6 +5035,48 @@ "node": ">= 0.8" } }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -5049,7 +5185,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -5087,6 +5223,16 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/cjs-module-lexer": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", @@ -5337,6 +5483,13 @@ "typedarray": "^0.0.6" } }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -5517,6 +5670,16 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -5530,6 +5693,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5549,6 +5719,13 @@ "node": ">= 0.8" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5652,6 +5829,17 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.254", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", @@ -5679,6 +5867,16 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -6129,6 +6327,53 @@ "url": "https://opencollective.com/express" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6589,6 +6834,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", @@ -7912,6 +8175,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8594,6 +8867,13 @@ "lodash": "^4.17.21" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8631,6 +8911,31 @@ "node": ">=8" } }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8652,6 +8957,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8968,11 +9280,25 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9072,6 +9398,18 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -9149,6 +9487,32 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9259,6 +9623,17 @@ "node": ">= 0.10" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9284,7 +9659,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -10297,6 +10672,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -10605,7 +10990,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index c273f08..e5a0d4d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,11 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio", + "prisma:seed": "ts-node prisma/seed.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -37,9 +41,11 @@ "passport-local": "^1.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "sharp": "^0.34.5" + "sharp": "^0.34.5", + "@prisma/client": "^6.9.0" }, "devDependencies": { + "prisma": "^6.9.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@nestjs/cli": "^11.0.0", @@ -82,5 +88,8 @@ ], "coverageDirectory": "../coverage", "testEnvironment": "node" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" } } diff --git a/prisma/migrations/20260310190727_init/migration.sql b/prisma/migrations/20260310190727_init/migration.sql new file mode 100644 index 0000000..d6eb7e5 --- /dev/null +++ b/prisma/migrations/20260310190727_init/migration.sql @@ -0,0 +1,143 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "firstName" TEXT NOT NULL DEFAULT '', + "lastName" TEXT NOT NULL DEFAULT '', + "phone" TEXT NOT NULL, + "email" TEXT NOT NULL DEFAULT '', + "password" TEXT, + "companyName" TEXT, + "inn" BIGINT, + "ogrn" BIGINT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "yachts" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "model" TEXT, + "year" INTEGER NOT NULL, + "length" DOUBLE PRECISION NOT NULL, + "speed" INTEGER DEFAULT 0, + "minCost" INTEGER DEFAULT 0, + "mainImageUrl" TEXT DEFAULT '', + "galleryUrls" JSONB, + "hasQuickRent" BOOLEAN NOT NULL DEFAULT false, + "isFeatured" BOOLEAN NOT NULL DEFAULT false, + "topText" TEXT, + "comfortCapacity" INTEGER DEFAULT 0, + "maxCapacity" INTEGER DEFAULT 0, + "width" DOUBLE PRECISION DEFAULT 0, + "cabinsCount" INTEGER DEFAULT 0, + "matherial" TEXT DEFAULT '', + "power" INTEGER DEFAULT 0, + "description" TEXT DEFAULT '', + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "yachts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reservations" ( + "id" SERIAL NOT NULL, + "yachtId" INTEGER NOT NULL, + "reservatorId" INTEGER NOT NULL, + "startUtc" INTEGER NOT NULL, + "endUtc" INTEGER NOT NULL, + + CONSTRAINT "reservations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reviews" ( + "id" SERIAL NOT NULL, + "reviewerId" INTEGER NOT NULL, + "yachtId" INTEGER NOT NULL, + "starsCount" INTEGER NOT NULL, + "description" TEXT NOT NULL, + + CONSTRAINT "reviews_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "refresh_tokens" ( + "id" SERIAL NOT NULL, + "token" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification_codes" ( + "id" SERIAL NOT NULL, + "phone" TEXT NOT NULL, + "code" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "verification_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sms_send_logs" ( + "id" SERIAL NOT NULL, + "phone" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sms_send_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verify_blocks" ( + "id" SERIAL NOT NULL, + "phone" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "blockedUntil" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verify_blocks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token"); + +-- CreateIndex +CREATE INDEX "verification_codes_phone_idx" ON "verification_codes"("phone"); + +-- CreateIndex +CREATE INDEX "sms_send_logs_phone_sentAt_idx" ON "sms_send_logs"("phone", "sentAt"); + +-- CreateIndex +CREATE INDEX "sms_send_logs_ip_sentAt_idx" ON "sms_send_logs"("ip", "sentAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "verify_blocks_phone_ip_key" ON "verify_blocks"("phone", "ip"); + +-- AddForeignKey +ALTER TABLE "yachts" ADD CONSTRAINT "yachts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reservations" ADD CONSTRAINT "reservations_yachtId_fkey" FOREIGN KEY ("yachtId") REFERENCES "yachts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reservations" ADD CONSTRAINT "reservations_reservatorId_fkey" FOREIGN KEY ("reservatorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_yachtId_fkey" FOREIGN KEY ("yachtId") REFERENCES "yachts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviews" ADD CONSTRAINT "reviews_reviewerId_fkey" FOREIGN KEY ("reviewerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..86eaf5c --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,127 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + firstName String @default("") + lastName String @default("") + phone String @unique + email String @default("") + password String? + companyName String? + inn BigInt? + ogrn BigInt? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + yachts Yacht[] + reservations Reservation[] @relation("Reservator") + reviews Review[] + refreshTokens RefreshToken[] +} + +model Yacht { + id Int @id @default(autoincrement()) + name String + model String? + year Int + length Float + speed Int? @default(0) + minCost Int? @default(0) + mainImageUrl String? @default("") + galleryUrls Json? + hasQuickRent Boolean @default(false) + isFeatured Boolean @default(false) + topText String? + comfortCapacity Int? @default(0) + maxCapacity Int? @default(0) + width Float? @default(0) + cabinsCount Int? @default(0) + matherial String? @default("") + power Int? @default(0) + description String? @default("") + userId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [userId], references: [id], onDelete: Cascade) + reservations Reservation[] + reviews Review[] + + @@map("yachts") +} + +model Reservation { + id Int @id @default(autoincrement()) + yachtId Int + reservatorId Int + startUtc Int + endUtc Int + + yacht Yacht @relation(fields: [yachtId], references: [id], onDelete: Cascade) + reservator User @relation("Reservator", fields: [reservatorId], references: [id], onDelete: Cascade) + + @@map("reservations") +} + +model Review { + id Int @id @default(autoincrement()) + reviewerId Int + yachtId Int + starsCount Int + description String + + yacht Yacht @relation(fields: [yachtId], references: [id], onDelete: Cascade) + reviewer User @relation(fields: [reviewerId], references: [id], onDelete: Cascade) + + @@map("reviews") +} + +model RefreshToken { + id Int @id @default(autoincrement()) + token String @unique + userId Int + expiresAt DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("refresh_tokens") +} + +model VerificationCode { + id Int @id @default(autoincrement()) + phone String + code String + createdAt DateTime @default(now()) + + @@index([phone]) + @@map("verification_codes") +} + +model SmsSendLog { + id Int @id @default(autoincrement()) + phone String + ip String + sentAt DateTime @default(now()) + + @@index([phone, sentAt]) + @@index([ip, sentAt]) + @@map("sms_send_logs") +} + +model VerifyBlock { + id Int @id @default(autoincrement()) + phone String + ip String + count Int @default(0) + blockedUntil DateTime + + @@unique([phone, ip]) + @@map("verify_blocks") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..f522a02 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,346 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const galleryUrls = [ + 'uploads/gal1.jpg', + 'uploads/gal2.jpg', + 'uploads/gal3.jpg', + 'uploads/gal4.jpg', + 'uploads/gal5.jpg', + 'uploads/gal6.jpg', + 'uploads/gal7.jpg', + 'uploads/gal8.jpg', + 'uploads/gal9.jpg', + 'uploads/gal10.jpg', +]; + +async function main() { + await prisma.review.deleteMany(); + await prisma.reservation.deleteMany(); + await prisma.yacht.deleteMany(); + await prisma.refreshToken.deleteMany(); + await prisma.verificationCode.deleteMany(); + await prisma.smsSendLog.deleteMany(); + await prisma.verifyBlock.deleteMany(); + await prisma.user.deleteMany(); + + const users = await Promise.all([ + prisma.user.create({ + data: { + firstName: 'Иван', + lastName: 'Андреев', + phone: '+79009009090', + email: 'ivan@yachting.ru', + password: 'admin', + companyName: 'Северный Флот', + inn: BigInt(1234567890), + ogrn: BigInt(1122334455667), + }, + }), + prisma.user.create({ + data: { + firstName: 'Сергей', + lastName: 'Большаков', + phone: '+79119119191', + email: 'sergey@yachting.ru', + password: 'admin', + companyName: 'Балтийские Просторы', + inn: BigInt(9876543210), + ogrn: BigInt(9988776655443), + }, + }), + prisma.user.create({ + data: { + firstName: 'Анна', + lastName: 'Петрова', + phone: '+79229229292', + email: 'anna@yachting.ru', + password: 'admin', + companyName: 'Ладожские Ветры', + inn: BigInt(5555555555), + ogrn: BigInt(3333444455556), + }, + }), + prisma.user.create({ + data: { + firstName: 'Дмитрий', + lastName: 'Соколов', + phone: '+79339339393', + email: 'dmitry@yachting.ru', + password: 'admin', + companyName: 'Финский Залив', + inn: BigInt(1111222233), + ogrn: BigInt(7777888899990), + }, + }), + ]); + + const yachtData = [ + { + name: 'Азимут 55', + length: 16.7, + speed: 32, + minCost: 85000, + mainImageUrl: 'uploads/1st.jpg', + hasQuickRent: true, + isFeatured: true, + year: 2022, + comfortCapacity: 8, + maxCapacity: 12, + width: 4.8, + cabinsCount: 3, + matherial: 'Стеклопластик', + power: 1200, + description: + 'Роскошная моторная яхта Азимут 55 - это воплощение итальянского стиля и российского качества. Идеально подходит для прогулок по Финскому заливу, корпоративных мероприятий и романтических свиданий. На борту: три комфортабельные каюты, просторный салон с панорамным остеклением, полностью оборудованная кухня и две ванные комнаты. Максимальная скорость 32 узла позволяет быстро добраться до самых живописных мест Карельского перешейка.', + userId: users[0].id, + }, + { + name: 'Сансикер Манхэттен 52', + length: 15.8, + speed: 34, + minCost: 92000, + mainImageUrl: 'uploads/2nd.jpg', + hasQuickRent: false, + isFeatured: false, + topText: '🔥 Лучшее предложение', + year: 2023, + comfortCapacity: 6, + maxCapacity: 10, + width: 4.5, + cabinsCount: 3, + matherial: 'Стеклопластик', + power: 1400, + description: + 'Британский шик и русская душа в одной яхте! Сансикер Манхэттен 52 - выбор настоящих ценителей морских путешествий.', + userId: users[1].id, + }, + { + name: 'Принцесс V55', + length: 16.7, + speed: 33, + minCost: 78000, + mainImageUrl: 'uploads/3rd.jpg', + hasQuickRent: true, + isFeatured: false, + topText: '🍷 Идеальна для заката с бокалом вина', + year: 2021, + comfortCapacity: 8, + maxCapacity: 12, + width: 4.7, + cabinsCount: 4, + matherial: 'Стеклопластик', + power: 1100, + description: 'Принцесс V55 - королева российских вод!', + userId: users[0].id, + }, + { + name: 'Ферретти 500', + length: 15.2, + speed: 31, + minCost: 68000, + mainImageUrl: 'uploads/4th.jpg', + hasQuickRent: true, + isFeatured: false, + topText: '⏳ Часто бронируется - успей', + year: 2020, + comfortCapacity: 6, + maxCapacity: 8, + width: 4.3, + cabinsCount: 3, + matherial: 'Стеклопластик', + power: 900, + description: 'Итальянская страсть в русской стихии!', + userId: users[1].id, + }, + { + name: 'Си Рей 510 Сандансер', + length: 15.5, + speed: 35, + minCost: 72000, + mainImageUrl: 'uploads/5th.jpg', + hasQuickRent: false, + isFeatured: false, + year: 2023, + comfortCapacity: 8, + maxCapacity: 10, + width: 4.6, + cabinsCount: 3, + matherial: 'Стеклопластик', + power: 1300, + description: 'Американская мощь для русского моря!', + userId: users[0].id, + }, + { + name: 'Бавария SR41', + length: 12.5, + speed: 28, + minCost: 45000, + mainImageUrl: 'uploads/6th.jpg', + hasQuickRent: true, + isFeatured: false, + year: 2019, + comfortCapacity: 6, + maxCapacity: 8, + width: 3.9, + cabinsCount: 2, + matherial: 'Стеклопластик', + power: 320, + description: 'Немецкое качество для русского характера!', + userId: users[1].id, + }, + { + name: 'Жанно Мери Фишер 895', + length: 8.9, + speed: 25, + minCost: 32000, + mainImageUrl: 'uploads/1st.jpg', + hasQuickRent: true, + isFeatured: false, + year: 2022, + comfortCapacity: 4, + maxCapacity: 6, + width: 3.0, + cabinsCount: 1, + matherial: 'Стеклопластик', + power: 250, + description: 'Французская элегантность для русского простора!', + userId: users[0].id, + }, + { + name: 'Бенето Свифт Троулер 41', + length: 12.5, + speed: 22, + minCost: 55000, + mainImageUrl: 'uploads/2nd.jpg', + hasQuickRent: false, + isFeatured: false, + year: 2021, + comfortCapacity: 6, + maxCapacity: 8, + width: 4.2, + cabinsCount: 2, + matherial: 'Стеклопластик', + power: 425, + description: 'Французский траулер для русского севера!', + userId: users[1].id, + }, + { + name: 'Лагун 450', + length: 13.5, + speed: 20, + minCost: 65000, + mainImageUrl: 'uploads/3rd.jpg', + hasQuickRent: true, + isFeatured: false, + year: 2020, + comfortCapacity: 8, + maxCapacity: 10, + width: 7.8, + cabinsCount: 4, + matherial: 'Стеклопластик', + power: 90, + description: 'Французский катамаран для русского размаха!', + userId: users[0].id, + }, + { + name: 'Фонтен Пажо Люсия 40', + length: 11.7, + speed: 18, + minCost: 58000, + mainImageUrl: 'uploads/4th.jpg', + hasQuickRent: true, + isFeatured: false, + year: 2023, + comfortCapacity: 8, + maxCapacity: 10, + width: 7.1, + cabinsCount: 4, + matherial: 'Стеклопластик', + power: 80, + description: 'Французский катамаран класса люкс!', + userId: users[1].id, + }, + { + name: 'Дюфур 460', + length: 14.1, + speed: 26, + minCost: 62000, + mainImageUrl: 'uploads/5th.jpg', + hasQuickRent: false, + isFeatured: false, + year: 2022, + comfortCapacity: 8, + maxCapacity: 10, + width: 4.5, + cabinsCount: 3, + matherial: 'Стеклопластик', + power: 380, + description: 'Французская парусная яхта для русского ветра!', + userId: users[0].id, + }, + { + name: 'Гранд Бэнкс 60', + length: 18.3, + speed: 24, + minCost: 125000, + mainImageUrl: 'uploads/6th.jpg', + hasQuickRent: true, + isFeatured: false, + year: 2023, + comfortCapacity: 6, + maxCapacity: 8, + width: 5.2, + cabinsCount: 3, + matherial: 'Стеклопластик', + power: 1600, + description: 'Американская легенда для русского океана!', + userId: users[1].id, + }, + ]; + + const yachts = await Promise.all( + yachtData.map((y) => + prisma.yacht.create({ + data: { + ...y, + galleryUrls: galleryUrls as object, + }, + }), + ), + ); + + await prisma.reservation.createMany({ + data: [ + { yachtId: yachts[0].id, reservatorId: users[0].id, startUtc: 1767369600, endUtc: 1767412800 }, + { yachtId: yachts[2].id, reservatorId: users[1].id, startUtc: 1767484800, endUtc: 1767715200 }, + { yachtId: yachts[4].id, reservatorId: users[0].id, startUtc: 1768070400, endUtc: 1768176000 }, + { yachtId: yachts[6].id, reservatorId: users[1].id, startUtc: 1768435200, endUtc: 1768684800 }, + { yachtId: yachts[8].id, reservatorId: users[0].id, startUtc: 1768944000, endUtc: 1769049600 }, + { yachtId: yachts[10].id, reservatorId: users[1].id, startUtc: 1769385600, endUtc: 1769635200 }, + ], + }); + + await prisma.review.createMany({ + data: [ + { reviewerId: users[0].id, yachtId: yachts[0].id, starsCount: 5, description: 'Excellent yacht!' }, + { reviewerId: users[1].id, yachtId: yachts[0].id, starsCount: 4, description: 'Very good experience' }, + { reviewerId: users[0].id, yachtId: yachts[2].id, starsCount: 3, description: 'Average condition' }, + { reviewerId: users[1].id, yachtId: yachts[4].id, starsCount: 5, description: 'Perfect for sailing' }, + { reviewerId: users[0].id, yachtId: yachts[6].id, starsCount: 4, description: 'Comfortable and fast' }, + { reviewerId: users[1].id, yachtId: yachts[8].id, starsCount: 2, description: 'Needs maintenance' }, + { reviewerId: users[0].id, yachtId: yachts[10].id, starsCount: 5, description: 'Luxury experience' }, + { reviewerId: users[1].id, yachtId: yachts[11].id, starsCount: 4, description: 'Great value for money' }, + ], + }); + + console.log('Seed completed: users', users.length, 'yachts', yachts.length); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app.module.ts b/src/app.module.ts index 297533d..0bc5c5f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { PrismaModule } from './prisma/prisma.module'; import { YachtsModule } from './yachts/yachts.module'; import { CatalogModule } from './catalog/catalog.module'; import { AuthModule } from './auth/auth.module'; @@ -13,6 +14,7 @@ import { ReservationsModule } from './reservations/reservations.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, YachtsModule, CatalogModule, AuthModule, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index e2ca9c1..ce77c82 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -38,19 +38,19 @@ export class AuthService { throw new BadRequestException('Некорректный номер телефона'); } - if (!this.verificationStore.canSendToPhone(normalizedPhone)) { + if (!(await this.verificationStore.canSendToPhone(normalizedPhone))) { throw new HttpException( 'Повторная отправка кода возможна не чаще 1 раза в 60 секунд', HttpStatus.TOO_MANY_REQUESTS, ); } - if (this.verificationStore.getSendCountByPhone(normalizedPhone) >= SMS_PER_PHONE_MAX) { + if ((await this.verificationStore.getSendCountByPhone(normalizedPhone)) >= SMS_PER_PHONE_MAX) { throw new HttpException( `Не более ${SMS_PER_PHONE_MAX} SMS на номер за 15 минут`, HttpStatus.TOO_MANY_REQUESTS, ); } - if (this.verificationStore.getSendCountByIp(ip) >= SMS_PER_IP_MAX) { + if ((await this.verificationStore.getSendCountByIp(ip)) >= SMS_PER_IP_MAX) { throw new HttpException( `Превышен лимит запросов с вашего IP (${SMS_PER_IP_MAX} за 15 минут)`, HttpStatus.TOO_MANY_REQUESTS, @@ -58,7 +58,7 @@ export class AuthService { } const code = this.generateCode(); - this.verificationStore.recordSend(normalizedPhone, ip, code); + await this.verificationStore.recordSend(normalizedPhone, ip, code); await this.smsService.sendVerificationCode(normalizedPhone, code); return { success: true }; } @@ -77,15 +77,15 @@ export class AuthService { throw new BadRequestException('Код должен содержать 4 символа'); } - if (this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) { - const remaining = this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip); + if (await this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) { + const remaining = await this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip); throw new HttpException( `Слишком много попыток. Попробуйте через ${remaining} секунд`, HttpStatus.TOO_MANY_REQUESTS, ); } - const result = this.verificationStore.checkCode(normalizedPhone, ip, code); + const result = await this.verificationStore.checkCode(normalizedPhone, ip, code); if (!result.ok) { if (result.reason === 'expired') { throw new BadRequestException('Код истёк. Запросите новый'); @@ -100,33 +100,33 @@ export class AuthService { user = await this.usersService.createByPhone(normalizedPhone); } - return this.issueTokens(user); + return await this.issueTokens(user); } /** Обновить access токен по refresh токену. */ async refresh(refreshToken: string): Promise<{ access_token: string; refresh_token: string }> { - const stored = this.refreshTokenStore.get(refreshToken); + const stored = await this.refreshTokenStore.get(refreshToken); if (!stored) { throw new BadRequestException('Недействительный или просроченный refresh token'); } try { this.jwtService.verify(refreshToken, { secret: this.configService.get('JWT_SECRET') }); } catch { - this.refreshTokenStore.remove(refreshToken); + await this.refreshTokenStore.remove(refreshToken); throw new BadRequestException('Недействительный refresh token'); } const user = await this.usersService.findById(stored.userId); if (!user) { - this.refreshTokenStore.remove(refreshToken); + await this.refreshTokenStore.remove(refreshToken); throw new BadRequestException('Пользователь не найден'); } - this.refreshTokenStore.remove(refreshToken); - return this.issueTokens(user); + await this.refreshTokenStore.remove(refreshToken); + return await this.issueTokens(user); } - private issueTokens(user: User): { access_token: string; refresh_token: string } { + private async issueTokens(user: User): Promise<{ access_token: string; refresh_token: string }> { const payload = { sub: user.userId, userId: user.userId, @@ -141,7 +141,7 @@ export class AuthService { ); const decoded = this.jwtService.decode(refresh_token) as { exp: number }; const expiresAt = decoded?.exp ? decoded.exp * 1000 : Date.now() + 30 * 24 * 60 * 60 * 1000; - this.refreshTokenStore.set(refresh_token, user.userId, expiresAt); + await this.refreshTokenStore.set(refresh_token, user.userId, expiresAt); return { access_token, refresh_token }; } diff --git a/src/auth/refresh-token-store.service.ts b/src/auth/refresh-token-store.service.ts index 308ed35..7cbf952 100644 --- a/src/auth/refresh-token-store.service.ts +++ b/src/auth/refresh-token-store.service.ts @@ -1,37 +1,36 @@ import { Injectable } from '@nestjs/common'; - -interface StoredRefresh { - userId: number; - expiresAt: number; -} +import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class RefreshTokenStoreService { - private readonly store = new Map(); private cleanupInterval: ReturnType | null = null; - constructor() { + constructor(private readonly prisma: PrismaService) { this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); } - private cleanup() { - const now = Date.now(); - for (const [token, data] of this.store.entries()) { - if (data.expiresAt < now) this.store.delete(token); - } + private async cleanup() { + const now = new Date(); + await this.prisma.refreshToken.deleteMany({ + where: { expiresAt: { lt: now } }, + }); } - set(token: string, userId: number, expiresAt: number): void { - this.store.set(token, { userId, expiresAt }); + async set(token: string, userId: number, expiresAt: number): Promise { + await this.prisma.refreshToken.create({ + data: { token, userId, expiresAt: new Date(expiresAt) }, + }); } - get(token: string): { userId: number } | null { - const data = this.store.get(token); - if (!data || data.expiresAt < Date.now()) return null; - return { userId: data.userId }; + async get(token: string): Promise<{ userId: number } | null> { + const row = await this.prisma.refreshToken.findUnique({ + where: { token }, + }); + if (!row || row.expiresAt.getTime() < Date.now()) return null; + return { userId: row.userId }; } - remove(token: string): void { - this.store.delete(token); + async remove(token: string): Promise { + await this.prisma.refreshToken.deleteMany({ where: { token } }); } } diff --git a/src/auth/verification-store.service.ts b/src/auth/verification-store.service.ts index ed04961..f170eed 100644 --- a/src/auth/verification-store.service.ts +++ b/src/auth/verification-store.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; import { CODE_TTL_SEC, MAX_VERIFY_ATTEMPTS, @@ -10,124 +11,134 @@ import { VERIFY_BLOCK_DURATION_SEC, } from './verification.constants'; -interface StoredCode { - code: string; - createdAt: number; -} - - -interface FailedAttempts { - count: number; - blockedUntil: number; -} - @Injectable() export class VerificationStoreService { - private readonly codes = new Map(); - private readonly lastSentAt = new Map(); - private readonly sentByPhone: { at: number; phone: string }[] = []; - private readonly sentByIp: { at: number; ip: string }[] = []; - private readonly failedAttempts = new Map(); private cleanupInterval: ReturnType | null = null; - constructor() { - this.startCleanup(); - } - - private startCleanup() { - if (this.cleanupInterval) return; + constructor(private readonly prisma: PrismaService) { this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); } - private cleanup() { - const now = Date.now(); - const borderPhone = now - SMS_PER_PHONE_WINDOW_SEC * 1000; - const borderIp = now - SMS_PER_IP_WINDOW_SEC * 1000; - while (this.sentByPhone.length && this.sentByPhone[0].at < borderPhone) this.sentByPhone.shift(); - while (this.sentByIp.length && this.sentByIp[0].at < borderIp) this.sentByIp.shift(); - - for (const [phone, data] of this.codes.entries()) { - if (data.createdAt + CODE_TTL_SEC * 1000 < now) this.codes.delete(phone); - } - - for (const [key, data] of this.failedAttempts.entries()) { - if (data.blockedUntil < now) this.failedAttempts.delete(key); - } + private async cleanup() { + const now = new Date(); + const codeBorder = new Date(now.getTime() - CODE_TTL_SEC * 1000); + const blockBorder = new Date(now.getTime() - VERIFY_BLOCK_DURATION_SEC * 1000); + await this.prisma.verificationCode.deleteMany({ + where: { createdAt: { lt: codeBorder } }, + }); + await this.prisma.smsSendLog.deleteMany({ + where: { sentAt: { lt: new Date(now.getTime() - Math.max(SMS_PER_PHONE_WINDOW_SEC, SMS_PER_IP_WINDOW_SEC) * 1000) } }, + }); + await this.prisma.verifyBlock.deleteMany({ + where: { blockedUntil: { lt: now } }, + }); } /** Можно ли отправить SMS на номер (60 сек с последней отправки). */ - canSendToPhone(phone: string): boolean { - const last = this.lastSentAt.get(phone); + async canSendToPhone(phone: string): Promise { + const last = await this.prisma.smsSendLog.findFirst({ + where: { phone }, + orderBy: { sentAt: 'desc' }, + select: { sentAt: true }, + }); if (!last) return true; - return Date.now() - last >= SMS_MIN_INTERVAL_SEC * 1000; + return Date.now() - last.sentAt.getTime() >= SMS_MIN_INTERVAL_SEC * 1000; } /** Количество отправок на номер за последние 15 мин. */ - getSendCountByPhone(phone: string): number { - const border = Date.now() - SMS_PER_PHONE_WINDOW_SEC * 1000; - return this.sentByPhone.filter((r) => r.at >= border && r.phone === phone).length; + async getSendCountByPhone(phone: string): Promise { + const border = new Date(Date.now() - SMS_PER_PHONE_WINDOW_SEC * 1000); + return this.prisma.smsSendLog.count({ + where: { phone, sentAt: { gte: border } }, + }); } /** Количество отправок с IP за последние 15 мин. */ - getSendCountByIp(ip: string): number { - const border = Date.now() - SMS_PER_IP_WINDOW_SEC * 1000; - return this.sentByIp.filter((r) => r.at >= border && r.ip === ip).length; + async getSendCountByIp(ip: string): Promise { + const border = new Date(Date.now() - SMS_PER_IP_WINDOW_SEC * 1000); + return this.prisma.smsSendLog.count({ + where: { ip, sentAt: { gte: border } }, + }); } /** Зарегистрировать отправку (инвалидировать старый код, записать лимиты). */ - recordSend(phone: string, ip: string, code: string): void { - const now = Date.now(); - this.codes.set(phone, { code, createdAt: now }); - this.lastSentAt.set(phone, now); - this.sentByPhone.push({ at: now, phone }); - this.sentByIp.push({ at: now, ip }); - this.cleanup(); + async recordSend(phone: string, ip: string, code: string): Promise { + await this.prisma.$transaction([ + this.prisma.verificationCode.deleteMany({ where: { phone } }), + this.prisma.verificationCode.create({ + data: { phone, code }, + }), + this.prisma.smsSendLog.create({ + data: { phone, ip }, + }), + ]); } /** Результат проверки кода. */ - checkCode( + async checkCode( phone: string, ip: string, code: string, - ): { ok: true } | { ok: false; reason: 'expired' } | { ok: false; reason: 'wrong'; remainingAttempts: number } { - const key = `${phone}:${ip}`; - const blocked = this.failedAttempts.get(key); - if (blocked && blocked.blockedUntil > Date.now()) return { ok: false, reason: 'expired' }; + ): Promise< + | { ok: true } + | { ok: false; reason: 'expired' } + | { ok: false; reason: 'wrong'; remainingAttempts: number } + > { + const key = { phone, ip }; + const blocked = await this.prisma.verifyBlock.findUnique({ + where: { phone_ip: key }, + }); + if (blocked && blocked.blockedUntil.getTime() > Date.now()) { + return { ok: false, reason: 'expired' }; + } - const stored = this.codes.get(phone); + const stored = await this.prisma.verificationCode.findFirst({ + where: { phone }, + orderBy: { createdAt: 'desc' }, + }); if (!stored) return { ok: false, reason: 'expired' }; - if (stored.createdAt + CODE_TTL_SEC * 1000 < Date.now()) { - this.codes.delete(phone); + if (stored.createdAt.getTime() + CODE_TTL_SEC * 1000 < Date.now()) { + await this.prisma.verificationCode.deleteMany({ where: { phone } }); return { ok: false, reason: 'expired' }; } if (stored.code !== code) { - const current = this.failedAttempts.get(key) ?? { count: 0, blockedUntil: 0 }; - current.count += 1; - if (current.count >= MAX_VERIFY_ATTEMPTS) { - current.blockedUntil = Date.now() + VERIFY_BLOCK_DURATION_SEC * 1000; - } - this.failedAttempts.set(key, current); - const remainingAttempts = Math.max(0, MAX_VERIFY_ATTEMPTS - current.count); + const current = await this.prisma.verifyBlock.findUnique({ + where: { phone_ip: key }, + }); + const count = (current?.count ?? 0) + 1; + const blockedUntil = + count >= MAX_VERIFY_ATTEMPTS + ? new Date(Date.now() + VERIFY_BLOCK_DURATION_SEC * 1000) + : current?.blockedUntil ?? new Date(0); + + await this.prisma.verifyBlock.upsert({ + where: { phone_ip: key }, + create: { phone, ip, count, blockedUntil }, + update: { count, blockedUntil }, + }); + const remainingAttempts = Math.max(0, MAX_VERIFY_ATTEMPTS - count); return { ok: false, reason: 'wrong', remainingAttempts }; } - this.codes.delete(phone); - this.failedAttempts.delete(key); + await this.prisma.verificationCode.deleteMany({ where: { phone } }); + await this.prisma.verifyBlock.deleteMany({ where: { phone, ip } }); return { ok: true }; } /** Заблокирован ли ввод кода по phone+ip. */ - isVerifyBlocked(phone: string, ip: string): boolean { - const key = `${phone}:${ip}`; - const data = this.failedAttempts.get(key); - if (!data) return false; - return data.blockedUntil > Date.now(); + async isVerifyBlocked(phone: string, ip: string): Promise { + const block = await this.prisma.verifyBlock.findUnique({ + where: { phone_ip: { phone, ip } }, + }); + if (!block) return false; + return block.blockedUntil.getTime() > Date.now(); } /** Оставшееся время блокировки в секундах (0 если не заблокирован). */ - getBlockedRemainingSec(phone: string, ip: string): number { - const key = `${phone}:${ip}`; - const data = this.failedAttempts.get(key); - if (!data || data.blockedUntil <= Date.now()) return 0; - return Math.ceil((data.blockedUntil - Date.now()) / 1000); + async getBlockedRemainingSec(phone: string, ip: string): Promise { + const block = await this.prisma.verifyBlock.findUnique({ + where: { phone_ip: { phone, ip } }, + }); + if (!block || block.blockedUntil.getTime() <= Date.now()) return 0; + return Math.ceil((block.blockedUntil.getTime() - Date.now()) / 1000); } } diff --git a/src/catalog/catalog.module.ts b/src/catalog/catalog.module.ts index 6af807d..e6404b1 100644 --- a/src/catalog/catalog.module.ts +++ b/src/catalog/catalog.module.ts @@ -1,15 +1,13 @@ import { Module, forwardRef } from '@nestjs/common'; import { CatalogService } from './catalog.service'; import { CatalogController } from './catalog.controller'; -import { UsersModule } from '../users/users.module'; import { ReservationsModule } from '../reservations/reservations.module'; import { ReviewsModule } from '../reviews/reviews.module'; @Module({ imports: [ - UsersModule, // This provides UsersService - forwardRef(() => ReservationsModule), // This provides ReservationsService - ReviewsModule, // This provides ReviewsService + forwardRef(() => ReservationsModule), + ReviewsModule, ], controllers: [CatalogController], providers: [CatalogService], diff --git a/src/catalog/catalog.service.ts b/src/catalog/catalog.service.ts index 4e5fdc2..65b7346 100644 --- a/src/catalog/catalog.service.ts +++ b/src/catalog/catalog.service.ts @@ -1,12 +1,11 @@ import { Injectable, Inject, forwardRef } from '@nestjs/common'; -import { UsersService } from '../users/users.service'; +import { PrismaService } from '../prisma/prisma.service'; import { ReservationsService } from '../reservations/reservations.service'; import { ReviewsService } from '../reviews/reviews.service'; import { CatalogItemShortDto, CatalogItemLongDto, } from './dto/catalog-item.dto'; -import { CatalogParamsDto } from './dto/catalog-params.dto'; import { CatalogResponseDto } from './dto/catalog-response.dto'; import { MainPageCatalogResponseDto } from './dto/main-page-catalog-response.dto'; import { CatalogFiltersDto } from './dto/catalog-filters.dto'; @@ -15,425 +14,74 @@ import { CreateYachtDto } from './dto/create-yacht.dto'; @Injectable() export class CatalogService { constructor( - private readonly usersService: UsersService, + private readonly prisma: PrismaService, @Inject(forwardRef(() => ReservationsService)) private readonly reservationsService: ReservationsService, private readonly reviewsService: ReviewsService, ) {} - private catalogItems: CatalogItemLongDto[] = [ - { - id: 1, - name: 'Азимут 55', - length: 16.7, - speed: 32, - minCost: 85000, - mainImageUrl: 'uploads/1st.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: true, - year: 2022, - comfortCapacity: 8, - maxCapacity: 12, - width: 4.8, - cabinsCount: 3, - matherial: 'Стеклопластик', - power: 1200, - description: - 'Роскошная моторная яхта Азимут 55 - это воплощение итальянского стиля и российского качества. Идеально подходит для прогулок по Финскому заливу, корпоративных мероприятий и романтических свиданий. На борту: три комфортабельные каюты, просторный салон с панорамным остеклением, полностью оборудованная кухня и две ванные комнаты. Максимальная скорость 32 узла позволяет быстро добраться до самых живописных мест Карельского перешейка.', - owner: { userId: 1 } as any, + private yachtRowToLongDto(row: { + id: number; + name: string; + length: number; + speed: number | null; + minCost: number | null; + mainImageUrl: string | null; + galleryUrls: unknown; + hasQuickRent: boolean; + isFeatured: boolean; + topText: string | null; + year: number; + comfortCapacity: number | null; + maxCapacity: number | null; + width: number | null; + cabinsCount: number | null; + matherial: string | null; + power: number | null; + description: string | null; + userId: number; + owner?: { id: number; firstName: string; lastName: string; phone: string; email: string; companyName: string | null; inn: bigint | null; ogrn: bigint | null }; + }): CatalogItemLongDto { + const galleryUrls = Array.isArray(row.galleryUrls) + ? row.galleryUrls + : typeof row.galleryUrls === 'string' + ? (JSON.parse(row.galleryUrls) as string[]) + : []; + return { + id: row.id, + name: row.name, + length: row.length, + speed: row.speed ?? 0, + minCost: row.minCost ?? 0, + mainImageUrl: row.mainImageUrl ?? '', + galleryUrls: galleryUrls as string[], + hasQuickRent: row.hasQuickRent, + isFeatured: row.isFeatured, + topText: row.topText ?? undefined, + year: row.year, + comfortCapacity: row.comfortCapacity ?? 0, + maxCapacity: row.maxCapacity ?? 0, + width: row.width ?? 0, + cabinsCount: row.cabinsCount ?? 0, + matherial: row.matherial ?? '', + power: row.power ?? 0, + description: row.description ?? '', + owner: row.owner + ? { + userId: row.owner.id, + firstName: row.owner.firstName, + lastName: row.owner.lastName, + phone: row.owner.phone, + email: row.owner.email, + companyName: row.owner.companyName ?? undefined, + inn: row.owner.inn != null ? Number(row.owner.inn) : undefined, + ogrn: row.owner.ogrn != null ? Number(row.owner.ogrn) : undefined, + } + : ({ userId: row.userId } as any), reviews: [], reservations: [], - }, - { - id: 2, - name: 'Сансикер Манхэттен 52', - length: 15.8, - speed: 34, - minCost: 92000, - mainImageUrl: 'uploads/2nd.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: false, - isFeatured: false, - topText: '🔥 Лучшее предложение', - year: 2023, - comfortCapacity: 6, - maxCapacity: 10, - width: 4.5, - cabinsCount: 3, - matherial: 'Стеклопластик', - power: 1400, - description: - 'Британский шик и русская душа в одной яхте! Сансикер Манхэттен 52 - выбор настоящих ценителей морских путешествий. Просторный кокпит с мягкими диванами, бар на 8 персон, система мультимедиа премиум-класса. Идеальна для празднования дня рождения на воде или деловой встречи с партнерами. Отличная маневренность позволяет заходить в марины Санкт-Петербурга и Кронштадта.', - owner: { userId: 2 } as any, - reviews: [], - reservations: [], - }, - { - id: 3, - name: 'Принцесс V55', - length: 16.7, - speed: 33, - minCost: 78000, - mainImageUrl: 'uploads/3rd.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - topText: '🍷 Идеальна для заката с бокалом вина', - year: 2021, - comfortCapacity: 8, - maxCapacity: 12, - width: 4.7, - cabinsCount: 4, - matherial: 'Стеклопластик', - power: 1100, - description: - 'Принцесс V55 - королева российских вод! Эта яхта создана для тех, кто ценит комфорт и элегантность. Четыре уютные каюты с кондиционером, гальюн с душем, полностью оборудованная камбузная зона. Особенность - огромный платц с гидравлическим трапом для купания в Ладожском озере. Отличный выбор для семейного отдыха или рыбалки с друзьями.', - owner: { userId: 1 } as any, - reviews: [], - reservations: [], - }, - { - id: 4, - name: 'Ферретти 500', - length: 15.2, - speed: 31, - minCost: 68000, - mainImageUrl: 'uploads/4th.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - topText: '⏳ Часто бронируется - успей', - year: 2020, - comfortCapacity: 6, - maxCapacity: 8, - width: 4.3, - cabinsCount: 3, - matherial: 'Стеклопластик', - power: 900, - description: - 'Итальянская страсть в русской стихии! Ферретти 500 сочетает в себе средиземноморский шарм и надежность для суровых условий Балтики. Просторный салон с панорамными окнами, обеденная зона на 6 человек, современная навигационная система. Идеально подходит для фотосессий на фоне разводных мостов Петербурга или романтического ужина под звуки волн.', - owner: { userId: 2 } as any, - reviews: [], - reservations: [], - }, - { - id: 5, - name: 'Си Рей 510 Сандансер', - length: 15.5, - speed: 35, - minCost: 72000, - mainImageUrl: 'uploads/5th.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: false, - isFeatured: false, - year: 2023, - comfortCapacity: 8, - maxCapacity: 10, - width: 4.6, - cabinsCount: 3, - matherial: 'Стеклопластик', - power: 1300, - description: - 'Американская мощь для русского моря! Си Рей 510 Сандансер - самая быстрая яхта в нашем флоте. Развивает скорость до 35 узлов, что позволяет за день обогнуть весь Финский залив. Три комфортабельные каюты, система стабилизации на стоянке, мощная аудиосистема с сабвуфером. Отличный выбор для любителей острых ощущений и скоростных прогулок.', - owner: { userId: 1 } as any, - reviews: [], - reservations: [], - }, - { - id: 6, - name: 'Бавария SR41', - length: 12.5, - speed: 28, - minCost: 45000, - mainImageUrl: 'uploads/6th.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - year: 2019, - comfortCapacity: 6, - maxCapacity: 8, - width: 3.9, - cabinsCount: 2, - matherial: 'Стеклопластик', - power: 320, - description: - 'Немецкое качество для русского характера! Бавария SR41 - надежная и экономичная яхта для спокойных прогулок по Ладоге. Две уютные каюты, просторный кокпит с тентом от дождя, лебедка для подъема парусов. Идеальный выбор для начинающих яхтсменов или семейного отдыха с детьми. Расход топлива всего 15 литров в час!', - owner: { userId: 2 } as any, - reviews: [], - reservations: [], - }, - { - id: 7, - name: 'Жанно Мери Фишер 895', - length: 8.9, - speed: 25, - minCost: 32000, - mainImageUrl: 'uploads/1st.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - year: 2022, - comfortCapacity: 4, - maxCapacity: 6, - width: 3.0, - cabinsCount: 1, - matherial: 'Стеклопластик', - power: 250, - description: - 'Французская элегантность для русского простора! Жанно Мери Фишер 895 - компактная, но вместительная яхта для рыбалки и пикников. Одна просторная каюта, открытый кокпит, столик для барбекю. Отлично подходит для выездов на природу, ночевки в бухтах или обучения детей управлению яхтой. Самый экономичный вариант в нашем флоте!', - owner: { userId: 1 } as any, - reviews: [], - reservations: [], - }, - { - id: 8, - name: 'Бенето Свифт Троулер 41', - length: 12.5, - speed: 22, - minCost: 55000, - mainImageUrl: 'uploads/2nd.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: false, - isFeatured: false, - year: 2021, - comfortCapacity: 6, - maxCapacity: 8, - width: 4.2, - cabinsCount: 2, - matherial: 'Стеклопластик', - power: 425, - description: - 'Французский траулер для русского севера! Бенето Свифт Троулер 41 создан для длительных путешествий по Белому морю. Экономичный дизельный двигатель, большой запас топлива, система опреснения воды. Две комфортабельные каюты с подогревом пола. Идеальный выбор для экспедиций или многодневных круизов по северным островам.', - owner: { userId: 2 } as any, - reviews: [], - reservations: [], - }, - { - id: 9, - name: 'Лагун 450', - length: 13.5, - speed: 20, - minCost: 65000, - mainImageUrl: 'uploads/3rd.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - year: 2020, - comfortCapacity: 8, - maxCapacity: 10, - width: 7.8, - cabinsCount: 4, - matherial: 'Стеклопластик', - power: 90, - description: - 'Французский катамаран для русского размаха! Лагун 450 - невероятно устойчивая и просторная яхта. Четыре отдельные каюты с санузлами, огромный салон-трансформер, две кухни. Идеально подходит для больших компаний, свадебных церемоний на воде или длительных круизов всей семьей. Не кренится даже в шторм!', - owner: { userId: 1 } as any, - reviews: [], - reservations: [], - }, - { - id: 10, - name: 'Фонтен Пажо Люсия 40', - length: 11.7, - speed: 18, - minCost: 58000, - mainImageUrl: 'uploads/4th.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - year: 2023, - comfortCapacity: 8, - maxCapacity: 10, - width: 7.1, - cabinsCount: 4, - matherial: 'Стеклопластик', - power: 80, - description: - 'Французский катамаран класса люкс! Фонтен Пажо Люсия 40 - это плавающий пятизвездочный отель. Четыре каюты-люкс с джакузи, салон с камином, профессиональная кухня с шеф-поваром. Система стабилизации на якоре, гидромассажный бассейн на палубе. Выбор настоящих ценителей роскоши и комфорта на воде.', - owner: { userId: 2 } as any, - reviews: [], - reservations: [], - }, - { - id: 11, - name: 'Дюфур 460', - length: 14.1, - speed: 26, - minCost: 62000, - mainImageUrl: 'uploads/5th.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: false, - isFeatured: false, - year: 2022, - comfortCapacity: 8, - maxCapacity: 10, - width: 4.5, - cabinsCount: 3, - matherial: 'Стеклопластик', - power: 380, - description: - 'Французская парусная яхта для русского ветра! Дюфур 460 - мечта любого яхтсмена. Три просторные каюты, кокпит с мягкими сиденьями, современное парусное вооружение. Идеально сбалансированная, легко управляется даже новичками. Отличный выбор для регат, обучения парусному спорту или романтических круизов под парусами.', - owner: { userId: 1 } as any, - reviews: [], - reservations: [], - }, - { - id: 12, - name: 'Гранд Бэнкс 60', - length: 18.3, - speed: 24, - minCost: 125000, - mainImageUrl: 'uploads/6th.jpg', - galleryUrls: [ - 'uploads/gal1.jpg', - 'uploads/gal2.jpg', - 'uploads/gal3.jpg', - 'uploads/gal4.jpg', - 'uploads/gal5.jpg', - 'uploads/gal6.jpg', - 'uploads/gal7.jpg', - 'uploads/gal8.jpg', - 'uploads/gal9.jpg', - 'uploads/gal10.jpg', - ], - hasQuickRent: true, - isFeatured: false, - year: 2023, - comfortCapacity: 6, - maxCapacity: 8, - width: 5.2, - cabinsCount: 3, - matherial: 'Стеклопластик', - power: 1600, - description: - 'Американская легенда для русского океана! Гранд Бэнкс 60 - экспедиционная яхта для самых смелых путешествий. Три каюты-люкс, салон с библиотекой, зимний сад, сауна. Автономность плавания - 30 дней! Способна пересечь Баренцево море и дойти до Шпицбергена. Выбор для настоящих морских волков и исследователей Арктики.', - owner: { userId: 2 } as any, - reviews: [], - reservations: [], - }, - ]; + }; + } private toShortDto(item: CatalogItemLongDto): CatalogItemShortDto { const { @@ -463,148 +111,131 @@ export class CatalogService { } async getCatalogItemById(id: number): Promise { - const item = this.catalogItems.find((item) => item.id === id); - if (!item) return null; - - const ownerId = [1, 2][(id - 1) % 2]; - const owner = await this.usersService.findById(ownerId); - - if (owner) { - item.owner = owner; - } - - item.reviews = this.reviewsService.getReviewsByYachtId(id); - item.reservations = this.reservationsService.getReservationsByYachtId(id); + const yacht = await this.prisma.yacht.findUnique({ + where: { id }, + include: { owner: true }, + }); + if (!yacht) return null; + const item = this.yachtRowToLongDto(yacht); + item.reviews = await this.reviewsService.getReviewsByYachtId(id); + item.reservations = await this.reservationsService.getReservationsByYachtId(id); return item; } async getMainPageCatalog(): Promise { - const clonedCatalog = [...this.catalogItems]; - const filteredCatalog = clonedCatalog.filter( - ({ isFeatured }) => !isFeatured, + const allYachts = await this.prisma.yacht.findMany({ + include: { owner: true }, + orderBy: { id: 'asc' }, + }); + + const fullItems = allYachts.map((row) => this.yachtRowToLongDto(row)); + const featuredYacht = fullItems.find((item) => item.isFeatured); + const filteredCatalog = fullItems.filter((item) => !item.isFeatured); + + if (!featuredYacht) return null; + + const minCost = Math.min( + ...filteredCatalog.map((item) => item.minCost || Infinity), ); + const mappedRestYachts = filteredCatalog.slice(0, 6).map((item) => ({ + ...this.toShortDto(item), + isBestOffer: item.minCost === minCost, + })); - const featuredYachtIndex = clonedCatalog.findIndex( - (item) => item.isFeatured === true, - ); - - if (featuredYachtIndex !== -1) { - const minCost = Math.min( - ...filteredCatalog.map((item) => item.minCost || Infinity), - ); - - const mappedRestYachts = filteredCatalog.slice(0, 6).map((item) => ({ - ...this.toShortDto(item), - isBestOffer: item.minCost === minCost, - })); - - return { - featuredYacht: this.toShortDto(clonedCatalog[featuredYachtIndex]), - restYachts: mappedRestYachts, - }; - } - - return null; + return { + featuredYacht: this.toShortDto(featuredYacht), + restYachts: mappedRestYachts, + }; } async getCatalog(filters?: CatalogFiltersDto): Promise { - // Start with all items converted to short DTO - let filteredItems = this.catalogItems.map((item) => this.toShortDto(item)); + const allYachts = await this.prisma.yacht.findMany({ + include: { owner: true }, + orderBy: { id: 'asc' }, + }); - // Get the full items for filtering by properties not in short DTO - const fullItems = this.catalogItems; + let filteredItems = allYachts.map((row) => + this.toShortDto(this.yachtRowToLongDto(row)), + ); + const fullItems = allYachts.map((row) => this.yachtRowToLongDto(row)); - // Search filter if (filters?.search) { const searchTerm = filters.search.toLowerCase(); filteredItems = filteredItems.filter((item) => { const fullItem = fullItems.find((fi) => fi.id === item.id); return ( item.name.toLowerCase().includes(searchTerm) || - (fullItem && fullItem.description.toLowerCase().includes(searchTerm)) + (fullItem && + fullItem.description.toLowerCase().includes(searchTerm)) ); }); } - // Length filter if (filters?.minLength !== undefined) { filteredItems = filteredItems.filter( - (item) => item.length >= filters.minLength!, + (item) => item.length >= filters!.minLength!, ); } if (filters?.maxLength !== undefined) { filteredItems = filteredItems.filter( - (item) => item.length <= filters.maxLength!, + (item) => item.length <= filters!.maxLength!, ); } - // Price filter if (filters?.minPrice !== undefined) { filteredItems = filteredItems.filter( - (item) => item.minCost >= filters.minPrice!, + (item) => item.minCost >= filters!.minPrice!, ); } if (filters?.maxPrice !== undefined) { filteredItems = filteredItems.filter( - (item) => item.minCost <= filters.maxPrice!, + (item) => item.minCost <= filters!.maxPrice!, ); } - // Year filter if (filters?.minYear !== undefined || filters?.maxYear !== undefined) { filteredItems = filteredItems.filter((item) => { const fullItem = fullItems.find((fi) => fi.id === item.id); if (!fullItem) return false; - - if (filters.minYear !== undefined && fullItem.year < filters.minYear) + if ( + filters!.minYear !== undefined && + fullItem.year < filters!.minYear! + ) return false; - if (filters.maxYear !== undefined && fullItem.year > filters.maxYear) + if ( + filters!.maxYear !== undefined && + fullItem.year > filters!.maxYear! + ) return false; return true; }); } if (filters?.guests !== undefined) { - const totalPeople = filters?.guests!; + const totalPeople = filters.guests; filteredItems = filteredItems.filter((item) => { const fullItem = fullItems.find((fi) => fi.id === item.id); - return fullItem && fullItem.maxCapacity >= totalPeople; + return fullItem != null && fullItem.maxCapacity >= totalPeople; }); } - // Quick booking filter if (filters?.quickBooking !== undefined) { filteredItems = filteredItems.filter( (item) => item.hasQuickRent === filters.quickBooking, ); } - // Has toilet filter (check if cabinsCount > 0) if (filters?.hasToilet !== undefined) { filteredItems = filteredItems.filter((item) => { const fullItem = fullItems.find((fi) => fi.id === item.id); if (!fullItem) return false; - return filters.hasToilet + return filters!.hasToilet ? fullItem.cabinsCount > 0 : fullItem.cabinsCount === 0; }); } - // Payment type filter (assuming all accept card payments) - if (filters?.paymentType) { - // For now, all yachts accept card payments - // You could add a paymentTypes array to CatalogItemLongDto if needed - console.log('Payment type filtering:', filters.paymentType); - } - - // Date/time filter (check reservations) - if (filters?.date || filters?.departureTime || filters?.arrivalTime) { - // This is more complex - would need to check reservations - // For now, we'll return all items - console.log('Date/time filtering available for future implementation'); - } - if (filters?.sortByPrice === 'asc') { filteredItems.sort((a, b) => a.minCost - b.minCost); } else if (filters?.sortByPrice === 'desc') { @@ -622,46 +253,55 @@ export class CatalogService { } async getAllCatalogItems(): Promise { - return this.catalogItems.map((item) => this.toShortDto(item)); + const allYachts = await this.prisma.yacht.findMany({ + orderBy: { id: 'asc' }, + }); + return allYachts.map((row) => + this.toShortDto(this.yachtRowToLongDto(row)), + ); } async getCatalogByUserId(userId: number): Promise { - // Логика определения ownerId: нечетные id -> ownerId=1, четные id -> ownerId=2 - const filteredItems = this.catalogItems.filter((item) => { - return item.owner?.userId === userId; + const yachts = await this.prisma.yacht.findMany({ + where: { userId }, + orderBy: { id: 'asc' }, }); - return filteredItems.map((item) => this.toShortDto(item)); + return yachts.map((row) => + this.toShortDto(this.yachtRowToLongDto(row)), + ); } - async createYacht(createYachtDto: CreateYachtDto): Promise { - const newId = Math.max(...this.catalogItems.map((item) => item.id || 0), 0) + 1; - const owner = await this.usersService.findById(createYachtDto.userId); + async createYacht( + createYachtDto: CreateYachtDto, + ): Promise { + const galleryUrls = createYachtDto.galleryUrls ?? []; + const created = await this.prisma.yacht.create({ + data: { + name: createYachtDto.name, + year: createYachtDto.year, + length: createYachtDto.length, + speed: createYachtDto.speed, + minCost: createYachtDto.minCost, + mainImageUrl: createYachtDto.mainImageUrl, + galleryUrls: galleryUrls as object, + hasQuickRent: createYachtDto.hasQuickRent, + isFeatured: createYachtDto.isFeatured, + topText: createYachtDto.topText, + comfortCapacity: createYachtDto.comfortCapacity, + maxCapacity: createYachtDto.maxCapacity, + width: createYachtDto.width, + cabinsCount: createYachtDto.cabinsCount, + matherial: createYachtDto.matherial, + power: createYachtDto.power, + description: createYachtDto.description, + userId: createYachtDto.userId, + }, + include: { owner: true }, + }); - const newYacht: CatalogItemLongDto = { - id: newId, - name: createYachtDto.name, - length: createYachtDto.length, - speed: createYachtDto.speed, - minCost: createYachtDto.minCost, - mainImageUrl: createYachtDto.mainImageUrl, - galleryUrls: createYachtDto.galleryUrls, - hasQuickRent: createYachtDto.hasQuickRent, - isFeatured: createYachtDto.isFeatured, - year: createYachtDto.year, - comfortCapacity: createYachtDto.comfortCapacity, - maxCapacity: createYachtDto.maxCapacity, - width: createYachtDto.width, - cabinsCount: createYachtDto.cabinsCount, - matherial: createYachtDto.matherial, - power: createYachtDto.power, - description: createYachtDto.description, - owner: owner || ({ userId: createYachtDto.userId } as any), - reviews: [], - reservations: [], - topText: createYachtDto.topText, - }; - - this.catalogItems.push(newYacht); - return newYacht; + const item = this.yachtRowToLongDto(created); + item.reviews = []; + item.reservations = []; + return item; } } diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts new file mode 100644 index 0000000..ec801d4 --- /dev/null +++ b/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..265ef79 --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/src/reservations/reservations.service.ts b/src/reservations/reservations.service.ts index 65ee9ba..ba1afce 100644 --- a/src/reservations/reservations.service.ts +++ b/src/reservations/reservations.service.ts @@ -1,4 +1,5 @@ import { Injectable, Inject, forwardRef } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; import { ReservationItemDto } from './reservation-item.dto'; import { CatalogService } from '../catalog/catalog.service'; import { CatalogItemLongDto } from '../catalog/dto/catalog-item.dto'; @@ -14,95 +15,79 @@ export interface ReservationWithYacht extends Reservation { @Injectable() export class ReservationsService { constructor( + private readonly prisma: PrismaService, @Inject(forwardRef(() => CatalogService)) private readonly catalogService: CatalogService, ) {} - private reservations: Reservation[] = [ - { - id: 1, - yachtId: 1, - reservatorId: 1, - // Corrected: Jan 1, 2026 20:00 UTC to Jan 2, 2026 08:00 UTC - startUtc: 1767369600, // Jan 1, 2026 20:00:00 UTC - endUtc: 1767412800, // Jan 2, 2026 08:00:00 UTC - }, - { - id: 2, - yachtId: 3, - reservatorId: 2, - // Jan 3, 2026 08:00 UTC to Jan 5, 2026 20:00 UTC - startUtc: 1767484800, // Jan 3, 2026 08:00:00 UTC - endUtc: 1767715200, // Jan 5, 2026 20:00:00 UTC - }, - { - id: 3, - yachtId: 5, - reservatorId: 1, - // Jan 10, 2026 20:00 UTC to Jan 12, 2026 08:00 UTC - startUtc: 1768070400, // Jan 10, 2026 20:00:00 UTC - endUtc: 1768176000, // Jan 12, 2026 08:00:00 UTC - }, - { - id: 4, - yachtId: 7, - reservatorId: 2, - // Jan 15, 2026 08:00 UTC to Jan 17, 2026 20:00 UTC - startUtc: 1768435200, // Jan 15, 2026 08:00:00 UTC - endUtc: 1768684800, // Jan 17, 2026 20:00:00 UTC - }, - { - id: 5, - yachtId: 9, - reservatorId: 1, - // Jan 20, 2026 20:00 UTC to Jan 22, 2026 08:00 UTC - startUtc: 1768944000, // Jan 20, 2026 20:00:00 UTC - endUtc: 1769049600, // Jan 22, 2026 08:00:00 UTC - }, - { - id: 6, - yachtId: 11, - reservatorId: 2, - // Jan 25, 2026 08:00 UTC to Jan 27, 2026 20:00 UTC - startUtc: 1769385600, // Jan 25, 2026 08:00:00 UTC - endUtc: 1769635200, // Jan 27, 2026 20:00:00 UTC - }, - ]; - - private idCounter = 7; - - createReservation(dto: ReservationItemDto): Reservation { - const reservation = { - id: this.idCounter++, - ...dto, - }; - - this.reservations.push(reservation); - return reservation; + createReservation(dto: ReservationItemDto): Promise { + return this.prisma.reservation + .create({ + data: { + yachtId: dto.yachtId, + reservatorId: dto.reservatorId, + startUtc: dto.startUtc, + endUtc: dto.endUtc, + }, + }) + .then((r) => ({ + id: r.id, + yachtId: r.yachtId, + reservatorId: r.reservatorId, + startUtc: r.startUtc, + endUtc: r.endUtc, + })); } async getReservationsByUserId(userId: number): Promise { - const reservations = this.reservations.filter((r) => r.reservatorId === userId); - - // Populate данные по яхте для каждой резервации + const reservations = await this.prisma.reservation.findMany({ + where: { reservatorId: userId }, + orderBy: { id: 'asc' }, + }); + const reservationsWithYacht = await Promise.all( reservations.map(async (reservation) => { - const yacht = await this.catalogService.getCatalogItemById(reservation.yachtId); + const yacht = await this.catalogService.getCatalogItemById( + reservation.yachtId, + ); return { - ...reservation, - yacht: yacht || undefined, + id: reservation.id, + yachtId: reservation.yachtId, + reservatorId: reservation.reservatorId, + startUtc: reservation.startUtc, + endUtc: reservation.endUtc, + yacht: yacht ?? undefined, }; - }) + }), ); return reservationsWithYacht; } - getReservationsByYachtId(yachtId: number): Reservation[] { - return this.reservations.filter((r) => r.yachtId === yachtId); + async getReservationsByYachtId(yachtId: number): Promise { + const rows = await this.prisma.reservation.findMany({ + where: { yachtId }, + orderBy: { id: 'asc' }, + }); + return rows.map((r) => ({ + id: r.id, + yachtId: r.yachtId, + reservatorId: r.reservatorId, + startUtc: r.startUtc, + endUtc: r.endUtc, + })); } - getAllReservations(): Reservation[] { - return this.reservations; + async getAllReservations(): Promise { + const rows = await this.prisma.reservation.findMany({ + orderBy: { id: 'asc' }, + }); + return rows.map((r) => ({ + id: r.id, + yachtId: r.yachtId, + reservatorId: r.reservatorId, + startUtc: r.startUtc, + endUtc: r.endUtc, + })); } } diff --git a/src/reviews/reviews.service.ts b/src/reviews/reviews.service.ts index b244a3c..b4400b0 100644 --- a/src/reviews/reviews.service.ts +++ b/src/reviews/reviews.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; import { ReviewItemDto } from './review-item.dto'; export interface Review extends ReviewItemDto { @@ -7,86 +8,64 @@ export interface Review extends ReviewItemDto { @Injectable() export class ReviewsService { - private reviews: Review[] = [ - { - id: 1, - reviewerId: 1, - yachtId: 1, - starsCount: 5, - description: 'Excellent yacht!', - }, - { - id: 2, - reviewerId: 2, - yachtId: 1, - starsCount: 4, - description: 'Very good experience', - }, - { - id: 3, - reviewerId: 1, - yachtId: 3, - starsCount: 3, - description: 'Average condition', - }, - { - id: 4, - reviewerId: 2, - yachtId: 5, - starsCount: 5, - description: 'Perfect for sailing', - }, - { - id: 5, - reviewerId: 1, - yachtId: 7, - starsCount: 4, - description: 'Comfortable and fast', - }, - { - id: 6, - reviewerId: 2, - yachtId: 9, - starsCount: 2, - description: 'Needs maintenance', - }, - { - id: 7, - reviewerId: 1, - yachtId: 11, - starsCount: 5, - description: 'Luxury experience', - }, - { - id: 8, - reviewerId: 2, - yachtId: 12, - starsCount: 4, - description: 'Great value for money', - }, - ]; + constructor(private readonly prisma: PrismaService) {} - private idCounter = 9; - - createReview(dto: ReviewItemDto): Review { - const review = { - id: this.idCounter++, - ...dto, + async createReview(dto: ReviewItemDto): Promise { + const created = await this.prisma.review.create({ + data: { + reviewerId: dto.reviewerId, + yachtId: dto.yachtId, + starsCount: dto.starsCount, + description: dto.description, + }, + }); + return { + id: created.id, + reviewerId: created.reviewerId, + yachtId: created.yachtId, + starsCount: created.starsCount, + description: created.description, }; - - this.reviews.push(review); - return review; } - getReviewsByUserId(userId: number): Review[] { - return this.reviews.filter((r) => r.reviewerId === userId); + async getReviewsByUserId(userId: number): Promise { + const rows = await this.prisma.review.findMany({ + where: { reviewerId: userId }, + orderBy: { id: 'asc' }, + }); + return rows.map((r) => ({ + id: r.id, + reviewerId: r.reviewerId, + yachtId: r.yachtId, + starsCount: r.starsCount, + description: r.description, + })); } - getReviewsByYachtId(yachtId: number): Review[] { - return this.reviews.filter((r) => r.yachtId === yachtId); + async getReviewsByYachtId(yachtId: number): Promise { + const rows = await this.prisma.review.findMany({ + where: { yachtId }, + orderBy: { id: 'asc' }, + }); + return rows.map((r) => ({ + id: r.id, + reviewerId: r.reviewerId, + yachtId: r.yachtId, + starsCount: r.starsCount, + description: r.description, + })); } - getAllReviews(): Review[] { - return this.reviews; + async getAllReviews(): Promise { + const rows = await this.prisma.review.findMany({ + orderBy: { id: 'asc' }, + }); + return rows.map((r) => ({ + id: r.id, + reviewerId: r.reviewerId, + yachtId: r.yachtId, + starsCount: r.starsCount, + description: r.description, + })); } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 7439133..60af3a2 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,124 +1,102 @@ import { Injectable, Logger } from '@nestjs/common'; -import { YachtsService } from '../yachts/yachts.service'; +import { PrismaService } from '../prisma/prisma.service'; import { User } from './user.entity'; +import { Yacht } from '../yachts/yacht.entity'; @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); - private readonly users: User[] = [ - { - userId: 1, - firstName: 'Иван', - lastName: 'Андреев', - phone: '+79009009090', - email: 'ivan@yachting.ru', - password: 'admin', - companyName: 'Северный Флот', - inn: 1234567890, - ogrn: 1122334455667, - }, - { - userId: 2, - firstName: 'Сергей', - lastName: 'Большаков', - phone: '+79119119191', - email: 'sergey@yachting.ru', - password: 'admin', - companyName: 'Балтийские Просторы', - inn: 9876543210, - ogrn: 9988776655443, - }, - { - userId: 3, - firstName: 'Анна', - lastName: 'Петрова', - phone: '+79229229292', - email: 'anna@yachting.ru', - password: 'admin', - companyName: 'Ладожские Ветры', - inn: 5555555555, - ogrn: 3333444455556, - }, - { - userId: 4, - firstName: 'Дмитрий', - lastName: 'Соколов', - phone: '+79339339393', - email: 'dmitry@yachting.ru', - password: 'admin', - companyName: 'Финский Залив', - inn: 1111222233, - ogrn: 7777888899990, - }, - ]; + constructor(private readonly prisma: PrismaService) {} + + private toUser(row: { + id: number; + firstName: string; + lastName: string; + phone: string; + email: string; + password: string | null; + companyName: string | null; + inn: bigint | null; + ogrn: bigint | null; + yachts?: { id: number; name: string; model: string | null; year: number; length: number; userId: number }[]; + }): User { + const user: User = { + userId: row.id, + firstName: row.firstName, + lastName: row.lastName, + phone: row.phone, + email: row.email, + password: row.password ?? undefined, + companyName: row.companyName ?? undefined, + inn: row.inn != null ? Number(row.inn) : undefined, + ogrn: row.ogrn != null ? Number(row.ogrn) : undefined, + }; + if (row.yachts) { + user.yachts = row.yachts.map((y) => ({ + yachtId: y.id, + name: y.name, + model: y.model ?? '', + year: y.year, + length: y.length, + userId: y.userId, + createdAt: new Date(), + updatedAt: new Date(), + })) as Yacht[]; + } + return user; + } async findOne( email: string, includeYachts: boolean = false, ): Promise { - const user = this.users.find((user) => user.email === email); - - if (user && includeYachts) { - user.yachts = []; - } - - return user; + const user = await this.prisma.user.findFirst({ + where: { email }, + include: includeYachts ? { yachts: true } : undefined, + }); + return user ? this.toUser(user) : undefined; } async findByPhone( phone: string, includeYachts: boolean = false, ): Promise { - const user = this.users.find((u) => u.phone === phone); - if (user && includeYachts) { - user.yachts = []; - } - return user; + const user = await this.prisma.user.findUnique({ + where: { phone }, + include: includeYachts ? { yachts: true } : undefined, + }); + return user ? this.toUser(user) : undefined; } /** Создать пользователя по номеру телефона (при первой авторизации по коду). */ async createByPhone(phone: string): Promise { - const maxId = this.users.length - ? Math.max(...this.users.map((u) => u.userId)) - : 0; - const newUser: User = { - userId: maxId + 1, - firstName: '', - lastName: '', - phone, - email: '', - yachts: [], - }; - this.users.push(newUser); - return newUser; + const created = await this.prisma.user.create({ + data: { + firstName: '', + lastName: '', + phone, + email: '', + }, + }); + return this.toUser(created); } async findById( userId: number, includeYachts: boolean = false, ): Promise { - const user = this.users.find((user) => user.userId === userId); - - if (user && includeYachts) { - user.yachts = []; - } - - return user; + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: includeYachts ? { yachts: true } : undefined, + }); + return user ? this.toUser(user) : undefined; } async findAll(includeYachts: boolean = false): Promise { - if (!includeYachts) { - return this.users; - } - - const usersWithYachts = await Promise.all( - this.users.map(async (user) => { - const yachts = []; - return { ...user, yachts }; - }), - ); - - return usersWithYachts; + const users = await this.prisma.user.findMany({ + include: includeYachts ? { yachts: true } : undefined, + }); + return users.map((u) => this.toUser(u)); } } diff --git a/src/yachts/yachts.service.ts b/src/yachts/yachts.service.ts index b05839b..a22c5d7 100644 --- a/src/yachts/yachts.service.ts +++ b/src/yachts/yachts.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; import { Yacht } from './yacht.entity'; import { CreateYachtDto } from './dto/create-yacht.dto'; import { UpdateYachtDto } from './dto/update-yacht.dto'; @@ -7,89 +8,100 @@ import { UpdateYachtDto } from './dto/update-yacht.dto'; export class YachtsService { private readonly logger = new Logger(YachtsService.name); - private yachts: Yacht[] = [ - { - yachtId: 1, - name: 'Sea Dream', - model: 'Sunseeker 76', - year: 2020, - length: 76, - userId: 1, - createdAt: new Date('2023-01-15'), - updatedAt: new Date('2023-01-15'), - }, - { - yachtId: 2, - name: 'Ocean Breeze', - model: 'Princess 68', - year: 2021, - length: 68, - userId: 1, - createdAt: new Date('2023-02-20'), - updatedAt: new Date('2023-02-20'), - }, - { - yachtId: 3, - name: 'Wave Rider', - model: 'Ferretti 70', - year: 2019, - length: 70, - userId: 2, - createdAt: new Date('2023-03-10'), - updatedAt: new Date('2023-03-10'), - }, - ]; + constructor(private readonly prisma: PrismaService) {} + + private toYacht(row: { + id: number; + name: string; + model: string | null; + year: number; + length: number; + userId: number; + createdAt: Date; + updatedAt: Date; + }): Yacht { + return { + yachtId: row.id, + name: row.name, + model: row.model ?? '', + year: row.year, + length: row.length, + userId: row.userId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } async findAll(): Promise { - return this.yachts; + const rows = await this.prisma.yacht.findMany({ + orderBy: { id: 'asc' }, + }); + return rows.map((r) => this.toYacht(r)); } async findById(yachtId: number): Promise { - const yacht = this.yachts.find((y) => y.yachtId === yachtId); + const yacht = await this.prisma.yacht.findUnique({ + where: { id: yachtId }, + }); if (!yacht) { throw new NotFoundException(`Yacht with ID ${yachtId} not found`); } - return yacht; + return this.toYacht(yacht); } async findByUserId(userId: number): Promise { - return this.yachts.filter((y) => y.userId === userId); + const rows = await this.prisma.yacht.findMany({ + where: { userId }, + orderBy: { id: 'asc' }, + }); + return rows.map((r) => this.toYacht(r)); } async create(createYachtDto: CreateYachtDto): Promise { - const newYacht: Yacht = { - yachtId: this.yachts.length + 1, - ...createYachtDto, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.yachts.push(newYacht); - return newYacht; + const created = await this.prisma.yacht.create({ + data: { + name: createYachtDto.name, + model: createYachtDto.model, + year: createYachtDto.year, + length: createYachtDto.length, + userId: createYachtDto.userId, + }, + }); + return this.toYacht(created); } async update( yachtId: number, updateYachtDto: UpdateYachtDto, ): Promise { - const index = this.yachts.findIndex((y) => y.yachtId === yachtId); - if (index === -1) { + const yacht = await this.prisma.yacht.findUnique({ + where: { id: yachtId }, + }); + if (!yacht) { throw new NotFoundException(`Yacht with ID ${yachtId} not found`); } - - this.yachts[index] = { - ...this.yachts[index], - ...updateYachtDto, - updatedAt: new Date(), - }; - - return this.yachts[index]; + const updated = await this.prisma.yacht.update({ + where: { id: yachtId }, + data: { + ...(updateYachtDto.name != null && { name: updateYachtDto.name }), + ...(updateYachtDto.model != null && { model: updateYachtDto.model }), + ...(updateYachtDto.year != null && { year: updateYachtDto.year }), + ...(updateYachtDto.length != null && { length: updateYachtDto.length }), + ...(updateYachtDto.userId != null && { userId: updateYachtDto.userId }), + }, + }); + return this.toYacht(updated); } async delete(yachtId: number): Promise { - const index = this.yachts.findIndex((y) => y.yachtId === yachtId); - if (index === -1) { + const yacht = await this.prisma.yacht.findUnique({ + where: { id: yachtId }, + }); + if (!yacht) { throw new NotFoundException(`Yacht with ID ${yachtId} not found`); } - this.yachts.splice(index, 1); + await this.prisma.yacht.delete({ + where: { id: yachtId }, + }); } }