diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e936689 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Node +node_modules/ + +# Env files +.env +ospabhost/frontend/.env +ospabhost/backend/.env + +# Build +/dist +/build diff --git a/ospabhost/.gitignore b/ospabhost/.gitignore new file mode 100644 index 0000000..29241a5 --- /dev/null +++ b/ospabhost/.gitignore @@ -0,0 +1,11 @@ +# Node +node_modules/ + +# Env files +.env +frontend/.env +backend/.env + +# Build +/dist +/build diff --git a/ospabhost/backend/package-lock.json b/ospabhost/backend/package-lock.json index 2b884ec..a79fc1b 100644 --- a/ospabhost/backend/package-lock.json +++ b/ospabhost/backend/package-lock.json @@ -10,12 +10,14 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.16.2", + "axios": "^1.12.2", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.2", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -23,6 +25,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.0.0", "@types/node": "^20.12.12", "prisma": "^6.16.2", "ts-node-dev": "^2.0.0", @@ -296,10 +299,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { - "version": "20.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.15.tgz", - "integrity": "sha512-W3bqcbLsRdFDVcmAM5l6oLlcl67vjevn8j1FPZ4nx+K5jNoWCh+FC/btxFoBPnvQlrHHDwfjp1kjIEDfwJ0Mog==", + "version": "20.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", + "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -410,6 +423,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -423,6 +442,23 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -521,9 +557,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -617,6 +663,18 @@ "consola": "^3.2.3" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -624,6 +682,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -723,6 +796,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -870,6 +952,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -992,6 +1089,42 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1155,6 +1288,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1478,7 +1626,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1503,6 +1650,36 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1729,6 +1906,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -1796,6 +1979,20 @@ "destr": "^2.0.3" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2045,6 +2242,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -2260,6 +2474,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -2290,6 +2510,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2326,7 +2552,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/ospabhost/backend/package.json b/ospabhost/backend/package.json index 15911dd..aea5dd3 100644 --- a/ospabhost/backend/package.json +++ b/ospabhost/backend/package.json @@ -13,12 +13,14 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.16.2", + "axios": "^1.12.2", "bcrypt": "^6.0.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.2", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -26,6 +28,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.0.0", "@types/node": "^20.12.12", "prisma": "^6.16.2", "ts-node-dev": "^2.0.0", diff --git a/ospabhost/backend/prisma/delete_windows_os.ts b/ospabhost/backend/prisma/delete_windows_os.ts new file mode 100644 index 0000000..5b56303 --- /dev/null +++ b/ospabhost/backend/prisma/delete_windows_os.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +async function main() { + await prisma.operatingSystem.deleteMany({ where: { type: 'windows' } }); + console.log('Все Windows Server ОС удалены!'); +} + +main().finally(() => prisma.$disconnect()); diff --git a/ospabhost/backend/prisma/migrations/20250918072636_ticket_system/migration.sql b/ospabhost/backend/prisma/migrations/20250918072636_ticket_system/migration.sql new file mode 100644 index 0000000..f9a1e39 --- /dev/null +++ b/ospabhost/backend/prisma/migrations/20250918072636_ticket_system/migration.sql @@ -0,0 +1,5 @@ +-- AddForeignKey +ALTER TABLE `Ticket` ADD CONSTRAINT `Ticket_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Response` ADD CONSTRAINT `Response_operatorId_fkey` FOREIGN KEY (`operatorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/ospabhost/backend/prisma/migrations/20250918093232_bill_check/migration.sql b/ospabhost/backend/prisma/migrations/20250918093232_bill_check/migration.sql new file mode 100644 index 0000000..34138c5 --- /dev/null +++ b/ospabhost/backend/prisma/migrations/20250918093232_bill_check/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE `Check` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `amount` DOUBLE NOT NULL, + `status` VARCHAR(191) NOT NULL DEFAULT 'pending', + `fileUrl` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Check` ADD CONSTRAINT `Check_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/ospabhost/backend/prisma/migrations/20250918103409_add_balance_to_user/migration.sql b/ospabhost/backend/prisma/migrations/20250918103409_add_balance_to_user/migration.sql new file mode 100644 index 0000000..e63d1d9 --- /dev/null +++ b/ospabhost/backend/prisma/migrations/20250918103409_add_balance_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `user` ADD COLUMN `balance` DOUBLE NOT NULL DEFAULT 0; diff --git a/ospabhost/backend/prisma/migrations/20250918111107_add_tariff_os_server/migration.sql b/ospabhost/backend/prisma/migrations/20250918111107_add_tariff_os_server/migration.sql new file mode 100644 index 0000000..dbe845a --- /dev/null +++ b/ospabhost/backend/prisma/migrations/20250918111107_add_tariff_os_server/migration.sql @@ -0,0 +1,45 @@ +-- CreateTable +CREATE TABLE `Tariff` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `price` DOUBLE NOT NULL, + `description` VARCHAR(191) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `Tariff_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `OperatingSystem` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `type` VARCHAR(191) NOT NULL, + `template` VARCHAR(191) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `OperatingSystem_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Server` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `tariffId` INTEGER NOT NULL, + `osId` INTEGER NOT NULL, + `status` VARCHAR(191) NOT NULL DEFAULT 'stopped', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Server` ADD CONSTRAINT `Server_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Server` ADD CONSTRAINT `Server_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Server` ADD CONSTRAINT `Server_osId_fkey` FOREIGN KEY (`osId`) REFERENCES `OperatingSystem`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/ospabhost/backend/prisma/schema.prisma b/ospabhost/backend/prisma/schema.prisma index dde4dbb..b2e8b64 100644 --- a/ospabhost/backend/prisma/schema.prisma +++ b/ospabhost/backend/prisma/schema.prisma @@ -1,4 +1,3 @@ -// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { @@ -10,14 +9,61 @@ datasource db { url = env("DATABASE_URL") } +// This is your Prisma schema file, +model Tariff { + id Int @id @default(autoincrement()) + name String @unique + price Float + description String? + createdAt DateTime @default(now()) + servers Server[] +} + +model OperatingSystem { + id Int @id @default(autoincrement()) + name String @unique + type String // linux, windows, etc + template String? // путь к шаблону для контейнера + createdAt DateTime @default(now()) + servers Server[] +} + +model Server { + id Int @id @default(autoincrement()) + userId Int + tariffId Int + osId Int + status String @default("stopped") // running, stopped, etc + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + tariff Tariff @relation(fields: [tariffId], references: [id]) + os OperatingSystem @relation(fields: [osId], references: [id]) +} + model User { id Int @id @default(autoincrement()) username String email String @unique password String createdAt DateTime @default(now()) - plans Plan[] - operator Int @default(0) // Добавляем новую колонку operator + plans Plan[] @relation("UserPlans") + operator Int @default(0) + tickets Ticket[] @relation("UserTickets") + responses Response[] @relation("OperatorResponses") + checks Check[] @relation("UserChecks") + balance Float @default(0) + servers Server[] +} + +model Check { + id Int @id @default(autoincrement()) + userId Int + amount Float + status String @default("pending") // pending, approved, rejected + fileUrl String + createdAt DateTime @default(now()) + user User @relation("UserChecks", fields: [userId], references: [id]) } model Plan { @@ -28,8 +74,8 @@ model Plan { isCustom Boolean @default(false) createdAt DateTime @default(now()) userId Int - owner User @relation(fields: [userId], references: [id]) - services Service[] + owner User @relation("UserPlans", fields: [userId], references: [id]) + services Service[] @relation("PlanServices") } model Service { @@ -37,7 +83,7 @@ model Service { name String @unique price Float planId Int? - plan Plan? @relation(fields: [planId], references: [id]) + plan Plan? @relation("PlanServices", fields: [planId], references: [id]) } model Ticket { @@ -48,10 +94,8 @@ model Ticket { status String @default("open") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - responses Response[] // связь - - // Если нужна связь с User: - // user User @relation(fields: [userId], references: [id]) + responses Response[] @relation("TicketResponses") + user User? @relation("UserTickets", fields: [userId], references: [id]) } model Response { @@ -60,5 +104,6 @@ model Response { operatorId Int message String createdAt DateTime @default(now()) - ticket Ticket @relation(fields: [ticketId], references: [id]) // <-- обратная связь + ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id]) + operator User @relation("OperatorResponses", fields: [operatorId], references: [id]) } \ No newline at end of file diff --git a/ospabhost/backend/prisma/seed.ts b/ospabhost/backend/prisma/seed.ts new file mode 100644 index 0000000..c673e58 --- /dev/null +++ b/ospabhost/backend/prisma/seed.ts @@ -0,0 +1,27 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +async function main() { + const tariffs = [ + { name: 'Минимальный', price: 150, description: '1 ядро, 1ГБ RAM, 20ГБ SSD' }, + { name: 'Базовый', price: 300, description: '2 ядра, 2ГБ RAM, 40ГБ SSD' }, + { name: 'Старт', price: 500, description: '2 ядра, 4ГБ RAM, 60ГБ SSD' }, + { name: 'Оптимальный', price: 700, description: '4 ядра, 4ГБ RAM, 80ГБ SSD' }, + { name: 'Профи', price: 1000, description: '4 ядра, 8ГБ RAM, 120ГБ SSD' }, + { name: 'Бизнес', price: 1500, description: '8 ядер, 16ГБ RAM, 200ГБ SSD' }, + { name: 'Корпоративный', price: 2000, description: '12 ядер, 24ГБ RAM, 300ГБ SSD' }, + { name: 'Премиум', price: 2500, description: '16 ядер, 32ГБ RAM, 400ГБ SSD' }, + { name: 'Энтерпрайз', price: 2800, description: '24 ядра, 48ГБ RAM, 500ГБ SSD' }, + { name: 'Максимум', price: 3000, description: '32 ядра, 64ГБ RAM, 1ТБ SSD' }, + ]; + for (const t of tariffs) { + await prisma.tariff.upsert({ + where: { name: t.name }, + update: t, + create: t, + }); + } + console.log('Тарифы успешно добавлены!'); +} + +main().finally(() => prisma.$disconnect()); diff --git a/ospabhost/backend/prisma/seed_os.ts b/ospabhost/backend/prisma/seed_os.ts new file mode 100644 index 0000000..29762b3 --- /dev/null +++ b/ospabhost/backend/prisma/seed_os.ts @@ -0,0 +1,24 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +async function main() { + const oses = [ + { name: 'Ubuntu 22.04', type: 'linux', template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst' }, + { name: 'Debian 12', type: 'linux', template: 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst' }, + { name: 'CentOS 9', type: 'linux', template: 'local:vztmpl/centos-9-stream-default_20240828_amd64.tar.xz' }, + { name: 'AlmaLinux 9', type: 'linux', template: 'local:vztmpl/almalinux-9-default_20240911_amd64.tar.xz' }, + { name: 'Rocky Linux 9', type: 'linux', template: 'local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz' }, + { name: 'Arch Linux', type: 'linux', template: 'local:vztmpl/archlinux-base_20240911-1_amd64.tar.zst' }, + { name: 'Fedora 41', type: 'linux', template: 'local:vztmpl/fedora-41-default_20241118_amd64.tar.xz' }, + ]; + for (const os of oses) { + await prisma.operatingSystem.upsert({ + where: { name: os.name }, + update: os, + create: os, + }); + } + console.log('ОС успешно добавлены!'); +} + +main().finally(() => prisma.$disconnect()); diff --git a/ospabhost/backend/proxmox/index.ts b/ospabhost/backend/proxmox/index.ts new file mode 100644 index 0000000..2d62346 --- /dev/null +++ b/ospabhost/backend/proxmox/index.ts @@ -0,0 +1,2 @@ +// Импорт и экспорт функций для работы с Proxmox +export * from './proxmoxApi'; diff --git a/ospabhost/backend/proxmox/proxmox.routes.ts b/ospabhost/backend/proxmox/proxmox.routes.ts new file mode 100644 index 0000000..c99c80a --- /dev/null +++ b/ospabhost/backend/proxmox/proxmox.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { createContainer } from './proxmoxApi'; + +const router = Router(); + + +// Маршрут для создания контейнера +router.post('/container', async (req, res) => { + try { + const { vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize } = req.body; + const result = await createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize }); + res.json(result); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : err }); + } +}); + +export default router; diff --git a/ospabhost/backend/proxmox/proxmoxApi.ts b/ospabhost/backend/proxmox/proxmoxApi.ts new file mode 100644 index 0000000..ec79e7e --- /dev/null +++ b/ospabhost/backend/proxmox/proxmoxApi.ts @@ -0,0 +1,45 @@ +import axios from 'axios'; +import dotenv from 'dotenv'; +dotenv.config(); + +const PROXMOX_API = `https://${process.env.PROXMOX_HOST}:${process.env.PROXMOX_PORT}/api2/json`; + +function getProxmoxHeaders() { + return { + Authorization: `PVEAPIToken=${process.env.PROXMOX_API_TOKEN_ID}=${process.env.PROXMOX_API_TOKEN_SECRET}`, + }; +} + +// Создание контейнера (LXC) из шаблона +export interface CreateContainerParams { + vmid: number; + hostname: string; + password: string; + ostemplate: string; + storage: string; + cores: number; + memory: number; + rootfsSize: number; +} + +export async function createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize }: CreateContainerParams) { + const url = `${PROXMOX_API}/nodes/${process.env.PROXMOX_NODE}/lxc`; + const data = { + vmid, + hostname, + password, + ostemplate, // например: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst' + storage, // например: 'local' + cores, // количество ядер + memory, // RAM в МБ + rootfs: `${storage}:${rootfsSize}`, // например: 'local:8' + net0: 'name=eth0,bridge=vmbr0,ip=dhcp', + // Дополнительные параметры по необходимости + }; + try { + const res = await axios.post(url, data, { headers: getProxmoxHeaders() }); + return res.data; + } catch (err) { + throw new Error('Ошибка создания контейнера: ' + (err instanceof Error ? err.message : err)); + } +} diff --git a/ospabhost/backend/src/index.ts b/ospabhost/backend/src/index.ts index 32f2062..db3cc97 100644 --- a/ospabhost/backend/src/index.ts +++ b/ospabhost/backend/src/index.ts @@ -2,6 +2,12 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import authRoutes from './modules/auth/auth.routes'; +import ticketRoutes from './modules/ticket/ticket.routes'; +import checkRoutes from './modules/check/check.routes'; +import proxmoxRoutes from '../proxmox/proxmox.routes'; +import tariffRoutes from './modules/tariff'; +import osRoutes from './modules/os'; +import serverRoutes from './modules/server'; dotenv.config(); @@ -30,7 +36,19 @@ app.get('/', (req, res) => { }); }); + + +// Статические файлы чеков +import path from 'path'; +app.use('/uploads/checks', express.static(path.join(__dirname, '../uploads/checks'))); + app.use('/api/auth', authRoutes); +app.use('/api/ticket', ticketRoutes); +app.use('/api/check', checkRoutes); +app.use('/api/proxmox', proxmoxRoutes); +app.use('/api/tariff', tariffRoutes); +app.use('/api/os', osRoutes); +app.use('/api/server', serverRoutes); const PORT = process.env.PORT || 5000; diff --git a/ospabhost/backend/src/modules/auth/auth.controller.ts b/ospabhost/backend/src/modules/auth/auth.controller.ts index c071009..82c9b80 100644 --- a/ospabhost/backend/src/modules/auth/auth.controller.ts +++ b/ospabhost/backend/src/modules/auth/auth.controller.ts @@ -67,11 +67,10 @@ export const login = async (req: Request, res: Response) => { export const getMe = async (req: Request, res: Response) => { try { - const userId = (req as any).userId; + const userId = req.user?.id; if (!userId) { return res.status(401).json({ message: 'Не авторизован.' }); } - const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -79,18 +78,19 @@ export const getMe = async (req: Request, res: Response) => { username: true, email: true, createdAt: true, - operator: true, // Добавляем поле operator + operator: true, + balance: true, + servers: true, + tickets: true, }, }); - + console.log('API /api/auth/me user:', user); if (!user) { return res.status(404).json({ message: 'Пользователь не найден.' }); } - res.status(200).json({ user }); - } catch (error) { console.error('Ошибка при получении данных пользователя:', error); res.status(500).json({ message: 'Ошибка сервера.' }); - } + } }; \ No newline at end of file diff --git a/ospabhost/backend/src/modules/auth/auth.middleware.ts b/ospabhost/backend/src/modules/auth/auth.middleware.ts index 41189da..87ba770 100644 --- a/ospabhost/backend/src/modules/auth/auth.middleware.ts +++ b/ospabhost/backend/src/modules/auth/auth.middleware.ts @@ -1,13 +1,11 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import { PrismaClient } from '@prisma/client'; -interface AuthRequest extends Request { - userId?: number; -} - +const prisma = new PrismaClient(); const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key'; -export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => { +export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => { try { const authHeader = req.headers.authorization; if (!authHeader) { @@ -20,9 +18,10 @@ export const authMiddleware = (req: AuthRequest, res: Response, next: NextFuncti } const decoded = jwt.verify(token, JWT_SECRET) as { id: number }; - req.userId = decoded.id; + const user = await prisma.user.findUnique({ where: { id: decoded.id } }); + if (!user) return res.status(401).json({ message: 'Пользователь не найден.' }); + req.user = user; next(); - } catch (error) { console.error('Ошибка в мидлваре аутентификации:', error); if (error instanceof jwt.JsonWebTokenError) { diff --git a/ospabhost/backend/src/modules/check/check.controller.ts b/ospabhost/backend/src/modules/check/check.controller.ts new file mode 100644 index 0000000..f52ba7f --- /dev/null +++ b/ospabhost/backend/src/modules/check/check.controller.ts @@ -0,0 +1,66 @@ +import { PrismaClient } from '@prisma/client'; +import { Request, Response } from 'express'; +import { Multer } from 'multer'; +import path from 'path'; +import fs from 'fs'; + +const prisma = new PrismaClient(); + +// Тип расширенного запроса с Multer +interface MulterRequest extends Request { + file?: Express.Multer.File; +} + +// Загрузка чека клиентом (с файлом) +export async function uploadCheck(req: MulterRequest, res: Response) { + const userId = req.user?.id; + const { amount } = req.body; + const file = req.file; + if (!userId || !amount || !file) return res.status(400).json({ error: 'Данные не заполнены или файл не загружен' }); + + // Сохраняем путь к файлу + const fileUrl = `/uploads/checks/${file.filename}`; + + const check = await prisma.check.create({ + data: { userId, amount: Number(amount), fileUrl } + }); + res.json(check); +} + +// Получить все чеки (оператор) +export async function getChecks(req: Request, res: Response) { + const isOperator = Number(req.user?.operator) === 1; + if (!isOperator) return res.status(403).json({ error: 'Нет прав' }); + const checks = await prisma.check.findMany({ + include: { user: true }, + orderBy: { createdAt: 'desc' } + }); + res.json(checks); +} + +// Подтвердить чек и пополнить баланс +export async function approveCheck(req: Request, res: Response) { + const { checkId } = req.body; + // Найти чек + const check = await prisma.check.findUnique({ where: { id: checkId } }); + if (!check) return res.status(404).json({ error: 'Чек не найден' }); + // Обновить статус + await prisma.check.update({ where: { id: checkId }, data: { status: 'approved' } }); + // Пополнить баланс пользователя + await prisma.user.update({ + where: { id: check.userId }, + data: { + balance: { + increment: check.amount + } + } + }); + res.json({ success: true }); +} + +// Отклонить чек +export async function rejectCheck(req: Request, res: Response) { + const { checkId } = req.body; + await prisma.check.update({ where: { id: checkId }, data: { status: 'rejected' } }); + res.json({ success: true }); +} diff --git a/ospabhost/backend/src/modules/check/check.routes.ts b/ospabhost/backend/src/modules/check/check.routes.ts new file mode 100644 index 0000000..f432193 --- /dev/null +++ b/ospabhost/backend/src/modules/check/check.routes.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { uploadCheck, getChecks, approveCheck, rejectCheck } from './check.controller'; +import { authMiddleware } from '../auth/auth.middleware'; +import multer, { MulterError } from 'multer'; +import path from 'path'; + +const router = Router(); + +// Настройка Multer для загрузки чеков +const storage = multer.diskStorage({ + destination: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) { + cb(null, path.join(__dirname, '../../../uploads/checks')); + }, + filename: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, uniqueSuffix + '-' + file.originalname); + } +}); +const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/jpg' +]; + +const upload = multer({ + storage, + fileFilter: (req, file, cb) => { + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + // Кастомная ошибка для Multer + const err: any = new Error('Недопустимый формат файла. Разрешены только изображения: jpg, jpeg, png, gif, webp.'); + err.code = 'LIMIT_FILE_FORMAT'; + cb(err, false); + } + } +}); + +router.use(authMiddleware); + +router.post('/upload', upload.single('file'), uploadCheck); +router.get('/', getChecks); +router.post('/approve', approveCheck); +router.post('/reject', rejectCheck); + +export default router; diff --git a/ospabhost/backend/src/modules/os/index.ts b/ospabhost/backend/src/modules/os/index.ts new file mode 100644 index 0000000..dc3e07b --- /dev/null +++ b/ospabhost/backend/src/modules/os/index.ts @@ -0,0 +1,2 @@ +import osRoutes from './os.routes'; +export default osRoutes; diff --git a/ospabhost/backend/src/modules/os/os.routes.ts b/ospabhost/backend/src/modules/os/os.routes.ts new file mode 100644 index 0000000..ae2d675 --- /dev/null +++ b/ospabhost/backend/src/modules/os/os.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { PrismaClient } from '@prisma/client'; + +const router = Router(); +const prisma = new PrismaClient(); + +// GET /api/os — получить все ОС +router.get('/', async (req, res) => { + try { + const oses = await prisma.operatingSystem.findMany(); + res.json(oses); + } catch (err) { + console.error('Ошибка получения ОС:', err); + res.status(500).json({ error: 'Ошибка получения ОС' }); + } +}); + +export default router; diff --git a/ospabhost/backend/src/modules/server/index.ts b/ospabhost/backend/src/modules/server/index.ts new file mode 100644 index 0000000..9a3609d --- /dev/null +++ b/ospabhost/backend/src/modules/server/index.ts @@ -0,0 +1,2 @@ +import serverRoutes from './server.routes'; +export default serverRoutes; diff --git a/ospabhost/backend/src/modules/server/server.routes.ts b/ospabhost/backend/src/modules/server/server.routes.ts new file mode 100644 index 0000000..e132e5b --- /dev/null +++ b/ospabhost/backend/src/modules/server/server.routes.ts @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { PrismaClient } from '@prisma/client'; +// import { createProxmoxContainer } from '../../proxmox/proxmoxApi'; // если есть интеграция + +const router = Router(); +const prisma = new PrismaClient(); + +// POST /api/server/create — создать сервер (контейнер) +router.post('/create', async (req, res) => { + try { + const { tariffId, osId } = req.body; + // TODO: получить userId из авторизации (req.user) + const userId = 1; // временно, заменить на реального пользователя + + // Получаем тариф и ОС + const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } }); + const os = await prisma.operatingSystem.findUnique({ where: { id: osId } }); + if (!tariff || !os) { + return res.status(400).json({ error: 'Тариф или ОС не найдены' }); + } + + // TODO: интеграция с Proxmox для создания контейнера + // Если интеграция с Proxmox есть, то только при успешном создании контейнера создавать запись в БД + // Например: + // let proxmoxResult; + // try { + // proxmoxResult = await createProxmoxContainer({ ... }); + // } catch (proxmoxErr) { + // console.error('Ошибка Proxmox:', proxmoxErr); + // return res.status(500).json({ error: 'Ошибка создания контейнера на Proxmox' }); + // } + + // Если всё успешно — создаём запись сервера в БД + const server = await prisma.server.create({ + data: { + userId, + tariffId, + osId, + status: 'creating', + }, + }); + res.json({ success: true, server }); + } catch (err) { + console.error('Ошибка создания сервера:', err); + // Не создавать сервер, если есть ошибка + return res.status(500).json({ error: 'Ошибка создания сервера' }); + } +}); + +// GET /api/server — получить все серверы пользователя +router.get('/', async (req, res) => { + try { + // TODO: получить userId из авторизации (req.user) + const userId = 1; // временно + const servers = await prisma.server.findMany({ + where: { userId }, + include: { + os: true, + tariff: true, + }, + }); + console.log('API /api/server ответ:', servers); + res.json(servers); + } catch (err) { + console.error('Ошибка получения серверов:', err); + res.status(500).json({ error: 'Ошибка получения серверов' }); + } +}); + +export default router; diff --git a/ospabhost/backend/src/modules/tariff/index.ts b/ospabhost/backend/src/modules/tariff/index.ts new file mode 100644 index 0000000..fee98f8 --- /dev/null +++ b/ospabhost/backend/src/modules/tariff/index.ts @@ -0,0 +1,2 @@ +import tariffRoutes from './tariff.routes'; +export default tariffRoutes; diff --git a/ospabhost/backend/src/modules/tariff/tariff.routes.ts b/ospabhost/backend/src/modules/tariff/tariff.routes.ts new file mode 100644 index 0000000..ad90acd --- /dev/null +++ b/ospabhost/backend/src/modules/tariff/tariff.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { PrismaClient } from '@prisma/client'; + +const router = Router(); +const prisma = new PrismaClient(); + +// GET /api/tariff — получить все тарифы +router.get('/', async (req, res) => { + try { + const tariffs = await prisma.tariff.findMany(); + res.json(tariffs); + } catch (err) { + console.error('Ошибка получения тарифов:', err); + res.status(500).json({ error: 'Ошибка получения тарифов' }); + } +}); + +export default router; diff --git a/ospabhost/backend/src/modules/ticket/ticket.controller.ts b/ospabhost/backend/src/modules/ticket/ticket.controller.ts new file mode 100644 index 0000000..5346a66 --- /dev/null +++ b/ospabhost/backend/src/modules/ticket/ticket.controller.ts @@ -0,0 +1,93 @@ +import { PrismaClient } from '@prisma/client'; +import { Request, Response } from 'express'; + + +const prisma = new PrismaClient(); + +// Расширяем тип Request для user +declare global { + namespace Express { + interface Request { + user?: { + id: number; + operator?: number; + // можно добавить другие поля при необходимости + }; + } + } +} + +// Создать тикет +export async function createTicket(req: Request, res: Response) { + const { title, message } = req.body; + const userId = req.user?.id; + if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); + try { + const ticket = await prisma.ticket.create({ + data: { title, message, userId }, + }); + res.json(ticket); + } catch (err) { + res.status(500).json({ error: 'Ошибка создания тикета' }); + } +} + +// Получить тикеты (клиент — свои, оператор — все) +export async function getTickets(req: Request, res: Response) { + const userId = req.user?.id; + const isOperator = Number(req.user?.operator) === 1; + if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); + try { + const tickets = await prisma.ticket.findMany({ + where: isOperator ? {} : { userId }, + include: { + responses: { include: { operator: true } }, + user: true + }, + orderBy: { createdAt: 'desc' }, + }); + res.json(tickets); + } catch (err) { + res.status(500).json({ error: 'Ошибка получения тикетов' }); + } +} + +// Ответить на тикет (только оператор) +export async function respondTicket(req: Request, res: Response) { + const { ticketId, message } = req.body; + const operatorId = req.user?.id; + const isOperator = Number(req.user?.operator) === 1; + if (!operatorId || !isOperator) return res.status(403).json({ error: 'Нет прав' }); + try { + const response = await prisma.response.create({ + data: { ticketId, operatorId, message }, + }); + await prisma.ticket.update({ + where: { id: ticketId }, + data: { status: 'answered' }, + }); + res.json(response); + } catch (err) { + res.status(500).json({ error: 'Ошибка ответа на тикет' }); + } +} + +// Закрыть тикет (клиент или оператор) +export async function closeTicket(req: Request, res: Response) { + const { ticketId } = req.body; + const userId = req.user?.id; + const isOperator = Number(req.user?.operator) === 1; + if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); + try { + const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } }); + if (!ticket) return res.status(404).json({ error: 'Тикет не найден' }); + if (!isOperator && ticket.userId !== userId) return res.status(403).json({ error: 'Нет прав' }); + await prisma.ticket.update({ + where: { id: ticketId }, + data: { status: 'closed' }, + }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Ошибка закрытия тикета' }); + } +} diff --git a/ospabhost/backend/src/modules/ticket/ticket.routes.ts b/ospabhost/backend/src/modules/ticket/ticket.routes.ts new file mode 100644 index 0000000..b9b77f8 --- /dev/null +++ b/ospabhost/backend/src/modules/ticket/ticket.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { createTicket, getTickets, respondTicket, closeTicket } from './ticket.controller'; +import { authMiddleware } from '../auth/auth.middleware'; + +const router = Router(); + +router.use(authMiddleware); + +router.post('/create', createTicket); +router.get('/', getTickets); +router.post('/respond', respondTicket); +router.post('/close', closeTicket); + +export default router; diff --git a/ospabhost/backend/uploads/checks/1758187786911-220370614-photo_2025-09-16_16-42-12.jpg b/ospabhost/backend/uploads/checks/1758187786911-220370614-photo_2025-09-16_16-42-12.jpg new file mode 100644 index 0000000..7b58f36 Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758187786911-220370614-photo_2025-09-16_16-42-12.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758188012424-293256169-check-subtotal-1.jpg b/ospabhost/backend/uploads/checks/1758188012424-293256169-check-subtotal-1.jpg new file mode 100644 index 0000000..9c012fc Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758188012424-293256169-check-subtotal-1.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758188528914-234176881-check-subtotal-1.jpg b/ospabhost/backend/uploads/checks/1758188528914-234176881-check-subtotal-1.jpg new file mode 100644 index 0000000..9c012fc Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758188528914-234176881-check-subtotal-1.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758191562298-855889719-check-subtotal-1.jpg b/ospabhost/backend/uploads/checks/1758191562298-855889719-check-subtotal-1.jpg new file mode 100644 index 0000000..9c012fc Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758191562298-855889719-check-subtotal-1.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758191920274-640892204-check-subtotal-1.jpg b/ospabhost/backend/uploads/checks/1758191920274-640892204-check-subtotal-1.jpg new file mode 100644 index 0000000..9c012fc Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758191920274-640892204-check-subtotal-1.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758192160022-347367032-photo_2025-09-16_16-42-12.jpg b/ospabhost/backend/uploads/checks/1758192160022-347367032-photo_2025-09-16_16-42-12.jpg new file mode 100644 index 0000000..7b58f36 Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758192160022-347367032-photo_2025-09-16_16-42-12.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758195497423-425876416-photo_2025-09-16_16-42-12.jpg b/ospabhost/backend/uploads/checks/1758195497423-425876416-photo_2025-09-16_16-42-12.jpg new file mode 100644 index 0000000..7b58f36 Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758195497423-425876416-photo_2025-09-16_16-42-12.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758200278394-965273615-check-subtotal-1.jpg b/ospabhost/backend/uploads/checks/1758200278394-965273615-check-subtotal-1.jpg new file mode 100644 index 0000000..9c012fc Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758200278394-965273615-check-subtotal-1.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758200421239-4661276-photo_2025-09-16_16-42-12.jpg b/ospabhost/backend/uploads/checks/1758200421239-4661276-photo_2025-09-16_16-42-12.jpg new file mode 100644 index 0000000..7b58f36 Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758200421239-4661276-photo_2025-09-16_16-42-12.jpg differ diff --git a/ospabhost/backend/uploads/checks/1758201868777-621130432-photo_2025-09-16_16-42-12.jpg b/ospabhost/backend/uploads/checks/1758201868777-621130432-photo_2025-09-16_16-42-12.jpg new file mode 100644 index 0000000..7b58f36 Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758201868777-621130432-photo_2025-09-16_16-42-12.jpg differ diff --git a/ospabhost/frontend/src/App.tsx b/ospabhost/frontend/src/App.tsx index 84866d6..36739a3 100644 --- a/ospabhost/frontend/src/App.tsx +++ b/ospabhost/frontend/src/App.tsx @@ -5,7 +5,7 @@ import Homepage from './pages/index'; import Dashboard from './pages/dashboard/mainpage'; import Loginpage from './pages/login'; import Registerpage from './pages/register'; -import Tariffspage from './pages/tariffs'; +import TariffsPage from './pages/tariffs'; import Aboutpage from './pages/about'; import Privateroute from './components/privateroute'; import { AuthProvider } from './context/authcontext'; @@ -17,7 +17,7 @@ function App() { {/* Обычные страницы с footer */} } /> - } /> + } /> } /> } /> } /> diff --git a/ospabhost/frontend/src/components/privateroute.tsx b/ospabhost/frontend/src/components/privateroute.tsx index 543085c..48c4269 100644 --- a/ospabhost/frontend/src/components/privateroute.tsx +++ b/ospabhost/frontend/src/components/privateroute.tsx @@ -1,4 +1,4 @@ -import { Navigate } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import React from 'react'; import useAuth from '../context/useAuth'; @@ -8,7 +8,8 @@ interface PrivateRouteProps { const PrivateRoute: React.FC = ({ children }) => { const { isLoggedIn } = useAuth(); - return isLoggedIn ? children : ; + const location = useLocation(); + return isLoggedIn ? children : ; }; export default PrivateRoute; \ No newline at end of file diff --git a/ospabhost/frontend/src/context/authcontext.tsx b/ospabhost/frontend/src/context/authcontext.tsx index 4baa64c..f3ab8b3 100644 --- a/ospabhost/frontend/src/context/authcontext.tsx +++ b/ospabhost/frontend/src/context/authcontext.tsx @@ -2,8 +2,12 @@ import { createContext, useState, useEffect } from 'react'; import type { ReactNode } from 'react'; +import type { UserData } from '../pages/dashboard/types'; + interface AuthContextType { isLoggedIn: boolean; + userData: UserData | null; + setUserData: (data: UserData | null) => void; login: (token: string) => void; logout: () => void; } @@ -11,6 +15,8 @@ interface AuthContextType { // Создаем контекст с начальными значениями const AuthContext = createContext({ isLoggedIn: false, + userData: null, + setUserData: () => {}, login: () => {}, logout: () => {}, }); @@ -22,11 +28,12 @@ interface AuthProviderProps { // Создаем провайдер, который будет управлять состоянием export const AuthProvider = ({ children }: AuthProviderProps) => { const [isLoggedIn, setIsLoggedIn] = useState(false); + const [userData, setUserData] = useState(null); - // Проверяем статус входа при загрузке приложения useEffect(() => { const token = localStorage.getItem('access_token'); setIsLoggedIn(!!token); + // Можно добавить загрузку userData при наличии токена }, []); const login = (token: string) => { @@ -37,10 +44,11 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const logout = () => { localStorage.removeItem('access_token'); setIsLoggedIn(false); + setUserData(null); }; return ( - + {children} ); diff --git a/ospabhost/frontend/src/main.tsx b/ospabhost/frontend/src/main.tsx index bef5202..475dce2 100644 --- a/ospabhost/frontend/src/main.tsx +++ b/ospabhost/frontend/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import App from './app.tsx' createRoot(document.getElementById('root')!).render( diff --git a/ospabhost/frontend/src/pages/dashboard/billing.tsx b/ospabhost/frontend/src/pages/dashboard/billing.tsx index 8fc77ea..44b9d0c 100644 --- a/ospabhost/frontend/src/pages/dashboard/billing.tsx +++ b/ospabhost/frontend/src/pages/dashboard/billing.tsx @@ -1,41 +1,62 @@ // 3. Исправляем frontend/src/pages/dashboard/billing.tsx + import { useState } from 'react'; -import { Link } from 'react-router-dom'; +import axios from 'axios'; import QRCode from 'react-qr-code'; +const sbpUrl = import.meta.env.VITE_SBP_QR_URL; +const cardNumber = import.meta.env.VITE_CARD_NUMBER; + + const Billing = () => { const [amount, setAmount] = useState(0); const [isPaymentGenerated, setIsPaymentGenerated] = useState(false); const [copyStatus, setCopyStatus] = useState(''); - - // ИСПРАВЛЕНО: используем правильные переменные окружения для Vite - const cardNumber = import.meta.env.VITE_CARD_NUMBER || ''; - const sbpUrl = import.meta.env.VITE_SBP_QR_URL || ''; + const [checkFile, setCheckFile] = useState(null); + const [checkStatus, setCheckStatus] = useState(''); + const [uploadLoading, setUploadLoading] = useState(false); const handleGeneratePayment = () => { - if (amount <= 0) { - alert('Пожалуйста, введите сумму больше нуля.'); - return; - } - if (!cardNumber || !sbpUrl) { - alert('Данные для оплаты не настроены. Пожалуйста, обратитесь к администратору.'); - return; - } - setIsPaymentGenerated(true); + if (amount > 0) setIsPaymentGenerated(true); }; const handleCopyCard = () => { if (cardNumber) { navigator.clipboard.writeText(cardNumber); - setCopyStatus('Номер карты скопирован!'); + setCopyStatus('Скопировано!'); setTimeout(() => setCopyStatus(''), 2000); } }; + const handleCheckUpload = async () => { + if (!checkFile || amount <= 0) return; + setUploadLoading(true); + try { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('file', checkFile); + formData.append('amount', String(amount)); + const response = await axios.post('http://localhost:5000/api/check/upload', formData, { + headers: { + Authorization: `Bearer ${token}`, + // 'Content-Type' не указываем вручную для FormData! + }, + withCredentials: true, + }); + setCheckStatus('Чек успешно загружен! Ожидайте проверки.'); + setCheckFile(null); + console.log('Чек успешно загружен:', response.data); + } catch (error) { + setCheckStatus('Ошибка загрузки чека.'); + console.error('Ошибка загрузки чека:', error); + } + setUploadLoading(false); + }; + return ( -
+

