подключение к бд через prisma

This commit is contained in:
Sergey Bolshakov 2026-03-10 23:20:11 +03:00
parent e7fc8553fb
commit 9b416815ef
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/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.9", "@nestjs/platform-express": "^11.1.9",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "^11.2.3",
"@prisma/client": "^6.9.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
@ -46,6 +47,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.9.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
@ -3192,6 +3194,91 @@
"url": "https://opencollective.com/pkgr" "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": { "node_modules/@scarf/scarf": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@ -3226,6 +3313,13 @@
"@sinonjs/commons": "^3.0.1" "@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": { "node_modules/@tokenizer/inflate": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.3.1.tgz",
@ -4941,6 +5035,48 @@
"node": ">= 0.8" "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": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "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", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
@ -5087,6 +5223,16 @@
"node": ">=8" "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": { "node_modules/cjs-module-lexer": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz",
@ -5337,6 +5483,13 @@
"typedarray": "^0.0.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": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@ -5517,6 +5670,16 @@
"node": ">=0.10.0" "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": { "node_modules/defaults": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@ -5530,6 +5693,13 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -5549,6 +5719,13 @@
"node": ">= 0.8" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -5652,6 +5829,17 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.254", "version": "1.5.254",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz",
@ -5679,6 +5867,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -6129,6 +6327,53 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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" "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": { "node_modules/glob": {
"version": "11.0.3", "version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
@ -7912,6 +8175,16 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8594,6 +8867,13 @@
"lodash": "^4.17.21" "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -8631,6 +8911,31 @@
"node": ">=8" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -8652,6 +8957,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -8968,11 +9280,25 @@
"node": ">=8" "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": { "node_modules/pause": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -9072,6 +9398,18 @@
"node": ">=8" "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": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@ -9149,6 +9487,32 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -9259,6 +9623,17 @@
"node": ">= 0.10" "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": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -9284,7 +9659,7 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.18.0" "node": ">= 14.18.0"
@ -10297,6 +10672,16 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -10605,7 +10990,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@ -17,7 +17,11 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "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": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@ -37,9 +41,11 @@
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.5" "sharp": "^0.34.5",
"@prisma/client": "^6.9.0"
}, },
"devDependencies": { "devDependencies": {
"prisma": "^6.9.0",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
@ -82,5 +88,8 @@
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "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 { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { YachtsModule } from './yachts/yachts.module'; import { YachtsModule } from './yachts/yachts.module';
import { CatalogModule } from './catalog/catalog.module'; import { CatalogModule } from './catalog/catalog.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
@ -13,6 +14,7 @@ import { ReservationsModule } from './reservations/reservations.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
YachtsModule, YachtsModule,
CatalogModule, CatalogModule,
AuthModule, AuthModule,

View File

@ -38,19 +38,19 @@ export class AuthService {
throw new BadRequestException('Некорректный номер телефона'); throw new BadRequestException('Некорректный номер телефона');
} }
if (!this.verificationStore.canSendToPhone(normalizedPhone)) { if (!(await this.verificationStore.canSendToPhone(normalizedPhone))) {
throw new HttpException( throw new HttpException(
'Повторная отправка кода возможна не чаще 1 раза в 60 секунд', 'Повторная отправка кода возможна не чаще 1 раза в 60 секунд',
HttpStatus.TOO_MANY_REQUESTS, 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( throw new HttpException(
`Не более ${SMS_PER_PHONE_MAX} SMS на номер за 15 минут`, `Не более ${SMS_PER_PHONE_MAX} SMS на номер за 15 минут`,
HttpStatus.TOO_MANY_REQUESTS, 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( throw new HttpException(
`Превышен лимит запросов с вашего IP (${SMS_PER_IP_MAX} за 15 минут)`, `Превышен лимит запросов с вашего IP (${SMS_PER_IP_MAX} за 15 минут)`,
HttpStatus.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS,
@ -58,7 +58,7 @@ export class AuthService {
} }
const code = this.generateCode(); const code = this.generateCode();
this.verificationStore.recordSend(normalizedPhone, ip, code); await this.verificationStore.recordSend(normalizedPhone, ip, code);
await this.smsService.sendVerificationCode(normalizedPhone, code); await this.smsService.sendVerificationCode(normalizedPhone, code);
return { success: true }; return { success: true };
} }
@ -77,15 +77,15 @@ export class AuthService {
throw new BadRequestException('Код должен содержать 4 символа'); throw new BadRequestException('Код должен содержать 4 символа');
} }
if (this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) { if (await this.verificationStore.isVerifyBlocked(normalizedPhone, ip)) {
const remaining = this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip); const remaining = await this.verificationStore.getBlockedRemainingSec(normalizedPhone, ip);
throw new HttpException( throw new HttpException(
`Слишком много попыток. Попробуйте через ${remaining} секунд`, `Слишком много попыток. Попробуйте через ${remaining} секунд`,
HttpStatus.TOO_MANY_REQUESTS, 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.ok) {
if (result.reason === 'expired') { if (result.reason === 'expired') {
throw new BadRequestException('Код истёк. Запросите новый'); throw new BadRequestException('Код истёк. Запросите новый');
@ -100,33 +100,33 @@ export class AuthService {
user = await this.usersService.createByPhone(normalizedPhone); user = await this.usersService.createByPhone(normalizedPhone);
} }
return this.issueTokens(user); return await this.issueTokens(user);
} }
/** Обновить access токен по refresh токену. */ /** Обновить access токен по refresh токену. */
async refresh(refreshToken: string): Promise<{ access_token: string; refresh_token: string }> { 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) { if (!stored) {
throw new BadRequestException('Недействительный или просроченный refresh token'); throw new BadRequestException('Недействительный или просроченный refresh token');
} }
try { try {
this.jwtService.verify(refreshToken, { secret: this.configService.get<string>('JWT_SECRET') }); this.jwtService.verify(refreshToken, { secret: this.configService.get<string>('JWT_SECRET') });
} catch { } catch {
this.refreshTokenStore.remove(refreshToken); await this.refreshTokenStore.remove(refreshToken);
throw new BadRequestException('Недействительный refresh token'); throw new BadRequestException('Недействительный refresh token');
} }
const user = await this.usersService.findById(stored.userId); const user = await this.usersService.findById(stored.userId);
if (!user) { if (!user) {
this.refreshTokenStore.remove(refreshToken); await this.refreshTokenStore.remove(refreshToken);
throw new BadRequestException('Пользователь не найден'); throw new BadRequestException('Пользователь не найден');
} }
this.refreshTokenStore.remove(refreshToken); await this.refreshTokenStore.remove(refreshToken);
return this.issueTokens(user); 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 = { const payload = {
sub: user.userId, sub: user.userId,
userId: user.userId, userId: user.userId,
@ -141,7 +141,7 @@ export class AuthService {
); );
const decoded = this.jwtService.decode(refresh_token) as { exp: number }; const decoded = this.jwtService.decode(refresh_token) as { exp: number };
const expiresAt = decoded?.exp ? decoded.exp * 1000 : Date.now() + 30 * 24 * 60 * 60 * 1000; 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 }; return { access_token, refresh_token };
} }

View File

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

View File

@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { import {
CODE_TTL_SEC, CODE_TTL_SEC,
MAX_VERIFY_ATTEMPTS, MAX_VERIFY_ATTEMPTS,
@ -10,124 +11,134 @@ import {
VERIFY_BLOCK_DURATION_SEC, VERIFY_BLOCK_DURATION_SEC,
} from './verification.constants'; } from './verification.constants';
interface StoredCode {
code: string;
createdAt: number;
}
interface FailedAttempts {
count: number;
blockedUntil: number;
}
@Injectable() @Injectable()
export class VerificationStoreService { 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; private cleanupInterval: ReturnType<typeof setInterval> | null = null;
constructor() { constructor(private readonly prisma: PrismaService) {
this.startCleanup();
}
private startCleanup() {
if (this.cleanupInterval) return;
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
} }
private cleanup() { private async cleanup() {
const now = Date.now(); const now = new Date();
const borderPhone = now - SMS_PER_PHONE_WINDOW_SEC * 1000; const codeBorder = new Date(now.getTime() - CODE_TTL_SEC * 1000);
const borderIp = now - SMS_PER_IP_WINDOW_SEC * 1000; const blockBorder = new Date(now.getTime() - VERIFY_BLOCK_DURATION_SEC * 1000);
while (this.sentByPhone.length && this.sentByPhone[0].at < borderPhone) this.sentByPhone.shift(); await this.prisma.verificationCode.deleteMany({
while (this.sentByIp.length && this.sentByIp[0].at < borderIp) this.sentByIp.shift(); where: { createdAt: { lt: codeBorder } },
});
for (const [phone, data] of this.codes.entries()) { await this.prisma.smsSendLog.deleteMany({
if (data.createdAt + CODE_TTL_SEC * 1000 < now) this.codes.delete(phone); 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({
for (const [key, data] of this.failedAttempts.entries()) { where: { blockedUntil: { lt: now } },
if (data.blockedUntil < now) this.failedAttempts.delete(key); });
}
} }
/** Можно ли отправить SMS на номер (60 сек с последней отправки). */ /** Можно ли отправить SMS на номер (60 сек с последней отправки). */
canSendToPhone(phone: string): boolean { async canSendToPhone(phone: string): Promise<boolean> {
const last = this.lastSentAt.get(phone); const last = await this.prisma.smsSendLog.findFirst({
where: { phone },
orderBy: { sentAt: 'desc' },
select: { sentAt: true },
});
if (!last) return 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 мин. */ /** Количество отправок на номер за последние 15 мин. */
getSendCountByPhone(phone: string): number { async getSendCountByPhone(phone: string): Promise<number> {
const border = Date.now() - SMS_PER_PHONE_WINDOW_SEC * 1000; const border = new Date(Date.now() - SMS_PER_PHONE_WINDOW_SEC * 1000);
return this.sentByPhone.filter((r) => r.at >= border && r.phone === phone).length; return this.prisma.smsSendLog.count({
where: { phone, sentAt: { gte: border } },
});
} }
/** Количество отправок с IP за последние 15 мин. */ /** Количество отправок с IP за последние 15 мин. */
getSendCountByIp(ip: string): number { async getSendCountByIp(ip: string): Promise<number> {
const border = Date.now() - SMS_PER_IP_WINDOW_SEC * 1000; const border = new Date(Date.now() - SMS_PER_IP_WINDOW_SEC * 1000);
return this.sentByIp.filter((r) => r.at >= border && r.ip === ip).length; return this.prisma.smsSendLog.count({
where: { ip, sentAt: { gte: border } },
});
} }
/** Зарегистрировать отправку (инвалидировать старый код, записать лимиты). */ /** Зарегистрировать отправку (инвалидировать старый код, записать лимиты). */
recordSend(phone: string, ip: string, code: string): void { async recordSend(phone: string, ip: string, code: string): Promise<void> {
const now = Date.now(); await this.prisma.$transaction([
this.codes.set(phone, { code, createdAt: now }); this.prisma.verificationCode.deleteMany({ where: { phone } }),
this.lastSentAt.set(phone, now); this.prisma.verificationCode.create({
this.sentByPhone.push({ at: now, phone }); data: { phone, code },
this.sentByIp.push({ at: now, ip }); }),
this.cleanup(); this.prisma.smsSendLog.create({
data: { phone, ip },
}),
]);
} }
/** Результат проверки кода. */ /** Результат проверки кода. */
checkCode( async checkCode(
phone: string, phone: string,
ip: string, ip: string,
code: string, code: string,
): { ok: true } | { ok: false; reason: 'expired' } | { ok: false; reason: 'wrong'; remainingAttempts: number } { ): Promise<
const key = `${phone}:${ip}`; | { ok: true }
const blocked = this.failedAttempts.get(key); | { ok: false; reason: 'expired' }
if (blocked && blocked.blockedUntil > Date.now()) return { 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) return { ok: false, reason: 'expired' };
if (stored.createdAt + CODE_TTL_SEC * 1000 < Date.now()) { if (stored.createdAt.getTime() + CODE_TTL_SEC * 1000 < Date.now()) {
this.codes.delete(phone); await this.prisma.verificationCode.deleteMany({ where: { phone } });
return { ok: false, reason: 'expired' }; return { ok: false, reason: 'expired' };
} }
if (stored.code !== code) { if (stored.code !== code) {
const current = this.failedAttempts.get(key) ?? { count: 0, blockedUntil: 0 }; const current = await this.prisma.verifyBlock.findUnique({
current.count += 1; where: { phone_ip: key },
if (current.count >= MAX_VERIFY_ATTEMPTS) { });
current.blockedUntil = Date.now() + VERIFY_BLOCK_DURATION_SEC * 1000; const count = (current?.count ?? 0) + 1;
} const blockedUntil =
this.failedAttempts.set(key, current); count >= MAX_VERIFY_ATTEMPTS
const remainingAttempts = Math.max(0, MAX_VERIFY_ATTEMPTS - current.count); ? 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 }; return { ok: false, reason: 'wrong', remainingAttempts };
} }
this.codes.delete(phone); await this.prisma.verificationCode.deleteMany({ where: { phone } });
this.failedAttempts.delete(key); await this.prisma.verifyBlock.deleteMany({ where: { phone, ip } });
return { ok: true }; return { ok: true };
} }
/** Заблокирован ли ввод кода по phone+ip. */ /** Заблокирован ли ввод кода по phone+ip. */
isVerifyBlocked(phone: string, ip: string): boolean { async isVerifyBlocked(phone: string, ip: string): Promise<boolean> {
const key = `${phone}:${ip}`; const block = await this.prisma.verifyBlock.findUnique({
const data = this.failedAttempts.get(key); where: { phone_ip: { phone, ip } },
if (!data) return false; });
return data.blockedUntil > Date.now(); if (!block) return false;
return block.blockedUntil.getTime() > Date.now();
} }
/** Оставшееся время блокировки в секундах (0 если не заблокирован). */ /** Оставшееся время блокировки в секундах (0 если не заблокирован). */
getBlockedRemainingSec(phone: string, ip: string): number { async getBlockedRemainingSec(phone: string, ip: string): Promise<number> {
const key = `${phone}:${ip}`; const block = await this.prisma.verifyBlock.findUnique({
const data = this.failedAttempts.get(key); where: { phone_ip: { phone, ip } },
if (!data || data.blockedUntil <= Date.now()) return 0; });
return Math.ceil((data.blockedUntil - Date.now()) / 1000); 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 { Module, forwardRef } from '@nestjs/common';
import { CatalogService } from './catalog.service'; import { CatalogService } from './catalog.service';
import { CatalogController } from './catalog.controller'; import { CatalogController } from './catalog.controller';
import { UsersModule } from '../users/users.module';
import { ReservationsModule } from '../reservations/reservations.module'; import { ReservationsModule } from '../reservations/reservations.module';
import { ReviewsModule } from '../reviews/reviews.module'; import { ReviewsModule } from '../reviews/reviews.module';
@Module({ @Module({
imports: [ imports: [
UsersModule, // This provides UsersService forwardRef(() => ReservationsModule),
forwardRef(() => ReservationsModule), // This provides ReservationsService ReviewsModule,
ReviewsModule, // This provides ReviewsService
], ],
controllers: [CatalogController], controllers: [CatalogController],
providers: [CatalogService], providers: [CatalogService],

View File

@ -1,12 +1,11 @@
import { Injectable, Inject, forwardRef } from '@nestjs/common'; 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 { ReservationsService } from '../reservations/reservations.service';
import { ReviewsService } from '../reviews/reviews.service'; import { ReviewsService } from '../reviews/reviews.service';
import { import {
CatalogItemShortDto, CatalogItemShortDto,
CatalogItemLongDto, CatalogItemLongDto,
} from './dto/catalog-item.dto'; } from './dto/catalog-item.dto';
import { CatalogParamsDto } from './dto/catalog-params.dto';
import { CatalogResponseDto } from './dto/catalog-response.dto'; import { CatalogResponseDto } from './dto/catalog-response.dto';
import { MainPageCatalogResponseDto } from './dto/main-page-catalog-response.dto'; import { MainPageCatalogResponseDto } from './dto/main-page-catalog-response.dto';
import { CatalogFiltersDto } from './dto/catalog-filters.dto'; import { CatalogFiltersDto } from './dto/catalog-filters.dto';
@ -15,425 +14,74 @@ import { CreateYachtDto } from './dto/create-yacht.dto';
@Injectable() @Injectable()
export class CatalogService { export class CatalogService {
constructor( constructor(
private readonly usersService: UsersService, private readonly prisma: PrismaService,
@Inject(forwardRef(() => ReservationsService)) @Inject(forwardRef(() => ReservationsService))
private readonly reservationsService: ReservationsService, private readonly reservationsService: ReservationsService,
private readonly reviewsService: ReviewsService, private readonly reviewsService: ReviewsService,
) {} ) {}
private catalogItems: CatalogItemLongDto[] = [ private yachtRowToLongDto(row: {
{ id: number;
id: 1, name: string;
name: 'Азимут 55', length: number;
length: 16.7, speed: number | null;
speed: 32, minCost: number | null;
minCost: 85000, mainImageUrl: string | null;
mainImageUrl: 'uploads/1st.jpg', galleryUrls: unknown;
galleryUrls: [ hasQuickRent: boolean;
'uploads/gal1.jpg', isFeatured: boolean;
'uploads/gal2.jpg', topText: string | null;
'uploads/gal3.jpg', year: number;
'uploads/gal4.jpg', comfortCapacity: number | null;
'uploads/gal5.jpg', maxCapacity: number | null;
'uploads/gal6.jpg', width: number | null;
'uploads/gal7.jpg', cabinsCount: number | null;
'uploads/gal8.jpg', matherial: string | null;
'uploads/gal9.jpg', power: number | null;
'uploads/gal10.jpg', description: string | null;
], userId: number;
hasQuickRent: true, owner?: { id: number; firstName: string; lastName: string; phone: string; email: string; companyName: string | null; inn: bigint | null; ogrn: bigint | null };
isFeatured: true, }): CatalogItemLongDto {
year: 2022, const galleryUrls = Array.isArray(row.galleryUrls)
comfortCapacity: 8, ? row.galleryUrls
maxCapacity: 12, : typeof row.galleryUrls === 'string'
width: 4.8, ? (JSON.parse(row.galleryUrls) as string[])
cabinsCount: 3, : [];
matherial: 'Стеклопластик', return {
power: 1200, id: row.id,
description: name: row.name,
'Роскошная моторная яхта Азимут 55 - это воплощение итальянского стиля и российского качества. Идеально подходит для прогулок по Финскому заливу, корпоративных мероприятий и романтических свиданий. На борту: три комфортабельные каюты, просторный салон с панорамным остеклением, полностью оборудованная кухня и две ванные комнаты. Максимальная скорость 32 узла позволяет быстро добраться до самых живописных мест Карельского перешейка.', length: row.length,
owner: { userId: 1 } as any, 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: [], reviews: [],
reservations: [], 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 { private toShortDto(item: CatalogItemLongDto): CatalogItemShortDto {
const { const {
@ -463,148 +111,131 @@ export class CatalogService {
} }
async getCatalogItemById(id: number): Promise<CatalogItemLongDto | null> { async getCatalogItemById(id: number): Promise<CatalogItemLongDto | null> {
const item = this.catalogItems.find((item) => item.id === id); const yacht = await this.prisma.yacht.findUnique({
if (!item) return null; where: { id },
include: { owner: true },
const ownerId = [1, 2][(id - 1) % 2]; });
const owner = await this.usersService.findById(ownerId); if (!yacht) return null;
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; return item;
} }
async getMainPageCatalog(): Promise<MainPageCatalogResponseDto | null> { async getMainPageCatalog(): Promise<MainPageCatalogResponseDto | null> {
const clonedCatalog = [...this.catalogItems]; const allYachts = await this.prisma.yacht.findMany({
const filteredCatalog = clonedCatalog.filter( include: { owner: true },
({ isFeatured }) => !isFeatured, orderBy: { id: 'asc' },
); });
const featuredYachtIndex = clonedCatalog.findIndex( const fullItems = allYachts.map((row) => this.yachtRowToLongDto(row));
(item) => item.isFeatured === true, const featuredYacht = fullItems.find((item) => item.isFeatured);
); const filteredCatalog = fullItems.filter((item) => !item.isFeatured);
if (!featuredYacht) return null;
if (featuredYachtIndex !== -1) {
const minCost = Math.min( const minCost = Math.min(
...filteredCatalog.map((item) => item.minCost || Infinity), ...filteredCatalog.map((item) => item.minCost || Infinity),
); );
const mappedRestYachts = filteredCatalog.slice(0, 6).map((item) => ({ const mappedRestYachts = filteredCatalog.slice(0, 6).map((item) => ({
...this.toShortDto(item), ...this.toShortDto(item),
isBestOffer: item.minCost === minCost, isBestOffer: item.minCost === minCost,
})); }));
return { return {
featuredYacht: this.toShortDto(clonedCatalog[featuredYachtIndex]), featuredYacht: this.toShortDto(featuredYacht),
restYachts: mappedRestYachts, restYachts: mappedRestYachts,
}; };
} }
return null;
}
async getCatalog(filters?: CatalogFiltersDto): Promise<CatalogResponseDto> { async getCatalog(filters?: CatalogFiltersDto): Promise<CatalogResponseDto> {
// Start with all items converted to short DTO const allYachts = await this.prisma.yacht.findMany({
let filteredItems = this.catalogItems.map((item) => this.toShortDto(item)); include: { owner: true },
orderBy: { id: 'asc' },
});
// Get the full items for filtering by properties not in short DTO let filteredItems = allYachts.map((row) =>
const fullItems = this.catalogItems; this.toShortDto(this.yachtRowToLongDto(row)),
);
const fullItems = allYachts.map((row) => this.yachtRowToLongDto(row));
// Search filter
if (filters?.search) { if (filters?.search) {
const searchTerm = filters.search.toLowerCase(); const searchTerm = filters.search.toLowerCase();
filteredItems = filteredItems.filter((item) => { filteredItems = filteredItems.filter((item) => {
const fullItem = fullItems.find((fi) => fi.id === item.id); const fullItem = fullItems.find((fi) => fi.id === item.id);
return ( return (
item.name.toLowerCase().includes(searchTerm) || item.name.toLowerCase().includes(searchTerm) ||
(fullItem && fullItem.description.toLowerCase().includes(searchTerm)) (fullItem &&
fullItem.description.toLowerCase().includes(searchTerm))
); );
}); });
} }
// Length filter
if (filters?.minLength !== undefined) { if (filters?.minLength !== undefined) {
filteredItems = filteredItems.filter( filteredItems = filteredItems.filter(
(item) => item.length >= filters.minLength!, (item) => item.length >= filters!.minLength!,
); );
} }
if (filters?.maxLength !== undefined) { if (filters?.maxLength !== undefined) {
filteredItems = filteredItems.filter( filteredItems = filteredItems.filter(
(item) => item.length <= filters.maxLength!, (item) => item.length <= filters!.maxLength!,
); );
} }
// Price filter
if (filters?.minPrice !== undefined) { if (filters?.minPrice !== undefined) {
filteredItems = filteredItems.filter( filteredItems = filteredItems.filter(
(item) => item.minCost >= filters.minPrice!, (item) => item.minCost >= filters!.minPrice!,
); );
} }
if (filters?.maxPrice !== undefined) { if (filters?.maxPrice !== undefined) {
filteredItems = filteredItems.filter( filteredItems = filteredItems.filter(
(item) => item.minCost <= filters.maxPrice!, (item) => item.minCost <= filters!.maxPrice!,
); );
} }
// Year filter
if (filters?.minYear !== undefined || filters?.maxYear !== undefined) { if (filters?.minYear !== undefined || filters?.maxYear !== undefined) {
filteredItems = filteredItems.filter((item) => { filteredItems = filteredItems.filter((item) => {
const fullItem = fullItems.find((fi) => fi.id === item.id); const fullItem = fullItems.find((fi) => fi.id === item.id);
if (!fullItem) return false; if (!fullItem) return false;
if (
if (filters.minYear !== undefined && fullItem.year < filters.minYear) filters!.minYear !== undefined &&
fullItem.year < filters!.minYear!
)
return false; return false;
if (filters.maxYear !== undefined && fullItem.year > filters.maxYear) if (
filters!.maxYear !== undefined &&
fullItem.year > filters!.maxYear!
)
return false; return false;
return true; return true;
}); });
} }
if (filters?.guests !== undefined) { if (filters?.guests !== undefined) {
const totalPeople = filters?.guests!; const totalPeople = filters.guests;
filteredItems = filteredItems.filter((item) => { filteredItems = filteredItems.filter((item) => {
const fullItem = fullItems.find((fi) => fi.id === item.id); 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) { if (filters?.quickBooking !== undefined) {
filteredItems = filteredItems.filter( filteredItems = filteredItems.filter(
(item) => item.hasQuickRent === filters.quickBooking, (item) => item.hasQuickRent === filters.quickBooking,
); );
} }
// Has toilet filter (check if cabinsCount > 0)
if (filters?.hasToilet !== undefined) { if (filters?.hasToilet !== undefined) {
filteredItems = filteredItems.filter((item) => { filteredItems = filteredItems.filter((item) => {
const fullItem = fullItems.find((fi) => fi.id === item.id); const fullItem = fullItems.find((fi) => fi.id === item.id);
if (!fullItem) return false; if (!fullItem) return false;
return filters.hasToilet return filters!.hasToilet
? fullItem.cabinsCount > 0 ? fullItem.cabinsCount > 0
: 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') { if (filters?.sortByPrice === 'asc') {
filteredItems.sort((a, b) => a.minCost - b.minCost); filteredItems.sort((a, b) => a.minCost - b.minCost);
} else if (filters?.sortByPrice === 'desc') { } else if (filters?.sortByPrice === 'desc') {
@ -622,32 +253,40 @@ export class CatalogService {
} }
async getAllCatalogItems(): Promise<CatalogItemShortDto[]> { 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[]> { async getCatalogByUserId(userId: number): Promise<CatalogItemShortDto[]> {
// Логика определения ownerId: нечетные id -> ownerId=1, четные id -> ownerId=2 const yachts = await this.prisma.yacht.findMany({
const filteredItems = this.catalogItems.filter((item) => { where: { userId },
return item.owner?.userId === 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> { async createYacht(
const newId = Math.max(...this.catalogItems.map((item) => item.id || 0), 0) + 1; createYachtDto: CreateYachtDto,
const owner = await this.usersService.findById(createYachtDto.userId); ): Promise<CatalogItemLongDto> {
const galleryUrls = createYachtDto.galleryUrls ?? [];
const newYacht: CatalogItemLongDto = { const created = await this.prisma.yacht.create({
id: newId, data: {
name: createYachtDto.name, name: createYachtDto.name,
year: createYachtDto.year,
length: createYachtDto.length, length: createYachtDto.length,
speed: createYachtDto.speed, speed: createYachtDto.speed,
minCost: createYachtDto.minCost, minCost: createYachtDto.minCost,
mainImageUrl: createYachtDto.mainImageUrl, mainImageUrl: createYachtDto.mainImageUrl,
galleryUrls: createYachtDto.galleryUrls, galleryUrls: galleryUrls as object,
hasQuickRent: createYachtDto.hasQuickRent, hasQuickRent: createYachtDto.hasQuickRent,
isFeatured: createYachtDto.isFeatured, isFeatured: createYachtDto.isFeatured,
year: createYachtDto.year, topText: createYachtDto.topText,
comfortCapacity: createYachtDto.comfortCapacity, comfortCapacity: createYachtDto.comfortCapacity,
maxCapacity: createYachtDto.maxCapacity, maxCapacity: createYachtDto.maxCapacity,
width: createYachtDto.width, width: createYachtDto.width,
@ -655,13 +294,14 @@ export class CatalogService {
matherial: createYachtDto.matherial, matherial: createYachtDto.matherial,
power: createYachtDto.power, power: createYachtDto.power,
description: createYachtDto.description, description: createYachtDto.description,
owner: owner || ({ userId: createYachtDto.userId } as any), userId: createYachtDto.userId,
reviews: [], },
reservations: [], include: { owner: true },
topText: createYachtDto.topText, });
};
this.catalogItems.push(newYacht); const item = this.yachtRowToLongDto(created);
return newYacht; 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 { Injectable, Inject, forwardRef } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ReservationItemDto } from './reservation-item.dto'; import { ReservationItemDto } from './reservation-item.dto';
import { CatalogService } from '../catalog/catalog.service'; import { CatalogService } from '../catalog/catalog.service';
import { CatalogItemLongDto } from '../catalog/dto/catalog-item.dto'; import { CatalogItemLongDto } from '../catalog/dto/catalog-item.dto';
@ -14,95 +15,79 @@ export interface ReservationWithYacht extends Reservation {
@Injectable() @Injectable()
export class ReservationsService { export class ReservationsService {
constructor( constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => CatalogService)) @Inject(forwardRef(() => CatalogService))
private readonly catalogService: CatalogService, private readonly catalogService: CatalogService,
) {} ) {}
private reservations: Reservation[] = [ createReservation(dto: ReservationItemDto): Promise<Reservation> {
{ return this.prisma.reservation
id: 1, .create({
yachtId: 1, data: {
reservatorId: 1, yachtId: dto.yachtId,
// Corrected: Jan 1, 2026 20:00 UTC to Jan 2, 2026 08:00 UTC reservatorId: dto.reservatorId,
startUtc: 1767369600, // Jan 1, 2026 20:00:00 UTC startUtc: dto.startUtc,
endUtc: 1767412800, // Jan 2, 2026 08:00:00 UTC endUtc: dto.endUtc,
}, },
{ })
id: 2, .then((r) => ({
yachtId: 3, id: r.id,
reservatorId: 2, yachtId: r.yachtId,
// Jan 3, 2026 08:00 UTC to Jan 5, 2026 20:00 UTC reservatorId: r.reservatorId,
startUtc: 1767484800, // Jan 3, 2026 08:00:00 UTC startUtc: r.startUtc,
endUtc: 1767715200, // Jan 5, 2026 20:00:00 UTC endUtc: r.endUtc,
}, }));
{
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[]> { async getReservationsByUserId(userId: number): Promise<ReservationWithYacht[]> {
const reservations = this.reservations.filter((r) => r.reservatorId === userId); const reservations = await this.prisma.reservation.findMany({
where: { reservatorId: userId },
orderBy: { id: 'asc' },
});
// Populate данные по яхте для каждой резервации
const reservationsWithYacht = await Promise.all( const reservationsWithYacht = await Promise.all(
reservations.map(async (reservation) => { reservations.map(async (reservation) => {
const yacht = await this.catalogService.getCatalogItemById(reservation.yachtId); const yacht = await this.catalogService.getCatalogItemById(
reservation.yachtId,
);
return { return {
...reservation, id: reservation.id,
yacht: yacht || undefined, yachtId: reservation.yachtId,
reservatorId: reservation.reservatorId,
startUtc: reservation.startUtc,
endUtc: reservation.endUtc,
yacht: yacht ?? undefined,
}; };
}) }),
); );
return reservationsWithYacht; return reservationsWithYacht;
} }
getReservationsByYachtId(yachtId: number): Reservation[] { async getReservationsByYachtId(yachtId: number): Promise<Reservation[]> {
return this.reservations.filter((r) => r.yachtId === yachtId); 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[] { async getAllReservations(): Promise<Reservation[]> {
return this.reservations; 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 { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ReviewItemDto } from './review-item.dto'; import { ReviewItemDto } from './review-item.dto';
export interface Review extends ReviewItemDto { export interface Review extends ReviewItemDto {
@ -7,86 +8,64 @@ export interface Review extends ReviewItemDto {
@Injectable() @Injectable()
export class ReviewsService { export class ReviewsService {
private reviews: Review[] = [ constructor(private readonly prisma: PrismaService) {}
{
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',
},
];
private idCounter = 9; async createReview(dto: ReviewItemDto): Promise<Review> {
const created = await this.prisma.review.create({
createReview(dto: ReviewItemDto): Review { data: {
const review = { reviewerId: dto.reviewerId,
id: this.idCounter++, yachtId: dto.yachtId,
...dto, 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[] { async getReviewsByUserId(userId: number): Promise<Review[]> {
return this.reviews.filter((r) => r.reviewerId === userId); 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[] { async getReviewsByYachtId(yachtId: number): Promise<Review[]> {
return this.reviews.filter((r) => r.yachtId === yachtId); 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[] { async getAllReviews(): Promise<Review[]> {
return this.reviews; 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 { Injectable, Logger } from '@nestjs/common';
import { YachtsService } from '../yachts/yachts.service'; import { PrismaService } from '../prisma/prisma.service';
import { User } from './user.entity'; import { User } from './user.entity';
import { Yacht } from '../yachts/yacht.entity';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
private readonly logger = new Logger(UsersService.name); private readonly logger = new Logger(UsersService.name);
private readonly users: User[] = [ constructor(private readonly prisma: PrismaService) {}
{
userId: 1, private toUser(row: {
firstName: 'Иван', id: number;
lastName: 'Андреев', firstName: string;
phone: '+79009009090', lastName: string;
email: 'ivan@yachting.ru', phone: string;
password: 'admin', email: string;
companyName: 'Северный Флот', password: string | null;
inn: 1234567890, companyName: string | null;
ogrn: 1122334455667, inn: bigint | null;
}, ogrn: bigint | null;
{ yachts?: { id: number; name: string; model: string | null; year: number; length: number; userId: number }[];
userId: 2, }): User {
firstName: 'Сергей', const user: User = {
lastName: 'Большаков', userId: row.id,
phone: '+79119119191', firstName: row.firstName,
email: 'sergey@yachting.ru', lastName: row.lastName,
password: 'admin', phone: row.phone,
companyName: 'Балтийские Просторы', email: row.email,
inn: 9876543210, password: row.password ?? undefined,
ogrn: 9988776655443, companyName: row.companyName ?? undefined,
}, inn: row.inn != null ? Number(row.inn) : undefined,
{ ogrn: row.ogrn != null ? Number(row.ogrn) : undefined,
userId: 3, };
firstName: 'Анна', if (row.yachts) {
lastName: 'Петрова', user.yachts = row.yachts.map((y) => ({
phone: '+79229229292', yachtId: y.id,
email: 'anna@yachting.ru', name: y.name,
password: 'admin', model: y.model ?? '',
companyName: 'Ладожские Ветры', year: y.year,
inn: 5555555555, length: y.length,
ogrn: 3333444455556, userId: y.userId,
}, createdAt: new Date(),
{ updatedAt: new Date(),
userId: 4, })) as Yacht[];
firstName: 'Дмитрий', }
lastName: 'Соколов', return user;
phone: '+79339339393', }
email: 'dmitry@yachting.ru',
password: 'admin',
companyName: 'Финский Залив',
inn: 1111222233,
ogrn: 7777888899990,
},
];
async findOne( async findOne(
email: string, email: string,
includeYachts: boolean = false, includeYachts: boolean = false,
): Promise<User | undefined> { ): Promise<User | undefined> {
const user = this.users.find((user) => user.email === email); const user = await this.prisma.user.findFirst({
where: { email },
if (user && includeYachts) { include: includeYachts ? { yachts: true } : undefined,
user.yachts = []; });
} return user ? this.toUser(user) : undefined;
return user;
} }
async findByPhone( async findByPhone(
phone: string, phone: string,
includeYachts: boolean = false, includeYachts: boolean = false,
): Promise<User | undefined> { ): Promise<User | undefined> {
const user = this.users.find((u) => u.phone === phone); const user = await this.prisma.user.findUnique({
if (user && includeYachts) { where: { phone },
user.yachts = []; include: includeYachts ? { yachts: true } : undefined,
} });
return user; return user ? this.toUser(user) : undefined;
} }
/** Создать пользователя по номеру телефона (при первой авторизации по коду). */ /** Создать пользователя по номеру телефона (при первой авторизации по коду). */
async createByPhone(phone: string): Promise<User> { async createByPhone(phone: string): Promise<User> {
const maxId = this.users.length const created = await this.prisma.user.create({
? Math.max(...this.users.map((u) => u.userId)) data: {
: 0;
const newUser: User = {
userId: maxId + 1,
firstName: '', firstName: '',
lastName: '', lastName: '',
phone, phone,
email: '', email: '',
yachts: [], },
}; });
this.users.push(newUser); return this.toUser(created);
return newUser;
} }
async findById( async findById(
userId: number, userId: number,
includeYachts: boolean = false, includeYachts: boolean = false,
): Promise<User | undefined> { ): Promise<User | undefined> {
const user = this.users.find((user) => user.userId === userId); const user = await this.prisma.user.findUnique({
where: { id: userId },
if (user && includeYachts) { include: includeYachts ? { yachts: true } : undefined,
user.yachts = []; });
} return user ? this.toUser(user) : undefined;
return user;
} }
async findAll(includeYachts: boolean = false): Promise<User[]> { async findAll(includeYachts: boolean = false): Promise<User[]> {
if (!includeYachts) { const users = await this.prisma.user.findMany({
return this.users; include: includeYachts ? { yachts: true } : undefined,
} });
return users.map((u) => this.toUser(u));
const usersWithYachts = await Promise.all(
this.users.map(async (user) => {
const yachts = [];
return { ...user, yachts };
}),
);
return usersWithYachts;
} }
} }

View File

@ -1,4 +1,5 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Yacht } from './yacht.entity'; import { Yacht } from './yacht.entity';
import { CreateYachtDto } from './dto/create-yacht.dto'; import { CreateYachtDto } from './dto/create-yacht.dto';
import { UpdateYachtDto } from './dto/update-yacht.dto'; import { UpdateYachtDto } from './dto/update-yacht.dto';
@ -7,89 +8,100 @@ import { UpdateYachtDto } from './dto/update-yacht.dto';
export class YachtsService { export class YachtsService {
private readonly logger = new Logger(YachtsService.name); private readonly logger = new Logger(YachtsService.name);
private yachts: Yacht[] = [ constructor(private readonly prisma: PrismaService) {}
{
yachtId: 1, private toYacht(row: {
name: 'Sea Dream', id: number;
model: 'Sunseeker 76', name: string;
year: 2020, model: string | null;
length: 76, year: number;
userId: 1, length: number;
createdAt: new Date('2023-01-15'), userId: number;
updatedAt: new Date('2023-01-15'), createdAt: Date;
}, updatedAt: Date;
{ }): Yacht {
yachtId: 2, return {
name: 'Ocean Breeze', yachtId: row.id,
model: 'Princess 68', name: row.name,
year: 2021, model: row.model ?? '',
length: 68, year: row.year,
userId: 1, length: row.length,
createdAt: new Date('2023-02-20'), userId: row.userId,
updatedAt: new Date('2023-02-20'), createdAt: row.createdAt,
}, updatedAt: row.updatedAt,
{ };
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[]> { 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> { 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) { if (!yacht) {
throw new NotFoundException(`Yacht with ID ${yachtId} not found`); throw new NotFoundException(`Yacht with ID ${yachtId} not found`);
} }
return yacht; return this.toYacht(yacht);
} }
async findByUserId(userId: number): Promise<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> { async create(createYachtDto: CreateYachtDto): Promise<Yacht> {
const newYacht: Yacht = { const created = await this.prisma.yacht.create({
yachtId: this.yachts.length + 1, data: {
...createYachtDto, name: createYachtDto.name,
createdAt: new Date(), model: createYachtDto.model,
updatedAt: new Date(), year: createYachtDto.year,
}; length: createYachtDto.length,
this.yachts.push(newYacht); userId: createYachtDto.userId,
return newYacht; },
});
return this.toYacht(created);
} }
async update( async update(
yachtId: number, yachtId: number,
updateYachtDto: UpdateYachtDto, updateYachtDto: UpdateYachtDto,
): Promise<Yacht> { ): Promise<Yacht> {
const index = this.yachts.findIndex((y) => y.yachtId === yachtId); const yacht = await this.prisma.yacht.findUnique({
if (index === -1) { where: { id: yachtId },
});
if (!yacht) {
throw new NotFoundException(`Yacht with ID ${yachtId} not found`); throw new NotFoundException(`Yacht with ID ${yachtId} not found`);
} }
const updated = await this.prisma.yacht.update({
this.yachts[index] = { where: { id: yachtId },
...this.yachts[index], data: {
...updateYachtDto, ...(updateYachtDto.name != null && { name: updateYachtDto.name }),
updatedAt: new Date(), ...(updateYachtDto.model != null && { model: updateYachtDto.model }),
}; ...(updateYachtDto.year != null && { year: updateYachtDto.year }),
...(updateYachtDto.length != null && { length: updateYachtDto.length }),
return this.yachts[index]; ...(updateYachtDto.userId != null && { userId: updateYachtDto.userId }),
},
});
return this.toYacht(updated);
} }
async delete(yachtId: number): Promise<void> { async delete(yachtId: number): Promise<void> {
const index = this.yachts.findIndex((y) => y.yachtId === yachtId); const yacht = await this.prisma.yacht.findUnique({
if (index === -1) { where: { id: yachtId },
});
if (!yacht) {
throw new NotFoundException(`Yacht with ID ${yachtId} not found`); throw new NotFoundException(`Yacht with ID ${yachtId} not found`);
} }
this.yachts.splice(index, 1); await this.prisma.yacht.delete({
where: { id: yachtId },
});
} }
} }