Compare commits

...

1 Commits

Author SHA1 Message Date
Sergey Bolshakov 9b416815ef подключение к бд через prisma 2026-03-10 23:20:11 +03:00
18 changed files with 1577 additions and 935 deletions

391
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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"

127
prisma/schema.prisma Normal file
View File

@ -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")
}

346
prisma/seed.ts Normal file
View File

@ -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());

View File

@ -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,

View File

@ -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<string>('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 };
}

View File

@ -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<string, StoredRefresh>();
private cleanupInterval: ReturnType<typeof setInterval> | 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<void> {
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<void> {
await this.prisma.refreshToken.deleteMany({ where: { token } });
}
}

View File

@ -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<string, StoredCode>();
private readonly lastSentAt = new Map<string, number>();
private readonly sentByPhone: { at: number; phone: string }[] = [];
private readonly sentByIp: { at: number; ip: string }[] = [];
private readonly failedAttempts = new Map<string, FailedAttempts>();
private cleanupInterval: ReturnType<typeof setInterval> | 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<boolean> {
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<number> {
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<number> {
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<void> {
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<boolean> {
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<number> {
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);
}
}

View File

@ -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],

View File

@ -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<CatalogItemLongDto | null> {
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<MainPageCatalogResponseDto | null> {
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<CatalogResponseDto> {
// 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<CatalogItemShortDto[]> {
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<CatalogItemShortDto[]> {
// Логика определения 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<CatalogItemLongDto> {
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<CatalogItemLongDto> {
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;
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -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();
}
}

View File

@ -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<Reservation> {
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<ReservationWithYacht[]> {
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<Reservation[]> {
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<Reservation[]> {
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,
}));
}
}

View File

@ -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<Review> {
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<Review[]> {
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<Review[]> {
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<Review[]> {
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,
}));
}
}

View File

@ -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<User | undefined> {
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<User | undefined> {
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<User> {
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<User | undefined> {
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<User[]> {
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));
}
}

View File

@ -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<Yacht[]> {
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<Yacht> {
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<Yacht[]> {
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<Yacht> {
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<Yacht> {
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<void> {
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 },
});
}
}