Compare commits

..

No commits in common. "feature/db-prism" and "main" have entirely different histories.

18 changed files with 935 additions and 1577 deletions

391
package-lock.json generated
View File

@ -17,7 +17,6 @@
"@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",
@ -47,7 +46,6 @@
"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",
@ -3194,91 +3192,6 @@
"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",
@ -3313,13 +3226,6 @@
"@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",
@ -5035,48 +4941,6 @@
"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",
@ -5185,7 +5049,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@ -5223,16 +5087,6 @@
"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",
@ -5483,13 +5337,6 @@
"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",
@ -5670,16 +5517,6 @@
"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",
@ -5693,13 +5530,6 @@
"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",
@ -5719,13 +5549,6 @@
"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",
@ -5829,17 +5652,6 @@
"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",
@ -5867,16 +5679,6 @@
"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",
@ -6327,53 +6129,6 @@
"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",
@ -6834,24 +6589,6 @@
"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",
@ -8175,16 +7912,6 @@
"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",
@ -8867,13 +8594,6 @@
"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",
@ -8911,31 +8631,6 @@
"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",
@ -8957,13 +8652,6 @@
"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",
@ -9280,25 +8968,11 @@
"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",
@ -9398,18 +9072,6 @@
"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",
@ -9487,32 +9149,6 @@
"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",
@ -9623,17 +9259,6 @@
"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",
@ -9659,7 +9284,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@ -10672,16 +10297,6 @@
"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",
@ -10990,7 +10605,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@ -17,11 +17,7 @@
"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",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node prisma/seed.ts"
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
@ -41,11 +37,9 @@
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"@prisma/client": "^6.9.0"
"sharp": "^0.34.5"
},
"devDependencies": {
"prisma": "^6.9.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
@ -88,8 +82,5 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View File

@ -1,143 +0,0 @@
-- 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

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,127 +0,0 @@
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")
}

View File

