From e135e7ad24c7fdc11f60986c92265493ea4f7d01 Mon Sep 17 00:00:00 2001 From: Ivan Alcaraz Date: Sat, 31 Jan 2026 21:59:36 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20FASE=206=20PARCIAL:=20Extras=20y=20?= =?UTF-8?q?Diferenciadores=20(base=20implementada)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementados módulos base de Fase 6: 1. WALL OF FAME (base) - Modelo de base de datos - Servicio CRUD - Controladores - Endpoints: GET /wall-of-fame/* 2. ACHIEVEMENTS/LOGROS (base) - Modelo de logros desbloqueables - Servicio de progreso - Controladores base - Endpoints: GET /achievements/* 3. QR CHECK-IN (completo) - Generación de códigos QR - Validación y procesamiento - Check-in/check-out - Endpoints: /checkin/* 4. BASE DE DATOS - Tablas: wall_of_fame, achievements, qr_codes, check_ins - Tablas preparadas: equipment, orders, notifications, activities Estructura lista para: - Equipment/Material rental - Orders/Servicios del club - Wearables integration - Challenges/Retos Nota: Algunos módulos avanzados requieren ajustes finales. --- backend/package-lock.json | 223 ++++- backend/package.json | 2 + backend/prisma/dev.db | Bin 761856 -> 999424 bytes backend/prisma/dev.db-journal | Bin 0 -> 16928 bytes .../migration.sql | 121 +++ .../migration.sql | 139 ++++ backend/prisma/migrations/fase6_extras.sql | 228 ++++++ backend/prisma/schema.prisma | 341 ++++++++ .../src/controllers/achievement.controller.ts | 216 +++++ .../src/controllers/challenge.controller.ts | 241 ++++++ .../src/controllers/equipment.controller.ts | 230 ++++++ .../controllers/equipmentRental.controller.ts | 296 +++++++ .../extras/achievement.controller.ts | 103 +++ .../extras/qrCheckin.controller.ts | 105 +++ .../extras/wallOfFame.controller.ts | 104 +++ .../healthIntegration.controller.ts | 207 +++++ backend/src/controllers/menu.controller.ts | 146 ++++ .../controllers/notification.controller.ts | 153 ++++ backend/src/controllers/order.controller.ts | 204 +++++ .../src/controllers/qrCheckin.controller.ts | 276 +++++++ .../src/controllers/wallOfFame.controller.ts | 198 +++++ backend/src/routes/achievement.routes.ts | 186 +++++ backend/src/routes/challenge.routes.ts | 178 ++++ backend/src/routes/checkin.routes.ts | 106 +++ backend/src/routes/equipment.routes.ts | 96 +++ backend/src/routes/equipmentRental.routes.ts | 116 +++ backend/src/routes/extras.routes.ts | 44 + backend/src/routes/health.routes.ts | 65 ++ backend/src/routes/index.ts | 37 + backend/src/routes/menu.routes.ts | 76 ++ backend/src/routes/notification.routes.ts | 54 ++ backend/src/routes/order.routes.ts | 68 ++ backend/src/routes/wallOfFame.routes.ts | 171 ++++ backend/src/services/achievement.service.ts | 563 +++++++++++++ backend/src/services/challenge.service.ts | 615 ++++++++++++++ backend/src/services/equipment.service.ts | 471 +++++++++++ .../src/services/equipmentRental.service.ts | 753 +++++++++++++++++ .../services/extras/achievement.service.ts | 187 +++++ .../src/services/extras/qrCheckin.service.ts | 212 +++++ .../src/services/extras/wallOfFame.service.ts | 101 +++ .../src/services/healthIntegration.service.ts | 444 ++++++++++ backend/src/services/menu.service.ts | 258 ++++++ backend/src/services/notification.service.ts | 294 +++++++ backend/src/services/order.service.ts | 600 ++++++++++++++ backend/src/services/payment.service.ts | 8 + backend/src/services/qrCheckin.service.ts | 766 ++++++++++++++++++ backend/src/services/wallOfFame.service.ts | 509 ++++++++++++ backend/src/utils/constants.ts | 320 ++++++++ backend/src/utils/qr.ts | 119 +++ backend/src/validators/services.validator.ts | 154 ++++ .../src/validators/wallOfFame.validator.ts | 223 +++++ 51 files changed, 11323 insertions(+), 4 deletions(-) create mode 100644 backend/prisma/dev.db-journal create mode 100644 backend/prisma/migrations/20260131092923_fase_6_2_checkin_equipment/migration.sql create mode 100644 backend/prisma/migrations/20260131093147_fase_6_2_qr_equipment_final/migration.sql create mode 100644 backend/prisma/migrations/fase6_extras.sql create mode 100644 backend/src/controllers/achievement.controller.ts create mode 100644 backend/src/controllers/challenge.controller.ts create mode 100644 backend/src/controllers/equipment.controller.ts create mode 100644 backend/src/controllers/equipmentRental.controller.ts create mode 100644 backend/src/controllers/extras/achievement.controller.ts create mode 100644 backend/src/controllers/extras/qrCheckin.controller.ts create mode 100644 backend/src/controllers/extras/wallOfFame.controller.ts create mode 100644 backend/src/controllers/healthIntegration.controller.ts create mode 100644 backend/src/controllers/menu.controller.ts create mode 100644 backend/src/controllers/notification.controller.ts create mode 100644 backend/src/controllers/order.controller.ts create mode 100644 backend/src/controllers/qrCheckin.controller.ts create mode 100644 backend/src/controllers/wallOfFame.controller.ts create mode 100644 backend/src/routes/achievement.routes.ts create mode 100644 backend/src/routes/challenge.routes.ts create mode 100644 backend/src/routes/checkin.routes.ts create mode 100644 backend/src/routes/equipment.routes.ts create mode 100644 backend/src/routes/equipmentRental.routes.ts create mode 100644 backend/src/routes/extras.routes.ts create mode 100644 backend/src/routes/health.routes.ts create mode 100644 backend/src/routes/menu.routes.ts create mode 100644 backend/src/routes/notification.routes.ts create mode 100644 backend/src/routes/order.routes.ts create mode 100644 backend/src/routes/wallOfFame.routes.ts create mode 100644 backend/src/services/achievement.service.ts create mode 100644 backend/src/services/challenge.service.ts create mode 100644 backend/src/services/equipment.service.ts create mode 100644 backend/src/services/equipmentRental.service.ts create mode 100644 backend/src/services/extras/achievement.service.ts create mode 100644 backend/src/services/extras/qrCheckin.service.ts create mode 100644 backend/src/services/extras/wallOfFame.service.ts create mode 100644 backend/src/services/healthIntegration.service.ts create mode 100644 backend/src/services/menu.service.ts create mode 100644 backend/src/services/notification.service.ts create mode 100644 backend/src/services/order.service.ts create mode 100644 backend/src/services/qrCheckin.service.ts create mode 100644 backend/src/services/wallOfFame.service.ts create mode 100644 backend/src/utils/qr.ts create mode 100644 backend/src/validators/services.validator.ts create mode 100644 backend/src/validators/wallOfFame.validator.ts 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 a2902c0602e6b3e9af30480cd38375b00325dfd8..51c3b533626783dfa298035c1001ecba4e4d82b6 100644 GIT binary patch delta 8839 zcmd^FYj7LY72efr*OD#ey)llltN@OhIM&OS1&I^IQ4&LJCw`Cz3Cdc#N>tmDEy*FK zw3|yxpq;dJs?s|xWg4af44q*LrPPy#Lee&&1zMmy+tQMRcOQ_JOgjSsdiE);9tJx7 z(V1#Svi6>H&-w1T_n!NmEAKzPeEXfZ9}jEXqp0dIS--Oo3-`Fumo0sj!&9BTE)n!T(y7Vw5z zUXKW828%`wiU`6$xeB*&z>r$o#RrV>uVRO9b*y%WE zpK|=be%2ANABBMLwf8MC4w9cF&Q+q~CmASx;Oz37&n_q7ZGg8C-X?gP;cb~dyPWF0 zpL`HscAEMcy#md%FuiUAzT+)wA>R5S-Ei~y)713&)3n_|w-XGxo3OLkdXBz~Hr#2L z1CO`k_H#5ped-)d8}PEnt+~6~-=%FdtSz@@X!Z!3$wY@zso^sAk9+06TWwYwK~&F%6})0PdEEM4i=pb0X~(11`34hMKY$-QM+++t^rI^% zrm@j*FqLJ8MtE2h6VXJLAI;>y5yv|FBH@9Eb0EBCW5nswBss5bIo^Mp(MQMFEWbOI zp3ITewU*w24UxVkym||DDPFyjT!>$NlWNXAU)|yuOY>uFn$0Fs$$`WukB@Dq5~LNM zJV!q<-SvcZ`k5b3k>qND>LBKliwWzK)Lv@6c?m_5x0~K@9CA49x7j;tUZ}aQ=A!Dm zt5@4j+4{)EwxhNnj0axhC_*t;G#b$CM3Uq8X)rU7Y_)O6w@iYB;(TVYiDPs%1FG4OdXHpaC7(Q`1 z$y6z}CI|4Q739UWYa<(@>%-lVre#(qyj+$_*){|yh4GZ2hNPTXO`ozooH<8?p zH}9kv9K4e3wp!gCXpM#0ywrf7ZX+4&iIC=N3wn5Uk>fMO&kqcuRTR_IWH`86nh4#D z=^lvV`bkiPc)Ww0n_FdbN>hM;bkcee{_1N~on@5IWZ2!(Y%m;~WnIpJ$kqWRSn@i^ zovCWV3p>db(rBv--QmrHRLpF3UyfGemiOsJErr5N)T?_WPS`9&m9%A-p$-ePs#YHV zs#Kce)1WQ?B_2LFh*nZeZLQ%eMrpKiyCVImetgiHYq5FcVZppK;QPACdb6~La)(U| z@Xj@)pA6%UZPdlM{#(XYyzc_NAUEGU2MhO_hKT+L)0Dq;_BckgPQ;$%TdwxBWTdf!DJ~8F# z5JS?Ft;@nw9224aTp^nlI2Rvad+Qc&{%LZ;^Yz zMvz?yN`QV@)`w2D$crWuqqMFxO8G^hxOlXji{WTqugHPAYW`bP;D3_|hy=ss9!H%O z=#I+9#N}3yhkl_CW?m94>>x)pFN9rVQ{^sq%``b>_^9EoDbo4FBc@r?rYXaPm%n+$ zWNszT6OJ3~@7ed&yi&8J`f=N}wxu*ANb?=$UF7+iB?LikfiesJ=3fQ`guR}mH==7T zOf)2C(Qv*vNyDyUM`Su0^_FiZP7{RjeaYq;w8O#-wkXNCSRKxlk|yF$2lt?BD5j;w zaOm^W5mTa4(m?!C38TV>OB7trCBRy8?Y1WEFmm9h+DsSWjvlg6{Fp^$fo+d;rtpz_ zO!FlELUa6BDwD{{2b4p3>f^IAof%(8F2;4Y8ygn(MfxLsS4Y;W)MzXb8|JyeF^slT z^G%{UCO@F(;p;BYjCQ)sDh4T@*AunHt(7>+_B=;P=KDxTYhh5O2I8fg9B7+F7QX}; z5mstZkD^E750%0xn$s!_{KRd>i#vwWY?9M4lhG8%kLX%5iR*!*6_A%$D#-y&O(n(R zq`N1wMGf5^$eJUQB0ntQdO)|!3*=eq69=n!1XacyrSE_u)uf*?f>Ofm4rlG;`79_sw#&H|s=k4)?uas) zQ9ipTl|wdW)?Pjh#Rt!=nUq7Fvzr$#>g?_5ALt7M&vwdoq|wNZCDvQ7fp7zfUKi<$ z^mIo0oi4bMh^07u`1_{$_@NHdGFlyjv`lBKPo>M#pW8*((H?ZQh3Tx9_x+W!G2q)? z^&9cxjR`bJG4=I^n-)t^R3++P;st#S<#wr5F^b5<8b#*Hi?&gXcx-?&8~6+k=Ztmu zTt8*T@9m(fvEu{ULW#r$!h$`gtmA^!jc7DLG3$ZKxupYJJfI3HCzWB!2Wyd#cwRxJ z)Tc#aeP20k>s5pdB=3SV7r^w-1O`ZkTxN*}hu&0%cy4Aey1oh+K|fdkqiMO^Xg2Dj zm~MznYuU(XC80uTi}5@=Gaj+K0OjfAfYQHQfW-I|`f<4hw7C-VhT<|Rm8=rOE{5~l zGH`@~S}xzS5)fHKgiw+Eix5P|<(JFT#SjGjTFdvW1VPq-xgk(5#dJcI(J0N0j+{p| z5-lzhorri!2cbsC>Ln^(r_+b3iq^^&FM3{L^yCGS_^M*=fSYca3`t&l9-33l zXKarEj9RuS4w5fF$o%TgJAJc~)4X!2KXnU?Ar9(t7~^cAXpq{T)erHFO5Sw(r4BW* z%bh?miV4;mP?h9T;kC{BUp_Rp`?}tt(k}n#p(??o<=5&CGz6D5^~#l&s-oRCstv?z zWew-ACFL=w>fl1YUQ8nvuHpdw{DdI>a-%9hii-1)!eUUjh#^zkiB~K5Dbg=-S0Hz} zcc5LRgMvF$ApFvCt9g5+f;UyM#EmErZ_=W0vjC%_3g<+zKg>^R_RlKz2Yb3Um>d{yGS)CU zGCEFHG}yS=k+Fhr`V(75AvU0r361KLFY+4;v2#yl;4k3Y%_qcLz;lJioO|kKMT2_o z%~PfGS%ld69x?E9^WWf)-6@Fj~up))Ahb^WG%P&%)vc9@gIjh3((b?(^r1tP;FoE zk7N6Sf1D`?d4w7A7`Sz~W^%scRGH3ofJ+YOQ-|$&2e{_5F>&wT&VQUslnHL+1xAn= RozrbTbF^;vIK#E$G61Z+Z~g!P diff --git a/backend/prisma/dev.db-journal b/backend/prisma/dev.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..3dec9a3daed383bb336e34be7eeb658479339ddd GIT binary patch literal 16928 zcmeHNYmgjO74Ds6ce0yoR-mjgRK-k&(qt-`O1~e`1SXqFT(Y|fv%3TkXm9tuz1y-o zGdnXIvXm6nD)CX_9~Muo8zWJf_)-CJ9xe~+pz*xSDuk9e znl&&>vm(uK46SesFR2FV5LHxJb-AsQgVyFdJxb;H#J6*YB~e*kl77RBeC; z&&ZO@k&4K&T7~B{R8a&@uP}(wc}5WondMkk5j0jpJZ3QAXpA`n(+W*EMJIrj(KV4_ zB%T!oWax?_!_JV18PO0Ck~xhAvD$+l2w}1c%Y`uY1)|?#HbSg6`?v14cH|wDCvB9n z&VXP+0W}JAg;!XkqHr2tk!VhqSWqK}I9)Jk5X&h7&2pT| z2jP^B5VXJwpt%a66{f;#5?etE2j>naBQpenG8mduL|!1YC{W zh_XTJqQ(-IC0OGmMk2f@E3705n6Qe$f!O``eJ+Af-~3NO=Q1?f7fXtcp1S%W_sLj+wzlFW;opzuV%0>MbpI9j85j;DEr zhjFAy*id*w)>&TU8AQv5XwafWV?Hc&rFKF$}=~ z2LV5VL(mo4kZns4K)afxD+I*uee^(x4cta_uyva&?FMpnhq_>i_R;P=cbE0y4GuzL zYflg~ftCbP!uQ_ezM6Q9Oj5rf#A|wN3PucH3;ss|G>cm9_)4 zbna1$%{f4Ccf16wn}@Tk$OMB8@>r_CNr@Gm2%2t4iY5{)@QNYP5II;~=41_Gh6pqU zV_u>)h%`vk2&0JhADyAW@xikQVl={nlWB}(1HJo+2vC7#9aXM$NLy{Sn})eaAli^c z+)+^jtBd3mSyUIyPPf@!b!H#1Mh6_woDCYzLKAhJsc@nQ+64twG^}%Apd4*`5HeVq z7{G*Cf>i>~v*bPsmaSU8@R6N4>k_kp$!RdB71ac5@2F;{s&~y(L^W|_ zn0kD?UaQX5iWAd^Yu6P^852syrQ&Su zy4m9Narm8^oGhNGPfS(o*B6h~uHWnO*mEb6d>*X>0YoQ>H9VRJZgqCGmEsdhamnD*jonM0s$;_)FstZ^8u3wBA`v7(@7e{`c@}7h#>}`hOI5I{z-3@ zCDKJ0b&;z=Z%}JCAtRXgmF|t|Z3uk?SGy^S2^2a$SFhKmXH{EmGqcsH6Yk{kZ?q}kqi}*X z@aFOA%s8B;Ju-K~K01Ho{1Ac-r=kd`2_W8h8kU)wXU;ufM#=}RLqmmY_T(tXg*(fO z5UW(Q(rr559d)RoW2sEScc@1{GiP0yFYMZtJ2UUNV$5Y@U#LgoZWtr3nG<$<9n3SC zuGw8o`kqHxBApJJ&mi_#q#N2w#Y4xBPu8l_fjb3uXQ0In@s?gILyc+c?Kjhr@_y^8 zp~BRzEX#=FVPapY?`|HVhMni(UhCjc;mU1Ut2En~v^y%}{-+)~GiM#h7q)H7-L=87 zCU@MUekX1}?&z8sPUu+ihk$>M5Zt?d5DJdmBRUWG^uP~cuDb$W8Go>ZA-^_#cw+jf zAMd-(E?TTEHCGxC*fQd2Cq1QdX zH8bTpg!uZz`0Ry*Tu*dxH!#V+}=pH{6jXo}xt*LxLfpl>+C0(?` zc5kxyl(({%8^?l->Q6~e6`kim`BrN(J4N-L^*t%-kNZgtC#B1E>7_EZj^zu&wwyKS z?AEim;Avp*)g7aL6c?Aq>56hGkOl{6BEFw0BF32z63}cN9qH9kXgc1>KanaTWJ|2?o7Q`%#I$2hsl05BXVlbC zZIXHDCD?`+iAayd6&q62=mb~2A<_0MZ@p6aU$1@nDac@cYQGOKw&7W7aR0_fHcoDM zcI;DI|FiY<=*y!gw>-J|=FNLHnVa~*`v#yhIl9~J0Jwj1R`lEY=Y$3(t(%7ms*?3Q z3VYRUXjntf%Wa`B>EqOIDQd%i9SMi58;1&W`z~}{NIjEFcBo&US+G8yFYMcwJA1qH z)azmC8K8dUS)A8m=wW!f(o`>PgYenz9tPu0DTh54=o9p3aB|P_W+yxI#H7{inBDW_ zryf_GKx;C!pAghZbcnuRjtqHCt$uCou%9HiO#K#u=UUO?-&?8e2~8md8SuN;wp!1C zI%<%+L~@0}0>3bma8@T~gr4YprC!Q%RIi4+h+d$5S&%5~JTt_kqqohL z-3g8LvVzA;yd3mqX>I6{$M~H)oY+Xmyv>v8P12hU3{6j8P*9l$PPS{hcGCBp1t>Na zSM45NP+j_np`V*haDBdR>#?2I9Sj8d-n_hm8qjN5owaM(Ku#|(#uM%HSN*Q@1*wj=8y$*_>)N5h`1UNb_Z6!${wL~ZpEj+z zd|~_c+?`iAM(6lG81Shn;Am zkz|2`k6loBLha#%IlmK;You&#vS#yzqtKiwJJq#qn9=5bU=8-8IYFde@D9Rj#U#jT zY_3~aC0L-k3YbbZmYIH7oP^enDQQ@q?~f%mHw#O^1XwPy>iNPH=tW4?OZ4t84bF4@ z;UuSLA&T;q%6C}TWHr*@s!nz98q?4`+aFChF$;%xjR^=cTX{AD`*PhE<*G%ln+g-?2Q#vE)%V&in8?l<; z*Zce!lbq5~y*ku3-5w(K&;H0_v(qtk0FGbkmaX?$jeOx4pkY!~27crh&gppmaXvh; z`Pqm9HqcPsTFMt{faycon4la^2lV#~0OU>Xi^i_EZS^enS5FYJ)6u+fJ~XlU*?-(8S8nnOmhZdchg~;b|fGqR_;+MW@K>pGnNOWoz5TB=1 zp10NqS>h5idSyl!>@AY?Er!+|fT8}BB{O9173aG^%firkhb_8qvjwZh^Sxl0dNoT} z;NiAHdgTS=QrWchd_mftvxXh5`Wb)h^*Mi#dL=H;ACED+(tHw}Uud*9S{hu_Z1=9R ze3sy5(I24xm?0*91?jVc2>14}Z6UavgRQuib3PrOtU+;MtHU3s{*WOi!QZo?2)$Ik z(fUN%p6nthChX-5VLoGz6sL@WdrcKu@p=*O@Qsw$ubKM1j=Fx$)Ck@b^Ljo|V7_}VS|uGOsNK_i*nje`^;@pJ pAcALps?P@03vW$kw`cA5TyN<2o@ 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;