Пополнение баланса

- + {/* Только QR-код и карта, без реквизитов */} {!isPaymentGenerated ? (

@@ -61,52 +82,53 @@ const Billing = () => {

) : (
-

- Для пополнения баланса переведите ₽{amount}. -

-

- Ваш заказ будет обработан вручную после проверки чека. -

- - {sbpUrl && ( -
-

Оплата по СБП

-
- -
-

- Отсканируйте QR-код через мобильное приложение вашего банка. -

+
+

+ Для пополнения баланса переведите ₽{amount}. +

+

+ Ваш заказ будет обработан вручную после проверки чека. +

+
+ {/* QR-код для оплаты по СБП */} +
+

Оплата по СБП

+
+
- )} - - {cardNumber && ( -
-

Оплата по номеру карты

-

{cardNumber}

- - {copyStatus &&

{copyStatus}

} -
- )} - +

+ Отсканируйте QR-код через мобильное приложение вашего банка. +

+
+ {/* Номер карты с кнопкой копирования */} +
+

Оплата по номеру карты

+

{cardNumber || '0000 0000 0000 0000'}

+ + {copyStatus &&

{copyStatus}

} +
+ {/* Форма загрузки чека и инструкции */} +
+

Загрузите чек для проверки:

+ setCheckFile(e.target.files?.[0] || null)} className="mt-2" /> + + {checkStatus &&
{checkStatus}
} +

Важно:

- После оплаты сделайте скриншот или сохраните чек и отправьте его нам в тикет поддержки. + После оплаты сделайте скриншот или сохраните чек и загрузите его для проверки.

-

- После подтверждения ваш баланс будет пополнен. Перейдите в раздел{' '} - - Тикеты - - , чтобы отправить нам чек. + После подтверждения ваш баланс будет пополнен. Ожидайте проверки чека оператором.

)} diff --git a/ospabhost/frontend/src/pages/dashboard/checkout.tsx b/ospabhost/frontend/src/pages/dashboard/checkout.tsx new file mode 100644 index 0000000..71de89d --- /dev/null +++ b/ospabhost/frontend/src/pages/dashboard/checkout.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import axios from 'axios'; + +interface Tariff { + id: number; + name: string; + price: number; + description?: string; +} + +interface OperatingSystem { + id: number; + name: string; + type: string; + template?: string; +} + +interface CheckoutProps { + onSuccess: () => void; +} + +const Checkout: React.FC = ({ onSuccess }) => { + const [tariffs, setTariffs] = useState([]); + const [oses, setOses] = useState([]); + const [selectedTariff, setSelectedTariff] = useState(null); + const [selectedOs, setSelectedOs] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const location = useLocation(); + + useEffect(() => { + // Загрузка тарифов и ОС + const fetchData = async () => { + try { + const [tariffRes, osRes] = await Promise.all([ + axios.get('http://localhost:5000/api/tariff'), + axios.get('http://localhost:5000/api/os'), + ]); + setTariffs(tariffRes.data); + setOses(osRes.data); + // Автовыбор тарифа из query + const params = new URLSearchParams(location.search); + const tariffId = params.get('tariff'); + if (tariffId) { + setSelectedTariff(Number(tariffId)); + } + } catch { + setError('Ошибка загрузки тарифов или ОС'); + } + }; + fetchData(); + }, [location.search]); + + const handleBuy = async () => { + if (!selectedTariff || !selectedOs) { + setError('Выберите тариф и ОС'); + return; + } + setLoading(true); + setError(''); + try { + console.log('Покупка сервера:', { tariffId: selectedTariff, osId: selectedOs }); + const res = await axios.post('http://localhost:5000/api/server/create', { + tariffId: selectedTariff, + osId: selectedOs, + }); + console.log('Ответ сервера:', res.data); + onSuccess(); + } catch (err) { + console.error('Ошибка покупки сервера:', err); + setError('Ошибка покупки сервера'); + } + setLoading(false); + }; + + return ( +
+

Покупка сервера

+ {error &&

{error}

} +
+ + +
+
+ + +
+ +
+ ); +}; + +export default Checkout; diff --git a/ospabhost/frontend/src/pages/dashboard/checkverification.tsx b/ospabhost/frontend/src/pages/dashboard/checkverification.tsx index 7e18c04..93ec7e9 100644 --- a/ospabhost/frontend/src/pages/dashboard/checkverification.tsx +++ b/ospabhost/frontend/src/pages/dashboard/checkverification.tsx @@ -1,8 +1,140 @@ -const CheckVerification = () => { + +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; + +interface IUser { + id: number; + username: string; + email: string; +} + +interface ICheck { + id: number; + userId: number; + amount: number; + status: 'pending' | 'approved' | 'rejected'; + fileUrl: string; + createdAt: string; + user?: IUser; +} + +const API_URL = 'http://localhost:5000/api/check'; + +const CheckVerification: React.FC = () => { + const [checks, setChecks] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchChecks = async (): Promise => { + setLoading(true); + setError(''); + try { + const token = localStorage.getItem('token'); + const res = await axios.get(API_URL, { + headers: { Authorization: `Bearer ${token}` }, + withCredentials: true, + }); + setChecks(res.data); + } catch { + setError('Ошибка загрузки чеков'); + setChecks([]); + } + setLoading(false); + }; + fetchChecks(); + }, []); + + const handleAction = async (checkId: number, action: 'approve' | 'reject'): Promise => { + setActionLoading(checkId); + setError(''); + try { + const token = localStorage.getItem('token'); + await axios.post(`${API_URL}/${action}`, { checkId }, { + headers: { Authorization: `Bearer ${token}` }, + withCredentials: true, + }); + setChecks((prevChecks: ICheck[]) => prevChecks.map((c: ICheck) => c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c)); + // Если подтверждение — обновить баланс пользователя + if (action === 'approve') { + try { + const userToken = localStorage.getItem('access_token') || token; + const headers = { Authorization: `Bearer ${userToken}` }; + const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers }); + // Глобально обновить userData через типизированное событие (для Dashboard) + window.dispatchEvent(new CustomEvent('userDataUpdate', { + detail: { + user: userRes.data.user, + balance: userRes.data.user.balance ?? 0, + servers: userRes.data.user.servers ?? [], + tickets: userRes.data.user.tickets ?? [], + } + })); + } catch (error) { + console.error('Ошибка обновления userData:', error); + } + } + } catch { + setError('Ошибка действия'); + } + setActionLoading(null); + }; + return ( -
+

Проверка чеков

-

Здесь будут отображаться чеки для проверки.

+ {loading ? ( +

Загрузка чеков...

+ ) : error ? ( +

{error}

+ ) : checks.length === 0 ? ( +

Нет чеков для проверки.

+ ) : ( +
+ {checks.map((check: ICheck) => ( +
+
+
+ Пользователь: {check.user?.username || check.user?.email} +
+
+ Сумма: ₽{check.amount} +
+
+ Статус: {check.status === 'pending' ? 'На проверке' : check.status === 'approved' ? 'Подтверждён' : 'Отклонён'} +
+
+ Дата: {new Date(check.createdAt).toLocaleString()} +
+
+
+ + Чек + + {check.status === 'pending' && ( + <> + + + + )} +
+
+ ))} +
+ )}
); }; diff --git a/ospabhost/frontend/src/pages/dashboard/mainpage.tsx b/ospabhost/frontend/src/pages/dashboard/mainpage.tsx index 6d2177c..e16b758 100644 --- a/ospabhost/frontend/src/pages/dashboard/mainpage.tsx +++ b/ospabhost/frontend/src/pages/dashboard/mainpage.tsx @@ -7,16 +7,18 @@ import { useContext } from 'react'; // Импортируем компоненты для вкладок import Summary from './summary'; -import Servers from './servers'; -import Tickets from './tickets'; +import ServerManagementPage from './servermanagement'; +import TicketsPage from './tickets'; import Billing from './billing'; import Settings from './settings'; import CheckVerification from './checkverification'; import TicketResponse from './ticketresponse'; +import Checkout from './checkout'; +import TariffsPage from '../tariffs'; const Dashboard = () => { const [userData, setUserData] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(true); const navigate = useNavigate(); const location = useLocation(); const { logout } = useContext(AuthContext); @@ -44,15 +46,13 @@ const Dashboard = () => { navigate('/login'); return; } - const headers = { Authorization: `Bearer ${token}` }; const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers }); - setUserData({ user: userRes.data.user, - balance: 1500, - servers: [], - tickets: [], + balance: userRes.data.user.balance ?? 0, + servers: userRes.data.user.servers ?? [], + tickets: userRes.data.user.tickets ?? [], }); } catch (err) { console.error('Ошибка загрузки данных:', err); @@ -67,35 +67,48 @@ const Dashboard = () => { fetchData(); }, [logout, navigate]); + // Функция для обновления userData из API + const updateUserData = async () => { + try { + const token = localStorage.getItem('access_token'); + if (!token) return; + const headers = { Authorization: `Bearer ${token}` }; + const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers }); + setUserData({ + user: userRes.data.user, + balance: userRes.data.user.balance ?? 0, + servers: userRes.data.user.servers ?? [], + tickets: userRes.data.user.tickets ?? [], + }); + } catch (err) { + console.error('Ошибка обновления userData:', err); + } + }; + + useEffect(() => { + const handleUserDataUpdate = () => { + try { + updateUserData(); + } catch (err) { + console.error('Ошибка в обработчике userDataUpdate:', err); + } + }; + window.addEventListener('userDataUpdate', handleUserDataUpdate); + return () => { + window.removeEventListener('userDataUpdate', handleUserDataUpdate); + }; + }, []); + + const isOperator = userData?.user?.operator === 1; + if (loading) { return ( -
-
-
-

Загрузка...

-
+
+ Загрузка...
); } - if (!userData || !userData.user) { - return ( -
-
-

Ошибка загрузки данных

- -
-
- ); - } - - const isOperator = userData.user.operator === 1; - return (
{/* Sidebar - фиксированный слева */} @@ -103,7 +116,7 @@ const Dashboard = () => { {/* Заголовок сайдбара */}

- Привет, {userData.user.username}! + Привет, {userData?.user?.username || 'Гость'}!

{isOperator && ( @@ -111,7 +124,7 @@ const Dashboard = () => { )}
- Баланс: ₽{userData.balance} + Баланс: ₽{userData?.balance ?? 0}
@@ -124,7 +137,6 @@ const Dashboard = () => { activeTab === 'summary' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - 📊 Сводка { activeTab === 'servers' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - 🖥️ Серверы { activeTab === 'tickets' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - 🎫 Тикеты { activeTab === 'billing' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - 💳 Пополнить баланс { activeTab === 'settings' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - ⚙️ Настройки
@@ -177,7 +185,6 @@ const Dashboard = () => { activeTab === 'checkverification' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - Проверка чеков { activeTab === 'ticketresponse' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100' }`} > - 💬 Ответы на тикеты
@@ -207,7 +213,7 @@ const Dashboard = () => {
{/* Хлебные крошки/заголовок */}
-
+

{activeTab === 'summary' ? 'Сводка' : @@ -228,32 +234,22 @@ const Dashboard = () => { })}

- - {/* Быстрые действия */} -
- - 💰 Пополнить - - - 🆘 Поддержка - -
{/* Контент страницы */}
- } /> - } /> - } /> - } /> + } /> + } /> + window.location.reload()} />} /> + } /> + {userData && ( + } /> + )} + {userData && ( + } /> + )} } /> {isOperator && ( diff --git a/ospabhost/frontend/src/pages/dashboard/servermanagement.tsx b/ospabhost/frontend/src/pages/dashboard/servermanagement.tsx new file mode 100644 index 0000000..c6b3fbb --- /dev/null +++ b/ospabhost/frontend/src/pages/dashboard/servermanagement.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import useAuth from '../../context/useAuth'; + +interface Server { + id: number; + status: string; + createdAt: string; + updatedAt: string; + tariff: { name: string; price: number }; + os: { name: string; type: string }; +} + +const ServerManagementPage = () => { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const { isLoggedIn } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isLoggedIn) { + navigate('/login'); + return; + } + const fetchServers = async () => { + try { + const token = localStorage.getItem('access_token'); + const res = await axios.get('http://localhost:5000/api/server', { + headers: { Authorization: `Bearer ${token}` }, + }); + setServers(res.data); + } catch { + setError('Ошибка загрузки серверов'); + setServers([]); + } + setLoading(false); + }; + fetchServers(); + }, [isLoggedIn, navigate]); + + // TODO: добавить управление сервером (включить, выключить, перезагрузить, переустановить ОС) + + try { + return ( +
+
+