@ -1,346 +0,0 @@
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,7 +2,6 @@ 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';
@ -14,7 +13,6 @@ 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 (!(await this.verificationStore.canSendToPhone(normalizedPhone))) {
if (!this.verificationStore.canSendToPhone(normalizedPhone)) {
throw new HttpException(
'Повторная отправка кода возможна не чаще 1 раза в 60 секунд',
HttpStatus.TOO_MANY_REQUESTS,
);
}
if ((await this.verificationStore.getSendCountByPhone(normalizedPhone)) >= SMS_PER_PHONE_MAX) {
if (this.verificationStore.getSendCountByPhone(normalizedPhone) >= SMS_PER_PHONE_MAX) {
throw new HttpException(
`Не более ${SMS_PER_PHONE_MAX} SMS на номер за 15 минут`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
if ((await this.verificationStore.getSendCountByIp(ip)) >= SMS_PER_IP_MAX) {
if (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();
await this.verificationStore.recordSend(normalizedPhone, ip, code);
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 (await this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) {
const remaining = await this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip);
if (this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) {
const remaining = this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip);
throw new HttpException(
`Слишком много попыток. Попробуйте через ${remaining} секунд`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
const result = await this.verificationStore.checkCode(normalizedPhone, ip, code);
const result = 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 await this.issueTokens(user);
return this.issueTokens(user);
}
/** Обновить access токен по refresh токену. */
async refresh(refreshToken: string): Promise<{ access_token: string; refresh_token: string }> {
const stored = await this.refreshTokenStore.get(refreshToken);
const stored = this.refreshTokenStore.get(refreshToken);
if (!stored) {
throw new BadRequestException('Недействительный или просроченный refresh token');
}
try {
this.jwtService.verify(refreshToken, { secret: this.configService.get<string>('JWT_SECRET') });
} catch {
await this.refreshTokenStore.remove(refreshToken);
this.refreshTokenStore.remove(refreshToken);
throw new BadRequestException('Недействительный refresh token');
}
const user = await this.usersService.findById(stored.userId);
if (!user) {
await this.refreshTokenStore.remove(refreshToken);
this.refreshTokenStore.remove(refreshToken);
throw new BadRequestException('Пользователь не найден');
}
await this.refreshTokenStore.remove(refreshToken);
return await this.issueTokens(user);
this.refreshTokenStore.remove(refreshToken);
return this.issueTokens(user);
}
private async issueTokens(user: User): Promise<{ access_token: string; refresh_token: string }> {
private issueTokens(user: User): { 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;
await this.refreshTokenStore.set(refresh_token, user.userId, expiresAt);
this.refreshTokenStore.set(refresh_token, user.userId, expiresAt);
return { access_token, refresh_token };
}

View File

@ -1,36 +1,37 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
interface StoredRefresh {
userId: number;
expiresAt: number;
}
@Injectable()
export class RefreshTokenStoreService {
private readonly store = new Map<string, StoredRefresh>();
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
constructor(private readonly prisma: PrismaService) {
constructor() {
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
}
private async cleanup() {
const now = new Date();
await this.prisma.refreshToken.deleteMany({
where: { expiresAt: { lt: now } },
});
private cleanup() {
const now = Date.now();
for (const [token, data] of this.store.entries()) {
if (data.expiresAt < now) this.store.delete(token);
}
}
async set(token: string, userId: number, expiresAt: number): Promise<void> {
await this.prisma.refreshToken.create({
data: { token, userId, expiresAt: new Date(expiresAt) },
});
set(token: string, userId: number, expiresAt: number): void {
this.store.set(token, { userId, expiresAt });
}
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 };
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 remove(token: string): Promise<void> {
await this.prisma.refreshToken.deleteMany({ where: { token } });
remove(token: string): void {
this.store.delete(token);
}
}

View File

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
CODE_TTL_SEC,
MAX_VERIFY_ATTEMPTS,
@ -11,134 +10,124 @@ 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(private readonly prisma: PrismaService) {
constructor() {
this.startCleanup();
}
private startCleanup() {
if (this.cleanupInterval) return;
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
}
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 } },
});
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);
}
}
/** Можно ли отправить SMS на номер (60 сек с последней отправки). */
async canSendToPhone(phone: string): Promise<boolean> {
const last = await this.prisma.smsSendLog.findFirst({
where: { phone },
orderBy: { sentAt: 'desc' },
select: { sentAt: true },
});
canSendToPhone(phone: string): boolean {
const last = this.lastSentAt.get(phone);
if (!last) return true;
return Date.now() - last.sentAt.getTime() >= SMS_MIN_INTERVAL_SEC * 1000;
return Date.now() - last >= SMS_MIN_INTERVAL_SEC * 1000;
}
/** Количество отправок на номер за последние 15 мин. */
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 } },
});
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;
}
/** Количество отправок с IP за последние 15 мин. */
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 } },
});
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 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 },
}),
]);
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 checkCode(
checkCode(
phone: string,
ip: string,
code: string,
): 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' };
}
): { 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' };
const stored = await this.prisma.verificationCode.findFirst({
where: { phone },
orderBy: { createdAt: 'desc' },
});
const stored = this.codes.get(phone);
if (!stored) return { ok: false, reason: 'expired' };
if (stored.createdAt.getTime() + CODE_TTL_SEC * 1000 < Date.now()) {
await this.prisma.verificationCode.deleteMany({ where: { phone } });
if (stored.createdAt + CODE_TTL_SEC * 1000 < Date.now()) {
this.codes.delete(phone);
return { ok: false, reason: 'expired' };
}
if (stored.code !== code) {
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);
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);
return { ok: false, reason: 'wrong', remainingAttempts };
}
await this.prisma.verificationCode.deleteMany({ where: { phone } });
await this.prisma.verifyBlock.deleteMany({ where: { phone, ip } });
this.codes.delete(phone);
this.failedAttempts.delete(key);
return { ok: true };
}
/** Заблокирован ли ввод кода по phone+ip. */
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();
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();
}
/** Оставшееся время блокировки в секундах (0 если не заблокирован). */
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);
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);
}
}

View File

