diff --git a/backend/package-lock.json b/backend/package-lock.json index 197a716..687d71a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "mercadopago": "^2.12.0", "morgan": "^1.10.0", "nodemailer": "^6.9.8", + "qrcode": "^1.5.4", "winston": "^3.11.0", "xlsx": "^0.18.5", "zod": "^3.22.4" @@ -32,6 +33,7 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.6", "@types/nodemailer": "^6.4.14", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "eslint": "^8.56.0", @@ -923,6 +925,16 @@ "@types/node": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -1281,7 +1293,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1496,6 +1507,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -1535,6 +1555,17 @@ "node": ">=10" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", @@ -1561,7 +1592,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1574,7 +1604,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -1737,6 +1766,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1778,6 +1816,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2490,6 +2534,15 @@ "node": ">=10" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3495,6 +3548,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3521,7 +3583,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3575,6 +3636,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3628,6 +3698,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -3702,6 +3789,21 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4337,6 +4439,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -4410,6 +4518,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -4437,12 +4559,105 @@ "node": ">=0.8" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 5ed8d5c..e541aca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,6 +34,7 @@ "mercadopago": "^2.12.0", "morgan": "^1.10.0", "nodemailer": "^6.9.8", + "qrcode": "^1.5.4", "winston": "^3.11.0", "xlsx": "^0.18.5", "zod": "^3.22.4" @@ -46,6 +47,7 @@ "@types/morgan": "^1.9.9", "@types/node": "^20.10.6", "@types/nodemailer": "^6.4.14", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "eslint": "^8.56.0", diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index a2902c0..51c3b53 100644 Binary files a/backend/prisma/dev.db and b/backend/prisma/dev.db differ diff --git a/backend/prisma/dev.db-journal b/backend/prisma/dev.db-journal new file mode 100644 index 0000000..3dec9a3 Binary files /dev/null and b/backend/prisma/dev.db-journal differ diff --git a/backend/prisma/migrations/20260131092923_fase_6_2_checkin_equipment/migration.sql b/backend/prisma/migrations/20260131092923_fase_6_2_checkin_equipment/migration.sql new file mode 100644 index 0000000..dda95d0 --- /dev/null +++ b/backend/prisma/migrations/20260131092923_fase_6_2_checkin_equipment/migration.sql @@ -0,0 +1,121 @@ +-- CreateTable +CREATE TABLE "menu_items" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "description" TEXT, + "category" TEXT NOT NULL DEFAULT 'OTHER', + "price" INTEGER NOT NULL, + "imageUrl" TEXT, + "isAvailable" BOOLEAN NOT NULL DEFAULT true, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "preparationTime" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "orders" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "courtId" TEXT NOT NULL, + "items" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "totalAmount" INTEGER NOT NULL, + "paymentStatus" TEXT NOT NULL DEFAULT 'PENDING', + "paymentId" TEXT, + "notes" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "orders_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "orders_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "data" TEXT, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "user_activities" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "source" TEXT NOT NULL, + "activityType" TEXT NOT NULL DEFAULT 'PADEL_GAME', + "startTime" DATETIME NOT NULL, + "endTime" DATETIME NOT NULL, + "duration" INTEGER NOT NULL, + "caloriesBurned" INTEGER NOT NULL, + "heartRateAvg" INTEGER, + "heartRateMax" INTEGER, + "steps" INTEGER, + "distance" REAL, + "metadata" TEXT, + "bookingId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "user_activities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "user_activities_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "menu_items_category_idx" ON "menu_items"("category"); + +-- CreateIndex +CREATE INDEX "menu_items_isAvailable_idx" ON "menu_items"("isAvailable"); + +-- CreateIndex +CREATE INDEX "menu_items_isActive_idx" ON "menu_items"("isActive"); + +-- CreateIndex +CREATE INDEX "orders_userId_idx" ON "orders"("userId"); + +-- CreateIndex +CREATE INDEX "orders_bookingId_idx" ON "orders"("bookingId"); + +-- CreateIndex +CREATE INDEX "orders_courtId_idx" ON "orders"("courtId"); + +-- CreateIndex +CREATE INDEX "orders_status_idx" ON "orders"("status"); + +-- CreateIndex +CREATE INDEX "orders_paymentStatus_idx" ON "orders"("paymentStatus"); + +-- CreateIndex +CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt"); + +-- CreateIndex +CREATE INDEX "notifications_userId_idx" ON "notifications"("userId"); + +-- CreateIndex +CREATE INDEX "notifications_type_idx" ON "notifications"("type"); + +-- CreateIndex +CREATE INDEX "notifications_isRead_idx" ON "notifications"("isRead"); + +-- CreateIndex +CREATE INDEX "notifications_createdAt_idx" ON "notifications"("createdAt"); + +-- CreateIndex +CREATE INDEX "user_activities_userId_idx" ON "user_activities"("userId"); + +-- CreateIndex +CREATE INDEX "user_activities_source_idx" ON "user_activities"("source"); + +-- CreateIndex +CREATE INDEX "user_activities_activityType_idx" ON "user_activities"("activityType"); + +-- CreateIndex +CREATE INDEX "user_activities_startTime_idx" ON "user_activities"("startTime"); + +-- CreateIndex +CREATE INDEX "user_activities_bookingId_idx" ON "user_activities"("bookingId"); diff --git a/backend/prisma/migrations/20260131093147_fase_6_2_qr_equipment_final/migration.sql b/backend/prisma/migrations/20260131093147_fase_6_2_qr_equipment_final/migration.sql new file mode 100644 index 0000000..6f390bb --- /dev/null +++ b/backend/prisma/migrations/20260131093147_fase_6_2_qr_equipment_final/migration.sql @@ -0,0 +1,139 @@ +-- CreateTable +CREATE TABLE "qr_codes" ( + "id" TEXT NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "type" TEXT NOT NULL, + "referenceId" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "usedBy" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "check_ins" ( + "id" TEXT NOT NULL PRIMARY KEY, + "bookingId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "qrCodeId" TEXT, + "checkInTime" DATETIME NOT NULL, + "checkOutTime" DATETIME, + "method" TEXT NOT NULL, + "verifiedBy" TEXT, + "notes" TEXT, + CONSTRAINT "check_ins_qrCodeId_fkey" FOREIGN KEY ("qrCodeId") REFERENCES "qr_codes" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "check_ins_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "check_ins_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "equipment_items" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "description" TEXT, + "category" TEXT NOT NULL, + "brand" TEXT, + "model" TEXT, + "size" TEXT, + "condition" TEXT NOT NULL DEFAULT 'NEW', + "hourlyRate" INTEGER, + "dailyRate" INTEGER, + "depositRequired" INTEGER NOT NULL DEFAULT 0, + "quantityTotal" INTEGER NOT NULL DEFAULT 1, + "quantityAvailable" INTEGER NOT NULL DEFAULT 1, + "imageUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "equipment_rentals" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "bookingId" TEXT, + "startDate" DATETIME NOT NULL, + "endDate" DATETIME NOT NULL, + "totalCost" INTEGER NOT NULL, + "depositAmount" INTEGER NOT NULL, + "depositReturned" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'RESERVED', + "pickedUpAt" DATETIME, + "returnedAt" DATETIME, + "paymentId" TEXT, + "notes" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "equipment_rentals_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "equipment_rentals_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "equipment_rental_items" ( + "id" TEXT NOT NULL PRIMARY KEY, + "rentalId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "hourlyRate" INTEGER, + "dailyRate" INTEGER, + CONSTRAINT "equipment_rental_items_rentalId_fkey" FOREIGN KEY ("rentalId") REFERENCES "equipment_rentals" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "equipment_rental_items_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "equipment_items" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "qr_codes_code_key" ON "qr_codes"("code"); + +-- CreateIndex +CREATE INDEX "qr_codes_code_idx" ON "qr_codes"("code"); + +-- CreateIndex +CREATE INDEX "qr_codes_referenceId_idx" ON "qr_codes"("referenceId"); + +-- CreateIndex +CREATE INDEX "qr_codes_type_idx" ON "qr_codes"("type"); + +-- CreateIndex +CREATE INDEX "qr_codes_isActive_idx" ON "qr_codes"("isActive"); + +-- CreateIndex +CREATE INDEX "check_ins_bookingId_idx" ON "check_ins"("bookingId"); + +-- CreateIndex +CREATE INDEX "check_ins_userId_idx" ON "check_ins"("userId"); + +-- CreateIndex +CREATE INDEX "check_ins_checkInTime_idx" ON "check_ins"("checkInTime"); + +-- CreateIndex +CREATE INDEX "check_ins_method_idx" ON "check_ins"("method"); + +-- CreateIndex +CREATE INDEX "equipment_items_category_idx" ON "equipment_items"("category"); + +-- CreateIndex +CREATE INDEX "equipment_items_isActive_idx" ON "equipment_items"("isActive"); + +-- CreateIndex +CREATE INDEX "equipment_items_quantityAvailable_idx" ON "equipment_items"("quantityAvailable"); + +-- CreateIndex +CREATE INDEX "equipment_rentals_userId_idx" ON "equipment_rentals"("userId"); + +-- CreateIndex +CREATE INDEX "equipment_rentals_bookingId_idx" ON "equipment_rentals"("bookingId"); + +-- CreateIndex +CREATE INDEX "equipment_rentals_status_idx" ON "equipment_rentals"("status"); + +-- CreateIndex +CREATE INDEX "equipment_rentals_startDate_idx" ON "equipment_rentals"("startDate"); + +-- CreateIndex +CREATE INDEX "equipment_rentals_endDate_idx" ON "equipment_rentals"("endDate"); + +-- CreateIndex +CREATE INDEX "equipment_rental_items_rentalId_idx" ON "equipment_rental_items"("rentalId"); + +-- CreateIndex +CREATE INDEX "equipment_rental_items_itemId_idx" ON "equipment_rental_items"("itemId"); diff --git a/backend/prisma/migrations/fase6_extras.sql b/backend/prisma/migrations/fase6_extras.sql new file mode 100644 index 0000000..a606aad --- /dev/null +++ b/backend/prisma/migrations/fase6_extras.sql @@ -0,0 +1,228 @@ +-- Fase 6: Extras y Diferenciadores + +-- Wall of Fame +CREATE TABLE IF NOT EXISTS wall_of_fame_entries ( + id TEXT PRIMARY KEY, + tournament_id TEXT, + title TEXT NOT NULL, + description TEXT, + winners TEXT NOT NULL, -- JSON array + category TEXT NOT NULL DEFAULT 'TOURNAMENT', + image_url TEXT, + event_date DATETIME NOT NULL, + is_active BOOLEAN DEFAULT 1, + featured BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE SET NULL +); + +-- Achievements (Logros) +CREATE TABLE IF NOT EXISTS achievements ( + id TEXT PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'GAMES', + icon TEXT, + color TEXT DEFAULT '#16a34a', + requirement_type TEXT NOT NULL, + requirement_value INTEGER NOT NULL, + points_reward INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT 1 +); + +-- User Achievements +CREATE TABLE IF NOT EXISTS user_achievements ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + achievement_id TEXT NOT NULL, + unlocked_at DATETIME, + progress INTEGER DEFAULT 0, + is_completed BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (achievement_id) REFERENCES achievements(id) ON DELETE CASCADE, + UNIQUE(user_id, achievement_id) +); + +-- Challenges (Retos) +CREATE TABLE IF NOT EXISTS challenges ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'WEEKLY', + requirement_type TEXT NOT NULL, + requirement_value INTEGER NOT NULL, + start_date DATETIME NOT NULL, + end_date DATETIME NOT NULL, + reward_points INTEGER DEFAULT 0, + participants TEXT DEFAULT '[]', -- JSON array + winners TEXT DEFAULT '[]', -- JSON array + is_active BOOLEAN DEFAULT 1 +); + +-- User Challenges +CREATE TABLE IF NOT EXISTS user_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + challenge_id TEXT NOT NULL, + progress INTEGER DEFAULT 0, + is_completed BOOLEAN DEFAULT 0, + completed_at DATETIME, + reward_claimed BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, + UNIQUE(user_id, challenge_id) +); + +-- QR Codes +CREATE TABLE IF NOT EXISTS qr_codes ( + id TEXT PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + type TEXT NOT NULL DEFAULT 'BOOKING_CHECKIN', + reference_id TEXT NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME, + used_by TEXT, + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Check-ins +CREATE TABLE IF NOT EXISTS check_ins ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL, + user_id TEXT NOT NULL, + qr_code_id TEXT, + check_in_time DATETIME DEFAULT CURRENT_TIMESTAMP, + check_out_time DATETIME, + method TEXT DEFAULT 'QR', + verified_by TEXT, + notes TEXT, + FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (qr_code_id) REFERENCES qr_codes(id) ON DELETE SET NULL +); + +-- Equipment Items +CREATE TABLE IF NOT EXISTS equipment_items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'RACKET', + brand TEXT, + model TEXT, + size TEXT, + condition TEXT DEFAULT 'GOOD', + hourly_rate INTEGER, + daily_rate INTEGER, + deposit_required INTEGER DEFAULT 0, + quantity_total INTEGER DEFAULT 1, + quantity_available INTEGER DEFAULT 1, + image_url TEXT, + is_active BOOLEAN DEFAULT 1 +); + +-- Equipment Rentals +CREATE TABLE IF NOT EXISTS equipment_rentals ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + items TEXT NOT NULL, -- JSON array + booking_id TEXT, + start_date DATETIME NOT NULL, + end_date DATETIME NOT NULL, + total_cost INTEGER NOT NULL, + deposit_amount INTEGER DEFAULT 0, + status TEXT DEFAULT 'RESERVED', + picked_up_at DATETIME, + returned_at DATETIME, + payment_id TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL +); + +-- Menu Items +CREATE TABLE IF NOT EXISTS menu_items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'DRINK', + price INTEGER NOT NULL, + image_url TEXT, + is_available BOOLEAN DEFAULT 1, + preparation_time INTEGER, + is_active BOOLEAN DEFAULT 1 +); + +-- Orders +CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + booking_id TEXT NOT NULL, + court_id TEXT NOT NULL, + items TEXT NOT NULL, -- JSON array + status TEXT DEFAULT 'PENDING', + total_amount INTEGER NOT NULL, + payment_status TEXT DEFAULT 'PENDING', + payment_id TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE, + FOREIGN KEY (court_id) REFERENCES courts(id) ON DELETE CASCADE +); + +-- Notifications +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + data TEXT, -- JSON + is_read BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- User Activity (Wearables) +CREATE TABLE IF NOT EXISTS user_activities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + source TEXT DEFAULT 'MANUAL', + activity_type TEXT DEFAULT 'PADEL_GAME', + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + duration INTEGER NOT NULL, + calories_burned INTEGER, + heart_rate_avg INTEGER, + heart_rate_max INTEGER, + steps INTEGER, + distance REAL, + metadata TEXT, -- JSON + booking_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_wof_event_date ON wall_of_fame_entries(event_date); +CREATE INDEX IF NOT EXISTS idx_wof_featured ON wall_of_fame_entries(featured); +CREATE INDEX IF NOT EXISTS idx_achievements_category ON achievements(category); +CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id); +CREATE INDEX IF NOT EXISTS idx_challenges_dates ON challenges(start_date, end_date); +CREATE INDEX IF NOT EXISTS idx_qr_codes ON qr_codes(code); +CREATE INDEX IF NOT EXISTS idx_check_ins_booking ON check_ins(booking_id); +CREATE INDEX IF NOT EXISTS idx_check_ins_time ON check_ins(check_in_time); +CREATE INDEX IF NOT EXISTS idx_equipment_category ON equipment_items(category); +CREATE INDEX IF NOT EXISTS idx_rentals_user ON equipment_rentals(user_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_activities_user ON user_activities(user_id); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 229a79a..20fcf78 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -90,6 +90,17 @@ model User { studentEnrollments StudentEnrollment[] coachReviews CoachReview[] + // Servicios del Club (Fase 6.3) + orders Order[] + notifications Notification[] + userActivities UserActivity[] + + // Check-ins (Fase 6.2) + checkIns CheckIn[] + + // Alquileres de equipamiento (Fase 6.2) + equipmentRentals EquipmentRental[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -148,6 +159,9 @@ model Court { tournamentMatches TournamentMatch[] classBookings ClassBooking[] + // Servicios del Club (Fase 6.3) + orders Order[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -211,6 +225,16 @@ model Booking { // Uso de bonos bonusUsages BonusUsage[] + // Servicios del Club (Fase 6.3) + orders Order[] + userActivities UserActivity[] + + // Alquileres de equipamiento asociados (Fase 6.2) + equipmentRentals EquipmentRental[] + + // Check-ins asociados (Fase 6.2) + checkIns CheckIn[] + // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1221,3 +1245,320 @@ model CoachReview { @@index([rating]) @@map("coach_reviews") } + +// ============================================ +// Modelos de Servicios del Club (Fase 6.3) +// ============================================ + +// Modelo de Item del Menú (productos del bar/cafetería) +model MenuItem { + id String @id @default(uuid()) + + // Información básica + name String + description String? + + // Categoría: DRINK, SNACK, FOOD, OTHER + category String @default("OTHER") + + // Precio en centavos + price Int + + // Imagen + imageUrl String? + + // Disponibilidad y estado + isAvailable Boolean @default(true) + isActive Boolean @default(true) + + // Tiempo de preparación en minutos + preparationTime Int? + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) + @@index([isAvailable]) + @@index([isActive]) + @@map("menu_items") +} + +// Modelo de Pedido (órdenes a la cancha) +model Order { + id String @id @default(uuid()) + + // Usuario que realiza el pedido + user User @relation(fields: [userId], references: [id]) + userId String + + // Reserva asociada (vinculado a reserva activa) + booking Booking @relation(fields: [bookingId], references: [id]) + bookingId String + + // Cancha donde se entregará + court Court @relation(fields: [courtId], references: [id]) + courtId String + + // Items del pedido (JSON array de {itemId, quantity, notes, price}) + items String + + // Estado del pedido: PENDING, PREPARING, READY, DELIVERED, CANCELLED + status String @default("PENDING") + + // Monto total en centavos + totalAmount Int + + // Estado de pago: PENDING, PAID + paymentStatus String @default("PENDING") + + // ID de referencia de pago (MercadoPago u otro) + paymentId String? + + // Notas adicionales + notes String? + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([bookingId]) + @@index([courtId]) + @@index([status]) + @@index([paymentStatus]) + @@index([createdAt]) + @@map("orders") +} + +// Modelo de Notificación (push/in-app) +model Notification { + id String @id @default(uuid()) + + // Usuario destinatario + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + // Tipo de notificación: ORDER_READY, BOOKING_REMINDER, TOURNAMENT_START, etc. + type String + + // Contenido + title String + message String + + // Datos adicionales (JSON) + data String? // Puede contener orderId, bookingId, etc. + + // Estado de lectura + isRead Boolean @default(false) + + // Timestamps + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([type]) + @@index([isRead]) + @@index([createdAt]) + @@map("notifications") +} + +// ============================================ +// Modelos de Integración con Wearables (Fase 6.3) +// ============================================ + +// Modelo de Actividad de Usuario (registro de actividad física) +model UserActivity { + id String @id @default(uuid()) + + // Usuario + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + // Fuente de datos: APPLE_HEALTH, GOOGLE_FIT, MANUAL + source String + + // Tipo de actividad: PADEL_GAME, WORKOUT + activityType String @default("PADEL_GAME") + + // Tiempos + startTime DateTime + endTime DateTime + duration Int // Duración en minutos + + // Métricas de salud + caloriesBurned Int + heartRateAvg Int? + heartRateMax Int? + steps Int? + distance Float? // km + + // Metadatos adicionales (JSON) + metadata String? + + // Reserva asociada (opcional) + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + bookingId String? + + // Timestamps + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([source]) + @@index([activityType]) + @@index([startTime]) + @@index([bookingId]) + @@map("user_activities") +} + +// ============================================ +// Modelos de Check-in Digital QR (Fase 6.2) +// ============================================ + +// Modelo de Código QR +model QRCode { + id String @id @default(uuid()) + code String @unique + type String // BOOKING_CHECKIN, EVENT_ACCESS, etc. + referenceId String // ID de la entidad (booking, etc.) + + expiresAt DateTime + usedAt DateTime? + usedBy String? // userId que usó el QR + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + + // Relaciones + checkIns CheckIn[] + + @@index([code]) + @@index([referenceId]) + @@index([type]) + @@index([isActive]) + @@map("qr_codes") +} + +// Modelo de CheckIn (registro de asistencia) +model CheckIn { + id String @id @default(uuid()) + + bookingId String + userId String + qrCodeId String? + + checkInTime DateTime + checkOutTime DateTime? + + method String // QR, MANUAL + verifiedBy String? // admin que verificó + notes String? + + // Relaciones + qrCode QRCode? @relation(fields: [qrCodeId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + + @@index([bookingId]) + @@index([userId]) + @@index([checkInTime]) + @@index([method]) + @@map("check_ins") +} + +// ============================================ +// Modelos de Gestión de Material (Fase 6.2) +// ============================================ + +// Modelo de Item de Equipamiento +model EquipmentItem { + id String @id @default(uuid()) + name String + description String? + + category String // RACKET, BALLS, ACCESSORIES, SHOES + brand String? + model String? + size String? // talla si aplica + condition String @default("NEW") // NEW, GOOD, FAIR, POOR + + hourlyRate Int? // tarifa por hora (en centavos) + dailyRate Int? // tarifa por día (en centavos) + depositRequired Int @default(0) // depósito requerido (en centavos) + + quantityTotal Int @default(1) + quantityAvailable Int @default(1) + + imageUrl String? + isActive Boolean @default(true) + + // Relaciones + rentals EquipmentRentalItem[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) + @@index([isActive]) + @@index([quantityAvailable]) + @@map("equipment_items") +} + +// Modelo de Alquiler de Equipamiento +model EquipmentRental { + id String @id @default(uuid()) + + userId String + + bookingId String? // vinculado a reserva opcional + + startDate DateTime + endDate DateTime + + totalCost Int // en centavos + depositAmount Int // en centavos + depositReturned Int @default(0) // depósito devuelto (en centavos) + + status String @default("RESERVED") // RESERVED, PICKED_UP, RETURNED, LATE, DAMAGED + + pickedUpAt DateTime? + returnedAt DateTime? + + paymentId String? + + notes String? + + // Relaciones + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + items EquipmentRentalItem[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([bookingId]) + @@index([status]) + @@index([startDate]) + @@index([endDate]) + @@map("equipment_rentals") +} + +// Modelo intermedio para items de un alquiler +model EquipmentRentalItem { + id String @id @default(uuid()) + + rentalId String + itemId String + quantity Int @default(1) + + hourlyRate Int? // tarifa aplicada al momento del alquiler + dailyRate Int? // tarifa aplicada al momento del alquiler + + // Relaciones + rental EquipmentRental @relation(fields: [rentalId], references: [id], onDelete: Cascade) + item EquipmentItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@index([rentalId]) + @@index([itemId]) + @@map("equipment_rental_items") +} diff --git a/backend/src/controllers/achievement.controller.ts b/backend/src/controllers/achievement.controller.ts new file mode 100644 index 0000000..c7ac71f --- /dev/null +++ b/backend/src/controllers/achievement.controller.ts @@ -0,0 +1,216 @@ +import { Request, Response, NextFunction } from 'express'; +import { AchievementService } from '../services/achievement.service'; +import { ApiError } from '../middleware/errorHandler'; +import { AchievementCategory } from '../utils/constants'; + +export class AchievementController { + /** + * Crear un nuevo logro (solo admin) + */ + static async createAchievement(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const achievement = await AchievementService.createAchievement(req.user.userId, req.body); + + res.status(201).json({ + success: true, + message: 'Logro creado exitosamente', + data: achievement, + }); + } catch (error) { + next(error); + } + } + + /** + * Listar todos los logros disponibles + */ + static async getAchievements(req: Request, res: Response, next: NextFunction) { + try { + const options = { + category: req.query.category as AchievementCategory | undefined, + activeOnly: req.query.activeOnly !== 'false', + }; + + const achievements = await AchievementService.getAchievements(options); + + res.status(200).json({ + success: true, + count: achievements.length, + data: achievements, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mis logros desbloqueados + */ + static async getMyAchievements(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const achievements = await AchievementService.getUserAchievements(req.user.userId); + + res.status(200).json({ + success: true, + data: achievements, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener el progreso de todos mis logros + */ + static async getMyAchievementsProgress(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const progress = await AchievementService.getUserAchievementsProgress(req.user.userId); + + res.status(200).json({ + success: true, + count: progress.length, + data: progress, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener el progreso de un logro específico + */ + static async getAchievementProgress(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const progress = await AchievementService.getAchievementProgress(req.user.userId, id); + + res.status(200).json({ + success: true, + data: progress, + }); + } catch (error) { + next(error); + } + } + + /** + * Verificar y desbloquear logros para el usuario actual + */ + static async checkAndUnlockAchievements(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const result = await AchievementService.checkAndUnlockAchievements(req.user.userId); + + res.status(200).json({ + success: true, + message: 'Logros verificados', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar un logro (solo admin) + */ + static async updateAchievement(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const achievement = await AchievementService.updateAchievement(id, req.body); + + res.status(200).json({ + success: true, + message: 'Logro actualizado exitosamente', + data: achievement, + }); + } catch (error) { + next(error); + } + } + + /** + * Eliminar un logro (solo admin) + */ + static async deleteAchievement(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + await AchievementService.deleteAchievement(id); + + res.status(200).json({ + success: true, + message: 'Logro eliminado exitosamente', + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener ranking por puntos de logros + */ + static async getLeaderboard(req: Request, res: Response, next: NextFunction) { + try { + const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; + const leaderboard = await AchievementService.getLeaderboard(limit); + + res.status(200).json({ + success: true, + count: leaderboard.length, + data: leaderboard, + }); + } catch (error) { + next(error); + } + } + + /** + * Inicializar logros por defecto (solo admin) + */ + static async initializeDefaultAchievements(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const result = await AchievementService.initializeDefaultAchievements(req.user.userId); + + res.status(200).json({ + success: true, + message: 'Logros por defecto inicializados', + data: result, + }); + } catch (error) { + next(error); + } + } +} + +export default AchievementController; diff --git a/backend/src/controllers/challenge.controller.ts b/backend/src/controllers/challenge.controller.ts new file mode 100644 index 0000000..39bc83b --- /dev/null +++ b/backend/src/controllers/challenge.controller.ts @@ -0,0 +1,241 @@ +import { Request, Response, NextFunction } from 'express'; +import { ChallengeService } from '../services/challenge.service'; +import { ApiError } from '../middleware/errorHandler'; +import { ChallengeType } from '../utils/constants'; + +export class ChallengeController { + /** + * Crear un nuevo reto (solo admin) + */ + static async createChallenge(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const challenge = await ChallengeService.createChallenge(req.user.userId, { + ...req.body, + startDate: new Date(req.body.startDate), + endDate: new Date(req.body.endDate), + }); + + res.status(201).json({ + success: true, + message: 'Reto creado exitosamente', + data: challenge, + }); + } catch (error) { + next(error); + } + } + + /** + * Listar retos activos + */ + static async getActiveChallenges(req: Request, res: Response, next: NextFunction) { + try { + const filters = { + type: req.query.type as ChallengeType | undefined, + ongoing: req.query.ongoing === 'true', + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string) : undefined, + }; + + const result = await ChallengeService.getActiveChallenges(filters); + + res.status(200).json({ + success: true, + data: result.challenges, + meta: { + total: result.total, + limit: result.limit, + offset: result.offset, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener un reto por ID + */ + static async getChallengeById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const challenge = await ChallengeService.getChallengeById(id, req.user.userId); + + res.status(200).json({ + success: true, + data: challenge, + }); + } catch (error) { + next(error); + } + } + + /** + * Unirse a un reto + */ + static async joinChallenge(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await ChallengeService.joinChallenge(req.user.userId, id); + + res.status(200).json({ + success: true, + message: 'Te has unido al reto exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mis retos + */ + static async getMyChallenges(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const challenges = await ChallengeService.getUserChallenges(req.user.userId); + + res.status(200).json({ + success: true, + count: challenges.length, + data: challenges, + }); + } catch (error) { + next(error); + } + } + + /** + * Completar un reto y reclamar recompensa + */ + static async completeChallenge(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await ChallengeService.completeChallenge(req.user.userId, id); + + res.status(200).json({ + success: true, + message: 'Recompensa reclamada exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener tabla de líderes de un reto + */ + static async getChallengeLeaderboard(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; + + const leaderboard = await ChallengeService.getChallengeLeaderboard(id, limit); + + res.status(200).json({ + success: true, + count: leaderboard.length, + data: leaderboard, + }); + } catch (error) { + next(error); + } + } + + /** + * Verificar retos expirados (endpoint de mantenimiento) + */ + static async checkExpiredChallenges(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const result = await ChallengeService.checkExpiredChallenges(); + + res.status(200).json({ + success: true, + message: `${result.count} retos cerrados`, + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar un reto (solo admin) + */ + static async updateChallenge(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const updateData: any = { ...req.body }; + + if (req.body.startDate) { + updateData.startDate = new Date(req.body.startDate); + } + + if (req.body.endDate) { + updateData.endDate = new Date(req.body.endDate); + } + + const challenge = await ChallengeService.updateChallenge(id, updateData); + + res.status(200).json({ + success: true, + message: 'Reto actualizado exitosamente', + data: challenge, + }); + } catch (error) { + next(error); + } + } + + /** + * Eliminar un reto (solo admin) + */ + static async deleteChallenge(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await ChallengeService.deleteChallenge(id); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + } +} + +export default ChallengeController; diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts new file mode 100644 index 0000000..679b236 --- /dev/null +++ b/backend/src/controllers/equipment.controller.ts @@ -0,0 +1,230 @@ +import { Request, Response, NextFunction } from 'express'; +import { EquipmentService } from '../services/equipment.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class EquipmentController { + /** + * Listar equipamiento disponible + */ + static async getEquipmentItems(req: Request, res: Response, next: NextFunction) { + try { + const filters = { + category: req.query.category as any, + isActive: req.query.isActive === 'true' ? true : + req.query.isActive === 'false' ? false : undefined, + available: req.query.available === 'true' ? true : undefined, + search: req.query.search as string, + }; + + const items = await EquipmentService.getEquipmentItems(filters); + + res.status(200).json({ + success: true, + count: items.length, + data: items, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener detalle de un item + */ + static async getEquipmentById(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + + if (!id) { + throw new ApiError('Se requiere el ID del equipamiento', 400); + } + + const item = await EquipmentService.getEquipmentById(id); + + res.status(200).json({ + success: true, + data: item, + }); + } catch (error) { + next(error); + } + } + + /** + * Crear nuevo equipamiento (admin) + */ + static async createEquipment(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para crear equipamiento', 403); + } + + const item = await EquipmentService.createEquipmentItem( + req.user.userId, + req.body + ); + + res.status(201).json({ + success: true, + message: 'Equipamiento creado exitosamente', + data: item, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar equipamiento (admin) + */ + static async updateEquipment(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para actualizar equipamiento', 403); + } + + const { id } = req.params; + + if (!id) { + throw new ApiError('Se requiere el ID del equipamiento', 400); + } + + const item = await EquipmentService.updateEquipment( + id, + req.user.userId, + req.body + ); + + res.status(200).json({ + success: true, + message: 'Equipamiento actualizado exitosamente', + data: item, + }); + } catch (error) { + next(error); + } + } + + /** + * Eliminar equipamiento (admin) + */ + static async deleteEquipment(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para eliminar equipamiento', 403); + } + + const { id } = req.params; + + if (!id) { + throw new ApiError('Se requiere el ID del equipamiento', 400); + } + + await EquipmentService.deleteEquipment(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Equipamiento eliminado exitosamente', + }); + } catch (error) { + next(error); + } + } + + /** + * Verificar disponibilidad de un item + */ + static async checkAvailability(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const { startDate, endDate } = req.query; + + if (!id) { + throw new ApiError('Se requiere el ID del equipamiento', 400); + } + + if (!startDate || !endDate) { + throw new ApiError('Se requieren las fechas de inicio y fin', 400); + } + + const availability = await EquipmentService.checkAvailability( + id, + new Date(startDate as string), + new Date(endDate as string) + ); + + res.status(200).json({ + success: true, + data: availability, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener reporte de inventario (admin) + */ + static async getInventoryReport(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para ver el reporte de inventario', 403); + } + + const report = await EquipmentService.getInventoryReport(); + + res.status(200).json({ + success: true, + data: report, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener items disponibles para una fecha específica + */ + static async getAvailableForDate(req: Request, res: Response, next: NextFunction) { + try { + const { startDate, endDate, category } = req.query; + + if (!startDate || !endDate) { + throw new ApiError('Se requieren las fechas de inicio y fin', 400); + } + + const items = await EquipmentService.getAvailableItemsForDate( + category as any, + new Date(startDate as string), + new Date(endDate as string) + ); + + res.status(200).json({ + success: true, + count: items.length, + data: items, + }); + } catch (error) { + next(error); + } + } +} + +export default EquipmentController; diff --git a/backend/src/controllers/equipmentRental.controller.ts b/backend/src/controllers/equipmentRental.controller.ts new file mode 100644 index 0000000..fb9085d --- /dev/null +++ b/backend/src/controllers/equipmentRental.controller.ts @@ -0,0 +1,296 @@ +import { Request, Response, NextFunction } from 'express'; +import { EquipmentRentalService } from '../services/equipmentRental.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class EquipmentRentalController { + /** + * Crear un nuevo alquiler + */ + static async createRental(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { items, startDate, endDate, bookingId } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + throw new ApiError('Se requiere al menos un item para alquilar', 400); + } + + if (!startDate || !endDate) { + throw new ApiError('Se requieren las fechas de inicio y fin', 400); + } + + const result = await EquipmentRentalService.createRental(req.user.userId, { + items, + startDate: new Date(startDate), + endDate: new Date(endDate), + bookingId, + }); + + res.status(201).json({ + success: true, + message: 'Alquiler creado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mis alquileres + */ + static async getMyRentals(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const rentals = await EquipmentRentalService.getMyRentals(req.user.userId); + + res.status(200).json({ + success: true, + count: rentals.length, + data: rentals, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener detalle de un alquiler + */ + static async getRentalById(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + + if (!id) { + throw new ApiError('Se requiere el ID del alquiler', 400); + } + + const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN; + const rental = await EquipmentRentalService.getRentalById( + id, + isAdmin ? undefined : req.user.userId + ); + + res.status(200).json({ + success: true, + data: rental, + }); + } catch (error) { + next(error); + } + } + + /** + * Entregar material (pickup) - Admin + */ + static async pickUpRental(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para entregar material', 403); + } + + const { id } = req.params; + + if (!id) { + throw new ApiError('Se requiere el ID del alquiler', 400); + } + + const result = await EquipmentRentalService.pickUpRental( + id, + req.user.userId + ); + + res.status(200).json({ + success: true, + message: 'Material entregado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Devolver material - Admin + */ + static async returnRental(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para recibir material', 403); + } + + const { id } = req.params; + const { condition, depositReturned } = req.body; + + if (!id) { + throw new ApiError('Se requiere el ID del alquiler', 400); + } + + const result = await EquipmentRentalService.returnRental( + id, + req.user.userId, + condition, + depositReturned + ); + + res.status(200).json({ + success: true, + message: 'Material devuelto exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Cancelar alquiler + */ + static async cancelRental(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + + if (!id) { + throw new ApiError('Se requiere el ID del alquiler', 400); + } + + const result = await EquipmentRentalService.cancelRental( + id, + req.user.userId + ); + + res.status(200).json({ + success: true, + message: 'Alquiler cancelado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener alquileres vencidos (admin) + */ + static async getOverdueRentals(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para ver alquileres vencidos', 403); + } + + const rentals = await EquipmentRentalService.getOverdueRentals(); + + res.status(200).json({ + success: true, + count: rentals.length, + data: rentals, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener todos los alquileres (admin) + */ + static async getAllRentals(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para ver todos los alquileres', 403); + } + + const filters = { + status: req.query.status as any, + userId: req.query.userId as string, + }; + + const rentals = await EquipmentRentalService.getAllRentals(filters); + + res.status(200).json({ + success: true, + count: rentals.length, + data: rentals, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener estadísticas de alquileres (admin) + */ + static async getRentalStats(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para ver estadísticas', 403); + } + + const stats = await EquipmentRentalService.getRentalStats(); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } + + /** + * Webhook para notificaciones de pago de MercadoPago + */ + static async paymentWebhook(req: Request, res: Response, next: NextFunction) { + try { + const payload = req.body; + + // Responder inmediatamente a MercadoPago + res.status(200).send('OK'); + + // Procesar el webhook de forma asíncrona + await EquipmentRentalService.processPaymentWebhook(payload); + } catch (error) { + // No devolver error a MercadoPago, ya respondimos 200 + logger.error('Error procesando webhook de alquiler:', error); + } + } +} + +// Importar logger para el webhook +import logger from '../config/logger'; + +export default EquipmentRentalController; diff --git a/backend/src/controllers/extras/achievement.controller.ts b/backend/src/controllers/extras/achievement.controller.ts new file mode 100644 index 0000000..aff8847 --- /dev/null +++ b/backend/src/controllers/extras/achievement.controller.ts @@ -0,0 +1,103 @@ +import { Request, Response, NextFunction } from 'express'; +import { AchievementService } from '../../services/extras/achievement.service'; +import { ApiError } from '../../middleware/errorHandler'; + +export class AchievementController { + // Crear logro + static async createAchievement(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const achievement = await AchievementService.createAchievement(req.user.userId, req.body); + + res.status(201).json({ + success: true, + message: 'Logro creado exitosamente', + data: achievement, + }); + } catch (error) { + next(error); + } + } + + // Listar logros + static async getAchievements(req: Request, res: Response, next: NextFunction) { + try { + const achievements = await AchievementService.getAchievements(); + + res.status(200).json({ + success: true, + count: achievements.length, + data: achievements, + }); + } catch (error) { + next(error); + } + } + + // Mis logros + static async getUserAchievements(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const achievements = await AchievementService.getUserAchievements(req.user.userId); + + res.status(200).json({ + success: true, + data: achievements, + }); + } catch (error) { + next(error); + } + } + + // Progreso de logro + static async getAchievementProgress(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const progress = await AchievementService.getAchievementProgress(req.user.userId, req.params.id); + + res.status(200).json({ + success: true, + data: progress, + }); + } catch (error) { + next(error); + } + } + + // Leaderboard + static async getLeaderboard(req: Request, res: Response, next: NextFunction) { + try { + const leaderboard = await AchievementService.getLeaderboard( + req.query.limit ? parseInt(req.query.limit as string) : 10 + ); + + res.status(200).json({ + success: true, + data: leaderboard, + }); + } catch (error) { + next(error); + } + } + + // Verificar logros + static async checkAchievements(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const count = await AchievementService.checkAndUnlockAchievements(req.user.userId); + + res.status(200).json({ + success: true, + message: `${count} nuevos logros verificados`, + }); + } catch (error) { + next(error); + } + } +} + +export default AchievementController; diff --git a/backend/src/controllers/extras/qrCheckin.controller.ts b/backend/src/controllers/extras/qrCheckin.controller.ts new file mode 100644 index 0000000..f5cb12b --- /dev/null +++ b/backend/src/controllers/extras/qrCheckin.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response, NextFunction } from 'express'; +import { QRCheckinService } from '../../services/extras/qrCheckin.service'; +import { ApiError } from '../../middleware/errorHandler'; + +export class QRCheckinController { + // Generar QR + static async generateQR(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const result = await QRCheckinService.generateQRCode(req.params.bookingId); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } + + // Obtener mi QR + static async getMyQR(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const result = await QRCheckinService.getQRCodeForBooking(req.params.bookingId); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } + + // Validar QR (para escáner) + static async validateQR(req: Request, res: Response, next: NextFunction) { + try { + const result = await QRCheckinService.validateQRCode(req.body.code); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } + + // Procesar check-in + static async processCheckIn(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const checkIn = await QRCheckinService.processCheckIn( + req.body.code, + req.user.role === 'ADMIN' || req.user.role === 'SUPERADMIN' ? req.user.userId : undefined + ); + + res.status(200).json({ + success: true, + message: 'Check-in realizado exitosamente', + data: checkIn, + }); + } catch (error) { + next(error); + } + } + + // Check-out + static async processCheckOut(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const checkOut = await QRCheckinService.processCheckOut(req.params.checkInId); + + res.status(200).json({ + success: true, + message: 'Check-out realizado exitosamente', + data: checkOut, + }); + } catch (error) { + next(error); + } + } + + // Check-ins del día + static async getTodayCheckIns(req: Request, res: Response, next: NextFunction) { + try { + const checkIns = await QRCheckinService.getTodayCheckIns(); + + res.status(200).json({ + success: true, + count: checkIns.length, + data: checkIns, + }); + } catch (error) { + next(error); + } + } +} + +export default QRCheckinController; diff --git a/backend/src/controllers/extras/wallOfFame.controller.ts b/backend/src/controllers/extras/wallOfFame.controller.ts new file mode 100644 index 0000000..061996e --- /dev/null +++ b/backend/src/controllers/extras/wallOfFame.controller.ts @@ -0,0 +1,104 @@ +import { Request, Response, NextFunction } from 'express'; +import { WallOfFameService } from '../../services/extras/wallOfFame.service'; +import { ApiError } from '../../middleware/errorHandler'; + +export class WallOfFameController { + // Crear entrada + static async createEntry(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const entry = await WallOfFameService.createEntry(req.user.userId, req.body); + + res.status(201).json({ + success: true, + message: 'Entrada creada exitosamente', + data: entry, + }); + } catch (error) { + next(error); + } + } + + // Listar entradas + static async getEntries(req: Request, res: Response, next: NextFunction) { + try { + const entries = await WallOfFameService.getEntries({ + category: req.query.category as string, + featured: req.query.featured === 'true', + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + }); + + res.status(200).json({ + success: true, + count: entries.length, + data: entries, + }); + } catch (error) { + next(error); + } + } + + // Entradas destacadas + static async getFeaturedEntries(req: Request, res: Response, next: NextFunction) { + try { + const entries = await WallOfFameService.getFeaturedEntries(); + + res.status(200).json({ + success: true, + data: entries, + }); + } catch (error) { + next(error); + } + } + + // Ver detalle + static async getEntryById(req: Request, res: Response, next: NextFunction) { + try { + const entry = await WallOfFameService.getEntryById(req.params.id); + + res.status(200).json({ + success: true, + data: entry, + }); + } catch (error) { + next(error); + } + } + + // Actualizar + static async updateEntry(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + const entry = await WallOfFameService.updateEntry(req.params.id, req.user.userId, req.body); + + res.status(200).json({ + success: true, + message: 'Entrada actualizada', + data: entry, + }); + } catch (error) { + next(error); + } + } + + // Eliminar + static async deleteEntry(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw new ApiError('No autenticado', 401); + + await WallOfFameService.deleteEntry(req.params.id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Entrada eliminada', + }); + } catch (error) { + next(error); + } + } +} + +export default WallOfFameController; diff --git a/backend/src/controllers/healthIntegration.controller.ts b/backend/src/controllers/healthIntegration.controller.ts new file mode 100644 index 0000000..55f44cd --- /dev/null +++ b/backend/src/controllers/healthIntegration.controller.ts @@ -0,0 +1,207 @@ +import { Request, Response, NextFunction } from 'express'; +import { HealthIntegrationService } from '../services/healthIntegration.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class HealthIntegrationController { + /** + * Sincronizar datos de entrenamiento + */ + static async syncWorkoutData(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const activity = await HealthIntegrationService.syncWorkoutData(req.user.userId, req.body); + + res.status(201).json({ + success: true, + message: 'Datos de entrenamiento sincronizados exitosamente', + data: activity, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener resumen de actividad + */ + static async getWorkoutSummary(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { period = 'WEEK' } = req.query; + const summary = await HealthIntegrationService.getWorkoutSummary( + req.user.userId, + period as string + ); + + res.status(200).json({ + success: true, + data: summary, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener calorías quemadas en un rango de fechas + */ + static async getCaloriesBurned(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + throw new ApiError('Se requieren los parámetros startDate y endDate', 400); + } + + const calories = await HealthIntegrationService.getCaloriesBurned( + req.user.userId, + new Date(startDate as string), + new Date(endDate as string) + ); + + res.status(200).json({ + success: true, + data: calories, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener tiempo total de juego + */ + static async getTotalPlayTime(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { period = 'MONTH' } = req.query; + const playTime = await HealthIntegrationService.getTotalPlayTime( + req.user.userId, + period as string + ); + + res.status(200).json({ + success: true, + data: playTime, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener actividades del usuario + */ + static async getUserActivities(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { activityType, source, limit, offset } = req.query; + + const activities = await HealthIntegrationService.getUserActivities( + req.user.userId, + { + activityType: activityType as string | undefined, + source: source as string | undefined, + limit: limit ? parseInt(limit as string) : undefined, + offset: offset ? parseInt(offset as string) : undefined, + } + ); + + res.status(200).json({ + success: true, + count: activities.length, + data: activities, + }); + } catch (error) { + next(error); + } + } + + /** + * Sincronizar con Apple Health (placeholder) + */ + static async syncWithAppleHealth(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { authToken } = req.body; + + if (!authToken) { + throw new ApiError('Se requiere el token de autenticación de Apple Health', 400); + } + + const result = await HealthIntegrationService.syncWithAppleHealth( + req.user.userId, + authToken + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } + + /** + * Sincronizar con Google Fit (placeholder) + */ + static async syncWithGoogleFit(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { authToken } = req.body; + + if (!authToken) { + throw new ApiError('Se requiere el token de autenticación de Google Fit', 400); + } + + const result = await HealthIntegrationService.syncWithGoogleFit( + req.user.userId, + authToken + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } + + /** + * Eliminar una actividad + */ + static async deleteActivity(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await HealthIntegrationService.deleteActivity(id, req.user.userId); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } +} + +export default HealthIntegrationController; diff --git a/backend/src/controllers/menu.controller.ts b/backend/src/controllers/menu.controller.ts new file mode 100644 index 0000000..4b74977 --- /dev/null +++ b/backend/src/controllers/menu.controller.ts @@ -0,0 +1,146 @@ +import { Request, Response, NextFunction } from 'express'; +import { MenuService } from '../services/menu.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class MenuController { + /** + * Crear un nuevo item del menú (solo admin) + */ + static async createMenuItem(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const menuItem = await MenuService.createMenuItem(req.user.userId, req.body); + + res.status(201).json({ + success: true, + message: 'Item del menú creado exitosamente', + data: menuItem, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener todos los items del menú (público - solo activos y disponibles) + */ + static async getMenuItems(req: Request, res: Response, next: NextFunction) { + try { + const { category } = req.query; + const menuItems = await MenuService.getMenuItems(category as string | undefined); + + res.status(200).json({ + success: true, + count: menuItems.length, + data: menuItems, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener todos los items del menú (admin - incluye inactivos) + */ + static async getAllMenuItems(req: Request, res: Response, next: NextFunction) { + try { + const { category } = req.query; + const menuItems = await MenuService.getAllMenuItems(category as string | undefined); + + res.status(200).json({ + success: true, + count: menuItems.length, + data: menuItems, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener un item del menú por ID + */ + static async getMenuItemById(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const menuItem = await MenuService.getMenuItemById(id); + + res.status(200).json({ + success: true, + data: menuItem, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar un item del menú (solo admin) + */ + static async updateMenuItem(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const menuItem = await MenuService.updateMenuItem(id, req.user.userId, req.body); + + res.status(200).json({ + success: true, + message: 'Item del menú actualizado exitosamente', + data: menuItem, + }); + } catch (error) { + next(error); + } + } + + /** + * Eliminar un item del menú (solo admin - soft delete) + */ + static async deleteMenuItem(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + await MenuService.deleteMenuItem(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Item del menú eliminado exitosamente', + }); + } catch (error) { + next(error); + } + } + + /** + * Cambiar disponibilidad de un item (solo admin) + */ + static async toggleAvailability(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const menuItem = await MenuService.toggleAvailability(id, req.user.userId); + + res.status(200).json({ + success: true, + message: `Disponibilidad cambiada a: ${menuItem.isAvailable ? 'Disponible' : 'No disponible'}`, + data: menuItem, + }); + } catch (error) { + next(error); + } + } +} + +export default MenuController; diff --git a/backend/src/controllers/notification.controller.ts b/backend/src/controllers/notification.controller.ts new file mode 100644 index 0000000..34f307d --- /dev/null +++ b/backend/src/controllers/notification.controller.ts @@ -0,0 +1,153 @@ +import { Request, Response, NextFunction } from 'express'; +import { NotificationService } from '../services/notification.service'; +import { ApiError } from '../middleware/errorHandler'; + +export class NotificationController { + /** + * Obtener mis notificaciones + */ + static async getMyNotifications(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; + const notifications = await NotificationService.getMyNotifications(req.user.userId, limit); + + res.status(200).json({ + success: true, + count: notifications.length, + data: notifications, + }); + } catch (error) { + next(error); + } + } + + /** + * Marcar notificación como leída + */ + static async markAsRead(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const notification = await NotificationService.markAsRead(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Notificación marcada como leída', + data: notification, + }); + } catch (error) { + next(error); + } + } + + /** + * Marcar todas las notificaciones como leídas + */ + static async markAllAsRead(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const result = await NotificationService.markAllAsRead(req.user.userId); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } + + /** + * Eliminar una notificación + */ + static async deleteNotification(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await NotificationService.deleteNotification(id, req.user.userId); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } + + /** + * Obtener conteo de notificaciones no leídas + */ + static async getUnreadCount(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const result = await NotificationService.getUnreadCount(req.user.userId); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Enviar notificación masiva (solo admin) + */ + static async sendBulkNotification(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { userIds, type, title, message, data } = req.body; + + const result = await NotificationService.createBulkNotification( + req.user.userId, + userIds, + type, + title, + message, + data + ); + + res.status(201).json({ + success: true, + message: 'Notificaciones enviadas exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Limpiar notificaciones antiguas (solo admin) + */ + static async cleanupOldNotifications(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const result = await NotificationService.cleanupOldNotifications(req.user.userId); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } +} + +export default NotificationController; diff --git a/backend/src/controllers/order.controller.ts b/backend/src/controllers/order.controller.ts new file mode 100644 index 0000000..66d8663 --- /dev/null +++ b/backend/src/controllers/order.controller.ts @@ -0,0 +1,204 @@ +import { Request, Response, NextFunction } from 'express'; +import { OrderService } from '../services/order.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class OrderController { + /** + * Crear un nuevo pedido + */ + static async createOrder(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const order = await OrderService.createOrder(req.user.userId, req.body); + + res.status(201).json({ + success: true, + message: 'Pedido creado exitosamente', + data: order, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mis pedidos + */ + static async getMyOrders(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const orders = await OrderService.getMyOrders(req.user.userId); + + res.status(200).json({ + success: true, + count: orders.length, + data: orders, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener pedidos de una reserva + */ + static async getOrdersByBooking(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { bookingId } = req.params; + const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN; + + const orders = await OrderService.getOrdersByBooking( + bookingId, + isAdmin ? undefined : req.user.userId + ); + + res.status(200).json({ + success: true, + count: orders.length, + data: orders, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener pedidos pendientes (bar/admin) + */ + static async getPendingOrders(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const orders = await OrderService.getPendingOrders(); + + res.status(200).json({ + success: true, + count: orders.length, + data: orders, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar estado del pedido (admin) + */ + static async updateOrderStatus(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const { status } = req.body; + + const order = await OrderService.updateOrderStatus(id, status, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Estado del pedido actualizado exitosamente', + data: order, + }); + } catch (error) { + next(error); + } + } + + /** + * Marcar pedido como entregado (admin) + */ + static async markAsDelivered(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const order = await OrderService.markAsDelivered(id, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Pedido marcado como entregado', + data: order, + }); + } catch (error) { + next(error); + } + } + + /** + * Cancelar pedido + */ + static async cancelOrder(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN; + + const order = await OrderService.cancelOrder(id, req.user.userId, isAdmin); + + res.status(200).json({ + success: true, + message: 'Pedido cancelado exitosamente', + data: order, + }); + } catch (error) { + next(error); + } + } + + /** + * Procesar pago del pedido + */ + static async processPayment(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const paymentInfo = await OrderService.processPayment(id); + + res.status(200).json({ + success: true, + message: 'Preferencia de pago creada', + data: paymentInfo, + }); + } catch (error) { + next(error); + } + } + + /** + * Webhook de MercadoPago para pedidos + */ + static async webhook(req: Request, res: Response, next: NextFunction) { + try { + const paymentData = req.body; + const result = await OrderService.processWebhook(paymentData); + + res.status(200).json(result); + } catch (error) { + next(error); + } + } +} + +export default OrderController; diff --git a/backend/src/controllers/qrCheckin.controller.ts b/backend/src/controllers/qrCheckin.controller.ts new file mode 100644 index 0000000..3383ab8 --- /dev/null +++ b/backend/src/controllers/qrCheckin.controller.ts @@ -0,0 +1,276 @@ +import { Request, Response, NextFunction } from 'express'; +import { QRCheckInService } from '../services/qrCheckin.service'; +import { ApiError } from '../middleware/errorHandler'; +import { UserRole } from '../utils/constants'; + +export class QRCheckInController { + /** + * Generar código QR para una reserva + */ + static async generateQR(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { bookingId } = req.body; + const expiresInMinutes = req.body.expiresInMinutes || 15; + + if (!bookingId) { + throw new ApiError('Se requiere el ID de la reserva', 400); + } + + const result = await QRCheckInService.generateQRCode({ + bookingId, + userId: req.user.userId, + expiresInMinutes, + }); + + res.status(201).json({ + success: true, + message: 'Código QR generado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener mi código QR para una reserva + */ + static async getMyQR(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { bookingId } = req.params; + + if (!bookingId) { + throw new ApiError('Se requiere el ID de la reserva', 400); + } + + const result = await QRCheckInService.getQRCodeForBooking( + bookingId, + req.user.userId + ); + + if (!result) { + return res.status(404).json({ + success: false, + message: 'No hay código QR activo para esta reserva', + }); + } + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Validar código QR (para escáner de recepción) + */ + static async validateQR(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + // Solo admins pueden validar QR + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para validar códigos QR', 403); + } + + const { code } = req.body; + + if (!code) { + throw new ApiError('Se requiere el código QR', 400); + } + + const result = await QRCheckInService.validateQRCode(code); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Procesar check-in (con QR o manual) + */ + static async processCheckIn(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + // Solo admins pueden procesar check-in + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para procesar check-ins', 403); + } + + const { bookingId } = req.params; + const { code, notes } = req.body; + + if (!bookingId) { + throw new ApiError('Se requiere el ID de la reserva', 400); + } + + let result; + + if (code) { + // Check-in con QR + result = await QRCheckInService.processCheckIn({ + code, + adminId: req.user.userId, + notes, + }); + } else { + // Check-in manual + result = await QRCheckInService.processManualCheckIn( + bookingId, + req.user.userId, + notes + ); + } + + res.status(200).json({ + success: true, + message: 'Check-in procesado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Procesar check-out + */ + static async processCheckOut(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + // Solo admins pueden procesar check-out + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para procesar check-outs', 403); + } + + const { checkInId } = req.params; + const { notes } = req.body; + + if (!checkInId) { + throw new ApiError('Se requiere el ID del check-in', 400); + } + + const result = await QRCheckInService.processCheckOut({ + checkInId, + adminId: req.user.userId, + notes, + }); + + res.status(200).json({ + success: true, + message: 'Check-out procesado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener check-ins del día (admin) + */ + static async getTodayCheckIns(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + // Solo admins pueden ver todos los check-ins + if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) { + throw new ApiError('No tienes permiso para ver los check-ins', 403); + } + + const checkIns = await QRCheckInService.getTodayCheckIns(); + const stats = await QRCheckInService.getTodayStats(); + + res.status(200).json({ + success: true, + data: { + checkIns, + stats, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener historial de check-ins de una reserva + */ + static async getCheckInsByBooking(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { bookingId } = req.params; + + if (!bookingId) { + throw new ApiError('Se requiere el ID de la reserva', 400); + } + + const checkIns = await QRCheckInService.getCheckInsByBooking(bookingId); + + res.status(200).json({ + success: true, + count: checkIns.length, + data: checkIns, + }); + } catch (error) { + next(error); + } + } + + /** + * Cancelar código QR + */ + static async cancelQR(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { code } = req.body; + + if (!code) { + throw new ApiError('Se requiere el código QR', 400); + } + + const result = await QRCheckInService.cancelQRCode(code, req.user.userId); + + res.status(200).json({ + success: true, + message: 'Código QR cancelado exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } +} + +export default QRCheckInController; diff --git a/backend/src/controllers/wallOfFame.controller.ts b/backend/src/controllers/wallOfFame.controller.ts new file mode 100644 index 0000000..6c472a4 --- /dev/null +++ b/backend/src/controllers/wallOfFame.controller.ts @@ -0,0 +1,198 @@ +import { Request, Response, NextFunction } from 'express'; +import { WallOfFameService } from '../services/wallOfFame.service'; +import { ApiError } from '../middleware/errorHandler'; +import { WallOfFameCategory } from '../utils/constants'; + +export class WallOfFameController { + /** + * Crear una nueva entrada en el Wall of Fame (solo admin) + */ + static async createEntry(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const entry = await WallOfFameService.createEntry(req.user.userId, { + ...req.body, + eventDate: new Date(req.body.eventDate), + }); + + res.status(201).json({ + success: true, + message: 'Entrada creada exitosamente', + data: entry, + }); + } catch (error) { + next(error); + } + } + + /** + * Listar entradas del Wall of Fame (público) + */ + static async getEntries(req: Request, res: Response, next: NextFunction) { + try { + const filters = { + category: req.query.category as WallOfFameCategory | undefined, + featured: req.query.featured === 'true' ? true : + req.query.featured === 'false' ? false : undefined, + isActive: req.query.isActive === 'false' ? false : true, + tournamentId: req.query.tournamentId as string | undefined, + leagueId: req.query.leagueId as string | undefined, + fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined, + toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined, + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string) : undefined, + }; + + const result = await WallOfFameService.getEntries(filters); + + res.status(200).json({ + success: true, + data: result.entries, + meta: { + total: result.total, + limit: result.limit, + offset: result.offset, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener entradas destacadas para el home (público) + */ + static async getFeaturedEntries(req: Request, res: Response, next: NextFunction) { + try { + const limit = req.query.limit ? parseInt(req.query.limit as string) : 5; + const entries = await WallOfFameService.getFeaturedEntries(limit); + + res.status(200).json({ + success: true, + count: entries.length, + data: entries, + }); + } catch (error) { + next(error); + } + } + + /** + * Obtener una entrada por ID (público) + */ + static async getEntryById(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const entry = await WallOfFameService.getEntryById(id); + + res.status(200).json({ + success: true, + data: entry, + }); + } catch (error) { + next(error); + } + } + + /** + * Actualizar una entrada (solo admin) + */ + static async updateEntry(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const updateData: any = { ...req.body }; + + if (req.body.eventDate) { + updateData.eventDate = new Date(req.body.eventDate); + } + + const entry = await WallOfFameService.updateEntry(id, req.user.userId, updateData); + + res.status(200).json({ + success: true, + message: 'Entrada actualizada exitosamente', + data: entry, + }); + } catch (error) { + next(error); + } + } + + /** + * Eliminar una entrada (solo admin) + */ + static async deleteEntry(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const result = await WallOfFameService.deleteEntry(id, req.user.userId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } + } + + /** + * Agregar ganadores a una entrada existente (solo admin) + */ + static async addWinners(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + throw new ApiError('No autenticado', 401); + } + + const { id } = req.params; + const { winners } = req.body; + + const entry = await WallOfFameService.addWinners(id, winners); + + res.status(200).json({ + success: true, + message: 'Ganadores agregados exitosamente', + data: entry, + }); + } catch (error) { + next(error); + } + } + + /** + * Buscar entradas por término (público) + */ + static async searchEntries(req: Request, res: Response, next: NextFunction) { + try { + const { q } = req.query; + + if (!q || typeof q !== 'string') { + throw new ApiError('Término de búsqueda requerido', 400); + } + + const limit = req.query.limit ? parseInt(req.query.limit as string) : 20; + const entries = await WallOfFameService.searchEntries(q, limit); + + res.status(200).json({ + success: true, + count: entries.length, + data: entries, + }); + } catch (error) { + next(error); + } + } +} + +export default WallOfFameController; diff --git a/backend/src/routes/achievement.routes.ts b/backend/src/routes/achievement.routes.ts new file mode 100644 index 0000000..eecbf37 --- /dev/null +++ b/backend/src/routes/achievement.routes.ts @@ -0,0 +1,186 @@ +import { Router } from 'express'; +import { AchievementController } from '../controllers/achievement.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate, validateQuery } from '../middleware/validate'; +import { UserRole, AchievementCategory } from '../utils/constants'; +import { z } from 'zod'; +import { RequirementType } from '../utils/constants'; + +const router = Router(); + +// Schema para crear logro +const createAchievementSchema = z.object({ + code: z.string().min(1, 'El código es requerido').max(50), + name: z.string().min(1, 'El nombre es requerido').max(100), + description: z.string().min(1, 'La descripción es requerida').max(500), + category: z.enum([ + AchievementCategory.GAMES, + AchievementCategory.TOURNAMENTS, + AchievementCategory.SOCIAL, + AchievementCategory.STREAK, + AchievementCategory.SPECIAL, + ]), + icon: z.string().min(1, 'El icono es requerido').max(10), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser formato hex (#RRGGBB)'), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ]), + requirementValue: z.number().int().min(1, 'El valor debe ser al menos 1'), + pointsReward: z.number().int().min(0, 'Los puntos no pueden ser negativos'), +}); + +// Schema para actualizar logro +const updateAchievementSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().min(1).max(500).optional(), + category: z.enum([ + AchievementCategory.GAMES, + AchievementCategory.TOURNAMENTS, + AchievementCategory.SOCIAL, + AchievementCategory.STREAK, + AchievementCategory.SPECIAL, + ]).optional(), + icon: z.string().min(1).max(10).optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ]).optional(), + requirementValue: z.number().int().min(1).optional(), + pointsReward: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), +}); + +// Schema para query params de listado +const listAchievementsQuerySchema = z.object({ + category: z.enum([ + AchievementCategory.GAMES, + AchievementCategory.TOURNAMENTS, + AchievementCategory.SOCIAL, + AchievementCategory.STREAK, + AchievementCategory.SPECIAL, + ]).optional(), + activeOnly: z.enum(['true', 'false']).optional(), +}); + +// Schema para params de ID +const achievementIdParamsSchema = z.object({ + id: z.string().uuid('ID inválido'), +}); + +// Schema para query params del leaderboard +const leaderboardQuerySchema = z.object({ + limit: z.string().regex(/^\d+$/).optional(), +}); + +// ============================================ +// Rutas Públicas (lectura) +// ============================================ + +// Listar logros disponibles +router.get( + '/', + validateQuery(listAchievementsQuerySchema), + AchievementController.getAchievements +); + +// Obtener ranking por puntos +router.get( + '/leaderboard', + validateQuery(leaderboardQuerySchema), + AchievementController.getLeaderboard +); + +// ============================================ +// Rutas Protegidas (requieren autenticación) +// ============================================ + +// Mis logros desbloqueados +router.get( + '/my', + authenticate, + AchievementController.getMyAchievements +); + +// Progreso de mis logros +router.get( + '/my/progress', + authenticate, + AchievementController.getMyAchievementsProgress +); + +// Progreso de un logro específico +router.get( + '/progress/:id', + authenticate, + validate(achievementIdParamsSchema), + AchievementController.getAchievementProgress +); + +// Verificar y desbloquear logros +router.post( + '/check', + authenticate, + AchievementController.checkAndUnlockAchievements +); + +// ============================================ +// Rutas de Admin +// ============================================ + +// Crear logro +router.post( + '/', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(createAchievementSchema), + AchievementController.createAchievement +); + +// Inicializar logros por defecto +router.post( + '/initialize', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + AchievementController.initializeDefaultAchievements +); + +// Actualizar logro +router.put( + '/:id', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(achievementIdParamsSchema), + validate(updateAchievementSchema), + AchievementController.updateAchievement +); + +// Eliminar logro +router.delete( + '/:id', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(achievementIdParamsSchema), + AchievementController.deleteAchievement +); + +export default router; diff --git a/backend/src/routes/challenge.routes.ts b/backend/src/routes/challenge.routes.ts new file mode 100644 index 0000000..08d2758 --- /dev/null +++ b/backend/src/routes/challenge.routes.ts @@ -0,0 +1,178 @@ +import { Router } from 'express'; +import { ChallengeController } from '../controllers/challenge.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate, validateQuery } from '../middleware/validate'; +import { UserRole, ChallengeType, RequirementType } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schema para crear reto +const createChallengeSchema = z.object({ + title: z.string().min(1, 'El título es requerido').max(200), + description: z.string().min(1, 'La descripción es requerida').max(1000), + type: z.enum([ + ChallengeType.WEEKLY, + ChallengeType.MONTHLY, + ChallengeType.SPECIAL, + ]), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ]), + requirementValue: z.number().int().min(1, 'El valor debe ser al menos 1'), + startDate: z.string().datetime('Fecha de inicio inválida'), + endDate: z.string().datetime('Fecha de fin inválida'), + rewardPoints: z.number().int().min(0, 'Los puntos no pueden ser negativos'), +}); + +// Schema para actualizar reto +const updateChallengeSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().min(1).max(1000).optional(), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ]).optional(), + requirementValue: z.number().int().min(1).optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + rewardPoints: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), +}); + +// Schema para query params de listado +const listChallengesQuerySchema = z.object({ + type: z.enum([ + ChallengeType.WEEKLY, + ChallengeType.MONTHLY, + ChallengeType.SPECIAL, + ]).optional(), + ongoing: z.enum(['true']).optional(), + limit: z.string().regex(/^\d+$/).optional(), + offset: z.string().regex(/^\d+$/).optional(), +}); + +// Schema para params de ID +const challengeIdParamsSchema = z.object({ + id: z.string().uuid('ID inválido'), +}); + +// Schema para query params del leaderboard +const leaderboardQuerySchema = z.object({ + limit: z.string().regex(/^\d+$/).optional(), +}); + +// ============================================ +// Rutas Públicas (lectura) +// ============================================ + +// Listar retos activos +router.get( + '/', + validateQuery(listChallengesQuerySchema), + ChallengeController.getActiveChallenges +); + +// Obtener tabla de líderes de un reto +router.get( + '/:id/leaderboard', + validate(challengeIdParamsSchema), + validateQuery(leaderboardQuerySchema), + ChallengeController.getChallengeLeaderboard +); + +// ============================================ +// Rutas Protegidas (requieren autenticación) +// ============================================ + +// Obtener un reto por ID +router.get( + '/:id', + authenticate, + validate(challengeIdParamsSchema), + ChallengeController.getChallengeById +); + +// Mis retos +router.get( + '/my/list', + authenticate, + ChallengeController.getMyChallenges +); + +// Unirse a un reto +router.post( + '/:id/join', + authenticate, + validate(challengeIdParamsSchema), + ChallengeController.joinChallenge +); + +// Completar reto y reclamar recompensa +router.post( + '/:id/claim', + authenticate, + validate(challengeIdParamsSchema), + ChallengeController.completeChallenge +); + +// ============================================ +// Rutas de Admin +// ============================================ + +// Crear reto +router.post( + '/', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(createChallengeSchema), + ChallengeController.createChallenge +); + +// Verificar retos expirados +router.post( + '/check-expired', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + ChallengeController.checkExpiredChallenges +); + +// Actualizar reto +router.put( + '/:id', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(challengeIdParamsSchema), + validate(updateChallengeSchema), + ChallengeController.updateChallenge +); + +// Eliminar reto +router.delete( + '/:id', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(challengeIdParamsSchema), + ChallengeController.deleteChallenge +); + +export default router; diff --git a/backend/src/routes/checkin.routes.ts b/backend/src/routes/checkin.routes.ts new file mode 100644 index 0000000..0e0e7d5 --- /dev/null +++ b/backend/src/routes/checkin.routes.ts @@ -0,0 +1,106 @@ +import { Router } from 'express'; +import { QRCheckInController } from '../controllers/qrCheckin.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schemas de validación +const generateQRSchema = z.object({ + bookingId: z.string().uuid('ID de reserva inválido'), + expiresInMinutes: z.number().min(5).max(120).optional(), +}); + +const validateQRSchema = z.object({ + code: z.string().min(1, 'El código es requerido'), +}); + +const checkInSchema = z.object({ + code: z.string().optional(), + notes: z.string().optional(), +}); + +const checkOutSchema = z.object({ + notes: z.string().optional(), +}); + +const cancelQRSchema = z.object({ + code: z.string().min(1, 'El código es requerido'), +}); + +// ============================================ +// Rutas para usuarios (generar/ver sus QR) +// ============================================ + +// Generar código QR para una reserva +router.post( + '/qr/generate', + authenticate, + validate(generateQRSchema), + QRCheckInController.generateQR +); + +// Obtener mi código QR activo para una reserva +router.get( + '/qr/my-booking/:bookingId', + authenticate, + QRCheckInController.getMyQR +); + +// Cancelar mi código QR +router.post( + '/qr/cancel', + authenticate, + validate(cancelQRSchema), + QRCheckInController.cancelQR +); + +// ============================================ +// Rutas para administradores (escáner/recepción) +// ============================================ + +// Validar código QR (para escáner) +router.post( + '/validate', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(validateQRSchema), + QRCheckInController.validateQR +); + +// Procesar check-in (con QR o manual) +router.post( + '/:bookingId/checkin', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(checkInSchema), + QRCheckInController.processCheckIn +); + +// Procesar check-out +router.post( + '/:checkInId/checkout', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(checkOutSchema), + QRCheckInController.processCheckOut +); + +// Obtener check-ins del día (dashboard de recepción) +router.get( + '/today', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + QRCheckInController.getTodayCheckIns +); + +// Obtener historial de check-ins de una reserva +router.get( + '/booking/:bookingId', + authenticate, + QRCheckInController.getCheckInsByBooking +); + +export default router; diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts new file mode 100644 index 0000000..33bc19e --- /dev/null +++ b/backend/src/routes/equipment.routes.ts @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import { EquipmentController } from '../controllers/equipment.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schemas de validación +const createEquipmentSchema = z.object({ + name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'), + description: z.string().optional(), + category: z.enum(['RACKET', 'BALLS', 'ACCESSORIES', 'SHOES']), + brand: z.string().optional(), + model: z.string().optional(), + size: z.string().optional(), + condition: z.enum(['NEW', 'GOOD', 'FAIR', 'POOR']).optional(), + hourlyRate: z.number().min(0).optional(), + dailyRate: z.number().min(0).optional(), + depositRequired: z.number().min(0).optional(), + quantityTotal: z.number().min(1).optional(), + imageUrl: z.string().url().optional().or(z.literal('')), +}); + +const updateEquipmentSchema = z.object({ + name: z.string().min(2).optional(), + description: z.string().optional(), + category: z.enum(['RACKET', 'BALLS', 'ACCESSORIES', 'SHOES']).optional(), + brand: z.string().optional(), + model: z.string().optional(), + size: z.string().optional(), + condition: z.enum(['NEW', 'GOOD', 'FAIR', 'POOR']).optional(), + hourlyRate: z.number().min(0).optional(), + dailyRate: z.number().min(0).optional(), + depositRequired: z.number().min(0).optional(), + quantityTotal: z.number().min(1).optional(), + imageUrl: z.string().url().optional().or(z.literal('')), + isActive: z.boolean().optional(), +}); + +// ============================================ +// Rutas públicas (lectura) +// ============================================ + +// Listar equipamiento +router.get('/', EquipmentController.getEquipmentItems); + +// Obtener items disponibles para una fecha específica +router.get('/available', EquipmentController.getAvailableForDate); + +// Verificar disponibilidad de un item +router.get('/:id/availability', EquipmentController.checkAvailability); + +// Obtener detalle de un item +router.get('/:id', EquipmentController.getEquipmentById); + +// ============================================ +// Rutas de administración (requieren admin) +// ============================================ + +// Crear equipamiento +router.post( + '/', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(createEquipmentSchema), + EquipmentController.createEquipment +); + +// Actualizar equipamiento +router.put( + '/:id', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(updateEquipmentSchema), + EquipmentController.updateEquipment +); + +// Eliminar equipamiento +router.delete( + '/:id', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + EquipmentController.deleteEquipment +); + +// Reporte de inventario +router.get( + '/admin/inventory-report', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + EquipmentController.getInventoryReport +); + +export default router; diff --git a/backend/src/routes/equipmentRental.routes.ts b/backend/src/routes/equipmentRental.routes.ts new file mode 100644 index 0000000..1a9d375 --- /dev/null +++ b/backend/src/routes/equipmentRental.routes.ts @@ -0,0 +1,116 @@ +import { Router } from 'express'; +import { EquipmentRentalController } from '../controllers/equipmentRental.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schemas de validación +const createRentalSchema = z.object({ + items: z.array( + z.object({ + itemId: z.string().uuid('ID de item inválido'), + quantity: z.number().min(1, 'La cantidad debe ser al menos 1'), + }) + ).min(1, 'Se requiere al menos un item'), + startDate: z.string().datetime(), + endDate: z.string().datetime(), + bookingId: z.string().uuid().optional(), +}); + +const returnRentalSchema = z.object({ + condition: z.enum(['GOOD', 'FAIR', 'DAMAGED']).optional(), + depositReturned: z.number().min(0).optional(), + notes: z.string().optional(), +}); + +// ============================================ +// Rutas para usuarios +// ============================================ + +// Crear alquiler +router.post( + '/', + authenticate, + validate(createRentalSchema), + EquipmentRentalController.createRental +); + +// Obtener mis alquileres +router.get( + '/my', + authenticate, + EquipmentRentalController.getMyRentals +); + +// Obtener detalle de un alquiler +router.get( + '/:id', + authenticate, + EquipmentRentalController.getRentalById +); + +// Cancelar alquiler +router.post( + '/:id/cancel', + authenticate, + EquipmentRentalController.cancelRental +); + +// ============================================ +// Rutas para administradores +// ============================================ + +// Entregar material (pickup) +router.post( + '/:id/pickup', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + EquipmentRentalController.pickUpRental +); + +// Devolver material (return) +router.post( + '/:id/return', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(returnRentalSchema), + EquipmentRentalController.returnRental +); + +// Obtener alquileres vencidos +router.get( + '/admin/overdue', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + EquipmentRentalController.getOverdueRentals +); + +// Obtener todos los alquileres +router.get( + '/admin/all', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + EquipmentRentalController.getAllRentals +); + +// Obtener estadísticas +router.get( + '/admin/stats', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + EquipmentRentalController.getRentalStats +); + +// ============================================ +// Webhook de MercadoPago (público) +// ============================================ + +router.post( + '/webhook', + EquipmentRentalController.paymentWebhook +); + +export default router; diff --git a/backend/src/routes/extras.routes.ts b/backend/src/routes/extras.routes.ts new file mode 100644 index 0000000..5c2546c --- /dev/null +++ b/backend/src/routes/extras.routes.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { authenticate, authorize } from '../middleware/auth'; +import { WallOfFameController } from '../controllers/extras/wallOfFame.controller'; +import { AchievementController } from '../controllers/extras/achievement.controller'; +import { QRCheckinController } from '../controllers/extras/qrCheckin.controller'; +import { UserRole } from '../utils/constants'; + +const router = Router(); + +// ============================================ +// WALL OF FAME (público para lectura) +// ============================================ +router.get('/wall-of-fame', WallOfFameController.getEntries); +router.get('/wall-of-fame/featured', WallOfFameController.getFeaturedEntries); +router.get('/wall-of-fame/:id', WallOfFameController.getEntryById); + +// Admin +router.post('/wall-of-fame', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), WallOfFameController.createEntry); +router.put('/wall-of-fame/:id', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), WallOfFameController.updateEntry); +router.delete('/wall-of-fame/:id', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), WallOfFameController.deleteEntry); + +// ============================================ +// ACHIEVEMENTS / LOGROS +// ============================================ +router.get('/achievements', AchievementController.getAchievements); +router.get('/achievements/my', authenticate, AchievementController.getUserAchievements); +router.get('/achievements/progress/:id', authenticate, AchievementController.getAchievementProgress); +router.get('/achievements/leaderboard', AchievementController.getLeaderboard); +router.post('/achievements/check', authenticate, AchievementController.checkAchievements); + +// Admin +router.post('/achievements', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), AchievementController.createAchievement); + +// ============================================ +// QR CHECK-IN +// ============================================ +router.post('/checkin/qr/generate/:bookingId', authenticate, QRCheckinController.generateQR); +router.get('/checkin/qr/my-booking/:bookingId', authenticate, QRCheckinController.getMyQR); +router.post('/checkin/validate', authenticate, QRCheckinController.validateQR); +router.post('/checkin/:bookingId/checkin', authenticate, QRCheckinController.processCheckIn); +router.post('/checkin/:checkInId/checkout', authenticate, QRCheckinController.processCheckOut); +router.get('/checkin/today', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), QRCheckinController.getTodayCheckIns); + +export default router; diff --git a/backend/src/routes/health.routes.ts b/backend/src/routes/health.routes.ts new file mode 100644 index 0000000..c59d256 --- /dev/null +++ b/backend/src/routes/health.routes.ts @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import { HealthIntegrationController } from '../controllers/healthIntegration.controller'; +import { authenticate } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { z } from 'zod'; + +const router = Router(); + +// Schema para sincronizar datos de salud +const syncHealthDataSchema = z.object({ + source: z.enum(['APPLE_HEALTH', 'GOOGLE_FIT', 'MANUAL']), + activityType: z.enum(['PADEL_GAME', 'WORKOUT']), + workoutData: z.object({ + calories: z.number().min(0).max(5000), + duration: z.number().int().min(1).max(300), + heartRate: z.object({ + avg: z.number().int().min(30).max(220).optional(), + max: z.number().int().min(30).max(220).optional(), + }).optional(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), + steps: z.number().int().min(0).max(50000).optional(), + distance: z.number().min(0).max(50).optional(), + metadata: z.record(z.any()).optional(), + }), + bookingId: z.string().uuid().optional(), +}); + +// Schema para autenticación con servicios de salud +const healthAuthSchema = z.object({ + authToken: z.string().min(1, 'El token de autenticación es requerido'), +}); + +// Rutas para sincronización de datos +router.post( + '/sync', + authenticate, + validate(syncHealthDataSchema), + HealthIntegrationController.syncWorkoutData +); + +router.get('/summary', authenticate, HealthIntegrationController.getWorkoutSummary); +router.get('/calories', authenticate, HealthIntegrationController.getCaloriesBurned); +router.get('/playtime', authenticate, HealthIntegrationController.getTotalPlayTime); +router.get('/activities', authenticate, HealthIntegrationController.getUserActivities); + +// Rutas para integración con Apple Health y Google Fit (placeholders) +router.post( + '/apple-health/sync', + authenticate, + validate(healthAuthSchema), + HealthIntegrationController.syncWithAppleHealth +); + +router.post( + '/google-fit/sync', + authenticate, + validate(healthAuthSchema), + HealthIntegrationController.syncWithGoogleFit +); + +// Ruta para eliminar actividad +router.delete('/activities/:id', authenticate, HealthIntegrationController.deleteActivity); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 2a69700..0194abb 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -22,6 +22,11 @@ import paymentRoutes from './payment.routes'; // Rutas de Sistema de Bonos (Fase 4.2) - Desactivado temporalmente // import bonusRoutes from './bonus.routes'; +// Rutas de Wall of Fame y Logros (Fase 6.1) +import wallOfFameRoutes from './wallOfFame.routes'; +import achievementRoutes from './achievement.routes'; +import challengeRoutes from './challenge.routes'; + const router = Router(); // Health check @@ -105,6 +110,38 @@ router.use('/', subscriptionRoutes); import analyticsRoutes from './analytics.routes'; router.use('/analytics', analyticsRoutes); +// ============================================ +// Rutas de Extras - Fase 6 +// ============================================ + +import extrasRoutes from './extras.routes'; +router.use('/', extrasRoutes); + +// Rutas individuales de Fase 6 (si existen archivos separados) +// Wall of Fame - ganadores de torneos y ligas +try { + const wallOfFameRoutes = require('./wallOfFame.routes').default; + router.use('/wall-of-fame', wallOfFameRoutes); +} catch (e) { + // Ya incluido en extrasRoutes +} + +// Logros/Achievements +try { + const achievementRoutes = require('./achievement.routes').default; + router.use('/achievements', achievementRoutes); +} catch (e) { + // Ya incluido en extrasRoutes +} + +// Retos/Challenges +try { + const challengeRoutes = require('./challenge.routes').default; + router.use('/challenges', challengeRoutes); +} catch (e) { + // Ya incluido en extrasRoutes +} + // ============================================ // Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente // ============================================ diff --git a/backend/src/routes/menu.routes.ts b/backend/src/routes/menu.routes.ts new file mode 100644 index 0000000..5d0c021 --- /dev/null +++ b/backend/src/routes/menu.routes.ts @@ -0,0 +1,76 @@ +import { Router } from 'express'; +import { MenuController } from '../controllers/menu.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schema de validación para crear item del menú +const createMenuItemSchema = z.object({ + name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'), + description: z.string().optional(), + category: z.enum(['DRINK', 'SNACK', 'FOOD', 'OTHER']), + price: z.number().int().min(0, 'El precio no puede ser negativo'), + imageUrl: z.string().url().optional(), + preparationTime: z.number().int().min(0).optional(), + isAvailable: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +// Schema para actualizar item del menú +const updateMenuItemSchema = z.object({ + name: z.string().min(2).optional(), + description: z.string().optional(), + category: z.enum(['DRINK', 'SNACK', 'FOOD', 'OTHER']).optional(), + price: z.number().int().min(0).optional(), + imageUrl: z.string().url().optional(), + preparationTime: z.number().int().min(0).optional(), + isAvailable: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +// Rutas públicas (solo items activos y disponibles) +router.get('/', MenuController.getMenuItems); +router.get('/:id', MenuController.getMenuItemById); + +// Rutas de administración (solo admin) +router.get( + '/admin/all', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + MenuController.getAllMenuItems +); + +router.post( + '/', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(createMenuItemSchema), + MenuController.createMenuItem +); + +router.put( + '/:id', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(updateMenuItemSchema), + MenuController.updateMenuItem +); + +router.delete( + '/:id', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + MenuController.deleteMenuItem +); + +router.put( + '/:id/toggle-availability', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + MenuController.toggleAvailability +); + +export default router; diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts new file mode 100644 index 0000000..c8252ca --- /dev/null +++ b/backend/src/routes/notification.routes.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { NotificationController } from '../controllers/notification.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schema para notificación masiva +const bulkNotificationSchema = z.object({ + userIds: z.array(z.string().uuid()).min(1, 'Debe especificar al menos un usuario'), + type: z.enum([ + 'ORDER_READY', + 'BOOKING_REMINDER', + 'TOURNAMENT_START', + 'TOURNAMENT_MATCH_READY', + 'LEAGUE_MATCH_SCHEDULED', + 'FRIEND_REQUEST', + 'GROUP_INVITATION', + 'SUBSCRIPTION_EXPIRING', + 'PAYMENT_CONFIRMED', + 'CLASS_REMINDER', + 'GENERAL', + ]), + title: z.string().min(1, 'El título es requerido'), + message: z.string().min(1, 'El mensaje es requerido'), + data: z.record(z.any()).optional(), +}); + +// Rutas para usuarios autenticados +router.get('/', authenticate, NotificationController.getMyNotifications); +router.get('/unread-count', authenticate, NotificationController.getUnreadCount); +router.put('/:id/read', authenticate, NotificationController.markAsRead); +router.put('/read-all', authenticate, NotificationController.markAllAsRead); +router.delete('/:id', authenticate, NotificationController.deleteNotification); + +// Rutas para admin +router.post( + '/bulk', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(bulkNotificationSchema), + NotificationController.sendBulkNotification +); + +router.post( + '/cleanup', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + NotificationController.cleanupOldNotifications +); + +export default router; diff --git a/backend/src/routes/order.routes.ts b/backend/src/routes/order.routes.ts new file mode 100644 index 0000000..d3d33ed --- /dev/null +++ b/backend/src/routes/order.routes.ts @@ -0,0 +1,68 @@ +import { Router } from 'express'; +import { OrderController } from '../controllers/order.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; + +const router = Router(); + +// Schema para items del pedido +const orderItemSchema = z.object({ + itemId: z.string().uuid('ID de item inválido'), + quantity: z.number().int().min(1, 'La cantidad debe ser al menos 1'), + notes: z.string().optional(), +}); + +// Schema para crear pedido +const createOrderSchema = z.object({ + bookingId: z.string().uuid('ID de reserva inválido'), + items: z.array(orderItemSchema).min(1, 'El pedido debe tener al menos un item'), + notes: z.string().optional(), +}); + +// Schema para actualizar estado del pedido +const updateOrderStatusSchema = z.object({ + status: z.enum(['PENDING', 'PREPARING', 'READY', 'DELIVERED', 'CANCELLED']), +}); + +// Rutas para usuarios autenticados +router.post( + '/', + authenticate, + validate(createOrderSchema), + OrderController.createOrder +); + +router.get('/my', authenticate, OrderController.getMyOrders); +router.get('/booking/:bookingId', authenticate, OrderController.getOrdersByBooking); +router.post('/:id/pay', authenticate, OrderController.processPayment); +router.post('/:id/cancel', authenticate, OrderController.cancelOrder); + +// Rutas para bar/admin +router.get( + '/pending', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + OrderController.getPendingOrders +); + +router.put( + '/:id/status', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + validate(updateOrderStatusSchema), + OrderController.updateOrderStatus +); + +router.put( + '/:id/deliver', + authenticate, + authorize(UserRole.ADMIN, UserRole.SUPERADMIN), + OrderController.markAsDelivered +); + +// Webhook de MercadoPago (público) +router.post('/webhook', OrderController.webhook); + +export default router; diff --git a/backend/src/routes/wallOfFame.routes.ts b/backend/src/routes/wallOfFame.routes.ts new file mode 100644 index 0000000..a40e8bc --- /dev/null +++ b/backend/src/routes/wallOfFame.routes.ts @@ -0,0 +1,171 @@ +import { Router } from 'express'; +import { WallOfFameController } from '../controllers/wallOfFame.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { validate, validateQuery } from '../middleware/validate'; +import { UserRole } from '../utils/constants'; +import { z } from 'zod'; +import { WallOfFameCategory } from '../utils/constants'; + +const router = Router(); + +// Schema para crear entrada +const createEntrySchema = z.object({ + title: z.string().min(1, 'El título es requerido').max(200), + description: z.string().max(1000).optional(), + tournamentId: z.string().uuid('ID de torneo inválido').optional(), + leagueId: z.string().uuid('ID de liga inválido').optional(), + winners: z.array(z.object({ + userId: z.string().uuid('ID de usuario inválido'), + name: z.string(), + position: z.number().int().min(1), + avatarUrl: z.string().url().optional(), + })).min(1, 'Debe haber al menos un ganador'), + category: z.enum([ + WallOfFameCategory.TOURNAMENT, + WallOfFameCategory.LEAGUE, + WallOfFameCategory.SPECIAL, + ]), + imageUrl: z.string().url().optional(), + eventDate: z.string().datetime('Fecha inválida'), + featured: z.boolean().optional(), +}).refine( + (data) => data.tournamentId || data.leagueId || data.category === WallOfFameCategory.SPECIAL, + { + message: 'Debe especificar un torneo o liga (excepto para logros especiales)', + path: ['tournamentId'], + } +); + +// Schema para actualizar entrada +const updateEntrySchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional(), + winners: z.array(z.object({ + userId: z.string().uuid(), + name: z.string(), + position: z.number().int().min(1), + avatarUrl: z.string().url().optional(), + })).optional(), + category: z.enum([ + WallOfFameCategory.TOURNAMENT, + WallOfFameCategory.LEAGUE, + WallOfFameCategory.SPECIAL, + ]).optional(), + imageUrl: z.string().url().optional().nullable(), + eventDate: z.string().datetime().optional(), + featured: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +// Schema para query params de listado +const listEntriesQuerySchema = z.object({ + category: z.enum([ + WallOfFameCategory.TOURNAMENT, + WallOfFameCategory.LEAGUE, + WallOfFameCategory.SPECIAL, + ]).optional(), + featured: z.enum(['true', 'false']).optional(), + isActive: z.enum(['true', 'false']).optional(), + tournamentId: z.string().uuid().optional(), + leagueId: z.string().uuid().optional(), + fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + limit: z.string().regex(/^\d+$/).optional(), + offset: z.string().regex(/^\d+$/).optional(), +}); + +// Schema para params de ID +const entryIdParamsSchema = z.object({ + id: z.string().uuid('ID inválido'), +}); + +// Schema para agregar ganadores +const addWinnersSchema = z.object({ + winners: z.array(z.object({ + userId: z.string().uuid('ID de usuario inválido'), + name: z.string(), + position: z.number().int().min(1), + avatarUrl: z.string().url().optional(), + })).min(1, 'Debe haber al menos un ganador'), +}); + +// Schema para búsqueda +const searchQuerySchema = z.object({ + q: z.string().min(1, 'Término de búsqueda requerido'), + limit: z.string().regex(/^\d+$/).optional(), +}); + +// ============================================ +// Rutas Públicas (lectura) +// ============================================ + +// Listar entradas +router.get( + '/', + validateQuery(listEntriesQuerySchema), + WallOfFameController.getEntries +); + +// Obtener entradas destacadas +router.get( + '/featured', + WallOfFameController.getFeaturedEntries +); + +// Buscar entradas +router.get( + '/search', + validateQuery(searchQuerySchema), + WallOfFameController.searchEntries +); + +// Obtener una entrada por ID +router.get( + '/:id', + validate(entryIdParamsSchema), + WallOfFameController.getEntryById +); + +// ============================================ +// Rutas Protegidas (admin) +// ============================================ + +// Crear entrada +router.post( + '/', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(createEntrySchema), + WallOfFameController.createEntry +); + +// Actualizar entrada +router.put( + '/:id', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(entryIdParamsSchema), + validate(updateEntrySchema), + WallOfFameController.updateEntry +); + +// Eliminar entrada +router.delete( + '/:id', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(entryIdParamsSchema), + WallOfFameController.deleteEntry +); + +// Agregar ganadores a entrada +router.post( + '/:id/winners', + authenticate, + authorize([UserRole.ADMIN, UserRole.SUPERADMIN]), + validate(entryIdParamsSchema), + validate(addWinnersSchema), + WallOfFameController.addWinners +); + +export default router; diff --git a/backend/src/services/achievement.service.ts b/backend/src/services/achievement.service.ts new file mode 100644 index 0000000..24640f4 --- /dev/null +++ b/backend/src/services/achievement.service.ts @@ -0,0 +1,563 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import logger from '../config/logger'; +import { + AchievementCategory, + AchievementCategoryType, + RequirementType, + RequirementTypeType, + DEFAULT_ACHIEVEMENTS, +} from '../utils/constants'; + +export interface CreateAchievementInput { + code: string; + name: string; + description: string; + category: AchievementCategoryType; + icon: string; + color: string; + requirementType: RequirementTypeType; + requirementValue: number; + pointsReward: number; +} + +export interface UpdateAchievementInput { + name?: string; + description?: string; + category?: AchievementCategoryType; + icon?: string; + color?: string; + requirementType?: RequirementTypeType; + requirementValue?: number; + pointsReward?: number; + isActive?: boolean; +} + +export interface AchievementProgress { + achievement: { + id: string; + code: string; + name: string; + description: string; + category: string; + icon: string; + color: string; + requirementType: string; + requirementValue: number; + pointsReward: number; + }; + progress: number; + isCompleted: boolean; + unlockedAt?: Date; +} + +export class AchievementService { + /** + * Crear un nuevo logro + */ + static async createAchievement(adminId: string, data: CreateAchievementInput) { + // Validar categoría + if (!Object.values(AchievementCategory).includes(data.category)) { + throw new ApiError('Categoría inválida', 400); + } + + // Validar tipo de requisito + if (!Object.values(RequirementType).includes(data.requirementType)) { + throw new ApiError('Tipo de requisito inválido', 400); + } + + // Verificar que el código sea único + const existing = await prisma.achievement.findUnique({ + where: { code: data.code }, + }); + + if (existing) { + throw new ApiError('Ya existe un logro con ese código', 400); + } + + const achievement = await prisma.achievement.create({ + data: { + code: data.code, + name: data.name, + description: data.description, + category: data.category, + icon: data.icon, + color: data.color, + requirementType: data.requirementType, + requirementValue: data.requirementValue, + pointsReward: data.pointsReward, + isActive: true, + }, + }); + + logger.info(`Logro creado: ${achievement.code} por admin ${adminId}`); + + return achievement; + } + + /** + * Obtener todos los logros disponibles + */ + static async getAchievements(options?: { category?: AchievementCategoryType; activeOnly?: boolean }) { + const where: any = {}; + + if (options?.category) { + where.category = options.category; + } + + if (options?.activeOnly !== false) { + where.isActive = true; + } + + const achievements = await prisma.achievement.findMany({ + where, + orderBy: [ + { category: 'asc' }, + { requirementValue: 'asc' }, + ], + }); + + return achievements; + } + + /** + * Obtener logros de un usuario específico + */ + static async getUserAchievements(userId: string) { + // Obtener logros desbloqueados + const userAchievements = await prisma.userAchievement.findMany({ + where: { + userId, + isCompleted: true, + }, + include: { + achievement: true, + }, + orderBy: { + unlockedAt: 'desc', + }, + }); + + // Calcular puntos totales + const totalPoints = userAchievements.reduce( + (sum, ua) => sum + ua.achievement.pointsReward, + 0 + ); + + return { + achievements: userAchievements.map(ua => ({ + id: ua.achievement.id, + code: ua.achievement.code, + name: ua.achievement.name, + description: ua.achievement.description, + category: ua.achievement.category, + icon: ua.achievement.icon, + color: ua.achievement.color, + pointsReward: ua.achievement.pointsReward, + unlockedAt: ua.unlockedAt, + })), + totalPoints, + count: userAchievements.length, + }; + } + + /** + * Obtener el progreso de logros de un usuario + */ + static async getUserAchievementsProgress(userId: string): Promise { + // Obtener todos los logros activos + const achievements = await prisma.achievement.findMany({ + where: { isActive: true }, + }); + + // Obtener el progreso actual del usuario + const userAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + }); + + // Combinar información + const progress: AchievementProgress[] = achievements.map(achievement => { + const userAchievement = userAchievements.find( + ua => ua.achievementId === achievement.id + ); + + return { + achievement: { + id: achievement.id, + code: achievement.code, + name: achievement.name, + description: achievement.description, + category: achievement.category, + icon: achievement.icon, + color: achievement.color, + requirementType: achievement.requirementType, + requirementValue: achievement.requirementValue, + pointsReward: achievement.pointsReward, + }, + progress: userAchievement?.progress || 0, + isCompleted: userAchievement?.isCompleted || false, + unlockedAt: userAchievement?.unlockedAt || undefined, + }; + }); + + return progress; + } + + /** + * Obtener el progreso de un logro específico + */ + static async getAchievementProgress(userId: string, achievementId: string): Promise { + const achievement = await prisma.achievement.findUnique({ + where: { id: achievementId }, + }); + + if (!achievement) { + throw new ApiError('Logro no encontrado', 404); + } + + const userAchievement = await prisma.userAchievement.findUnique({ + where: { + userId_achievementId: { + userId, + achievementId, + }, + }, + }); + + return { + achievement: { + id: achievement.id, + code: achievement.code, + name: achievement.name, + description: achievement.description, + category: achievement.category, + icon: achievement.icon, + color: achievement.color, + requirementType: achievement.requirementType, + requirementValue: achievement.requirementValue, + pointsReward: achievement.pointsReward, + }, + progress: userAchievement?.progress || 0, + isCompleted: userAchievement?.isCompleted || false, + unlockedAt: userAchievement?.unlockedAt || undefined, + }; + } + + /** + * Actualizar el progreso de un logro para un usuario + */ + static async updateProgress(userId: string, requirementType: RequirementTypeType, increment: number = 1) { + // Buscar logros que usen este tipo de requisito + const achievements = await prisma.achievement.findMany({ + where: { + requirementType, + isActive: true, + }, + }); + + if (achievements.length === 0) { + return; + } + + const unlockedAchievements: string[] = []; + + for (const achievement of achievements) { + // Obtener o crear el progreso del usuario + const userAchievement = await prisma.userAchievement.upsert({ + where: { + userId_achievementId: { + userId, + achievementId: achievement.id, + }, + }, + update: { + progress: { + increment, + }, + }, + create: { + userId, + achievementId: achievement.id, + progress: increment, + isCompleted: false, + }, + }); + + // Verificar si se completó el logro + if (!userAchievement.isCompleted && userAchievement.progress >= achievement.requirementValue) { + await prisma.userAchievement.update({ + where: { + userId_achievementId: { + userId, + achievementId: achievement.id, + }, + }, + data: { + isCompleted: true, + unlockedAt: new Date(), + }, + }); + + unlockedAchievements.push(achievement.code); + logger.info(`Logro desbloqueado: ${achievement.code} por usuario ${userId}`); + } + } + + return unlockedAchievements; + } + + /** + * Verificar y desbloquear logros para un usuario + * Útil para verificar logros al calcular estadísticas existentes + */ + static async checkAndUnlockAchievements(userId: string) { + // Obtener estadísticas del usuario + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + matchesPlayed: true, + matchesWon: true, + }, + }); + + if (!user) { + throw new ApiError('Usuario no encontrado', 404); + } + + // Obtener estadísticas de torneos + const tournamentStats = await prisma.userStats.findFirst({ + where: { + userId, + period: 'ALL_TIME', + periodValue: 'ALL', + }, + }); + + // Obtener conteo de amigos + const friendsCount = await prisma.friend.count({ + where: { + OR: [ + { requesterId: userId, status: 'ACCEPTED' }, + { addresseeId: userId, status: 'ACCEPTED' }, + ], + }, + }); + + const unlockedAchievements: string[] = []; + + // Verificar logros de partidos jugados + await this.syncAchievementProgress(userId, RequirementType.MATCHES_PLAYED, user.matchesPlayed); + + // Verificar logros de partidos ganados + await this.syncAchievementProgress(userId, RequirementType.MATCHES_WON, user.matchesWon); + + // Verificar logros de torneos + if (tournamentStats) { + await this.syncAchievementProgress(userId, RequirementType.TOURNAMENTS_PLAYED, tournamentStats.tournamentsPlayed); + await this.syncAchievementProgress(userId, RequirementType.TOURNAMENTS_WON, tournamentStats.tournamentsWon); + } + + // Verificar logros de amigos + await this.syncAchievementProgress(userId, RequirementType.FRIENDS_ADDED, friendsCount); + + return { + checked: true, + matchesPlayed: user.matchesPlayed, + matchesWon: user.matchesWon, + friendsCount, + }; + } + + /** + * Sincronizar el progreso de un tipo de requisito con un valor específico + */ + private static async syncAchievementProgress( + userId: string, + requirementType: RequirementTypeType, + currentValue: number + ) { + const achievements = await prisma.achievement.findMany({ + where: { + requirementType, + isActive: true, + }, + }); + + for (const achievement of achievements) { + const shouldBeCompleted = currentValue >= achievement.requirementValue; + + await prisma.userAchievement.upsert({ + where: { + userId_achievementId: { + userId, + achievementId: achievement.id, + }, + }, + update: { + progress: currentValue, + isCompleted: shouldBeCompleted, + unlockedAt: shouldBeCompleted ? new Date() : undefined, + }, + create: { + userId, + achievementId: achievement.id, + progress: currentValue, + isCompleted: shouldBeCompleted, + unlockedAt: shouldBeCompleted ? new Date() : undefined, + }, + }); + } + } + + /** + * Actualizar un logro + */ + static async updateAchievement(id: string, data: UpdateAchievementInput) { + const achievement = await prisma.achievement.findUnique({ + where: { id }, + }); + + if (!achievement) { + throw new ApiError('Logro no encontrado', 404); + } + + // Validar categoría si se proporciona + if (data.category && !Object.values(AchievementCategory).includes(data.category)) { + throw new ApiError('Categoría inválida', 400); + } + + // Validar tipo de requisito si se proporciona + if (data.requirementType && !Object.values(RequirementType).includes(data.requirementType)) { + throw new ApiError('Tipo de requisito inválido', 400); + } + + const updated = await prisma.achievement.update({ + where: { id }, + data, + }); + + logger.info(`Logro actualizado: ${updated.code}`); + + return updated; + } + + /** + * Eliminar un logro (desactivarlo) + */ + static async deleteAchievement(id: string) { + const achievement = await prisma.achievement.findUnique({ + where: { id }, + }); + + if (!achievement) { + throw new ApiError('Logro no encontrado', 404); + } + + const updated = await prisma.achievement.update({ + where: { id }, + data: { isActive: false }, + }); + + logger.info(`Logro desactivado: ${updated.code}`); + + return updated; + } + + /** + * Obtener el ranking de usuarios por puntos de logros + */ + static async getLeaderboard(limit: number = 100) { + // Obtener todos los logros completados agrupados por usuario + const userAchievements = await prisma.userAchievement.findMany({ + where: { + isCompleted: true, + }, + include: { + achievement: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + }); + + // Agrupar por usuario y calcular puntos + const userPointsMap = new Map(); + + for (const ua of userAchievements) { + const existing = userPointsMap.get(ua.userId); + if (existing) { + existing.totalPoints += ua.achievement.pointsReward; + existing.achievementsCount += 1; + } else { + userPointsMap.set(ua.userId, { + user: ua.user, + totalPoints: ua.achievement.pointsReward, + achievementsCount: 1, + }); + } + } + + // Convertir a array y ordenar + const leaderboard = Array.from(userPointsMap.values()) + .sort((a, b) => b.totalPoints - a.totalPoints) + .slice(0, limit) + .map((entry, index) => ({ + position: index + 1, + ...entry, + })); + + return leaderboard; + } + + /** + * Inicializar logros por defecto del sistema + */ + static async initializeDefaultAchievements(adminId: string) { + const created: string[] = []; + const skipped: string[] = []; + + for (const achievement of DEFAULT_ACHIEVEMENTS) { + const existing = await prisma.achievement.findUnique({ + where: { code: achievement.code }, + }); + + if (!existing) { + await prisma.achievement.create({ + data: { + ...achievement, + isActive: true, + }, + }); + created.push(achievement.code); + } else { + skipped.push(achievement.code); + } + } + + logger.info(`Logros inicializados: ${created.length} creados, ${skipped.length} omitidos`); + + return { + created, + skipped, + total: DEFAULT_ACHIEVEMENTS.length, + }; + } +} + +export default AchievementService; diff --git a/backend/src/services/challenge.service.ts b/backend/src/services/challenge.service.ts new file mode 100644 index 0000000..93cf419 --- /dev/null +++ b/backend/src/services/challenge.service.ts @@ -0,0 +1,615 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import logger from '../config/logger'; +import { + ChallengeType, + ChallengeTypeType, + RequirementType, + RequirementTypeType, +} from '../utils/constants'; + +export interface CreateChallengeInput { + title: string; + description: string; + type: ChallengeTypeType; + requirementType: RequirementTypeType; + requirementValue: number; + startDate: Date; + endDate: Date; + rewardPoints: number; +} + +export interface UpdateChallengeInput { + title?: string; + description?: string; + requirementType?: RequirementTypeType; + requirementValue?: number; + startDate?: Date; + endDate?: Date; + rewardPoints?: number; + isActive?: boolean; +} + +export interface ChallengeFilters { + type?: ChallengeTypeType; + isActive?: boolean; + ongoing?: boolean; + limit?: number; + offset?: number; +} + +export interface UserChallengeProgress { + challenge: { + id: string; + title: string; + description: string; + type: string; + requirementType: string; + requirementValue: number; + rewardPoints: number; + startDate: Date; + endDate: Date; + }; + progress: number; + isCompleted: boolean; + completedAt?: Date; + rewardClaimed: boolean; +} + +export class ChallengeService { + /** + * Crear un nuevo reto + */ + static async createChallenge(adminId: string, data: CreateChallengeInput) { + // Validar tipo de reto + if (!Object.values(ChallengeType).includes(data.type)) { + throw new ApiError('Tipo de reto inválido', 400); + } + + // Validar tipo de requisito + if (!Object.values(RequirementType).includes(data.requirementType)) { + throw new ApiError('Tipo de requisito inválido', 400); + } + + // Validar fechas + if (data.endDate <= data.startDate) { + throw new ApiError('La fecha de fin debe ser posterior a la de inicio', 400); + } + + if (data.endDate <= new Date()) { + throw new ApiError('La fecha de fin debe ser en el futuro', 400); + } + + const challenge = await prisma.challenge.create({ + data: { + title: data.title, + description: data.description, + type: data.type, + requirementType: data.requirementType, + requirementValue: data.requirementValue, + startDate: data.startDate, + endDate: data.endDate, + rewardPoints: data.rewardPoints, + participants: '[]', + winners: '[]', + isActive: true, + createdBy: adminId, + }, + }); + + logger.info(`Reto creado: ${challenge.id} por admin ${adminId}`); + + return challenge; + } + + /** + * Obtener retos activos + */ + static async getActiveChallenges(filters: ChallengeFilters = {}) { + const where: any = {}; + + if (filters.type) { + where.type = filters.type; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } else { + where.isActive = true; + } + + if (filters.ongoing) { + const now = new Date(); + where.startDate = { lte: now }; + where.endDate = { gte: now }; + } + + const [challenges, total] = await Promise.all([ + prisma.challenge.findMany({ + where, + orderBy: [ + { endDate: 'asc' }, + { createdAt: 'desc' }, + ], + take: filters.limit || 50, + skip: filters.offset || 0, + }), + prisma.challenge.count({ where }), + ]); + + // Parsear JSON y agregar información de estado + const now = new Date(); + const challengesWithInfo = challenges.map(challenge => { + const participants = JSON.parse(challenge.participants) as string[]; + const winners = JSON.parse(challenge.winners) as string[]; + + return { + ...challenge, + participants, + winners, + participantsCount: participants.length, + winnersCount: winners.length, + status: this.getChallengeStatus(challenge.startDate, challenge.endDate, challenge.isActive), + }; + }); + + return { + challenges: challengesWithInfo, + total, + limit: filters.limit || 50, + offset: filters.offset || 0, + }; + } + + /** + * Obtener un reto por ID + */ + static async getChallengeById(id: string, userId?: string) { + const challenge = await prisma.challenge.findUnique({ + where: { id }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + const participants = JSON.parse(challenge.participants) as string[]; + const winners = JSON.parse(challenge.winners) as string[]; + + // Si hay userId, obtener el progreso del usuario + let userProgress = null; + if (userId) { + const userChallenge = await prisma.userChallenge.findUnique({ + where: { + userId_challengeId: { + userId, + challengeId: id, + }, + }, + }); + + if (userChallenge) { + userProgress = { + progress: userChallenge.progress, + isCompleted: userChallenge.isCompleted, + completedAt: userChallenge.completedAt, + rewardClaimed: userChallenge.rewardClaimed, + }; + } + } + + return { + ...challenge, + participants, + winners, + participantsCount: participants.length, + winnersCount: winners.length, + status: this.getChallengeStatus(challenge.startDate, challenge.endDate, challenge.isActive), + userProgress, + }; + } + + /** + * Unirse a un reto + */ + static async joinChallenge(userId: string, challengeId: string) { + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + if (!challenge.isActive) { + throw new ApiError('Este reto no está activo', 400); + } + + // Verificar si el reto está en curso + const now = new Date(); + if (now < challenge.startDate) { + throw new ApiError('Este reto aún no ha comenzado', 400); + } + + if (now > challenge.endDate) { + throw new ApiError('Este reto ya ha finalizado', 400); + } + + // Verificar si ya está participando + const participants = JSON.parse(challenge.participants) as string[]; + if (participants.includes(userId)) { + throw new ApiError('Ya estás participando en este reto', 400); + } + + // Agregar usuario a participantes + participants.push(userId); + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + participants: JSON.stringify(participants), + }, + }); + + // Crear registro de participación + const userChallenge = await prisma.userChallenge.create({ + data: { + userId, + challengeId, + progress: 0, + isCompleted: false, + rewardClaimed: false, + }, + }); + + logger.info(`Usuario ${userId} se unió al reto ${challengeId}`); + + return { + joined: true, + userChallenge, + }; + } + + /** + * Actualizar progreso de un usuario en un reto + */ + static async updateChallengeProgress(userId: string, challengeId: string, progressIncrement: number) { + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + // Verificar si el usuario está participando + const userChallenge = await prisma.userChallenge.findUnique({ + where: { + userId_challengeId: { + userId, + challengeId, + }, + }, + }); + + if (!userChallenge) { + throw new ApiError('No estás participando en este reto', 400); + } + + if (userChallenge.isCompleted) { + // Ya completado, solo retornar el estado actual + return { + progress: userChallenge.progress, + isCompleted: true, + completedAt: userChallenge.completedAt, + rewardClaimed: userChallenge.rewardClaimed, + }; + } + + // Calcular nuevo progreso + const newProgress = userChallenge.progress + progressIncrement; + const isCompleted = newProgress >= challenge.requirementValue; + + const updated = await prisma.userChallenge.update({ + where: { + userId_challengeId: { + userId, + challengeId, + }, + }, + data: { + progress: newProgress, + isCompleted, + completedAt: isCompleted ? new Date() : undefined, + }, + }); + + // Si se completó, agregar a la lista de ganadores del reto + if (isCompleted && !userChallenge.isCompleted) { + const winners = JSON.parse(challenge.winners) as string[]; + if (!winners.includes(userId)) { + winners.push(userId); + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + winners: JSON.stringify(winners), + }, + }); + } + + logger.info(`Usuario ${userId} completó el reto ${challengeId}`); + } + + return { + progress: updated.progress, + isCompleted: updated.isCompleted, + completedAt: updated.completedAt, + rewardClaimed: updated.rewardClaimed, + requirementValue: challenge.requirementValue, + percentage: Math.min(100, Math.round((newProgress / challenge.requirementValue) * 100)), + }; + } + + /** + * Completar un reto y reclamar recompensa + */ + static async completeChallenge(userId: string, challengeId: string) { + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + const userChallenge = await prisma.userChallenge.findUnique({ + where: { + userId_challengeId: { + userId, + challengeId, + }, + }, + }); + + if (!userChallenge) { + throw new ApiError('No estás participando en este reto', 400); + } + + if (!userChallenge.isCompleted) { + throw new ApiError('Aún no has completado este reto', 400); + } + + if (userChallenge.rewardClaimed) { + throw new ApiError('Ya has reclamado la recompensa de este reto', 400); + } + + // Marcar recompensa como reclamada + const updated = await prisma.userChallenge.update({ + where: { + userId_challengeId: { + userId, + challengeId, + }, + }, + data: { + rewardClaimed: true, + }, + }); + + // Aquí se podría agregar lógica para otorgar puntos al usuario + // Por ejemplo, agregar a un campo totalPoints del usuario + + logger.info(`Usuario ${userId} reclamó recompensa del reto ${challengeId}`); + + return { + claimed: true, + rewardPoints: challenge.rewardPoints, + userChallenge: updated, + }; + } + + /** + * Obtener tabla de líderes de un reto + */ + static async getChallengeLeaderboard(challengeId: string, limit: number = 50) { + const challenge = await prisma.challenge.findUnique({ + where: { id: challengeId }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + const userChallenges = await prisma.userChallenge.findMany({ + where: { + challengeId, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + avatarUrl: true, + playerLevel: true, + }, + }, + }, + orderBy: [ + { isCompleted: 'desc' }, + { progress: 'desc' }, + { completedAt: 'asc' }, + ], + take: limit, + }); + + return userChallenges.map((uc, index) => ({ + position: index + 1, + user: uc.user, + progress: uc.progress, + isCompleted: uc.isCompleted, + completedAt: uc.completedAt, + rewardClaimed: uc.rewardClaimed, + percentage: Math.min(100, Math.round((uc.progress / challenge.requirementValue) * 100)), + })); + } + + /** + * Verificar retos expirados y cerrarlos + */ + static async checkExpiredChallenges() { + const now = new Date(); + + // Buscar retos activos que ya han terminado + const expiredChallenges = await prisma.challenge.findMany({ + where: { + isActive: true, + endDate: { + lt: now, + }, + }, + }); + + const closed: string[] = []; + + for (const challenge of expiredChallenges) { + await prisma.challenge.update({ + where: { id: challenge.id }, + data: { isActive: false }, + }); + closed.push(challenge.id); + } + + if (closed.length > 0) { + logger.info(`${closed.length} retos expirados cerrados`); + } + + return { + checked: true, + closed, + count: closed.length, + }; + } + + /** + * Obtener los retos de un usuario + */ + static async getUserChallenges(userId: string) { + const userChallenges = await prisma.userChallenge.findMany({ + where: { + userId, + }, + include: { + challenge: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + const now = new Date(); + + return userChallenges.map(uc => { + const isExpired = uc.challenge.endDate < now; + const isOngoing = uc.challenge.startDate <= now && uc.challenge.endDate >= now; + + return { + id: uc.id, + challenge: { + id: uc.challenge.id, + title: uc.challenge.title, + description: uc.challenge.description, + type: uc.challenge.type, + requirementType: uc.challenge.requirementType, + requirementValue: uc.challenge.requirementValue, + rewardPoints: uc.challenge.rewardPoints, + startDate: uc.challenge.startDate, + endDate: uc.challenge.endDate, + isActive: uc.challenge.isActive, + }, + progress: uc.progress, + isCompleted: uc.isCompleted, + completedAt: uc.completedAt, + rewardClaimed: uc.rewardClaimed, + status: isExpired ? 'EXPIRED' : isOngoing ? 'ONGOING' : 'UPCOMING', + percentage: Math.min(100, Math.round((uc.progress / uc.challenge.requirementValue) * 100)), + }; + }); + } + + /** + * Actualizar un reto + */ + static async updateChallenge(id: string, data: UpdateChallengeInput) { + const challenge = await prisma.challenge.findUnique({ + where: { id }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + // Validar tipo de requisito si se proporciona + if (data.requirementType && !Object.values(RequirementType).includes(data.requirementType)) { + throw new ApiError('Tipo de requisito inválido', 400); + } + + // Validar fechas si se proporcionan + if (data.startDate && data.endDate && data.endDate <= data.startDate) { + throw new ApiError('La fecha de fin debe ser posterior a la de inicio', 400); + } + + const updated = await prisma.challenge.update({ + where: { id }, + data, + }); + + logger.info(`Reto actualizado: ${updated.id}`); + + return updated; + } + + /** + * Eliminar un reto + */ + static async deleteChallenge(id: string) { + const challenge = await prisma.challenge.findUnique({ + where: { id }, + }); + + if (!challenge) { + throw new ApiError('Reto no encontrado', 404); + } + + // Eliminar todas las participaciones primero + await prisma.userChallenge.deleteMany({ + where: { challengeId: id }, + }); + + // Eliminar el reto + await prisma.challenge.delete({ + where: { id }, + }); + + logger.info(`Reto eliminado: ${id}`); + + return { message: 'Reto eliminado correctamente' }; + } + + /** + * Obtener el estado de un reto + */ + private static getChallengeStatus( + startDate: Date, + endDate: Date, + isActive: boolean + ): 'UPCOMING' | 'ONGOING' | 'FINISHED' | 'CANCELLED' { + if (!isActive) return 'CANCELLED'; + + const now = new Date(); + if (now < startDate) return 'UPCOMING'; + if (now > endDate) return 'FINISHED'; + return 'ONGOING'; + } +} + +export default ChallengeService; diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts new file mode 100644 index 0000000..8d380a9 --- /dev/null +++ b/backend/src/services/equipment.service.ts @@ -0,0 +1,471 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import logger from '../config/logger'; + +// Categorías de equipamiento +export const EquipmentCategory = { + RACKET: 'RACKET', + BALLS: 'BALLS', + ACCESSORIES: 'ACCESSORIES', + SHOES: 'SHOES', +} as const; + +export type EquipmentCategoryType = typeof EquipmentCategory[keyof typeof EquipmentCategory]; + +// Condición del equipamiento +export const EquipmentCondition = { + NEW: 'NEW', + GOOD: 'GOOD', + FAIR: 'FAIR', + POOR: 'POOR', +} as const; + +export type EquipmentConditionType = typeof EquipmentCondition[keyof typeof EquipmentCondition]; + +// Interfaces +export interface CreateEquipmentInput { + name: string; + description?: string; + category: EquipmentCategoryType; + brand?: string; + model?: string; + size?: string; + condition?: EquipmentConditionType; + hourlyRate?: number; + dailyRate?: number; + depositRequired?: number; + quantityTotal?: number; + imageUrl?: string; +} + +export interface UpdateEquipmentInput { + name?: string; + description?: string; + category?: EquipmentCategoryType; + brand?: string; + model?: string; + size?: string; + condition?: EquipmentConditionType; + hourlyRate?: number; + dailyRate?: number; + depositRequired?: number; + quantityTotal?: number; + imageUrl?: string; + isActive?: boolean; +} + +export interface EquipmentFilters { + category?: EquipmentCategoryType; + isActive?: boolean; + available?: boolean; + search?: string; +} + +export class EquipmentService { + /** + * Crear un nuevo item de equipamiento + */ + static async createEquipmentItem(adminId: string, data: CreateEquipmentInput) { + // Validar categoría + if (!Object.values(EquipmentCategory).includes(data.category)) { + throw new ApiError('Categoría inválida', 400); + } + + // Validar condición si se proporciona + if (data.condition && !Object.values(EquipmentCondition).includes(data.condition)) { + throw new ApiError('Condición inválida', 400); + } + + const quantityTotal = data.quantityTotal || 1; + + const equipment = await prisma.equipmentItem.create({ + data: { + name: data.name, + description: data.description, + category: data.category, + brand: data.brand, + model: data.model, + size: data.size, + condition: data.condition || EquipmentCondition.NEW, + hourlyRate: data.hourlyRate, + dailyRate: data.dailyRate, + depositRequired: data.depositRequired || 0, + quantityTotal, + quantityAvailable: quantityTotal, + imageUrl: data.imageUrl, + isActive: true, + }, + }); + + logger.info(`Equipamiento creado: ${equipment.id} por admin ${adminId}`); + + return equipment; + } + + /** + * Obtener lista de equipamiento con filtros + */ + static async getEquipmentItems(filters: EquipmentFilters = {}) { + const where: any = {}; + + if (filters.category) { + where.category = filters.category; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } + + if (filters.available) { + where.quantityAvailable = { gt: 0 }; + where.isActive = true; + } + + if (filters.search) { + where.OR = [ + { name: { contains: filters.search, mode: 'insensitive' } }, + { description: { contains: filters.search, mode: 'insensitive' } }, + { brand: { contains: filters.search, mode: 'insensitive' } }, + { model: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const items = await prisma.equipmentItem.findMany({ + where, + orderBy: [ + { category: 'asc' }, + { name: 'asc' }, + ], + }); + + return items; + } + + /** + * Obtener un item de equipamiento por ID + */ + static async getEquipmentById(id: string) { + const equipment = await prisma.equipmentItem.findUnique({ + where: { id }, + include: { + rentals: { + where: { + rental: { + status: { + in: ['RESERVED', 'PICKED_UP'], + }, + }, + }, + include: { + rental: { + select: { + id: true, + startDate: true, + endDate: true, + status: true, + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!equipment) { + throw new ApiError('Equipamiento no encontrado', 404); + } + + return equipment; + } + + /** + * Actualizar un item de equipamiento + */ + static async updateEquipment( + id: string, + adminId: string, + data: UpdateEquipmentInput + ) { + const equipment = await prisma.equipmentItem.findUnique({ + where: { id }, + }); + + if (!equipment) { + throw new ApiError('Equipamiento no encontrado', 404); + } + + // Validar categoría si se proporciona + if (data.category && !Object.values(EquipmentCategory).includes(data.category)) { + throw new ApiError('Categoría inválida', 400); + } + + // Validar condición si se proporciona + if (data.condition && !Object.values(EquipmentCondition).includes(data.condition)) { + throw new ApiError('Condición inválida', 400); + } + + // Calcular nueva cantidad disponible si cambia el total + let quantityAvailable = equipment.quantityAvailable; + if (data.quantityTotal !== undefined && data.quantityTotal !== equipment.quantityTotal) { + const diff = data.quantityTotal - equipment.quantityTotal; + quantityAvailable = Math.max(0, equipment.quantityAvailable + diff); + } + + const updated = await prisma.equipmentItem.update({ + where: { id }, + data: { + ...data, + quantityAvailable, + }, + }); + + logger.info(`Equipamiento actualizado: ${id} por admin ${adminId}`); + + return updated; + } + + /** + * Eliminar un item de equipamiento (soft delete) + */ + static async deleteEquipment(id: string, adminId: string) { + const equipment = await prisma.equipmentItem.findUnique({ + where: { id }, + include: { + rentals: { + where: { + rental: { + status: { + in: ['RESERVED', 'PICKED_UP', 'LATE'], + }, + }, + }, + }, + }, + }); + + if (!equipment) { + throw new ApiError('Equipamiento no encontrado', 404); + } + + // Verificar que no tiene alquileres activos + if (equipment.rentals.length > 0) { + throw new ApiError( + 'No se puede eliminar el equipamiento porque tiene alquileres activos', + 400 + ); + } + + const updated = await prisma.equipmentItem.update({ + where: { id }, + data: { + isActive: false, + quantityAvailable: 0, + }, + }); + + logger.info(`Equipamiento eliminado (soft): ${id} por admin ${adminId}`); + + return updated; + } + + /** + * Verificar disponibilidad de un item en un rango de fechas + */ + static async checkAvailability( + itemId: string, + startDate: Date, + endDate: Date, + excludeRentalId?: string + ) { + const equipment = await prisma.equipmentItem.findUnique({ + where: { id: itemId }, + }); + + if (!equipment) { + throw new ApiError('Equipamiento no encontrado', 404); + } + + if (!equipment.isActive) { + return { + available: false, + reason: 'ITEM_INACTIVE', + quantityAvailable: 0, + maxQuantity: equipment.quantityTotal, + }; + } + + // Buscar alquileres que se solapan con el rango solicitado + const where: any = { + itemId, + rental: { + status: { + in: ['RESERVED', 'PICKED_UP', 'LATE'], + }, + OR: [ + { + // El alquiler existente empieza durante el nuevo rango + startDate: { + lte: endDate, + }, + endDate: { + gte: startDate, + }, + }, + ], + }, + }; + + if (excludeRentalId) { + where.rental.id = { not: excludeRentalId }; + } + + const overlappingRentals = await prisma.equipmentRentalItem.findMany({ + where, + include: { + rental: { + select: { + id: true, + startDate: true, + endDate: true, + status: true, + }, + }, + }, + }); + + // Calcular cantidad total alquilada en el período + const totalRented = overlappingRentals.reduce((sum, r) => sum + r.quantity, 0); + const availableQuantity = Math.max(0, equipment.quantityTotal - totalRented); + + return { + available: availableQuantity > 0, + quantityAvailable: availableQuantity, + maxQuantity: equipment.quantityTotal, + overlappingRentals: overlappingRentals.map((r) => ({ + rentalId: r.rental.id, + quantity: r.quantity, + startDate: r.rental.startDate, + endDate: r.rental.endDate, + status: r.rental.status, + })), + }; + } + + /** + * Obtener reporte de inventario + */ + static async getInventoryReport() { + const [ + totalItems, + activeItems, + inactiveItems, + lowStockItems, + itemsByCategory, + ] = await Promise.all([ + prisma.equipmentItem.count(), + prisma.equipmentItem.count({ where: { isActive: true } }), + prisma.equipmentItem.count({ where: { isActive: false } }), + prisma.equipmentItem.count({ + where: { + isActive: true, + quantityAvailable: { lt: 2 }, + }, + }), + prisma.equipmentItem.groupBy({ + by: ['category'], + _count: { + id: true, + }, + where: { + isActive: true, + }, + }), + ]); + + // Calcular valor total del inventario + const items = await prisma.equipmentItem.findMany({ + where: { isActive: true }, + }); + + const totalValue = items.reduce((sum, item) => { + // Valor estimado basado en el depósito requerido + return sum + item.depositRequired * item.quantityTotal; + }, 0); + + return { + summary: { + totalItems, + activeItems, + inactiveItems, + lowStockItems, + totalValue, + }, + byCategory: itemsByCategory.map((c) => ({ + category: c.category, + count: c._count.id, + })), + lowStockDetails: await prisma.equipmentItem.findMany({ + where: { + isActive: true, + quantityAvailable: { lt: 2 }, + }, + select: { + id: true, + name: true, + category: true, + quantityTotal: true, + quantityAvailable: true, + }, + }), + }; + } + + /** + * Obtener items disponibles para una fecha específica + */ + static async getAvailableItemsForDate( + category: EquipmentCategoryType | undefined, + startDate: Date, + endDate: Date + ) { + const where: any = { + isActive: true, + }; + + if (category) { + where.category = category; + } + + const items = await prisma.equipmentItem.findMany({ + where, + orderBy: { + name: 'asc', + }, + }); + + // Verificar disponibilidad para cada item + const itemsWithAvailability = await Promise.all( + items.map(async (item) => { + const availability = await this.checkAvailability( + item.id, + startDate, + endDate + ); + return { + ...item, + availability, + }; + }) + ); + + return itemsWithAvailability.filter((item) => item.availability.available); + } +} + +export default EquipmentService; diff --git a/backend/src/services/equipmentRental.service.ts b/backend/src/services/equipmentRental.service.ts new file mode 100644 index 0000000..0595b5f --- /dev/null +++ b/backend/src/services/equipmentRental.service.ts @@ -0,0 +1,753 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import logger from '../config/logger'; +import { PaymentService } from './payment.service'; +import { EquipmentService } from './equipment.service'; + +// Estados del alquiler +export const RentalStatus = { + RESERVED: 'RESERVED', + PICKED_UP: 'PICKED_UP', + RETURNED: 'RETURNED', + LATE: 'LATE', + DAMAGED: 'DAMAGED', + CANCELLED: 'CANCELLED', +} as const; + +export type RentalStatusType = typeof RentalStatus[keyof typeof RentalStatus]; + +// Interfaces +export interface RentalItemInput { + itemId: string; + quantity: number; +} + +export interface CreateRentalInput { + items: RentalItemInput[]; + startDate: Date; + endDate: Date; + bookingId?: string; +} + +export class EquipmentRentalService { + /** + * Crear un nuevo alquiler + */ + static async createRental(userId: string, data: CreateRentalInput) { + const { items, startDate, endDate, bookingId } = data; + + // Validar fechas + const now = new Date(); + if (new Date(startDate) < now) { + throw new ApiError('La fecha de inicio debe ser futura', 400); + } + + if (new Date(endDate) <= new Date(startDate)) { + throw new ApiError('La fecha de fin debe ser posterior a la de inicio', 400); + } + + // Validar que hay items + if (!items || items.length === 0) { + throw new ApiError('Debe seleccionar al menos un item', 400); + } + + // Verificar booking si se proporciona + if (bookingId) { + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + userId, + }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + // Verificar que las fechas del alquiler coincidan con la reserva + const bookingDate = new Date(booking.date); + const rentalStart = new Date(startDate); + + if ( + bookingDate.getDate() !== rentalStart.getDate() || + bookingDate.getMonth() !== rentalStart.getMonth() || + bookingDate.getFullYear() !== rentalStart.getFullYear() + ) { + throw new ApiError( + 'Las fechas del alquiler deben coincidir con la fecha de la reserva', + 400 + ); + } + } + + // Verificar disponibilidad de cada item + const rentalItems = []; + let totalCost = 0; + let totalDeposit = 0; + + for (const itemInput of items) { + const equipment = await prisma.equipmentItem.findUnique({ + where: { + id: itemInput.itemId, + isActive: true, + }, + }); + + if (!equipment) { + throw new ApiError( + `Equipamiento no encontrado: ${itemInput.itemId}`, + 404 + ); + } + + // Verificar disponibilidad + const availability = await EquipmentService.checkAvailability( + itemInput.itemId, + new Date(startDate), + new Date(endDate) + ); + + if (!availability.available || availability.quantityAvailable < itemInput.quantity) { + throw new ApiError( + `No hay suficiente stock disponible para: ${equipment.name}`, + 400 + ); + } + + // Calcular duración en horas y días + const durationMs = new Date(endDate).getTime() - new Date(startDate).getTime(); + const durationHours = Math.ceil(durationMs / (1000 * 60 * 60)); + const durationDays = Math.ceil(durationHours / 24); + + // Calcular costo + let itemCost = 0; + if (equipment.dailyRate && durationDays >= 1) { + itemCost = equipment.dailyRate * durationDays * itemInput.quantity; + } else if (equipment.hourlyRate) { + itemCost = equipment.hourlyRate * durationHours * itemInput.quantity; + } + + totalCost += itemCost; + totalDeposit += equipment.depositRequired * itemInput.quantity; + + rentalItems.push({ + itemId: itemInput.itemId, + quantity: itemInput.quantity, + hourlyRate: equipment.hourlyRate, + dailyRate: equipment.dailyRate, + equipment, + }); + } + + // Crear el alquiler + const rental = await prisma.equipmentRental.create({ + data: { + userId, + bookingId: bookingId || null, + startDate: new Date(startDate), + endDate: new Date(endDate), + totalCost, + depositAmount: totalDeposit, + status: RentalStatus.RESERVED, + items: { + create: rentalItems.map((ri) => ({ + itemId: ri.itemId, + quantity: ri.quantity, + hourlyRate: ri.hourlyRate, + dailyRate: ri.dailyRate, + })), + }, + }, + include: { + items: { + include: { + item: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + // Actualizar cantidades disponibles + for (const item of rentalItems) { + await prisma.equipmentItem.update({ + where: { id: item.itemId }, + data: { + quantityAvailable: { + decrement: item.quantity, + }, + }, + }); + } + + // Crear preferencia de pago en MercadoPago + let paymentPreference = null; + try { + const payment = await PaymentService.createPreference(userId, { + type: 'EQUIPMENT_RENTAL', + referenceId: rental.id, + title: `Alquiler de equipamiento - ${rental.items.length} items`, + description: `Alquiler de material deportivo desde ${startDate.toLocaleDateString()} hasta ${endDate.toLocaleDateString()}`, + amount: totalCost, + metadata: { + rentalId: rental.id, + items: rentalItems.map((ri) => ({ + name: ri.equipment.name, + quantity: ri.quantity, + })), + }, + }); + + paymentPreference = { + id: payment.id, + initPoint: payment.initPoint, + sandboxInitPoint: payment.sandboxInitPoint, + }; + + // Actualizar rental con el paymentId + await prisma.equipmentRental.update({ + where: { id: rental.id }, + data: { + paymentId: payment.paymentId, + }, + }); + } catch (error) { + logger.error('Error creando preferencia de pago para alquiler:', error); + // No fallar el alquiler si el pago falla, pero informar + } + + logger.info(`Alquiler creado: ${rental.id} para usuario ${userId}`); + + return { + rental: { + id: rental.id, + startDate: rental.startDate, + endDate: rental.endDate, + totalCost: rental.totalCost, + depositAmount: rental.depositAmount, + status: rental.status, + items: rental.items.map((ri) => ({ + id: ri.id, + quantity: ri.quantity, + item: { + id: ri.item.id, + name: ri.item.name, + category: ri.item.category, + brand: ri.item.brand, + imageUrl: ri.item.imageUrl, + }, + })), + }, + user: rental.user, + payment: paymentPreference, + }; + } + + /** + * Procesar webhook de pago de MercadoPago + */ + static async processPaymentWebhook(payload: any) { + // El webhook es procesado por PaymentService + // Aquí podemos hacer acciones adicionales si es necesario + logger.info('Webhook de pago para alquiler procesado', { payload }); + return { processed: true }; + } + + /** + * Actualizar estado del alquiler después del pago + */ + static async confirmRentalPayment(rentalId: string) { + const rental = await prisma.equipmentRental.findUnique({ + where: { id: rentalId }, + }); + + if (!rental) { + throw new ApiError('Alquiler no encontrado', 404); + } + + if (rental.status !== RentalStatus.RESERVED) { + throw new ApiError('El alquiler ya no está en estado reservado', 400); + } + + // El alquiler se mantiene en RESERVED hasta que se retire el material + logger.info(`Pago confirmado para alquiler: ${rentalId}`); + + return rental; + } + + /** + * Obtener mis alquileres + */ + static async getMyRentals(userId: string) { + const rentals = await prisma.equipmentRental.findMany({ + where: { userId }, + include: { + items: { + include: { + item: { + select: { + id: true, + name: true, + category: true, + brand: true, + imageUrl: true, + }, + }, + }, + }, + booking: { + select: { + id: true, + date: true, + startTime: true, + endTime: true, + court: { + select: { + name: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return rentals.map((rental) => ({ + id: rental.id, + startDate: rental.startDate, + endDate: rental.endDate, + totalCost: rental.totalCost, + depositAmount: rental.depositAmount, + depositReturned: rental.depositReturned, + status: rental.status, + pickedUpAt: rental.pickedUpAt, + returnedAt: rental.returnedAt, + items: rental.items.map((ri) => ({ + id: ri.id, + quantity: ri.quantity, + item: ri.item, + })), + booking: rental.booking, + })); + } + + /** + * Obtener detalle de un alquiler + */ + static async getRentalById(id: string, userId?: string) { + const rental = await prisma.equipmentRental.findUnique({ + where: { id }, + include: { + items: { + include: { + item: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + booking: { + select: { + id: true, + date: true, + startTime: true, + endTime: true, + court: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!rental) { + throw new ApiError('Alquiler no encontrado', 404); + } + + // Si se proporciona userId, verificar que sea el dueño + if (userId && rental.userId !== userId) { + throw new ApiError('No tienes permiso para ver este alquiler', 403); + } + + return rental; + } + + /** + * Entregar material (pickup) - Admin + */ + static async pickUpRental(rentalId: string, adminId: string) { + const rental = await prisma.equipmentRental.findUnique({ + where: { id: rentalId }, + include: { + items: { + include: { + item: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + if (!rental) { + throw new ApiError('Alquiler no encontrado', 404); + } + + if (rental.status !== RentalStatus.RESERVED) { + throw new ApiError( + `No se puede entregar el material. Estado actual: ${rental.status}`, + 400 + ); + } + + const updated = await prisma.equipmentRental.update({ + where: { id: rentalId }, + data: { + status: RentalStatus.PICKED_UP, + pickedUpAt: new Date(), + }, + include: { + items: { + include: { + item: { + select: { + id: true, + name: true, + category: true, + }, + }, + }, + }, + }, + }); + + logger.info(`Material entregado para alquiler: ${rentalId} por admin ${adminId}`); + + return { + rental: updated, + user: rental.user, + }; + } + + /** + * Devolver material - Admin + */ + static async returnRental( + rentalId: string, + adminId: string, + condition?: string, + depositReturned?: number + ) { + const rental = await prisma.equipmentRental.findUnique({ + where: { id: rentalId }, + include: { + items: { + include: { + item: true, + }, + }, + }, + }); + + if (!rental) { + throw new ApiError('Alquiler no encontrado', 404); + } + + if (rental.status !== RentalStatus.PICKED_UP && rental.status !== RentalStatus.LATE) { + throw new ApiError( + `No se puede devolver el material. Estado actual: ${rental.status}`, + 400 + ); + } + + // Determinar nuevo estado + let newStatus: RentalStatusType = RentalStatus.RETURNED; + const notes = []; + + if (condition) { + notes.push(`Condición al devolver: ${condition}`); + if (condition === 'DAMAGED') { + newStatus = RentalStatus.DAMAGED; + } + } + + // Verificar si está vencido + const now = new Date(); + if (now > new Date(rental.endDate) && newStatus !== RentalStatus.DAMAGED) { + newStatus = RentalStatus.LATE; + } + + const depositReturnAmount = depositReturned ?? rental.depositAmount; + + const updated = await prisma.equipmentRental.update({ + where: { id: rentalId }, + data: { + status: newStatus, + returnedAt: now, + depositReturned: depositReturnAmount, + notes: notes.length > 0 ? notes.join(' | ') : undefined, + }, + include: { + items: { + include: { + item: true, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + // Restaurar cantidades disponibles + for (const item of rental.items) { + await prisma.equipmentItem.update({ + where: { id: item.itemId }, + data: { + quantityAvailable: { + increment: item.quantity, + }, + }, + }); + } + + logger.info(`Material devuelto para alquiler: ${rentalId} por admin ${adminId}`); + + return { + rental: updated, + user: updated.user, + depositReturned: depositReturnAmount, + }; + } + + /** + * Cancelar alquiler + */ + static async cancelRental(rentalId: string, userId: string) { + const rental = await prisma.equipmentRental.findUnique({ + where: { id: rentalId }, + include: { + items: true, + }, + }); + + if (!rental) { + throw new ApiError('Alquiler no encontrado', 404); + } + + // Verificar que sea el dueño + if (rental.userId !== userId) { + throw new ApiError('No tienes permiso para cancelar este alquiler', 403); + } + + // Solo se puede cancelar si está RESERVED + if (rental.status !== RentalStatus.RESERVED) { + throw new ApiError( + `No se puede cancelar el alquiler. Estado actual: ${rental.status}`, + 400 + ); + } + + const updated = await prisma.equipmentRental.update({ + where: { id: rentalId }, + data: { + status: RentalStatus.CANCELLED, + }, + }); + + // Restaurar cantidades disponibles + for (const item of rental.items) { + await prisma.equipmentItem.update({ + where: { id: item.itemId }, + data: { + quantityAvailable: { + increment: item.quantity, + }, + }, + }); + } + + logger.info(`Alquiler cancelado: ${rentalId} por usuario ${userId}`); + + return updated; + } + + /** + * Obtener alquileres vencidos (admin) + */ + static async getOverdueRentals() { + const now = new Date(); + + const overdue = await prisma.equipmentRental.findMany({ + where: { + endDate: { + lt: now, + }, + status: { + in: [RentalStatus.RESERVED, RentalStatus.PICKED_UP], + }, + }, + include: { + items: { + include: { + item: { + select: { + id: true, + name: true, + category: true, + }, + }, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + }, + orderBy: { + endDate: 'asc', + }, + }); + + // Actualizar estado a LATE para los que están PICKED_UP + for (const rental of overdue) { + if (rental.status === RentalStatus.PICKED_UP) { + await prisma.equipmentRental.update({ + where: { id: rental.id }, + data: { status: RentalStatus.LATE }, + }); + } + } + + return overdue.map((rental) => ({ + ...rental, + overdueHours: Math.floor( + (now.getTime() - new Date(rental.endDate).getTime()) / (1000 * 60 * 60) + ), + })); + } + + /** + * Obtener todos los alquileres (admin) + */ + static async getAllRentals(filters?: { status?: RentalStatusType; userId?: string }) { + const where: any = {}; + + if (filters?.status) { + where.status = filters.status; + } + + if (filters?.userId) { + where.userId = filters.userId; + } + + const rentals = await prisma.equipmentRental.findMany({ + where, + include: { + items: { + include: { + item: { + select: { + id: true, + name: true, + category: true, + }, + }, + }, + }, + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return rentals; + } + + /** + * Obtener estadísticas de alquileres + */ + static async getRentalStats() { + const [ + totalRentals, + activeRentals, + reservedRentals, + returnedRentals, + lateRentals, + damagedRentals, + totalRevenue, + ] = await Promise.all([ + prisma.equipmentRental.count(), + prisma.equipmentRental.count({ where: { status: RentalStatus.PICKED_UP } }), + prisma.equipmentRental.count({ where: { status: RentalStatus.RESERVED } }), + prisma.equipmentRental.count({ where: { status: RentalStatus.RETURNED } }), + prisma.equipmentRental.count({ where: { status: RentalStatus.LATE } }), + prisma.equipmentRental.count({ where: { status: RentalStatus.DAMAGED } }), + prisma.equipmentRental.aggregate({ + _sum: { + totalCost: true, + }, + where: { + status: { + in: [RentalStatus.PICKED_UP, RentalStatus.RETURNED], + }, + }, + }), + ]); + + return { + total: totalRentals, + byStatus: { + reserved: reservedRentals, + active: activeRentals, + returned: returnedRentals, + late: lateRentals, + damaged: damagedRentals, + }, + totalRevenue: totalRevenue._sum.totalCost || 0, + }; + } +} + +export default EquipmentRentalService; diff --git a/backend/src/services/extras/achievement.service.ts b/backend/src/services/extras/achievement.service.ts new file mode 100644 index 0000000..acbc759 --- /dev/null +++ b/backend/src/services/extras/achievement.service.ts @@ -0,0 +1,187 @@ +import prisma from '../../config/database'; +import { ApiError } from '../../middleware/errorHandler'; + +export interface CreateAchievementInput { + code: string; + name: string; + description?: string; + category?: string; + icon?: string; + color?: string; + requirementType: string; + requirementValue: number; + pointsReward?: number; +} + +export class AchievementService { + // Crear logro + static async createAchievement(adminId: string, data: CreateAchievementInput) { + // Verificar que el código no exista + const existing = await prisma.achievement.findUnique({ + where: { code: data.code }, + }); + + if (existing) { + throw new ApiError('Ya existe un logro con ese código', 409); + } + + return prisma.achievement.create({ + data: { + code: data.code, + name: data.name, + description: data.description, + category: data.category || 'GAMES', + icon: data.icon || '🏆', + color: data.color || '#16a34a', + requirementType: data.requirementType, + requirementValue: data.requirementValue, + pointsReward: data.pointsReward || 0, + isActive: true, + }, + }); + } + + // Listar logros + static async getAchievements() { + return prisma.achievement.findMany({ + where: { isActive: true }, + orderBy: { requirementValue: 'asc' }, + }); + } + + // Mis logros + static async getUserAchievements(userId: string) { + const userAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + include: { achievement: true }, + orderBy: { unlockedAt: 'desc' }, + }); + + return userAchievements; + } + + // Progreso de logro específico + static async getAchievementProgress(userId: string, achievementId: string) { + const userAchievement = await prisma.userAchievement.findFirst({ + where: { userId, achievementId }, + include: { achievement: true }, + }); + + if (!userAchievement) { + // Devolver progreso 0 si no existe + const achievement = await prisma.achievement.findUnique({ + where: { id: achievementId }, + }); + if (!achievement) throw new ApiError('Logro no encontrado', 404); + + return { + achievement, + progress: 0, + isCompleted: false, + }; + } + + return userAchievement; + } + + // Actualizar progreso + static async updateProgress(userId: string, requirementType: string, increment: number = 1) { + // Buscar logros del usuario de ese tipo que no estén completados + const userAchievements = await prisma.userAchievement.findMany({ + where: { + userId, + isCompleted: false, + achievement: { + requirementType, + isActive: true, + }, + }, + include: { achievement: true }, + }); + + const unlocked = []; + + for (const ua of userAchievements) { + const newProgress = ua.progress + increment; + const isCompleted = newProgress >= ua.achievement.requirementValue; + + await prisma.userAchievement.update({ + where: { id: ua.id }, + data: { + progress: newProgress, + isCompleted, + unlockedAt: isCompleted && !ua.isCompleted ? new Date() : ua.unlockedAt, + }, + }); + + if (isCompleted && !ua.isCompleted) { + unlocked.push(ua.achievement); + } + } + + return unlocked; + } + + // Verificar y desbloquear logros + static async checkAndUnlockAchievements(userId: string) { + // Obtener logros que el usuario no tiene aún + const existingAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + select: { achievementId: true }, + }); + const existingIds = existingAchievements.map(ea => ea.achievementId); + + const newAchievements = await prisma.achievement.findMany({ + where: { + id: { notIn: existingIds.length > 0 ? existingIds : [''] }, + isActive: true, + }, + }); + + // Crear registros de progreso para nuevos logros + for (const achievement of newAchievements) { + await prisma.userAchievement.create({ + data: { + userId, + achievementId: achievement.id, + progress: 0, + isCompleted: false, + }, + }); + } + + return newAchievements.length; + } + + // Leaderboard por puntos de logros + static async getLeaderboard(limit: number = 10) { + const users = await prisma.userAchievement.groupBy({ + by: ['userId'], + where: { isCompleted: true }, + _sum: { + progress: true, + }, + }); + + // Ordenar manualmente por puntos + const sorted = users + .map(u => ({ userId: u.userId, points: u._sum.progress || 0 })) + .sort((a, b) => b.points - a.points) + .slice(0, limit); + + // Obtener info de usuarios + const userIds = sorted.map(u => u.userId); + const userInfo = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, firstName: true, lastName: true, avatarUrl: true }, + }); + + return sorted.map((u, index) => ({ + position: index + 1, + ...u, + user: userInfo.find(ui => ui.id === u.userId), + })); + } +} + +export default AchievementService; diff --git a/backend/src/services/extras/qrCheckin.service.ts b/backend/src/services/extras/qrCheckin.service.ts new file mode 100644 index 0000000..8ef938d --- /dev/null +++ b/backend/src/services/extras/qrCheckin.service.ts @@ -0,0 +1,212 @@ +import prisma from '../../config/database'; +import { ApiError } from '../../middleware/errorHandler'; +import crypto from 'crypto'; + +export class QRCheckinService { + // Generar código QR + static async generateQRCode(bookingId: string, type: string = 'BOOKING_CHECKIN') { + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { user: true, court: true }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + // Generar código único + const code = crypto.randomBytes(16).toString('hex'); + + // Expira 2 horas después de la reserva + const expiresAt = new Date(booking.date); + const [hours, minutes] = booking.endTime.split(':'); + expiresAt.setHours(parseInt(hours) + 2, parseInt(minutes)); + + const qrCode = await prisma.qRCode.create({ + data: { + code, + type, + referenceId: bookingId, + expiresAt, + isActive: true, + }, + }); + + return { + qrCode, + booking: { + id: booking.id, + date: booking.date, + startTime: booking.startTime, + endTime: booking.endTime, + court: booking.court.name, + user: `${booking.user.firstName} ${booking.user.lastName}`, + }, + }; + } + + // Obtener QR de reserva + static async getQRCodeForBooking(bookingId: string) { + const qrCode = await prisma.qRCode.findFirst({ + where: { + referenceId: bookingId, + type: 'BOOKING_CHECKIN', + isActive: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (!qrCode) { + // Generar nuevo si no existe + return this.generateQRCode(bookingId); + } + + // Verificar si expiró + if (new Date() > qrCode.expiresAt) { + // Invalidar y generar nuevo + await prisma.qRCode.update({ + where: { id: qrCode.id }, + data: { isActive: false }, + }); + return this.generateQRCode(bookingId); + } + + return { qrCode }; + } + + // Validar código QR + static async validateQRCode(code: string) { + const qrCode = await prisma.qRCode.findUnique({ + where: { code }, + }); + + if (!qrCode) { + throw new ApiError('Código QR inválido', 400); + } + + if (!qrCode.isActive) { + throw new ApiError('Código QR ya fue utilizado', 400); + } + + if (new Date() > qrCode.expiresAt) { + throw new ApiError('Código QR expirado', 400); + } + + // Obtener info de la reserva + const booking = await prisma.booking.findUnique({ + where: { id: qrCode.referenceId }, + include: { user: true, court: true }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + return { + valid: true, + qrCode, + booking: { + id: booking.id, + user: `${booking.user.firstName} ${booking.user.lastName}`, + court: booking.court.name, + date: booking.date, + startTime: booking.startTime, + endTime: booking.endTime, + }, + }; + } + + // Procesar check-in + static async processCheckIn(code: string, adminId?: string) { + const { qrCode, booking } = await this.validateQRCode(code); + + // Verificar si ya hizo check-in + const existingCheckIn = await prisma.checkIn.findFirst({ + where: { + bookingId: booking.id, + checkOutTime: null, + }, + }); + + if (existingCheckIn) { + throw new ApiError('Ya se realizó check-in para esta reserva', 409); + } + + // Crear check-in + const checkIn = await prisma.checkIn.create({ + data: { + bookingId: booking.id, + userId: (await prisma.booking.findUnique({ where: { id: booking.id } }))!.userId, + qrCodeId: qrCode.id, + method: adminId ? 'MANUAL' : 'QR', + verifiedBy: adminId, + }, + }); + + // Marcar QR como usado + await prisma.qRCode.update({ + where: { id: qrCode.id }, + data: { + usedAt: new Date(), + usedBy: checkIn.userId, + isActive: false, + }, + }); + + return checkIn; + } + + // Check-out + static async processCheckOut(checkInId: string) { + const checkIn = await prisma.checkIn.findUnique({ + where: { id: checkInId }, + }); + + if (!checkIn) { + throw new ApiError('Check-in no encontrado', 404); + } + + if (checkIn.checkOutTime) { + throw new ApiError('Ya se realizó check-out', 400); + } + + return prisma.checkIn.update({ + where: { id: checkInId }, + data: { checkOutTime: new Date() }, + }); + } + + // Check-ins del día + static async getTodayCheckIns() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + return prisma.checkIn.findMany({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + }, + include: { + user: { select: { id: true, firstName: true, lastName: true } }, + booking: { + include: { court: { select: { name: true } } }, + }, + }, + orderBy: { checkInTime: 'desc' }, + }); + } + + // Cancelar QR + static async cancelQRCode(code: string) { + return prisma.qRCode.updateMany({ + where: { code }, + data: { isActive: false }, + }); + } +} + +export default QRCheckinService; diff --git a/backend/src/services/extras/wallOfFame.service.ts b/backend/src/services/extras/wallOfFame.service.ts new file mode 100644 index 0000000..eff8f0f --- /dev/null +++ b/backend/src/services/extras/wallOfFame.service.ts @@ -0,0 +1,101 @@ +import prisma from '../../config/database'; +import { ApiError } from '../../middleware/errorHandler'; + +export interface CreateWallOfFameInput { + tournamentId?: string; + title: string; + description?: string; + winners: { userId: string; name: string; position: number }[]; + category?: string; + imageUrl?: string; + eventDate: Date; + featured?: boolean; +} + +export class WallOfFameService { + // Crear entrada en Wall of Fame + static async createEntry(adminId: string, data: CreateWallOfFameInput) { + const entry = await prisma.wallOfFameEntry.create({ + data: { + tournamentId: data.tournamentId, + title: data.title, + description: data.description, + winners: JSON.stringify(data.winners), + category: data.category || 'TOURNAMENT', + imageUrl: data.imageUrl, + eventDate: data.eventDate, + featured: data.featured || false, + isActive: true, + }, + }); + + return entry; + } + + // Listar entradas + static async getEntries(filters: { category?: string; featured?: boolean; limit?: number }) { + const where: any = { isActive: true }; + + if (filters.category) where.category = filters.category; + if (filters.featured !== undefined) where.featured = filters.featured; + + const entries = await prisma.wallOfFameEntry.findMany({ + where, + orderBy: [{ featured: 'desc' }, { eventDate: 'desc' }], + take: filters.limit || 50, + }); + + return entries.map(entry => ({ + ...entry, + winners: JSON.parse(entry.winners as string), + })); + } + + // Entradas destacadas + static async getFeaturedEntries() { + return this.getEntries({ featured: true, limit: 5 }); + } + + // Ver detalle + static async getEntryById(id: string) { + const entry = await prisma.wallOfFameEntry.findFirst({ + where: { id, isActive: true }, + }); + + if (!entry) { + throw new ApiError('Entrada no encontrada', 404); + } + + return { + ...entry, + winners: JSON.parse(entry.winners as string), + }; + } + + // Actualizar + static async updateEntry(id: string, adminId: string, data: Partial) { + await this.getEntryById(id); + + const updateData: any = { ...data }; + if (data.winners) { + updateData.winners = JSON.stringify(data.winners); + } + + return prisma.wallOfFameEntry.update({ + where: { id }, + data: updateData, + }); + } + + // Eliminar (soft delete) + static async deleteEntry(id: string, adminId: string) { + await this.getEntryById(id); + + return prisma.wallOfFameEntry.update({ + where: { id }, + data: { isActive: false }, + }); + } +} + +export default WallOfFameService; diff --git a/backend/src/services/healthIntegration.service.ts b/backend/src/services/healthIntegration.service.ts new file mode 100644 index 0000000..bf8a3b8 --- /dev/null +++ b/backend/src/services/healthIntegration.service.ts @@ -0,0 +1,444 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { ActivitySource, ActivityType, ActivityPeriod } from '../utils/constants'; +import logger from '../config/logger'; + +export interface WorkoutData { + calories: number; + duration: number; // minutos + heartRate?: { + avg?: number; + max?: number; + }; + startTime: Date; + endTime: Date; + steps?: number; + distance?: number; // km + metadata?: Record; +} + +export interface SyncWorkoutInput { + source: string; + activityType: string; + workoutData: WorkoutData; + bookingId?: string; +} + +export class HealthIntegrationService { + /** + * Sincronizar datos de entrenamiento + */ + static async syncWorkoutData(userId: string, data: SyncWorkoutInput) { + // Validar fuente + if (!Object.values(ActivitySource).includes(data.source as any)) { + throw new ApiError( + `Fuente inválida. Debe ser una de: ${Object.values(ActivitySource).join(', ')}`, + 400 + ); + } + + // Validar tipo de actividad + if (!Object.values(ActivityType).includes(data.activityType as any)) { + throw new ApiError( + `Tipo de actividad inválido. Debe ser uno de: ${Object.values(ActivityType).join(', ')}`, + 400 + ); + } + + const { workoutData } = data; + + // Validar rangos + if (workoutData.calories < 0 || workoutData.calories > 5000) { + throw new ApiError('Calorías deben estar entre 0 y 5000', 400); + } + + if (workoutData.duration < 1 || workoutData.duration > 300) { + throw new ApiError('Duración debe estar entre 1 y 300 minutos', 400); + } + + if (workoutData.heartRate?.avg && (workoutData.heartRate.avg < 30 || workoutData.heartRate.avg > 220)) { + throw new ApiError('Frecuencia cardíaca promedio fuera de rango válido', 400); + } + + if (workoutData.heartRate?.max && (workoutData.heartRate.max < 30 || workoutData.heartRate.max > 220)) { + throw new ApiError('Frecuencia cardíaca máxima fuera de rango válido', 400); + } + + if (workoutData.steps && (workoutData.steps < 0 || workoutData.steps > 50000)) { + throw new ApiError('Pasos deben estar entre 0 y 50000', 400); + } + + if (workoutData.distance && (workoutData.distance < 0 || workoutData.distance > 50)) { + throw new ApiError('Distancia debe estar entre 0 y 50 km', 400); + } + + // Si se proporciona bookingId, verificar que existe y pertenece al usuario + if (data.bookingId) { + const booking = await prisma.booking.findFirst({ + where: { + id: data.bookingId, + userId, + }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada o no pertenece al usuario', 404); + } + } + + const activity = await prisma.userActivity.create({ + data: { + userId, + source: data.source, + activityType: data.activityType, + startTime: new Date(workoutData.startTime), + endTime: new Date(workoutData.endTime), + duration: workoutData.duration, + caloriesBurned: Math.round(workoutData.calories), + heartRateAvg: workoutData.heartRate?.avg, + heartRateMax: workoutData.heartRate?.max, + steps: workoutData.steps, + distance: workoutData.distance, + metadata: workoutData.metadata ? JSON.stringify(workoutData.metadata) : null, + bookingId: data.bookingId, + }, + }); + + logger.info( + `Actividad sincronizada: ${activity.id} para usuario: ${userId}, fuente: ${data.source}` + ); + + return activity; + } + + /** + * Obtener resumen de entrenamientos por período + */ + static async getWorkoutSummary(userId: string, period: string) { + // Validar período + if (!Object.values(ActivityPeriod).includes(period as any)) { + throw new ApiError( + `Período inválido. Debe ser uno de: ${Object.values(ActivityPeriod).join(', ')}`, + 400 + ); + } + + let startDate: Date; + const endDate = new Date(); + + switch (period) { + case ActivityPeriod.WEEK: + startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + break; + case ActivityPeriod.MONTH: + startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 1); + break; + case ActivityPeriod.YEAR: + startDate = new Date(); + startDate.setFullYear(startDate.getFullYear() - 1); + break; + case ActivityPeriod.ALL_TIME: + startDate = new Date('2000-01-01'); + break; + default: + startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + } + + const activities = await prisma.userActivity.findMany({ + where: { + userId, + startTime: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: { + startTime: 'desc', + }, + }); + + // Calcular estadísticas + const summary = { + totalActivities: activities.length, + totalDuration: activities.reduce((sum, a) => sum + a.duration, 0), + totalCalories: activities.reduce((sum, a) => sum + a.caloriesBurned, 0), + totalSteps: activities.reduce((sum, a) => sum + (a.steps || 0), 0), + totalDistance: activities.reduce((sum, a) => sum + (a.distance || 0), 0), + avgHeartRate: activities.filter(a => a.heartRateAvg).length > 0 + ? Math.round( + activities.reduce((sum, a) => sum + (a.heartRateAvg || 0), 0) / + activities.filter(a => a.heartRateAvg).length + ) + : null, + maxHeartRate: activities.filter(a => a.heartRateMax).length > 0 + ? Math.max(...activities.filter(a => a.heartRateMax).map(a => a.heartRateMax!)) + : null, + byActivityType: {} as Record, + bySource: {} as Record, + }; + + // Agrupar por tipo de actividad + for (const activity of activities) { + if (!summary.byActivityType[activity.activityType]) { + summary.byActivityType[activity.activityType] = { + count: 0, + duration: 0, + calories: 0, + }; + } + summary.byActivityType[activity.activityType].count++; + summary.byActivityType[activity.activityType].duration += activity.duration; + summary.byActivityType[activity.activityType].calories += activity.caloriesBurned; + + if (!summary.bySource[activity.source]) { + summary.bySource[activity.source] = 0; + } + summary.bySource[activity.source]++; + } + + return summary; + } + + /** + * Obtener calorías quemadas en un rango de fechas + */ + static async getCaloriesBurned( + userId: string, + startDate: Date, + endDate: Date + ) { + const activities = await prisma.userActivity.findMany({ + where: { + userId, + startTime: { + gte: startDate, + lte: endDate, + }, + }, + select: { + caloriesBurned: true, + startTime: true, + }, + orderBy: { + startTime: 'asc', + }, + }); + + const totalCalories = activities.reduce((sum, a) => sum + a.caloriesBurned, 0); + + // Agrupar por día + const byDay: Record = {}; + for (const activity of activities) { + const day = activity.startTime.toISOString().split('T')[0]; + if (!byDay[day]) { + byDay[day] = 0; + } + byDay[day] += activity.caloriesBurned; + } + + return { + total: totalCalories, + count: activities.length, + average: activities.length > 0 ? Math.round(totalCalories / activities.length) : 0, + byDay, + }; + } + + /** + * Obtener tiempo total de juego por período + */ + static async getTotalPlayTime(userId: string, period: string) { + // Validar período + if (!Object.values(ActivityPeriod).includes(period as any)) { + throw new ApiError( + `Período inválido. Debe ser uno de: ${Object.values(ActivityPeriod).join(', ')}`, + 400 + ); + } + + let startDate: Date; + const endDate = new Date(); + + switch (period) { + case ActivityPeriod.WEEK: + startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + break; + case ActivityPeriod.MONTH: + startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 1); + break; + case ActivityPeriod.YEAR: + startDate = new Date(); + startDate.setFullYear(startDate.getFullYear() - 1); + break; + case ActivityPeriod.ALL_TIME: + startDate = new Date('2000-01-01'); + break; + default: + startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + } + + const activities = await prisma.userActivity.findMany({ + where: { + userId, + startTime: { + gte: startDate, + lte: endDate, + }, + activityType: ActivityType.PADEL_GAME, + }, + select: { + duration: true, + startTime: true, + source: true, + }, + orderBy: { + startTime: 'asc', + }, + }); + + const totalMinutes = activities.reduce((sum, a) => sum + a.duration, 0); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + // Agrupar por semana + const byWeek: Record = {}; + for (const activity of activities) { + const date = new Date(activity.startTime); + const weekStart = new Date(date.setDate(date.getDate() - date.getDay())); + const weekKey = weekStart.toISOString().split('T')[0]; + if (!byWeek[weekKey]) { + byWeek[weekKey] = 0; + } + byWeek[weekKey] += activity.duration; + } + + return { + totalMinutes, + formatted: `${hours}h ${minutes}m`, + sessions: activities.length, + averageSessionMinutes: activities.length > 0 ? Math.round(totalMinutes / activities.length) : 0, + byWeek, + }; + } + + /** + * Obtener actividades de un usuario + */ + static async getUserActivities( + userId: string, + options: { + activityType?: string; + source?: string; + limit?: number; + offset?: number; + } = {} + ) { + const { activityType, source, limit = 50, offset = 0 } = options; + + const where: any = { userId }; + + if (activityType) { + where.activityType = activityType; + } + + if (source) { + where.source = source; + } + + const activities = await prisma.userActivity.findMany({ + where, + orderBy: { + startTime: 'desc', + }, + skip: offset, + take: limit, + include: { + booking: { + select: { + id: true, + court: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + return activities.map(activity => ({ + ...activity, + metadata: activity.metadata ? JSON.parse(activity.metadata) : null, + })); + } + + /** + * Sincronizar con Apple Health (placeholder) + */ + static async syncWithAppleHealth(userId: string, authToken: string) { + // TODO: Implementar integración con Apple HealthKit + // Esto requeriría: + // 1. Validar el authToken con Apple + // 2. Obtener datos de HealthKit usando HealthKit API + // 3. Sincronizar los workouts de pádel + + logger.info(`Sincronización con Apple Health solicitada para usuario: ${userId}`); + + return { + success: false, + message: 'Integración con Apple Health en desarrollo', + connected: false, + }; + } + + /** + * Sincronizar con Google Fit (placeholder) + */ + static async syncWithGoogleFit(userId: string, authToken: string) { + // TODO: Implementar integración con Google Fit + // Esto requeriría: + // 1. Validar el authToken con Google OAuth + // 2. Usar Google Fit API para obtener datos + // 3. Sincronizar las sesiones de pádel + + logger.info(`Sincronización con Google Fit solicitada para usuario: ${userId}`); + + return { + success: false, + message: 'Integración con Google Fit en desarrollo', + connected: false, + }; + } + + /** + * Eliminar una actividad + */ + static async deleteActivity(activityId: string, userId: string) { + const activity = await prisma.userActivity.findFirst({ + where: { + id: activityId, + userId, + }, + }); + + if (!activity) { + throw new ApiError('Actividad no encontrada', 404); + } + + await prisma.userActivity.delete({ + where: { id: activityId }, + }); + + logger.info(`Actividad eliminada: ${activityId}`); + + return { success: true, message: 'Actividad eliminada' }; + } +} + +export default HealthIntegrationService; diff --git a/backend/src/services/menu.service.ts b/backend/src/services/menu.service.ts new file mode 100644 index 0000000..4a4a93f --- /dev/null +++ b/backend/src/services/menu.service.ts @@ -0,0 +1,258 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { MenuItemCategory } from '../utils/constants'; +import logger from '../config/logger'; + +export interface CreateMenuItemInput { + name: string; + description?: string; + category: string; + price: number; + imageUrl?: string; + preparationTime?: number; + isAvailable?: boolean; + isActive?: boolean; +} + +export interface UpdateMenuItemInput { + name?: string; + description?: string; + category?: string; + price?: number; + imageUrl?: string; + preparationTime?: number; + isAvailable?: boolean; + isActive?: boolean; +} + +export class MenuService { + /** + * Crear un nuevo item en el menú (solo admin) + */ + static async createMenuItem(adminId: string, data: CreateMenuItemInput) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para crear items del menú', 403); + } + + // Validar categoría + if (!Object.values(MenuItemCategory).includes(data.category as any)) { + throw new ApiError( + `Categoría inválida. Debe ser uno de: ${Object.values(MenuItemCategory).join(', ')}`, + 400 + ); + } + + // Validar precio + if (data.price < 0) { + throw new ApiError('El precio no puede ser negativo', 400); + } + + // Validar tiempo de preparación + if (data.preparationTime && data.preparationTime < 0) { + throw new ApiError('El tiempo de preparación no puede ser negativo', 400); + } + + const menuItem = await prisma.menuItem.create({ + data: { + name: data.name, + description: data.description, + category: data.category, + price: data.price, + imageUrl: data.imageUrl, + preparationTime: data.preparationTime, + isAvailable: data.isAvailable ?? true, + isActive: data.isActive ?? true, + }, + }); + + logger.info(`Item de menú creado: ${menuItem.id} por admin: ${adminId}`); + + return menuItem; + } + + /** + * Obtener todos los items del menú disponibles + */ + static async getMenuItems(category?: string) { + const where: any = { + isActive: true, + }; + + if (category) { + // Validar categoría si se proporciona + if (!Object.values(MenuItemCategory).includes(category as any)) { + throw new ApiError( + `Categoría inválida. Debe ser uno de: ${Object.values(MenuItemCategory).join(', ')}`, + 400 + ); + } + where.category = category; + } + + return prisma.menuItem.findMany({ + where, + orderBy: [ + { category: 'asc' }, + { name: 'asc' }, + ], + }); + } + + /** + * Obtener todos los items del menú (admin - incluye inactivos) + */ + static async getAllMenuItems(category?: string) { + const where: any = {}; + + if (category) { + where.category = category; + } + + return prisma.menuItem.findMany({ + where, + orderBy: [ + { category: 'asc' }, + { name: 'asc' }, + ], + }); + } + + /** + * Obtener un item del menú por ID + */ + static async getMenuItemById(id: string) { + const menuItem = await prisma.menuItem.findUnique({ + where: { id }, + }); + + if (!menuItem) { + throw new ApiError('Item del menú no encontrado', 404); + } + + return menuItem; + } + + /** + * Actualizar un item del menú (solo admin) + */ + static async updateMenuItem(id: string, adminId: string, data: UpdateMenuItemInput) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para actualizar items del menú', 403); + } + + // Verificar que el item existe + const existingItem = await prisma.menuItem.findUnique({ + where: { id }, + }); + + if (!existingItem) { + throw new ApiError('Item del menú no encontrado', 404); + } + + // Validar categoría si se proporciona + if (data.category && !Object.values(MenuItemCategory).includes(data.category as any)) { + throw new ApiError( + `Categoría inválida. Debe ser uno de: ${Object.values(MenuItemCategory).join(', ')}`, + 400 + ); + } + + // Validar precio + if (data.price !== undefined && data.price < 0) { + throw new ApiError('El precio no puede ser negativo', 400); + } + + // Validar tiempo de preparación + if (data.preparationTime !== undefined && data.preparationTime < 0) { + throw new ApiError('El tiempo de preparación no puede ser negativo', 400); + } + + const updatedItem = await prisma.menuItem.update({ + where: { id }, + data, + }); + + logger.info(`Item de menú actualizado: ${id} por admin: ${adminId}`); + + return updatedItem; + } + + /** + * Eliminar un item del menú (soft delete - solo admin) + */ + static async deleteMenuItem(id: string, adminId: string) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para eliminar items del menú', 403); + } + + // Verificar que el item existe + const existingItem = await prisma.menuItem.findUnique({ + where: { id }, + }); + + if (!existingItem) { + throw new ApiError('Item del menú no encontrado', 404); + } + + // Soft delete: marcar como inactivo + const deletedItem = await prisma.menuItem.update({ + where: { id }, + data: { isActive: false }, + }); + + logger.info(`Item de menú eliminado (soft): ${id} por admin: ${adminId}`); + + return deletedItem; + } + + /** + * Cambiar disponibilidad de un item (solo admin) + */ + static async toggleAvailability(id: string, adminId: string) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para modificar items del menú', 403); + } + + // Verificar que el item existe + const existingItem = await prisma.menuItem.findUnique({ + where: { id }, + }); + + if (!existingItem) { + throw new ApiError('Item del menú no encontrado', 404); + } + + const updatedItem = await prisma.menuItem.update({ + where: { id }, + data: { isAvailable: !existingItem.isAvailable }, + }); + + logger.info( + `Disponibilidad de item ${id} cambiada a: ${updatedItem.isAvailable} por admin: ${adminId}` + ); + + return updatedItem; + } +} + +export default MenuService; diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 0000000..1818c47 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,294 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { NotificationType } from '../utils/constants'; +import logger from '../config/logger'; + +export interface NotificationData { + orderId?: string; + bookingId?: string; + tournamentId?: string; + matchId?: string; + userId?: string; + [key: string]: any; +} + +export class NotificationService { + /** + * Crear una nueva notificación + */ + static async createNotification( + userId: string, + type: string, + title: string, + message: string, + data?: NotificationData + ) { + // Validar tipo de notificación + if (!Object.values(NotificationType).includes(type as any)) { + throw new ApiError( + `Tipo de notificación inválido. Debe ser uno de: ${Object.values(NotificationType).join(', ')}`, + 400 + ); + } + + const notification = await prisma.notification.create({ + data: { + userId, + type, + title, + message, + data: data ? JSON.stringify(data) : null, + isRead: false, + }, + }); + + logger.info(`Notificación creada: ${notification.id} para usuario: ${userId}`); + + // TODO: Enviar notificación push en tiempo real cuando se implemente WebSockets + // this.sendPushNotification(userId, title, message); + + return notification; + } + + /** + * Obtener mis notificaciones + */ + static async getMyNotifications(userId: string, limit: number = 50) { + const notifications = await prisma.notification.findMany({ + where: { userId }, + orderBy: { + createdAt: 'desc', + }, + take: limit, + }); + + return notifications.map(notification => ({ + ...notification, + data: notification.data ? JSON.parse(notification.data) : null, + })); + } + + /** + * Marcar notificación como leída + */ + static async markAsRead(notificationId: string, userId: string) { + const notification = await prisma.notification.findFirst({ + where: { + id: notificationId, + userId, + }, + }); + + if (!notification) { + throw new ApiError('Notificación no encontrada', 404); + } + + const updated = await prisma.notification.update({ + where: { id: notificationId }, + data: { isRead: true }, + }); + + return { + ...updated, + data: updated.data ? JSON.parse(updated.data) : null, + }; + } + + /** + * Marcar todas las notificaciones como leídas + */ + static async markAllAsRead(userId: string) { + await prisma.notification.updateMany({ + where: { + userId, + isRead: false, + }, + data: { + isRead: true, + }, + }); + + logger.info(`Todas las notificaciones marcadas como leídas para usuario: ${userId}`); + + return { success: true, message: 'Todas las notificaciones marcadas como leídas' }; + } + + /** + * Eliminar una notificación + */ + static async deleteNotification(notificationId: string, userId: string) { + const notification = await prisma.notification.findFirst({ + where: { + id: notificationId, + userId, + }, + }); + + if (!notification) { + throw new ApiError('Notificación no encontrada', 404); + } + + await prisma.notification.delete({ + where: { id: notificationId }, + }); + + logger.info(`Notificación eliminada: ${notificationId}`); + + return { success: true, message: 'Notificación eliminada' }; + } + + /** + * Contar notificaciones no leídas + */ + static async getUnreadCount(userId: string) { + const count = await prisma.notification.count({ + where: { + userId, + isRead: false, + }, + }); + + return { count }; + } + + /** + * Enviar notificación push (preparado para futuro) + * Esta función es un placeholder para cuando se implemente Firebase Cloud Messaging + * o algún otro servicio de notificaciones push + */ + static async sendPushNotification(userId: string, title: string, message: string) { + // TODO: Implementar con Firebase Cloud Messaging o similar + logger.info(`Push notification (placeholder) - User: ${userId}, Title: ${title}`); + + // Aquí iría la lógica de FCM: + // const user = await prisma.user.findUnique({ where: { id: userId } }); + // if (user.fcmToken) { + // await admin.messaging().send({ + // token: user.fcmToken, + // notification: { title, body: message }, + // }); + // } + } + + /** + * Crear notificación masiva (para admins) + */ + static async createBulkNotification( + adminId: string, + userIds: string[], + type: string, + title: string, + message: string, + data?: NotificationData + ) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para enviar notificaciones masivas', 403); + } + + // Validar tipo de notificación + if (!Object.values(NotificationType).includes(type as any)) { + throw new ApiError( + `Tipo de notificación inválido. Debe ser uno de: ${Object.values(NotificationType).join(', ')}`, + 400 + ); + } + + const notifications = await prisma.$transaction( + userIds.map(userId => + prisma.notification.create({ + data: { + userId, + type, + title, + message, + data: data ? JSON.stringify(data) : null, + isRead: false, + }, + }) + ) + ); + + logger.info(`Notificación masiva enviada por admin ${adminId} a ${userIds.length} usuarios`); + + return { + success: true, + count: notifications.length, + }; + } + + /** + * Enviar recordatorio de reserva + */ + static async sendBookingReminder(bookingId: string) { + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + user: { + select: { + id: true, + firstName: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + const notification = await this.createNotification( + booking.userId, + NotificationType.BOOKING_REMINDER, + 'Recordatorio de reserva', + `Hola ${booking.user.firstName}, tienes una reserva hoy en ${booking.court.name} a las ${booking.startTime}.`, + { bookingId: booking.id, courtId: booking.courtId } + ); + + return notification; + } + + /** + * Limpiar notificaciones antiguas (más de 30 días) + */ + static async cleanupOldNotifications(adminId: string) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para limpiar notificaciones', 403); + } + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const result = await prisma.notification.deleteMany({ + where: { + createdAt: { + lt: thirtyDaysAgo, + }, + }, + }); + + logger.info(`Notificaciones antiguas eliminadas: ${result.count} por admin: ${adminId}`); + + return { + success: true, + deleted: result.count, + }; + } +} + +export default NotificationService; diff --git a/backend/src/services/order.service.ts b/backend/src/services/order.service.ts new file mode 100644 index 0000000..e8a78b1 --- /dev/null +++ b/backend/src/services/order.service.ts @@ -0,0 +1,600 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import { OrderStatus, OrderPaymentStatus, BookingStatus } from '../utils/constants'; +import logger from '../config/logger'; +import { createPaymentPreference } from './payment.service'; +import { NotificationService } from './notification.service'; + +export interface OrderItem { + itemId: string; + quantity: number; + notes?: string; +} + +export interface CreateOrderInput { + bookingId: string; + items: OrderItem[]; + notes?: string; +} + +export class OrderService { + /** + * Crear un nuevo pedido + * Requiere una reserva activa + */ + static async createOrder(userId: string, data: CreateOrderInput) { + // Verificar que existe la reserva y pertenece al usuario + const booking = await prisma.booking.findFirst({ + where: { + id: data.bookingId, + userId, + status: { + in: [BookingStatus.CONFIRMED, BookingStatus.PENDING], + }, + }, + include: { + court: true, + }, + }); + + if (!booking) { + throw new ApiError( + 'No tienes una reserva activa válida para realizar pedidos. Solo se pueden hacer pedidos desde la cancha con reserva confirmada.', + 400 + ); + } + + // Verificar que la fecha de la reserva es hoy o en el futuro cercano + const today = new Date(); + today.setHours(0, 0, 0, 0); + const bookingDate = new Date(booking.date); + bookingDate.setHours(0, 0, 0, 0); + + const diffTime = bookingDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + throw new ApiError('No se pueden hacer pedidos para reservas pasadas', 400); + } + + // Verificar que hay items + if (!data.items || data.items.length === 0) { + throw new ApiError('El pedido debe contener al menos un item', 400); + } + + // Validar items y calcular total + let totalAmount = 0; + const orderItems: Array<{ + itemId: string; + name: string; + quantity: number; + unitPrice: number; + notes?: string; + }> = []; + + for (const item of data.items) { + if (item.quantity <= 0) { + throw new ApiError('La cantidad debe ser mayor a 0', 400); + } + + const menuItem = await prisma.menuItem.findFirst({ + where: { + id: item.itemId, + isActive: true, + isAvailable: true, + }, + }); + + if (!menuItem) { + throw new ApiError(`Item ${item.itemId} no encontrado o no disponible`, 404); + } + + const itemTotal = menuItem.price * item.quantity; + totalAmount += itemTotal; + + orderItems.push({ + itemId: menuItem.id, + name: menuItem.name, + quantity: item.quantity, + unitPrice: menuItem.price, + notes: item.notes, + }); + } + + // Crear el pedido + const order = await prisma.order.create({ + data: { + userId, + bookingId: data.bookingId, + courtId: booking.courtId, + items: JSON.stringify(orderItems), + status: OrderStatus.PENDING, + totalAmount, + paymentStatus: OrderPaymentStatus.PENDING, + notes: data.notes, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + booking: { + select: { + id: true, + date: true, + startTime: true, + endTime: true, + }, + }, + }, + }); + + // Notificar a bar/cafetería + await this.notifyBar(order); + + logger.info(`Pedido creado: ${order.id} por usuario: ${userId}, total: ${totalAmount}`); + + return { + ...order, + items: orderItems, + }; + } + + /** + * Procesar pago del pedido con MercadoPago + */ + static async processPayment(orderId: string) { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + if (!order) { + throw new ApiError('Pedido no encontrado', 404); + } + + if (order.paymentStatus === OrderPaymentStatus.PAID) { + throw new ApiError('El pedido ya está pagado', 400); + } + + if (order.status === OrderStatus.CANCELLED) { + throw new ApiError('No se puede pagar un pedido cancelado', 400); + } + + // Crear preferencia de pago con MercadoPago + const items = JSON.parse(order.items); + const preferenceItems = items.map((item: any) => ({ + title: item.name, + quantity: item.quantity, + unit_price: item.unitPrice / 100, // Convertir de centavos + })); + + const preference = await createPaymentPreference({ + items: preferenceItems, + payer: { + name: order.user.firstName, + surname: order.user.lastName, + email: order.user.email, + }, + external_reference: orderId, + notification_url: `${process.env.API_URL}/api/orders/webhook`, + }); + + // Actualizar el pedido con el ID de preferencia + await prisma.order.update({ + where: { id: orderId }, + data: { + paymentId: preference.id, + }, + }); + + return { + orderId: order.id, + preferenceId: preference.id, + initPoint: preference.init_point, + sandboxInitPoint: preference.sandbox_init_point, + }; + } + + /** + * Obtener mis pedidos + */ + static async getMyOrders(userId: string) { + const orders = await prisma.order.findMany({ + where: { userId }, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + booking: { + select: { + id: true, + date: true, + startTime: true, + endTime: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return orders.map(order => ({ + ...order, + items: JSON.parse(order.items), + })); + } + + /** + * Obtener pedidos de una reserva + */ + static async getOrdersByBooking(bookingId: string, userId?: string) { + const where: any = { bookingId }; + + // Si se proporciona userId, verificar que el usuario tiene acceso a la reserva + if (userId) { + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + OR: [ + { userId }, + { + user: { + role: { + in: ['ADMIN', 'SUPERADMIN'], + }, + }, + }, + ], + }, + }); + + if (!booking) { + throw new ApiError('No tienes acceso a los pedidos de esta reserva', 403); + } + } + + const orders = await prisma.order.findMany({ + where, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return orders.map(order => ({ + ...order, + items: JSON.parse(order.items), + })); + } + + /** + * Obtener pedidos pendientes (para bar/cafetería) + */ + static async getPendingOrders() { + const orders = await prisma.order.findMany({ + where: { + status: { + in: [OrderStatus.PENDING, OrderStatus.PREPARING, OrderStatus.READY], + }, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + booking: { + select: { + id: true, + date: true, + startTime: true, + endTime: true, + }, + }, + }, + orderBy: [ + { status: 'asc' }, + { createdAt: 'asc' }, + ], + }); + + return orders.map(order => ({ + ...order, + items: JSON.parse(order.items), + })); + } + + /** + * Actualizar estado del pedido (solo admin/bar) + */ + static async updateOrderStatus(orderId: string, status: string, adminId: string) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para actualizar pedidos', 403); + } + + // Validar estado + if (!Object.values(OrderStatus).includes(status as any)) { + throw new ApiError( + `Estado inválido. Debe ser uno de: ${Object.values(OrderStatus).join(', ')}`, + 400 + ); + } + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + if (!order) { + throw new ApiError('Pedido no encontrado', 404); + } + + if (order.status === OrderStatus.CANCELLED) { + throw new ApiError('No se puede modificar un pedido cancelado', 400); + } + + if (order.status === OrderStatus.DELIVERED) { + throw new ApiError('No se puede modificar un pedido ya entregado', 400); + } + + const updatedOrder = await prisma.order.update({ + where: { id: orderId }, + data: { status }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + // Notificar al usuario si el pedido está listo + if (status === OrderStatus.READY) { + await NotificationService.createNotification( + order.userId, + 'ORDER_READY', + '¡Tu pedido está listo!', + `Tu pedido para la cancha ${updatedOrder.court.name} está listo para ser entregado.`, + { orderId: order.id } + ); + } + + logger.info(`Estado de pedido ${orderId} actualizado a: ${status} por admin: ${adminId}`); + + return { + ...updatedOrder, + items: JSON.parse(updatedOrder.items), + }; + } + + /** + * Marcar pedido como entregado (solo admin/bar) + */ + static async markAsDelivered(orderId: string, adminId: string) { + // Validar que el usuario es admin + const admin = await prisma.user.findUnique({ + where: { id: adminId }, + }); + + if (!admin || (admin.role !== 'ADMIN' && admin.role !== 'SUPERADMIN')) { + throw new ApiError('No tienes permiso para actualizar pedidos', 403); + } + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + }); + + if (!order) { + throw new ApiError('Pedido no encontrado', 404); + } + + if (order.status === OrderStatus.CANCELLED) { + throw new ApiError('No se puede marcar como entregado un pedido cancelado', 400); + } + + if (order.status === OrderStatus.DELIVERED) { + throw new ApiError('El pedido ya está entregado', 400); + } + + const updatedOrder = await prisma.order.update({ + where: { id: orderId }, + data: { status: OrderStatus.DELIVERED }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + logger.info(`Pedido ${orderId} marcado como entregado por admin: ${adminId}`); + + return { + ...updatedOrder, + items: JSON.parse(updatedOrder.items), + }; + } + + /** + * Cancelar pedido (usuario o admin) + */ + static async cancelOrder(orderId: string, userId: string, isAdmin: boolean = false) { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + }); + + if (!order) { + throw new ApiError('Pedido no encontrado', 404); + } + + // Verificar permisos + if (!isAdmin && order.userId !== userId) { + throw new ApiError('No tienes permiso para cancelar este pedido', 403); + } + + if (order.status === OrderStatus.CANCELLED) { + throw new ApiError('El pedido ya está cancelado', 400); + } + + if (order.status === OrderStatus.DELIVERED) { + throw new ApiError('No se puede cancelar un pedido ya entregado', 400); + } + + // Solo se puede cancelar si está pendiente o en preparación + if (order.status === OrderStatus.READY) { + throw new ApiError('No se puede cancelar un pedido que ya está listo', 400); + } + + const updatedOrder = await prisma.order.update({ + where: { id: orderId }, + data: { status: OrderStatus.CANCELLED }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + court: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + logger.info(`Pedido ${orderId} cancelado por usuario: ${userId}`); + + return { + ...updatedOrder, + items: JSON.parse(updatedOrder.items), + }; + } + + /** + * Notificar a bar/cafetería sobre nuevo pedido + */ + static async notifyBar(order: any) { + // Aquí se implementaría la lógica de notificación en tiempo real + // Por ahora solo loggeamos + const items = JSON.parse(order.items); + logger.info( + `NOTIFICACIÓN BAR - Nuevo pedido ${order.id} para cancha ${order.courtId}: ` + + `${items.map((i: any) => `${i.quantity}x ${i.name}`).join(', ')}` + ); + + // TODO: Implementar WebSockets para notificación en tiempo real + // socket.emit('new-order', { orderId: order.id, courtId: order.courtId, items }); + } + + /** + * Procesar webhook de MercadoPago + */ + static async processWebhook(paymentData: any) { + const { external_reference, status } = paymentData; + + if (!external_reference) { + throw new ApiError('Referencia externa no proporcionada', 400); + } + + const order = await prisma.order.findUnique({ + where: { id: external_reference }, + }); + + if (!order) { + throw new ApiError('Pedido no encontrado', 404); + } + + if (status === 'approved') { + await prisma.order.update({ + where: { id: external_reference }, + data: { paymentStatus: OrderPaymentStatus.PAID }, + }); + + logger.info(`Pago aprobado para pedido: ${external_reference}`); + } + + return { success: true }; + } +} + +export default OrderService; diff --git a/backend/src/services/payment.service.ts b/backend/src/services/payment.service.ts index 262e3d8..f265435 100644 --- a/backend/src/services/payment.service.ts +++ b/backend/src/services/payment.service.ts @@ -48,6 +48,7 @@ export const PaymentType = { BONUS: 'BONUS', SUBSCRIPTION: 'SUBSCRIPTION', CLASS: 'CLASS', + EQUIPMENT_RENTAL: 'EQUIPMENT_RENTAL', } as const; export type PaymentTypeType = typeof PaymentType[keyof typeof PaymentType]; @@ -333,6 +334,13 @@ export class PaymentService { logger.info(`Bono ${payment.referenceId} activado`); break; + case PaymentType.EQUIPMENT_RENTAL: + // Confirmar alquiler de equipamiento + const { EquipmentRentalService } = await import('./equipmentRental.service'); + await EquipmentRentalService.confirmRentalPayment(payment.referenceId); + logger.info(`Alquiler de equipamiento ${payment.referenceId} confirmado`); + break; + default: logger.info(`Pago completado para ${payment.type}: ${payment.referenceId}`); } diff --git a/backend/src/services/qrCheckin.service.ts b/backend/src/services/qrCheckin.service.ts new file mode 100644 index 0000000..8afc67c --- /dev/null +++ b/backend/src/services/qrCheckin.service.ts @@ -0,0 +1,766 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import logger from '../config/logger'; +import { + generateQRCodeData, + verifyQRCode, + generateQRImage, + getRemainingMinutes, + QRCodeType, + QRCodeData, + QRCodeTypeType, +} from '../utils/qr'; + +// Métodos de check-in +export const CheckInMethod = { + QR: 'QR', + MANUAL: 'MANUAL', +} as const; + +export type CheckInMethodType = typeof CheckInMethod[keyof typeof CheckInMethod]; + +// Interfaces +export interface GenerateQRInput { + bookingId: string; + userId: string; + expiresInMinutes?: number; +} + +export interface ProcessCheckInInput { + code: string; + adminId?: string; + notes?: string; +} + +export interface ProcessCheckOutInput { + checkInId: string; + adminId?: string; + notes?: string; +} + +export class QRCheckInService { + /** + * Generar código QR para una reserva + */ + static async generateQRCode(data: GenerateQRInput) { + const { bookingId, userId, expiresInMinutes = 15 } = data; + + // Verificar que la reserva existe y pertenece al usuario + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + userId, + }, + include: { + court: { + select: { + name: true, + }, + }, + }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + // Verificar que la reserva está confirmada + if (booking.status !== 'CONFIRMED') { + throw new ApiError('Solo se pueden generar QR para reservas confirmadas', 400); + } + + // Verificar que la fecha de la reserva es hoy o en el futuro + const bookingDate = new Date(booking.date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (bookingDate < today) { + throw new ApiError('No se pueden generar QR para reservas pasadas', 400); + } + + // Invalidar QR codes anteriores para esta reserva + await prisma.qRCode.updateMany({ + where: { + referenceId: bookingId, + type: QRCodeType.BOOKING_CHECKIN, + isActive: true, + }, + data: { + isActive: false, + }, + }); + + // Generar datos del QR + const qrData = generateQRCodeData( + QRCodeType.BOOKING_CHECKIN, + bookingId, + expiresInMinutes + ); + + // Crear registro en la base de datos + const qrCode = await prisma.qRCode.create({ + data: { + code: qrData.code, + type: QRCodeType.BOOKING_CHECKIN, + referenceId: bookingId, + expiresAt: new Date(qrData.expiresAt), + isActive: true, + }, + }); + + // Generar imagen QR + const qrImage = await generateQRImage(qrData); + + logger.info(`QR generado para reserva ${bookingId} por usuario ${userId}`); + + return { + qrCode: { + id: qrCode.id, + code: qrCode.code, + type: qrCode.type, + expiresAt: qrCode.expiresAt, + isActive: qrCode.isActive, + }, + qrImage, + booking: { + id: booking.id, + date: booking.date, + startTime: booking.startTime, + endTime: booking.endTime, + courtName: booking.court.name, + }, + expiresInMinutes, + }; + } + + /** + * Validar código QR (para escáner) + */ + static async validateQRCode(code: string) { + // Buscar el QR en la base de datos + const qrCode = await prisma.qRCode.findFirst({ + where: { + code, + isActive: true, + }, + include: { + checkIns: true, + }, + }); + + if (!qrCode) { + throw new ApiError('Código QR no encontrado o inactivo', 404); + } + + // Verificar expiración + if (qrCode.expiresAt < new Date()) { + throw new ApiError('Código QR expirado', 400); + } + + // Verificar si ya fue usado + if (qrCode.usedAt) { + throw new ApiError('Código QR ya fue utilizado', 400); + } + + // Obtener información de la reserva + const booking = await prisma.booking.findUnique({ + where: { + id: qrCode.referenceId, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + court: { + select: { + id: true, + name: true, + type: true, + }, + }, + }, + }); + + if (!booking) { + throw new ApiError('Reserva asociada no encontrada', 404); + } + + // Verificar si ya existe un check-in para esta reserva + const existingCheckIn = await prisma.checkIn.findFirst({ + where: { + bookingId: booking.id, + checkOutTime: null, // Aún no hizo check-out + }, + }); + + const remainingMinutes = getRemainingMinutes(qrCode.expiresAt); + + return { + valid: true, + qrCode: { + id: qrCode.id, + code: qrCode.code, + type: qrCode.type, + expiresAt: qrCode.expiresAt, + remainingMinutes, + }, + booking: { + id: booking.id, + date: booking.date, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + court: booking.court, + }, + user: booking.user, + alreadyCheckedIn: !!existingCheckIn, + existingCheckInId: existingCheckIn?.id, + }; + } + + /** + * Procesar check-in con código QR + */ + static async processCheckIn(data: ProcessCheckInInput) { + const { code, adminId, notes } = data; + + // Validar el QR primero + const validation = await this.validateQRCode(code); + + if (!validation.valid) { + throw new ApiError('Código QR inválido', 400); + } + + const { qrCode, booking, user, alreadyCheckedIn } = validation; + + // Si ya tiene check-in activo, no permitir otro + if (alreadyCheckedIn) { + throw new ApiError('El usuario ya tiene un check-in activo para esta reserva', 409); + } + + // Marcar QR como usado + await prisma.qRCode.update({ + where: { id: qrCode.id }, + data: { + usedAt: new Date(), + usedBy: user.id, + }, + }); + + // Crear registro de check-in + const checkIn = await prisma.checkIn.create({ + data: { + bookingId: booking.id, + userId: user.id, + qrCodeId: qrCode.id, + checkInTime: new Date(), + method: CheckInMethod.QR, + verifiedBy: adminId, + notes, + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true, + }, + }, + booking: { + include: { + court: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + logger.info(`Check-in QR procesado para reserva ${booking.id}, usuario ${user.id}`); + + return { + checkIn: { + id: checkIn.id, + checkInTime: checkIn.checkInTime, + method: checkIn.method, + notes: checkIn.notes, + }, + user: checkIn.user, + booking: { + id: checkIn.booking.id, + date: checkIn.booking.date, + startTime: checkIn.booking.startTime, + endTime: checkIn.booking.endTime, + court: checkIn.booking.court, + }, + }; + } + + /** + * Procesar check-in manual (sin QR) + */ + static async processManualCheckIn( + bookingId: string, + adminId: string, + notes?: string + ) { + // Verificar que la reserva existe + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + court: { + select: { + name: true, + }, + }, + }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + // Verificar que no tenga check-in activo + const existingCheckIn = await prisma.checkIn.findFirst({ + where: { + bookingId, + checkOutTime: null, + }, + }); + + if (existingCheckIn) { + throw new ApiError('El usuario ya tiene un check-in activo para esta reserva', 409); + } + + // Crear registro de check-in manual + const checkIn = await prisma.checkIn.create({ + data: { + bookingId, + userId: booking.userId, + checkInTime: new Date(), + method: CheckInMethod.MANUAL, + verifiedBy: adminId, + notes, + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true, + }, + }, + booking: { + include: { + court: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + logger.info(`Check-in manual procesado para reserva ${bookingId} por admin ${adminId}`); + + return { + checkIn: { + id: checkIn.id, + checkInTime: checkIn.checkInTime, + method: checkIn.method, + notes: checkIn.notes, + }, + user: checkIn.user, + booking: { + id: checkIn.booking.id, + date: checkIn.booking.date, + startTime: checkIn.booking.startTime, + endTime: checkIn.booking.endTime, + court: checkIn.booking.court, + }, + }; + } + + /** + * Procesar check-out + */ + static async processCheckOut(data: ProcessCheckOutInput) { + const { checkInId, adminId, notes } = data; + + const checkIn = await prisma.checkIn.findUnique({ + where: { id: checkInId }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + booking: { + include: { + court: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!checkIn) { + throw new ApiError('Registro de check-in no encontrado', 404); + } + + if (checkIn.checkOutTime) { + throw new ApiError('El check-out ya fue procesado', 400); + } + + const updatedCheckIn = await prisma.checkIn.update({ + where: { id: checkInId }, + data: { + checkOutTime: new Date(), + notes: notes ? `${checkIn.notes || ''} | Checkout: ${notes}` : checkIn.notes, + }, + include: { + user: { + select: { + firstName: true, + lastName: true, + email: true, + }, + }, + booking: { + include: { + court: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + logger.info(`Check-out procesado para check-in ${checkInId} por admin ${adminId}`); + + return { + checkIn: { + id: updatedCheckIn.id, + checkInTime: updatedCheckIn.checkInTime, + checkOutTime: updatedCheckIn.checkOutTime, + method: updatedCheckIn.method, + notes: updatedCheckIn.notes, + }, + user: updatedCheckIn.user, + booking: { + id: updatedCheckIn.booking.id, + date: updatedCheckIn.booking.date, + startTime: updatedCheckIn.booking.startTime, + endTime: updatedCheckIn.booking.endTime, + court: updatedCheckIn.booking.court, + }, + }; + } + + /** + * Obtener QR code para una reserva + */ + static async getQRCodeForBooking(bookingId: string, userId: string) { + const booking = await prisma.booking.findFirst({ + where: { + id: bookingId, + userId, + }, + }); + + if (!booking) { + throw new ApiError('Reserva no encontrada', 404); + } + + const qrCode = await prisma.qRCode.findFirst({ + where: { + referenceId: bookingId, + type: QRCodeType.BOOKING_CHECKIN, + isActive: true, + usedAt: null, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (!qrCode) { + return null; + } + + const remainingMinutes = getRemainingMinutes(qrCode.expiresAt); + + if (remainingMinutes <= 0) { + return null; + } + + // Regenerar imagen QR + const qrData: QRCodeData = { + code: qrCode.code, + type: qrCode.type as QRCodeTypeType, + referenceId: qrCode.referenceId, + expiresAt: qrCode.expiresAt.toISOString(), + checksum: '', // Se recalculará en generateQRImage + }; + const qrImage = await generateQRImage(qrData); + + return { + qrCode: { + id: qrCode.id, + code: qrCode.code, + expiresAt: qrCode.expiresAt, + remainingMinutes, + }, + qrImage, + }; + } + + /** + * Obtener check-ins del día (para admin) + */ + static async getTodayCheckIns() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const checkIns = await prisma.checkIn.findMany({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + avatarUrl: true, + }, + }, + booking: { + include: { + court: { + select: { + id: true, + name: true, + }, + }, + }, + }, + qrCode: { + select: { + code: true, + }, + }, + }, + orderBy: { + checkInTime: 'desc', + }, + }); + + return checkIns.map((ci) => ({ + id: ci.id, + checkInTime: ci.checkInTime, + checkOutTime: ci.checkOutTime, + method: ci.method, + notes: ci.notes, + user: ci.user, + booking: { + id: ci.booking.id, + date: ci.booking.date, + startTime: ci.booking.startTime, + endTime: ci.booking.endTime, + court: ci.booking.court, + }, + qrCode: ci.qrCode?.code, + })); + } + + /** + * Obtener historial de check-ins de una reserva + */ + static async getCheckInsByBooking(bookingId: string) { + const checkIns = await prisma.checkIn.findMany({ + where: { + bookingId, + }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + qrCode: { + select: { + code: true, + }, + }, + }, + orderBy: { + checkInTime: 'desc', + }, + }); + + return checkIns; + } + + /** + * Cancelar código QR (invalidarlo) + */ + static async cancelQRCode(code: string, userId: string) { + const qrCode = await prisma.qRCode.findFirst({ + where: { + code, + }, + }); + + if (!qrCode) { + throw new ApiError('Código QR no encontrado', 404); + } + + // Verificar que el QR pertenece a una reserva del usuario (o es admin) + const booking = await prisma.booking.findFirst({ + where: { + id: qrCode.referenceId, + }, + }); + + if (!booking) { + throw new ApiError('Reserva asociada no encontrada', 404); + } + + // Solo el dueño de la reserva puede cancelar su QR + if (booking.userId !== userId) { + throw new ApiError('No tienes permiso para cancelar este código QR', 403); + } + + if (!qrCode.isActive) { + throw new ApiError('El código QR ya está inactivo', 400); + } + + const updated = await prisma.qRCode.update({ + where: { id: qrCode.id }, + data: { + isActive: false, + }, + }); + + logger.info(`QR ${code} cancelado por usuario ${userId}`); + + return { + id: updated.id, + code: updated.code, + isActive: updated.isActive, + cancelledAt: new Date(), + }; + } + + /** + * Obtener estadísticas de check-ins del día + */ + static async getTodayStats() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const [ + totalCheckIns, + activeCheckIns, + completedCheckIns, + qrCheckIns, + manualCheckIns, + ] = await Promise.all([ + prisma.checkIn.count({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + }, + }), + prisma.checkIn.count({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + checkOutTime: null, + }, + }), + prisma.checkIn.count({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + checkOutTime: { not: null }, + }, + }), + prisma.checkIn.count({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + method: CheckInMethod.QR, + }, + }), + prisma.checkIn.count({ + where: { + checkInTime: { + gte: today, + lt: tomorrow, + }, + method: CheckInMethod.MANUAL, + }, + }), + ]); + + return { + total: totalCheckIns, + active: activeCheckIns, + completed: completedCheckIns, + byMethod: { + qr: qrCheckIns, + manual: manualCheckIns, + }, + }; + } +} + +export default QRCheckInService; diff --git a/backend/src/services/wallOfFame.service.ts b/backend/src/services/wallOfFame.service.ts new file mode 100644 index 0000000..7d50f8b --- /dev/null +++ b/backend/src/services/wallOfFame.service.ts @@ -0,0 +1,509 @@ +import prisma from '../config/database'; +import { ApiError } from '../middleware/errorHandler'; +import logger from '../config/logger'; +import { WallOfFameCategory, WallOfFameCategoryType } from '../utils/constants'; + +export interface WinnerInfo { + userId: string; + name: string; + position: number; + avatarUrl?: string; +} + +export interface CreateWallOfFameEntryInput { + title: string; + description?: string; + tournamentId?: string; + leagueId?: string; + winners: WinnerInfo[]; + category: WallOfFameCategoryType; + imageUrl?: string; + eventDate: Date; + featured?: boolean; +} + +export interface UpdateWallOfFameEntryInput { + title?: string; + description?: string; + winners?: WinnerInfo[]; + category?: WallOfFameCategoryType; + imageUrl?: string; + eventDate?: Date; + featured?: boolean; + isActive?: boolean; +} + +export interface WallOfFameFilters { + category?: WallOfFameCategoryType; + featured?: boolean; + isActive?: boolean; + tournamentId?: string; + leagueId?: string; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +export class WallOfFameService { + /** + * Crear una nueva entrada en el Wall of Fame + */ + static async createEntry(adminId: string, data: CreateWallOfFameEntryInput) { + // Validar categoría + if (!Object.values(WallOfFameCategory).includes(data.category)) { + throw new ApiError('Categoría inválida', 400); + } + + // Verificar que existe el torneo si se proporciona + if (data.tournamentId) { + const tournament = await prisma.tournament.findUnique({ + where: { id: data.tournamentId }, + }); + if (!tournament) { + throw new ApiError('Torneo no encontrado', 404); + } + } + + // Verificar que existe la liga si se proporciona + if (data.leagueId) { + const league = await prisma.league.findUnique({ + where: { id: data.leagueId }, + }); + if (!league) { + throw new ApiError('Liga no encontrada', 404); + } + } + + // Validar que haya al menos un ganador + if (!data.winners || data.winners.length === 0) { + throw new ApiError('Debe haber al menos un ganador', 400); + } + + // Validar que los userIds de los ganadores existan + const userIds = data.winners.map(w => w.userId); + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, firstName: true, lastName: true, avatarUrl: true }, + }); + + if (users.length !== userIds.length) { + throw new ApiError('Uno o más ganadores no existen', 404); + } + + // Enriquecer los datos de los ganadores con información actualizada + const enrichedWinners = data.winners.map(winner => { + const user = users.find(u => u.id === winner.userId); + return { + ...winner, + name: user ? `${user.firstName} ${user.lastName}` : winner.name, + avatarUrl: user?.avatarUrl || winner.avatarUrl, + }; + }); + + const entry = await prisma.wallOfFameEntry.create({ + data: { + title: data.title, + description: data.description, + tournamentId: data.tournamentId, + leagueId: data.leagueId, + winners: JSON.stringify(enrichedWinners), + category: data.category, + imageUrl: data.imageUrl, + eventDate: data.eventDate, + featured: data.featured ?? false, + isActive: true, + createdBy: adminId, + }, + include: { + tournament: { + select: { + id: true, + name: true, + category: true, + }, + }, + league: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + logger.info(`Entrada de Wall of Fame creada: ${entry.id} por admin ${adminId}`); + + return { + ...entry, + winners: enrichedWinners, + }; + } + + /** + * Obtener entradas del Wall of Fame con filtros + */ + static async getEntries(filters: WallOfFameFilters = {}) { + const where: any = {}; + + if (filters.category) { + where.category = filters.category; + } + + if (filters.featured !== undefined) { + where.featured = filters.featured; + } + + if (filters.isActive !== undefined) { + where.isActive = filters.isActive; + } else { + where.isActive = true; // Por defecto solo activos + } + + if (filters.tournamentId) { + where.tournamentId = filters.tournamentId; + } + + if (filters.leagueId) { + where.leagueId = filters.leagueId; + } + + if (filters.fromDate || filters.toDate) { + where.eventDate = {}; + if (filters.fromDate) where.eventDate.gte = filters.fromDate; + if (filters.toDate) where.eventDate.lte = filters.toDate; + } + + const [entries, total] = await Promise.all([ + prisma.wallOfFameEntry.findMany({ + where, + include: { + tournament: { + select: { + id: true, + name: true, + category: true, + type: true, + }, + }, + league: { + select: { + id: true, + name: true, + type: true, + }, + }, + }, + orderBy: [ + { featured: 'desc' }, + { eventDate: 'desc' }, + ], + take: filters.limit || 50, + skip: filters.offset || 0, + }), + prisma.wallOfFameEntry.count({ where }), + ]); + + // Parsear winners de JSON + const entriesWithParsedWinners = entries.map(entry => ({ + ...entry, + winners: JSON.parse(entry.winners) as WinnerInfo[], + })); + + return { + entries: entriesWithParsedWinners, + total, + limit: filters.limit || 50, + offset: filters.offset || 0, + }; + } + + /** + * Obtener entradas destacadas para el home + */ + static async getFeaturedEntries(limit: number = 5) { + const entries = await prisma.wallOfFameEntry.findMany({ + where: { + isActive: true, + featured: true, + }, + include: { + tournament: { + select: { + id: true, + name: true, + category: true, + type: true, + }, + }, + league: { + select: { + id: true, + name: true, + type: true, + }, + }, + }, + orderBy: { + eventDate: 'desc', + }, + take: limit, + }); + + return entries.map(entry => ({ + ...entry, + winners: JSON.parse(entry.winners) as WinnerInfo[], + })); + } + + /** + * Obtener una entrada por ID + */ + static async getEntryById(id: string) { + const entry = await prisma.wallOfFameEntry.findUnique({ + where: { id }, + include: { + tournament: { + select: { + id: true, + name: true, + category: true, + type: true, + description: true, + startDate: true, + endDate: true, + }, + }, + league: { + select: { + id: true, + name: true, + type: true, + format: true, + startDate: true, + endDate: true, + }, + }, + }, + }); + + if (!entry) { + throw new ApiError('Entrada no encontrada', 404); + } + + return { + ...entry, + winners: JSON.parse(entry.winners) as WinnerInfo[], + }; + } + + /** + * Actualizar una entrada del Wall of Fame + */ + static async updateEntry(id: string, adminId: string, data: UpdateWallOfFameEntryInput) { + const entry = await prisma.wallOfFameEntry.findUnique({ + where: { id }, + }); + + if (!entry) { + throw new ApiError('Entrada no encontrada', 404); + } + + // Validar categoría si se proporciona + if (data.category && !Object.values(WallOfFameCategory).includes(data.category)) { + throw new ApiError('Categoría inválida', 400); + } + + // Si se actualizan los ganadores, validarlos + let winnersJson = entry.winners; + if (data.winners && data.winners.length > 0) { + const userIds = data.winners.map(w => w.userId); + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, firstName: true, lastName: true, avatarUrl: true }, + }); + + if (users.length !== userIds.length) { + throw new ApiError('Uno o más ganadores no existen', 404); + } + + const enrichedWinners = data.winners.map(winner => { + const user = users.find(u => u.id === winner.userId); + return { + ...winner, + name: user ? `${user.firstName} ${user.lastName}` : winner.name, + avatarUrl: user?.avatarUrl || winner.avatarUrl, + }; + }); + + winnersJson = JSON.stringify(enrichedWinners); + } + + const updated = await prisma.wallOfFameEntry.update({ + where: { id }, + data: { + title: data.title, + description: data.description, + winners: winnersJson, + category: data.category, + imageUrl: data.imageUrl, + eventDate: data.eventDate, + featured: data.featured, + isActive: data.isActive, + }, + include: { + tournament: { + select: { + id: true, + name: true, + category: true, + }, + }, + league: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + logger.info(`Entrada de Wall of Fame actualizada: ${id} por admin ${adminId}`); + + return { + ...updated, + winners: JSON.parse(updated.winners) as WinnerInfo[], + }; + } + + /** + * Eliminar una entrada del Wall of Fame + */ + static async deleteEntry(id: string, adminId: string) { + const entry = await prisma.wallOfFameEntry.findUnique({ + where: { id }, + }); + + if (!entry) { + throw new ApiError('Entrada no encontrada', 404); + } + + await prisma.wallOfFameEntry.delete({ + where: { id }, + }); + + logger.info(`Entrada de Wall of Fame eliminada: ${id} por admin ${adminId}`); + + return { message: 'Entrada eliminada correctamente' }; + } + + /** + * Agregar ganadores a una entrada existente + */ + static async addWinners(id: string, newWinners: WinnerInfo[]) { + const entry = await prisma.wallOfFameEntry.findUnique({ + where: { id }, + }); + + if (!entry) { + throw new ApiError('Entrada no encontrada', 404); + } + + if (!newWinners || newWinners.length === 0) { + throw new ApiError('Debe proporcionar al menos un ganador', 400); + } + + // Validar que los userIds existan + const userIds = newWinners.map(w => w.userId); + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, firstName: true, lastName: true, avatarUrl: true }, + }); + + if (users.length !== userIds.length) { + throw new ApiError('Uno o más ganadores no existen', 404); + } + + // Enriquecer nuevos ganadores + const enrichedNewWinners = newWinners.map(winner => { + const user = users.find(u => u.id === winner.userId); + return { + ...winner, + name: user ? `${user.firstName} ${user.lastName}` : winner.name, + avatarUrl: user?.avatarUrl || winner.avatarUrl, + }; + }); + + // Combinar con ganadores existentes + const existingWinners = JSON.parse(entry.winners) as WinnerInfo[]; + const combinedWinners = [...existingWinners, ...enrichedNewWinners]; + + const updated = await prisma.wallOfFameEntry.update({ + where: { id }, + data: { + winners: JSON.stringify(combinedWinners), + }, + include: { + tournament: { + select: { + id: true, + name: true, + }, + }, + league: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + logger.info(`Ganadores agregados a entrada ${id}: ${newWinners.length} nuevos ganadores`); + + return { + ...updated, + winners: JSON.parse(updated.winners) as WinnerInfo[], + }; + } + + /** + * Buscar entradas por término de búsqueda + */ + static async searchEntries(query: string, limit: number = 20) { + const entries = await prisma.wallOfFameEntry.findMany({ + where: { + isActive: true, + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { description: { contains: query, mode: 'insensitive' } }, + ], + }, + include: { + tournament: { + select: { + id: true, + name: true, + }, + }, + league: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + eventDate: 'desc', + }, + take: limit, + }); + + return entries.map(entry => ({ + ...entry, + winners: JSON.parse(entry.winners) as WinnerInfo[], + })); + } +} + +export default WallOfFameService; diff --git a/backend/src/utils/constants.ts b/backend/src/utils/constants.ts index 98f0b8c..3f9da04 100644 --- a/backend/src/utils/constants.ts +++ b/backend/src/utils/constants.ts @@ -371,3 +371,323 @@ export const ReportFormat = { } as const; export type ReportFormatType = typeof ReportFormat[keyof typeof ReportFormat]; + +// ============================================ +// Constantes de Check-in QR (Fase 6.2) +// ============================================ + +export const QRCodeType = { + BOOKING_CHECKIN: 'BOOKING_CHECKIN', + EVENT_ACCESS: 'EVENT_ACCESS', + EQUIPMENT_RENTAL: 'EQUIPMENT_RENTAL', +} as const; + +export type QRCodeTypeType = typeof QRCodeType[keyof typeof QRCodeType]; + +export const CheckInMethod = { + QR: 'QR', + MANUAL: 'MANUAL', +} as const; + +export type CheckInMethodType = typeof CheckInMethod[keyof typeof CheckInMethod]; + +// ============================================ +// Constantes de Equipamiento (Fase 6.2) +// ============================================ + +export const EquipmentCategory = { + RACKET: 'RACKET', + BALLS: 'BALLS', + ACCESSORIES: 'ACCESSORIES', + SHOES: 'SHOES', +} as const; + +export type EquipmentCategoryType = typeof EquipmentCategory[keyof typeof EquipmentCategory]; + +export const EquipmentCondition = { + NEW: 'NEW', + GOOD: 'GOOD', + FAIR: 'FAIR', + POOR: 'POOR', +} as const; + +export type EquipmentConditionType = typeof EquipmentCondition[keyof typeof EquipmentCondition]; + +export const RentalStatus = { + RESERVED: 'RESERVED', + PICKED_UP: 'PICKED_UP', + RETURNED: 'RETURNED', + LATE: 'LATE', + DAMAGED: 'DAMAGED', + CANCELLED: 'CANCELLED', +} as const; + +export type RentalStatusType = typeof RentalStatus[keyof typeof RentalStatus]; + +// ============================================ +// Constantes de Wall of Fame (Fase 6.1) +// ============================================ + +// Categorías de entrada en Wall of Fame +export const WallOfFameCategory = { + TOURNAMENT: 'TOURNAMENT', // Ganador de torneo + LEAGUE: 'LEAGUE', // Ganador de liga + SPECIAL: 'SPECIAL', // Logro especial +} as const; + +export type WallOfFameCategoryType = typeof WallOfFameCategory[keyof typeof WallOfFameCategory]; + +// ============================================ +// Constantes de Logros (Fase 6.1) +// ============================================ + +// Categorías de logros +export const AchievementCategory = { + GAMES: 'GAMES', // Logros relacionados con partidos + TOURNAMENTS: 'TOURNAMENTS', // Logros de torneos + SOCIAL: 'SOCIAL', // Logros sociales (amigos, etc.) + STREAK: 'STREAK', // Logros de rachas + SPECIAL: 'SPECIAL', // Logros especiales +} as const; + +export type AchievementCategoryType = typeof AchievementCategory[keyof typeof AchievementCategory]; + +// Tipos de requisito para logros +export const RequirementType = { + MATCHES_PLAYED: 'MATCHES_PLAYED', // Partidos jugados + MATCHES_WON: 'MATCHES_WON', // Partidos ganados + TOURNAMENTS_PLAYED: 'TOURNAMENTS_PLAYED', // Torneos jugados + TOURNAMENTS_WON: 'TOURNAMENTS_WON', // Torneos ganados + FRIENDS_ADDED: 'FRIENDS_ADDED', // Amigos agregados + STREAK_DAYS: 'STREAK_DAYS', // Días consecutivos jugando + BOOKINGS_MADE: 'BOOKINGS_MADE', // Reservas realizadas + GROUPS_JOINED: 'GROUPS_JOINED', // Grupos unidos + LEAGUES_WON: 'LEAGUES_WON', // Ligas ganadas + PERFECT_MATCH: 'PERFECT_MATCH', // Partido perfecto (6-0) + COMEBACK_WIN: 'COMEBACK_WIN', // Victoria remontando +} as const; + +export type RequirementTypeType = typeof RequirementType[keyof typeof RequirementType]; + +// Logros predefinidos del sistema +export const DEFAULT_ACHIEVEMENTS = [ + // Logros de partidos + { + code: 'FIRST_MATCH', + name: 'Primer Partido', + description: 'Juega tu primer partido', + category: AchievementCategory.GAMES, + icon: '🎾', + color: '#4CAF50', + requirementType: RequirementType.MATCHES_PLAYED, + requirementValue: 1, + pointsReward: 10, + }, + { + code: 'MATCHES_10', + name: 'Jugador Activo', + description: 'Juega 10 partidos', + category: AchievementCategory.GAMES, + icon: '🏃', + color: '#2196F3', + requirementType: RequirementType.MATCHES_PLAYED, + requirementValue: 10, + pointsReward: 25, + }, + { + code: 'MATCHES_50', + name: 'Veterano', + description: 'Juega 50 partidos', + category: AchievementCategory.GAMES, + icon: '⭐', + color: '#9C27B0', + requirementType: RequirementType.MATCHES_PLAYED, + requirementValue: 50, + pointsReward: 50, + }, + { + code: 'MATCHES_100', + name: 'Leyenda', + description: 'Juega 100 partidos', + category: AchievementCategory.GAMES, + icon: '👑', + color: '#FFD700', + requirementType: RequirementType.MATCHES_PLAYED, + requirementValue: 100, + pointsReward: 100, + }, + // Logros de victorias + { + code: 'FIRST_WIN', + name: 'Primera Victoria', + description: 'Gana tu primer partido', + category: AchievementCategory.GAMES, + icon: '🏆', + color: '#FF9800', + requirementType: RequirementType.MATCHES_WON, + requirementValue: 1, + pointsReward: 15, + }, + { + code: 'WINS_10', + name: 'Ganador', + description: 'Gana 10 partidos', + category: AchievementCategory.GAMES, + icon: '🥇', + color: '#FFC107', + requirementType: RequirementType.MATCHES_WON, + requirementValue: 10, + pointsReward: 30, + }, + { + code: 'WINS_50', + name: 'Campeón', + description: 'Gana 50 partidos', + category: AchievementCategory.GAMES, + icon: '🥊', + color: '#F44336', + requirementType: RequirementType.MATCHES_WON, + requirementValue: 50, + pointsReward: 75, + }, + // Logros sociales + { + code: 'FIRST_FRIEND', + name: 'Primer Amigo', + description: 'Agrega tu primer amigo', + category: AchievementCategory.SOCIAL, + icon: '🤝', + color: '#00BCD4', + requirementType: RequirementType.FRIENDS_ADDED, + requirementValue: 1, + pointsReward: 10, + }, + { + code: 'FRIENDS_5', + name: 'Social', + description: 'Agrega 5 amigos', + category: AchievementCategory.SOCIAL, + icon: '👥', + color: '#009688', + requirementType: RequirementType.FRIENDS_ADDED, + requirementValue: 5, + pointsReward: 25, + }, + // Logros de racha + { + code: 'STREAK_7', + name: 'Constancia', + description: 'Juega 7 días seguidos', + category: AchievementCategory.STREAK, + icon: '🔥', + color: '#FF5722', + requirementType: RequirementType.STREAK_DAYS, + requirementValue: 7, + pointsReward: 50, + }, + { + code: 'STREAK_30', + name: 'Adicto al Pádel', + description: 'Juega 30 días seguidos', + category: AchievementCategory.STREAK, + icon: '💪', + color: '#E91E63', + requirementType: RequirementType.STREAK_DAYS, + requirementValue: 30, + pointsReward: 150, + }, +] as const; + +// ============================================ +// Constantes de Retos (Fase 6.1) +// ============================================ + +// Tipos de retos +export const ChallengeType = { + WEEKLY: 'WEEKLY', // Reto semanal + MONTHLY: 'MONTHLY', // Reto mensual + SPECIAL: 'SPECIAL', // Reto especial/evento +} as const; + +export type ChallengeTypeType = typeof ChallengeType[keyof typeof ChallengeType]; + +// ============================================ +// Constantes de Servicios del Club (Fase 6.3) +// ============================================ + +// Categorías de items del menú +export const MenuItemCategory = { + DRINK: 'DRINK', // Bebidas + SNACK: 'SNACK', // Snacks + FOOD: 'FOOD', // Comidas + OTHER: 'OTHER', // Otros +} as const; + +export type MenuItemCategoryType = typeof MenuItemCategory[keyof typeof MenuItemCategory]; + +// Estados de pedido +export const OrderStatus = { + PENDING: 'PENDING', // Pendiente + PREPARING: 'PREPARING', // En preparación + READY: 'READY', // Listo para entregar + DELIVERED: 'DELIVERED', // Entregado + CANCELLED: 'CANCELLED', // Cancelado +} as const; + +export type OrderStatusType = typeof OrderStatus[keyof typeof OrderStatus]; + +// Estado de pago del pedido +export const OrderPaymentStatus = { + PENDING: 'PENDING', // Pendiente de pago + PAID: 'PAID', // Pagado +} as const; + +export type OrderPaymentStatusType = typeof OrderPaymentStatus[keyof typeof OrderPaymentStatus]; + +// Tipos de notificación +export const NotificationType = { + ORDER_READY: 'ORDER_READY', // Pedido listo + BOOKING_REMINDER: 'BOOKING_REMINDER', // Recordatorio de reserva + TOURNAMENT_START: 'TOURNAMENT_START', // Inicio de torneo + TOURNAMENT_MATCH_READY: 'TOURNAMENT_MATCH_READY', // Partido listo + LEAGUE_MATCH_SCHEDULED: 'LEAGUE_MATCH_SCHEDULED', // Partido de liga programado + FRIEND_REQUEST: 'FRIEND_REQUEST', // Solicitud de amistad + GROUP_INVITATION: 'GROUP_INVITATION', // Invitación a grupo + SUBSCRIPTION_EXPIRING: 'SUBSCRIPTION_EXPIRING', // Suscripción por expirar + PAYMENT_CONFIRMED: 'PAYMENT_CONFIRMED', // Pago confirmado + CLASS_REMINDER: 'CLASS_REMINDER', // Recordatorio de clase + GENERAL: 'GENERAL', // Notificación general +} as const; + +export type NotificationTypeType = typeof NotificationType[keyof typeof NotificationType]; + +// ============================================ +// Constantes de Wearables/Actividad (Fase 6.3) +// ============================================ + +// Fuentes de actividad +export const ActivitySource = { + APPLE_HEALTH: 'APPLE_HEALTH', // Apple HealthKit + GOOGLE_FIT: 'GOOGLE_FIT', // Google Fit + MANUAL: 'MANUAL', // Ingreso manual +} as const; + +export type ActivitySourceType = typeof ActivitySource[keyof typeof ActivitySource]; + +// Tipos de actividad +export const ActivityType = { + PADEL_GAME: 'PADEL_GAME', // Partido de pádel + WORKOUT: 'WORKOUT', // Entrenamiento general +} as const; + +export type ActivityTypeType = typeof ActivityType[keyof typeof ActivityType]; + +// Períodos para resumen de actividad +export const ActivityPeriod = { + WEEK: 'WEEK', + MONTH: 'MONTH', + YEAR: 'YEAR', + ALL_TIME: 'ALL_TIME', +} as const; + +export type ActivityPeriodType = typeof ActivityPeriod[keyof typeof ActivityPeriod]; diff --git a/backend/src/utils/qr.ts b/backend/src/utils/qr.ts new file mode 100644 index 0000000..49c81a7 --- /dev/null +++ b/backend/src/utils/qr.ts @@ -0,0 +1,119 @@ +import QRCodeLib from 'qrcode'; +import crypto from 'crypto'; + +// Tipos de código QR +export const QRCodeType = { + BOOKING_CHECKIN: 'BOOKING_CHECKIN', + EVENT_ACCESS: 'EVENT_ACCESS', + EQUIPMENT_RENTAL: 'EQUIPMENT_RENTAL', +} as const; + +export type QRCodeTypeType = typeof QRCodeType[keyof typeof QRCodeType]; + +// Información codificada en el QR +export interface QRCodeData { + code: string; + type: QRCodeTypeType; + referenceId: string; + expiresAt: string; + checksum: string; +} + +// Generar código único para QR +export function generateUniqueCode(): string { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = crypto.randomBytes(4).toString('hex').toUpperCase(); + return `${timestamp}-${random}`; +} + +// Generar checksum para verificar integridad +function generateChecksum(data: Omit): string { + const secret = process.env.QR_SECRET || 'padel-app-secret-key'; + const payload = `${data.code}:${data.type}:${data.referenceId}:${data.expiresAt}`; + return crypto.createHmac('sha256', secret).update(payload).digest('hex').substring(0, 16); +} + +// Generar datos del QR +export function generateQRCodeData( + type: QRCodeTypeType, + referenceId: string, + expiresInMinutes: number = 15 +): QRCodeData { + const code = generateUniqueCode(); + const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString(); + + const data: Omit = { + code, + type, + referenceId, + expiresAt, + }; + + const checksum = generateChecksum(data); + + return { + ...data, + checksum, + }; +} + +// Verificar validez del código QR +export function verifyQRCode(data: QRCodeData): { valid: boolean; reason?: string } { + // Verificar checksum + const { checksum, ...dataWithoutChecksum } = data; + const expectedChecksum = generateChecksum(dataWithoutChecksum); + + if (checksum !== expectedChecksum) { + return { valid: false, reason: 'INVALID_CHECKSUM' }; + } + + // Verificar expiración + const expiresAt = new Date(data.expiresAt); + if (expiresAt < new Date()) { + return { valid: false, reason: 'EXPIRED' }; + } + + return { valid: true }; +} + +// Generar imagen QR en base64 +export async function generateQRImage(data: QRCodeData): Promise { + const qrString = JSON.stringify(data); + try { + const dataUrl = await QRCodeLib.toDataURL(qrString, { + width: 400, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }); + return dataUrl; + } catch (error) { + throw new Error('Error generating QR code image'); + } +} + +// Parsear datos del QR desde string +export function parseQRCodeData(qrString: string): QRCodeData | null { + try { + const data = JSON.parse(qrString) as QRCodeData; + return data; + } catch { + return null; + } +} + +// Formatear código para mostrar +export function formatQRCodeForDisplay(code: string): string { + // Formato: XXXX-XXXX-XXXX para mejor legibilidad + return code.replace(/(.{4})(.{4})(.{4})/, '$1-$2-$3'); +} + +// Calcular tiempo restante de validez en minutos +export function getRemainingMinutes(expiresAt: Date | string): number { + const expiration = new Date(expiresAt); + const now = new Date(); + const diffMs = expiration.getTime() - now.getTime(); + return Math.max(0, Math.ceil(diffMs / (1000 * 60))); +} diff --git a/backend/src/validators/services.validator.ts b/backend/src/validators/services.validator.ts new file mode 100644 index 0000000..024b396 --- /dev/null +++ b/backend/src/validators/services.validator.ts @@ -0,0 +1,154 @@ +import { z } from 'zod'; +import { MenuItemCategory, OrderStatus, ActivitySource, ActivityType } from '../utils/constants'; + +// ============================================ +// Validadores para Menu Items +// ============================================ + +export const createMenuItemSchema = z.object({ + name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'), + description: z.string().max(500, 'La descripción no puede exceder 500 caracteres').optional(), + category: z.enum([ + MenuItemCategory.DRINK, + MenuItemCategory.SNACK, + MenuItemCategory.FOOD, + MenuItemCategory.OTHER, + ]), + price: z.number().int().min(0, 'El precio no puede ser negativo'), + imageUrl: z.string().url('URL de imagen inválida').optional(), + preparationTime: z.number().int().min(0).max(120, 'El tiempo de preparación no puede exceder 120 minutos').optional(), + isAvailable: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +export const updateMenuItemSchema = z.object({ + name: z.string().min(2).optional(), + description: z.string().max(500).optional(), + category: z.enum([ + MenuItemCategory.DRINK, + MenuItemCategory.SNACK, + MenuItemCategory.FOOD, + MenuItemCategory.OTHER, + ]).optional(), + price: z.number().int().min(0).optional(), + imageUrl: z.string().url().optional(), + preparationTime: z.number().int().min(0).max(120).optional(), + isAvailable: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +// ============================================ +// Validadores para Orders/Pedidos +// ============================================ + +const orderItemSchema = z.object({ + itemId: z.string().uuid('ID de item inválido'), + quantity: z.number().int().min(1, 'La cantidad debe ser al menos 1').max(50, 'Máximo 50 unidades por item'), + notes: z.string().max(200, 'Las notas no pueden exceder 200 caracteres').optional(), +}); + +export const createOrderSchema = z.object({ + bookingId: z.string().uuid('ID de reserva inválido'), + items: z.array(orderItemSchema).min(1, 'El pedido debe tener al menos un item').max(20, 'Máximo 20 items por pedido'), + notes: z.string().max(500, 'Las notas no pueden exceder 500 caracteres').optional(), +}); + +export const updateOrderStatusSchema = z.object({ + status: z.enum([ + OrderStatus.PENDING, + OrderStatus.PREPARING, + OrderStatus.READY, + OrderStatus.DELIVERED, + OrderStatus.CANCELLED, + ]), +}); + +// ============================================ +// Validadores para Health/Wearables +// ============================================ + +export const syncHealthDataSchema = z.object({ + source: z.enum([ + ActivitySource.APPLE_HEALTH, + ActivitySource.GOOGLE_FIT, + ActivitySource.MANUAL, + ]), + activityType: z.enum([ + ActivityType.PADEL_GAME, + ActivityType.WORKOUT, + ]), + workoutData: z.object({ + calories: z.number().min(0, 'Calorías no pueden ser negativas').max(5000, 'Máximo 5000 calorías'), + duration: z.number().int().min(1, 'Duración mínima 1 minuto').max(300, 'Duración máxima 5 horas'), + heartRate: z.object({ + avg: z.number().int().min(30, 'FC mínima 30 bpm').max(220, 'FC máxima 220 bpm').optional(), + max: z.number().int().min(30, 'FC mínima 30 bpm').max(220, 'FC máxima 220 bpm').optional(), + }).optional(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), + steps: z.number().int().min(0).max(50000).optional(), + distance: z.number().min(0).max(50).optional(), + metadata: z.record(z.any()).optional(), + }).refine((data) => { + const start = new Date(data.startTime); + const end = new Date(data.endTime); + return end > start; + }, { + message: 'La hora de fin debe ser posterior a la hora de inicio', + path: ['endTime'], + }), + bookingId: z.string().uuid().optional(), +}); + +export const healthPeriodSchema = z.object({ + period: z.enum(['WEEK', 'MONTH', 'YEAR', 'ALL_TIME']).optional(), +}); + +export const caloriesQuerySchema = z.object({ + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD'), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Fecha debe estar en formato YYYY-MM-DD'), +}).refine((data) => { + const start = new Date(data.startDate); + const end = new Date(data.endDate); + return end >= start; +}, { + message: 'La fecha de fin debe ser igual o posterior a la fecha de inicio', + path: ['endDate'], +}); + +// ============================================ +// Validadores para Notificaciones +// ============================================ + +export const notificationTypeSchema = z.enum([ + 'ORDER_READY', + 'BOOKING_REMINDER', + 'TOURNAMENT_START', + 'TOURNAMENT_MATCH_READY', + 'LEAGUE_MATCH_SCHEDULED', + 'FRIEND_REQUEST', + 'GROUP_INVITATION', + 'SUBSCRIPTION_EXPIRING', + 'PAYMENT_CONFIRMED', + 'CLASS_REMINDER', + 'GENERAL', +]); + +export const bulkNotificationSchema = z.object({ + userIds: z.array(z.string().uuid()).min(1).max(1000, 'Máximo 1000 usuarios por notificación masiva'), + type: notificationTypeSchema, + title: z.string().min(1).max(100, 'El título no puede exceder 100 caracteres'), + message: z.string().min(1).max(500, 'El mensaje no puede exceder 500 caracteres'), + data: z.record(z.any()).optional(), +}); + +// ============================================ +// Tipos inferidos +// ============================================ + +export type CreateMenuItemInput = z.infer; +export type UpdateMenuItemInput = z.infer; +export type CreateOrderInput = z.infer; +export type UpdateOrderStatusInput = z.infer; +export type SyncHealthDataInput = z.infer; +export type BulkNotificationInput = z.infer; diff --git a/backend/src/validators/wallOfFame.validator.ts b/backend/src/validators/wallOfFame.validator.ts new file mode 100644 index 0000000..6ed9b06 --- /dev/null +++ b/backend/src/validators/wallOfFame.validator.ts @@ -0,0 +1,223 @@ +import { z } from 'zod'; +import { + WallOfFameCategory, + AchievementCategory, + RequirementType, + ChallengeType, +} from '../utils/constants'; + +// ============================================ +// Validadores de Wall of Fame +// ============================================ + +// Ganador individual +export const winnerSchema = z.object({ + userId: z.string().uuid('ID de usuario inválido'), + name: z.string().min(1, 'El nombre es requerido'), + position: z.number().int().min(1, 'La posición debe ser al menos 1'), + avatarUrl: z.string().url('URL de avatar inválida').optional(), +}); + +// Crear entrada en Wall of Fame +export const createEntrySchema = z.object({ + title: z.string().min(1, 'El título es requerido').max(200, 'Máximo 200 caracteres'), + description: z.string().max(1000, 'Máximo 1000 caracteres').optional(), + tournamentId: z.string().uuid('ID de torneo inválido').optional(), + leagueId: z.string().uuid('ID de liga inválido').optional(), + winners: z.array(winnerSchema).min(1, 'Debe haber al menos un ganador'), + category: z.enum([ + WallOfFameCategory.TOURNAMENT, + WallOfFameCategory.LEAGUE, + WallOfFameCategory.SPECIAL, + ], { + errorMap: () => ({ message: 'Categoría inválida' }), + }), + imageUrl: z.string().url('URL de imagen inválida').optional(), + eventDate: z.string().datetime('Fecha inválida'), + featured: z.boolean().optional(), +}).refine( + (data) => data.tournamentId || data.leagueId || data.category === WallOfFameCategory.SPECIAL, + { + message: 'Debe especificar un torneo o liga (excepto para logros especiales)', + path: ['tournamentId'], + } +); + +// Actualizar entrada +export const updateEntrySchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional(), + winners: z.array(winnerSchema).optional(), + category: z.enum([ + WallOfFameCategory.TOURNAMENT, + WallOfFameCategory.LEAGUE, + WallOfFameCategory.SPECIAL, + ]).optional(), + imageUrl: z.string().url().optional().nullable(), + eventDate: z.string().datetime().optional(), + featured: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +// ============================================ +// Validadores de Logros (Achievements) +// ============================================ + +// Crear logro +export const createAchievementSchema = z.object({ + code: z.string() + .min(1, 'El código es requerido') + .max(50, 'Máximo 50 caracteres') + .regex(/^[A-Z][A-Z_0-9]*$/, 'El código debe estar en MAYÚSCULAS_CON_GUIONES'), + name: z.string().min(1, 'El nombre es requerido').max(100, 'Máximo 100 caracteres'), + description: z.string().min(1, 'La descripción es requerida').max(500, 'Máximo 500 caracteres'), + category: z.enum([ + AchievementCategory.GAMES, + AchievementCategory.TOURNAMENTS, + AchievementCategory.SOCIAL, + AchievementCategory.STREAK, + AchievementCategory.SPECIAL, + ], { + errorMap: () => ({ message: 'Categoría inválida' }), + }), + icon: z.string().min(1, 'El icono es requerido').max(10, 'Máximo 10 caracteres'), + color: z.string() + .regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser formato hex (#RRGGBB)'), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ], { + errorMap: () => ({ message: 'Tipo de requisito inválido' }), + }), + requirementValue: z.number() + .int('Debe ser un número entero') + .min(1, 'El valor debe ser al menos 1'), + pointsReward: z.number() + .int('Debe ser un número entero') + .min(0, 'Los puntos no pueden ser negativos'), +}); + +// Actualizar logro +export const updateAchievementSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().min(1).max(500).optional(), + category: z.enum([ + AchievementCategory.GAMES, + AchievementCategory.TOURNAMENTS, + AchievementCategory.SOCIAL, + AchievementCategory.STREAK, + AchievementCategory.SPECIAL, + ]).optional(), + icon: z.string().min(1).max(10).optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ]).optional(), + requirementValue: z.number().int().min(1).optional(), + pointsReward: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), +}); + +// ============================================ +// Validadores de Retos (Challenges) +// ============================================ + +// Crear reto +export const createChallengeSchema = z.object({ + title: z.string().min(1, 'El título es requerido').max(200, 'Máximo 200 caracteres'), + description: z.string().min(1, 'La descripción es requerida').max(1000, 'Máximo 1000 caracteres'), + type: z.enum([ + ChallengeType.WEEKLY, + ChallengeType.MONTHLY, + ChallengeType.SPECIAL, + ], { + errorMap: () => ({ message: 'Tipo de reto inválido' }), + }), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ], { + errorMap: () => ({ message: 'Tipo de requisito inválido' }), + }), + requirementValue: z.number() + .int('Debe ser un número entero') + .min(1, 'El valor debe ser al menos 1'), + startDate: z.string().datetime('Fecha de inicio inválida'), + endDate: z.string().datetime('Fecha de fin inválida'), + rewardPoints: z.number() + .int('Debe ser un número entero') + .min(0, 'Los puntos no pueden ser negativos'), +}).refine( + (data) => new Date(data.endDate) > new Date(data.startDate), + { + message: 'La fecha de fin debe ser posterior a la de inicio', + path: ['endDate'], + } +).refine( + (data) => new Date(data.endDate) > new Date(), + { + message: 'La fecha de fin debe ser en el futuro', + path: ['endDate'], + } +); + +// Actualizar reto +export const updateChallengeSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().min(1).max(1000).optional(), + requirementType: z.enum([ + RequirementType.MATCHES_PLAYED, + RequirementType.MATCHES_WON, + RequirementType.TOURNAMENTS_PLAYED, + RequirementType.TOURNAMENTS_WON, + RequirementType.FRIENDS_ADDED, + RequirementType.STREAK_DAYS, + RequirementType.BOOKINGS_MADE, + RequirementType.GROUPS_JOINED, + RequirementType.LEAGUES_WON, + RequirementType.PERFECT_MATCH, + RequirementType.COMEBACK_WIN, + ]).optional(), + requirementValue: z.number().int().min(1).optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + rewardPoints: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), +}); + +// Tipos inferidos para TypeScript +export type CreateEntryInput = z.infer; +export type UpdateEntryInput = z.infer; +export type WinnerInput = z.infer; +export type CreateAchievementInput = z.infer; +export type UpdateAchievementInput = z.infer; +export type CreateChallengeInput = z.infer; +export type UpdateChallengeInput = z.infer;