Управление серверами

+ {loading ? ( +

Загрузка...

+ ) : error ? ( +
+

{error}

+ +
+ ) : servers.length === 0 ? ( +

У вас нет серверов.

+ ) : ( +
+ {servers.map(server => ( +
+
+

{server.tariff.name}

+

ОС: {server.os.name} ({server.os.type})

+

Статус: {server.status}

+

Создан: {new Date(server.createdAt).toLocaleString()}

+
+ {/* TODO: Кнопки управления сервером */} +
+ + + + +
+
+ ))} +
+ )} +
+
+ ); + } catch { + return ( +
+
+

Ошибка отображения страницы

+

Произошла критическая ошибка. Попробуйте перезагрузить страницу.

+ +
+
+ ); + } +}; + +export default ServerManagementPage; diff --git a/ospabhost/frontend/src/pages/dashboard/servers.tsx b/ospabhost/frontend/src/pages/dashboard/servers.tsx index c010267..97a3d95 100644 --- a/ospabhost/frontend/src/pages/dashboard/servers.tsx +++ b/ospabhost/frontend/src/pages/dashboard/servers.tsx @@ -1,17 +1,70 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; -interface ServersProps { - servers: unknown[]; +interface Server { + id: number; + status: string; + createdAt: string; + updatedAt: string; + os: { name: string; type: string }; + tariff: { name: string; price: number }; } -const Servers: React.FC = ({ servers }) => { +const Servers: React.FC = () => { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const fetchServers = async () => { + try { + const res = await axios.get('http://localhost:5000/api/server'); + console.log('Ответ API серверов:', res.data); + // Защита от получения HTML вместо JSON + if (typeof res.data === 'string' && res.data.startsWith(' -

Серверы

- {servers.length === 0 ? ( -

У вас пока нет активных серверов.

+
+
+

Серверы

+ {/* Кнопка 'Купить сервер' только если серверов нет */} + {servers.length === 0 && !loading && !error && ( + Купить сервер + )} +
+ {loading ? ( +

Загрузка...

+ ) : error ? ( +
+

{error}

+ +
+ ) : servers.length === 0 ? ( +
+

У вас пока нет активных серверов.

+ Посмотреть тарифы +
) : ( -

Список ваших серверов будет здесь...

+ )}
); diff --git a/ospabhost/frontend/src/pages/dashboard/summary.tsx b/ospabhost/frontend/src/pages/dashboard/summary.tsx index d88dd41..ac2c4d9 100644 --- a/ospabhost/frontend/src/pages/dashboard/summary.tsx +++ b/ospabhost/frontend/src/pages/dashboard/summary.tsx @@ -1,29 +1,37 @@ import { Link } from 'react-router-dom'; -import type { UserData } from './types'; +import type { UserData, Ticket, Server } from './types'; interface SummaryProps { userData: UserData; } const Summary = ({ userData }: SummaryProps) => { + // Фильтрация открытых тикетов и активных серверов + const openTickets = Array.isArray(userData.tickets) + ? userData.tickets.filter((t: Ticket) => t.status !== 'closed') + : []; + const activeServers = Array.isArray(userData.servers) + ? userData.servers.filter((s: Server) => s.status === 'active') + : []; + return (

Сводка по аккаунту

Баланс:

-

₽ {userData.balance.toFixed(2)}

+

₽ {userData.balance?.toFixed ? userData.balance.toFixed(2) : Number(userData.balance).toFixed(2)}

Пополнить баланс →

Активные серверы:

-

{userData.servers.length}

+

{activeServers.length}

Управлять →

Открытые тикеты:

-

{userData.tickets.length}

+

{openTickets.length}

Служба поддержки →
diff --git a/ospabhost/frontend/src/pages/dashboard/ticketresponse.tsx b/ospabhost/frontend/src/pages/dashboard/ticketresponse.tsx index c0df09f..64131b1 100644 --- a/ospabhost/frontend/src/pages/dashboard/ticketresponse.tsx +++ b/ospabhost/frontend/src/pages/dashboard/ticketresponse.tsx @@ -1,8 +1,155 @@ -const TicketResponse = () => { +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; + +interface Response { + id: number; + message: string; + createdAt: string; + operator?: { username: string }; +} + +interface Ticket { + id: number; + title: string; + message: string; + status: string; + createdAt: string; + responses: Response[]; + user?: { username: string }; +} + +const TicketResponse: React.FC = () => { + const [tickets, setTickets] = useState([]); + const [responseMsg, setResponseMsg] = useState<{ [key: number]: string }>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + fetchTickets(); + }, []); + + const fetchTickets = async () => { + setError(''); + try { + const token = localStorage.getItem('token'); + const res = await axios.get('http://localhost:5000/api/ticket', { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + if (Array.isArray(res.data)) { + setTickets(res.data); + } else { + setTickets([]); + } + } catch { + setError('Ошибка загрузки тикетов'); + setTickets([]); + } + }; + + const respondTicket = async (ticketId: number) => { + setLoading(true); + setError(''); + try { + const token = localStorage.getItem('token'); + await axios.post('http://localhost:5000/api/ticket/respond', { + ticketId, + message: responseMsg[ticketId] + }, { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + setResponseMsg(prev => ({ ...prev, [ticketId]: '' })); + fetchTickets(); + } catch { + setError('Ошибка отправки ответа'); + } finally { + setLoading(false); + } + }; + + // Функция закрытия тикета + const closeTicket = async (ticketId: number) => { + setLoading(true); + setError(''); + try { + const token = localStorage.getItem('token'); + await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + fetchTickets(); + } catch { + setError('Ошибка закрытия тикета'); + } finally { + setLoading(false); + } + }; + return (

Ответы на тикеты

-

Здесь будут отображаться тикеты для ответов.

+ {error &&
{error}
} + {tickets.length === 0 ? ( +

Нет тикетов для ответа.

+ ) : ( +
+ {tickets.map(ticket => ( +
+
{ticket.title}
+
{ticket.message}
+
Статус: {ticket.status} | Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}
+ {/* Чат сообщений */} +
+
+
+ {ticket.user?.username || 'Клиент'}: {ticket.message} +
+
+ {(ticket.responses || []).map(r => ( +
+
+ {r.operator?.username || 'Оператор'}: {r.message} + {new Date(r.createdAt).toLocaleString()} +
+
+ ))} +
+ {/* Форма ответа и кнопка закрытия */} + {ticket.status !== 'closed' && ( +
+ setResponseMsg(prev => ({ ...prev, [ticket.id]: e.target.value }))} + placeholder="Ваш ответ..." + className="border rounded p-2 flex-1" + disabled={loading} + /> + + +
+ )} + {ticket.status === 'closed' && ( +
Тикет закрыт
+ )} +
+ ))} +
+ )}
); }; diff --git a/ospabhost/frontend/src/pages/dashboard/tickets.tsx b/ospabhost/frontend/src/pages/dashboard/tickets.tsx index 21543dc..a719dc6 100644 --- a/ospabhost/frontend/src/pages/dashboard/tickets.tsx +++ b/ospabhost/frontend/src/pages/dashboard/tickets.tsx @@ -1,20 +1,220 @@ -import React from 'react'; +import type { UserData, Ticket } from './types'; +import React, { useEffect, useState } from 'react'; +import useAuth from '../../context/useAuth'; +import axios from 'axios'; -interface TicketsProps { - tickets: unknown[]; -} +// Глобальный логгер ошибок для axios +axios.interceptors.response.use( + response => response, + error => { + if (error.response) { + console.error('Ошибка ответа:', error.response.data); + } else if (error.request) { + console.error('Нет ответа от сервера:', error.request); + } else { + console.error('Ошибка запроса:', error.message); + } + return Promise.reject(error); + } +); + +type TicketsPageProps = { + setUserData: (data: UserData) => void; +}; + +const TicketsPage: React.FC = ({ setUserData }) => { + const { user } = useAuth() as { user?: { username: string; operator?: number } }; + const [tickets, setTickets] = useState([]); + const [title, setTitle] = useState(''); + const [message, setMessage] = useState(''); + const [formError, setFormError] = useState(''); + const [formSuccess, setFormSuccess] = useState(''); + const [loading, setLoading] = useState(false); + const [responseMsg, setResponseMsg] = useState(''); + + useEffect(() => { + fetchTickets(); + }, []); + + const fetchTickets = async () => { + try { + const token = localStorage.getItem('token'); + const res = await axios.get('http://localhost:5000/api/ticket', { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + if (Array.isArray(res.data)) { + setTickets(res.data); + } else { + setTickets([]); + } + } catch { + setTickets([]); + } + }; + + const updateUserData = async () => { + try { + const token = localStorage.getItem('access_token') || localStorage.getItem('token'); + if (!token) return; + const headers = { Authorization: `Bearer ${token}` }; + const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers }); + setUserData({ + user: userRes.data.user, + balance: userRes.data.user.balance ?? 0, + servers: userRes.data.user.servers ?? [], + tickets: userRes.data.user.tickets ?? [], + }); + } catch (err) { + console.error('Ошибка обновления userData после тикета:', err); + } + }; + + const createTicket = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(''); + setFormSuccess(''); + if (!title.trim() || !message.trim()) { + setFormError('Заполните тему и сообщение'); + return; + } + setLoading(true); + try { + const token = localStorage.getItem('token'); + await axios.post('http://localhost:5000/api/ticket/create', { title, message }, { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + setTitle(''); + setMessage(''); + setFormSuccess('Тикет успешно создан!'); + fetchTickets(); + await updateUserData(); + } catch { + setFormError('Ошибка создания тикета'); + } finally { + setLoading(false); + } + }; + + const respondTicket = async (ticketId: number) => { + const token = localStorage.getItem('token'); + await axios.post('http://localhost:5000/api/ticket/respond', { ticketId, message: responseMsg }, { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + setResponseMsg(''); + fetchTickets(); + await updateUserData(); + }; + + const closeTicket = async (ticketId: number) => { + const token = localStorage.getItem('token'); + await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, { + withCredentials: true, + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); + fetchTickets(); + await updateUserData(); + }; -const Tickets: React.FC = ({ tickets }) => { return (
-

Тикеты поддержки

- {tickets.length === 0 ? ( -

У вас пока нет открытых тикетов.

- ) : ( -

Список ваших тикетов будет здесь...

- )} +

Мои тикеты

+
+ + setTitle(e.target.value)} + placeholder="Введите тему..." + className="border rounded-xl p-3 focus:outline-blue-400 text-base" + /> + +