@ -1,13 +1,15 @@
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: [
forwardRef(() => ReservationsModule),
ReviewsModule,
UsersModule, // This provides UsersService
forwardRef(() => ReservationsModule), // This provides ReservationsService
ReviewsModule, // This provides ReviewsService
],
controllers: [CatalogController],
providers: [CatalogService],

View File

@ -1,11 +1,12 @@
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.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';
@ -14,74 +15,425 @@ import { CreateYachtDto } from './dto/create-yacht.dto';
@Injectable()
export class CatalogService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService,
@Inject(forwardRef(() => ReservationsService))
private readonly reservationsService: ReservationsService,
private readonly reviewsService: ReviewsService,
) {}
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),
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,
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 {
@ -111,131 +463,148 @@ export class CatalogService {
}
async getCatalogItemById(id: number): Promise<CatalogItemLongDto | null> {
const yacht = await this.prisma.yacht.findUnique({
where: { id },
include: { owner: true },
});
if (!yacht) return 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 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 allYachts = await this.prisma.yacht.findMany({
include: { owner: true },
orderBy: { id: 'asc' },
});
const clonedCatalog = [...this.catalogItems];
const filteredCatalog = clonedCatalog.filter(
({ isFeatured }) => !isFeatured,
);
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 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(featuredYacht),
featuredYacht: this.toShortDto(clonedCatalog[featuredYachtIndex]),
restYachts: mappedRestYachts,
};
}
return null;
}
async getCatalog(filters?: CatalogFiltersDto): Promise<CatalogResponseDto> {
const allYachts = await this.prisma.yacht.findMany({
include: { owner: true },
orderBy: { id: 'asc' },
});
// Start with all items converted to short DTO
let filteredItems = this.catalogItems.map((item) => this.toShortDto(item));
let filteredItems = allYachts.map((row) =>
this.toShortDto(this.yachtRowToLongDto(row)),
);
const fullItems = allYachts.map((row) => this.yachtRowToLongDto(row));
// Get the full items for filtering by properties not in short DTO
const fullItems = this.catalogItems;
// 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 != null && fullItem.maxCapacity >= totalPeople;
return fullItem && 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') {
@ -253,40 +622,32 @@ export class CatalogService {
}
async getAllCatalogItems(): Promise<CatalogItemShortDto[]> {
const allYachts = await this.prisma.yacht.findMany({
orderBy: { id: 'asc' },
});
return allYachts.map((row) =>
this.toShortDto(this.yachtRowToLongDto(row)),
);
return this.catalogItems.map((item) => this.toShortDto(item));
}
async getCatalogByUserId(userId: number): Promise<CatalogItemShortDto[]> {
const yachts = await this.prisma.yacht.findMany({
where: { userId },
orderBy: { id: 'asc' },
// Логика определения ownerId: нечетные id -> ownerId=1, четные id -> ownerId=2
const filteredItems = this.catalogItems.filter((item) => {
return item.owner?.userId === userId;
});
return yachts.map((row) =>
this.toShortDto(this.yachtRowToLongDto(row)),
);
return filteredItems.map((item) => this.toShortDto(item));
}
async createYacht(
createYachtDto: CreateYachtDto,
): Promise<CatalogItemLongDto> {
const galleryUrls = createYachtDto.galleryUrls ?? [];
const created = await this.prisma.yacht.create({
data: {
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);
const newYacht: CatalogItemLongDto = {
id: newId,
name: createYachtDto.name,
year: createYachtDto.year,
length: createYachtDto.length,
speed: createYachtDto.speed,
minCost: createYachtDto.minCost,
mainImageUrl: createYachtDto.mainImageUrl,
galleryUrls: galleryUrls as object,
galleryUrls: createYachtDto.galleryUrls,
hasQuickRent: createYachtDto.hasQuickRent,
isFeatured: createYachtDto.isFeatured,
topText: createYachtDto.topText,
year: createYachtDto.year,
comfortCapacity: createYachtDto.comfortCapacity,
maxCapacity: createYachtDto.maxCapacity,
width: createYachtDto.width,
@ -294,14 +655,13 @@ export class CatalogService {
matherial: createYachtDto.matherial,
power: createYachtDto.power,
description: createYachtDto.description,
userId: createYachtDto.userId,
},
include: { owner: true },
});
owner: owner || ({ userId: createYachtDto.userId } as any),
reviews: [],
reservations: [],
topText: createYachtDto.topText,
};
const item = this.yachtRowToLongDto(created);
item.reviews = [];
item.reservations = [];
return item;
this.catalogItems.push(newYacht);
return newYacht;
}
}

View File

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

View File

@ -1,16 +0,0 @@
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,5 +1,4 @@
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';
@ -15,79 +14,95 @@ export interface ReservationWithYacht extends Reservation {
@Injectable()
export class ReservationsService {
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => CatalogService))
private readonly catalogService: CatalogService,
) {}
createReservation(dto: ReservationItemDto): Promise<Reservation> {
return this.prisma.reservation
.create({
data: {
yachtId: dto.yachtId,
reservatorId: dto.reservatorId,
startUtc: dto.startUtc,
endUtc: dto.endUtc,
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
},
})
.then((r) => ({
id: r.id,
yachtId: r.yachtId,
reservatorId: r.reservatorId,
startUtc: r.startUtc,
endUtc: r.endUtc,
}));
{
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;
}
async getReservationsByUserId(userId: number): Promise<ReservationWithYacht[]> {
const reservations = await this.prisma.reservation.findMany({
where: { reservatorId: userId },
orderBy: { id: 'asc' },
});
const reservations = this.reservations.filter((r) => r.reservatorId === userId);
// Populate данные по яхте для каждой резервации
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 {
id: reservation.id,
yachtId: reservation.yachtId,
reservatorId: reservation.reservatorId,
startUtc: reservation.startUtc,
endUtc: reservation.endUtc,
yacht: yacht ?? undefined,
...reservation,
yacht: yacht || undefined,
};
}),
})
);
return reservationsWithYacht;
}
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,
}));
getReservationsByYachtId(yachtId: number): Reservation[] {
return this.reservations.filter((r) => r.yachtId === yachtId);
}
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,
}));
getAllReservations(): Reservation[] {
return this.reservations;
}
}

View File

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ReviewItemDto } from './review-item.dto';
export interface Review extends ReviewItemDto {
@ -8,64 +7,86 @@ export interface Review extends ReviewItemDto {
@Injectable()
export class ReviewsService {
constructor(private readonly prisma: PrismaService) {}
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,
private reviews: Review[] = [
{
id: 1,
reviewerId: 1,
yachtId: 1,
starsCount: 5,
description: 'Excellent yacht!',
},
});
return {
id: created.id,
reviewerId: created.reviewerId,
yachtId: created.yachtId,
starsCount: created.starsCount,
description: created.description,
{
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',
},
];
private idCounter = 9;
createReview(dto: ReviewItemDto): Review {
const review = {
id: this.idCounter++,
...dto,
};
this.reviews.push(review);
return review;
}
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,
}));
getReviewsByUserId(userId: number): Review[] {
return this.reviews.filter((r) => r.reviewerId === userId);
}
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,
}));
getReviewsByYachtId(yachtId: number): Review[] {
return this.reviews.filter((r) => r.yachtId === yachtId);
}
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,
}));
getAllReviews(): Review[] {
return this.reviews;
}
}

View File

@ -1,102 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { YachtsService } from '../yachts/yachts.service';
import { User } from './user.entity';
import { Yacht } from '../yachts/yacht.entity';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
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;
}
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,
},
];
async findOne(
email: string,
includeYachts: boolean = false,
): Promise<User | undefined> {
const user = await this.prisma.user.findFirst({
where: { email },
include: includeYachts ? { yachts: true } : undefined,
});
return user ? this.toUser(user) : undefined;
const user = this.users.find((user) => user.email === email);
if (user && includeYachts) {
user.yachts = [];
}
return user;
}
async findByPhone(
phone: string,
includeYachts: boolean = false,
): Promise<User | undefined> {
const user = await this.prisma.user.findUnique({
where: { phone },
include: includeYachts ? { yachts: true } : undefined,
});
return user ? this.toUser(user) : undefined;
const user = this.users.find((u) => u.phone === phone);
if (user && includeYachts) {
user.yachts = [];
}
return user;
}
/** Создать пользователя по номеру телефона (при первой авторизации по коду). */
async createByPhone(phone: string): Promise<User> {
const created = await this.prisma.user.create({
data: {
const maxId = this.users.length
? Math.max(...this.users.map((u) => u.userId))
: 0;
const newUser: User = {
userId: maxId + 1,
firstName: '',
lastName: '',
phone,
email: '',
},
});
return this.toUser(created);
yachts: [],
};
this.users.push(newUser);
return newUser;
}
async findById(
userId: number,
includeYachts: boolean = false,
): Promise<User | undefined> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: includeYachts ? { yachts: true } : undefined,
});
return user ? this.toUser(user) : undefined;
const user = this.users.find((user) => user.userId === userId);
if (user && includeYachts) {
user.yachts = [];
}
return user;
}
async findAll(includeYachts: boolean = false): Promise<User[]> {
const users = await this.prisma.user.findMany({
include: includeYachts ? { yachts: true } : undefined,
});
return users.map((u) => this.toUser(u));
if (!includeYachts) {
return this.users;
}
const usersWithYachts = await Promise.all(
this.users.map(async (user) => {
const yachts = [];
return { ...user, yachts };
}),
);
return usersWithYachts;
}
}

View File

@ -1,5 +1,4 @@
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';
@ -8,100 +7,89 @@ import { UpdateYachtDto } from './dto/update-yacht.dto';
export class YachtsService {
private readonly logger = new Logger(YachtsService.name);
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,
};
}
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'),
},
];
async findAll(): Promise<Yacht[]> {
const rows = await this.prisma.yacht.findMany({
orderBy: { id: 'asc' },
});
return rows.map((r) => this.toYacht(r));
return this.yachts;
}
async findById(yachtId: number): Promise<Yacht> {
const yacht = await this.prisma.yacht.findUnique({
where: { id: yachtId },
});
const yacht = this.yachts.find((y) => y.yachtId === yachtId);
if (!yacht) {
throw new NotFoundException(`Yacht with ID ${yachtId} not found`);
}
return this.toYacht(yacht);
return yacht;
}
async findByUserId(userId: number): Promise<Yacht[]> {
const rows = await this.prisma.yacht.findMany({
where: { userId },
orderBy: { id: 'asc' },
});
return rows.map((r) => this.toYacht(r));
return this.yachts.filter((y) => y.userId === userId);
}
async create(createYachtDto: CreateYachtDto): Promise<Yacht> {
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);
const newYacht: Yacht = {
yachtId: this.yachts.length + 1,
...createYachtDto,
createdAt: new Date(),
updatedAt: new Date(),
};
this.yachts.push(newYacht);
return newYacht;
}
async update(
yachtId: number,
updateYachtDto: UpdateYachtDto,
): Promise<Yacht> {
const yacht = await this.prisma.yacht.findUnique({
where: { id: yachtId },
});
if (!yacht) {
const index = this.yachts.findIndex((y) => y.yachtId === yachtId);
if (index === -1) {
throw new NotFoundException(`Yacht with ID ${yachtId} not found`);
}
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);
this.yachts[index] = {
...this.yachts[index],
...updateYachtDto,
updatedAt: new Date(),
};
return this.yachts[index];
}
async delete(yachtId: number): Promise<void> {
const yacht = await this.prisma.yacht.findUnique({
where: { id: yachtId },
});
if (!yacht) {
const index = this.yachts.findIndex((y) => y.yachtId === yachtId);
if (index === -1) {
throw new NotFoundException(`Yacht with ID ${yachtId} not found`);
}
await this.prisma.yacht.delete({
where: { id: yachtId },
});
this.yachts.splice(index, 1);
}
}