✅ FASE 6 PARCIAL: Extras y Diferenciadores (base implementada)
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.
This commit is contained in:
223
backend/package-lock.json
generated
223
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
BIN
backend/prisma/dev.db-journal
Normal file
BIN
backend/prisma/dev.db-journal
Normal file
Binary file not shown.
@@ -0,0 +1,121 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "menu_items" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" TEXT NOT NULL DEFAULT 'OTHER',
|
||||
"price" INTEGER NOT NULL,
|
||||
"imageUrl" TEXT,
|
||||
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"preparationTime" INTEGER,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "orders" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"courtId" TEXT NOT NULL,
|
||||
"items" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"totalAmount" INTEGER NOT NULL,
|
||||
"paymentStatus" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"paymentId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "orders_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "orders_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notifications" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"data" TEXT,
|
||||
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_activities" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"activityType" TEXT NOT NULL DEFAULT 'PADEL_GAME',
|
||||
"startTime" DATETIME NOT NULL,
|
||||
"endTime" DATETIME NOT NULL,
|
||||
"duration" INTEGER NOT NULL,
|
||||
"caloriesBurned" INTEGER NOT NULL,
|
||||
"heartRateAvg" INTEGER,
|
||||
"heartRateMax" INTEGER,
|
||||
"steps" INTEGER,
|
||||
"distance" REAL,
|
||||
"metadata" TEXT,
|
||||
"bookingId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "user_activities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "user_activities_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menu_items_category_idx" ON "menu_items"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menu_items_isAvailable_idx" ON "menu_items"("isAvailable");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menu_items_isActive_idx" ON "menu_items"("isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_userId_idx" ON "orders"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_bookingId_idx" ON "orders"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_courtId_idx" ON "orders"("courtId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_status_idx" ON "orders"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_paymentStatus_idx" ON "orders"("paymentStatus");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notifications_userId_idx" ON "notifications"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notifications_type_idx" ON "notifications"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notifications_isRead_idx" ON "notifications"("isRead");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notifications_createdAt_idx" ON "notifications"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_activities_userId_idx" ON "user_activities"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_activities_source_idx" ON "user_activities"("source");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_activities_activityType_idx" ON "user_activities"("activityType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_activities_startTime_idx" ON "user_activities"("startTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_activities_bookingId_idx" ON "user_activities"("bookingId");
|
||||
@@ -0,0 +1,139 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "qr_codes" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"code" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"referenceId" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"usedAt" DATETIME,
|
||||
"usedBy" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "check_ins" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"bookingId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"qrCodeId" TEXT,
|
||||
"checkInTime" DATETIME NOT NULL,
|
||||
"checkOutTime" DATETIME,
|
||||
"method" TEXT NOT NULL,
|
||||
"verifiedBy" TEXT,
|
||||
"notes" TEXT,
|
||||
CONSTRAINT "check_ins_qrCodeId_fkey" FOREIGN KEY ("qrCodeId") REFERENCES "qr_codes" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "check_ins_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "check_ins_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "equipment_items" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" TEXT NOT NULL,
|
||||
"brand" TEXT,
|
||||
"model" TEXT,
|
||||
"size" TEXT,
|
||||
"condition" TEXT NOT NULL DEFAULT 'NEW',
|
||||
"hourlyRate" INTEGER,
|
||||
"dailyRate" INTEGER,
|
||||
"depositRequired" INTEGER NOT NULL DEFAULT 0,
|
||||
"quantityTotal" INTEGER NOT NULL DEFAULT 1,
|
||||
"quantityAvailable" INTEGER NOT NULL DEFAULT 1,
|
||||
"imageUrl" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "equipment_rentals" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"bookingId" TEXT,
|
||||
"startDate" DATETIME NOT NULL,
|
||||
"endDate" DATETIME NOT NULL,
|
||||
"totalCost" INTEGER NOT NULL,
|
||||
"depositAmount" INTEGER NOT NULL,
|
||||
"depositReturned" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'RESERVED',
|
||||
"pickedUpAt" DATETIME,
|
||||
"returnedAt" DATETIME,
|
||||
"paymentId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "equipment_rentals_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "equipment_rentals_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "equipment_rental_items" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"rentalId" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"hourlyRate" INTEGER,
|
||||
"dailyRate" INTEGER,
|
||||
CONSTRAINT "equipment_rental_items_rentalId_fkey" FOREIGN KEY ("rentalId") REFERENCES "equipment_rentals" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "equipment_rental_items_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "equipment_items" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "qr_codes_code_key" ON "qr_codes"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qr_codes_code_idx" ON "qr_codes"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qr_codes_referenceId_idx" ON "qr_codes"("referenceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qr_codes_type_idx" ON "qr_codes"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "qr_codes_isActive_idx" ON "qr_codes"("isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "check_ins_bookingId_idx" ON "check_ins"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "check_ins_userId_idx" ON "check_ins"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "check_ins_checkInTime_idx" ON "check_ins"("checkInTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "check_ins_method_idx" ON "check_ins"("method");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_items_category_idx" ON "equipment_items"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_items_isActive_idx" ON "equipment_items"("isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_items_quantityAvailable_idx" ON "equipment_items"("quantityAvailable");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rentals_userId_idx" ON "equipment_rentals"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rentals_bookingId_idx" ON "equipment_rentals"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rentals_status_idx" ON "equipment_rentals"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rentals_startDate_idx" ON "equipment_rentals"("startDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rentals_endDate_idx" ON "equipment_rentals"("endDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rental_items_rentalId_idx" ON "equipment_rental_items"("rentalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "equipment_rental_items_itemId_idx" ON "equipment_rental_items"("itemId");
|
||||
228
backend/prisma/migrations/fase6_extras.sql
Normal file
228
backend/prisma/migrations/fase6_extras.sql
Normal file
@@ -0,0 +1,228 @@
|
||||
-- Fase 6: Extras y Diferenciadores
|
||||
|
||||
-- Wall of Fame
|
||||
CREATE TABLE IF NOT EXISTS wall_of_fame_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
tournament_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
winners TEXT NOT NULL, -- JSON array
|
||||
category TEXT NOT NULL DEFAULT 'TOURNAMENT',
|
||||
image_url TEXT,
|
||||
event_date DATETIME NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
featured BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Achievements (Logros)
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'GAMES',
|
||||
icon TEXT,
|
||||
color TEXT DEFAULT '#16a34a',
|
||||
requirement_type TEXT NOT NULL,
|
||||
requirement_value INTEGER NOT NULL,
|
||||
points_reward INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- User Achievements
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
achievement_id TEXT NOT NULL,
|
||||
unlocked_at DATETIME,
|
||||
progress INTEGER DEFAULT 0,
|
||||
is_completed BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (achievement_id) REFERENCES achievements(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, achievement_id)
|
||||
);
|
||||
|
||||
-- Challenges (Retos)
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'WEEKLY',
|
||||
requirement_type TEXT NOT NULL,
|
||||
requirement_value INTEGER NOT NULL,
|
||||
start_date DATETIME NOT NULL,
|
||||
end_date DATETIME NOT NULL,
|
||||
reward_points INTEGER DEFAULT 0,
|
||||
participants TEXT DEFAULT '[]', -- JSON array
|
||||
winners TEXT DEFAULT '[]', -- JSON array
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- User Challenges
|
||||
CREATE TABLE IF NOT EXISTS user_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
challenge_id TEXT NOT NULL,
|
||||
progress INTEGER DEFAULT 0,
|
||||
is_completed BOOLEAN DEFAULT 0,
|
||||
completed_at DATETIME,
|
||||
reward_claimed BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, challenge_id)
|
||||
);
|
||||
|
||||
-- QR Codes
|
||||
CREATE TABLE IF NOT EXISTS qr_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'BOOKING_CHECKIN',
|
||||
reference_id TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used_at DATETIME,
|
||||
used_by TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Check-ins
|
||||
CREATE TABLE IF NOT EXISTS check_ins (
|
||||
id TEXT PRIMARY KEY,
|
||||
booking_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
qr_code_id TEXT,
|
||||
check_in_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
check_out_time DATETIME,
|
||||
method TEXT DEFAULT 'QR',
|
||||
verified_by TEXT,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (qr_code_id) REFERENCES qr_codes(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Equipment Items
|
||||
CREATE TABLE IF NOT EXISTS equipment_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'RACKET',
|
||||
brand TEXT,
|
||||
model TEXT,
|
||||
size TEXT,
|
||||
condition TEXT DEFAULT 'GOOD',
|
||||
hourly_rate INTEGER,
|
||||
daily_rate INTEGER,
|
||||
deposit_required INTEGER DEFAULT 0,
|
||||
quantity_total INTEGER DEFAULT 1,
|
||||
quantity_available INTEGER DEFAULT 1,
|
||||
image_url TEXT,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- Equipment Rentals
|
||||
CREATE TABLE IF NOT EXISTS equipment_rentals (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
items TEXT NOT NULL, -- JSON array
|
||||
booking_id TEXT,
|
||||
start_date DATETIME NOT NULL,
|
||||
end_date DATETIME NOT NULL,
|
||||
total_cost INTEGER NOT NULL,
|
||||
deposit_amount INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'RESERVED',
|
||||
picked_up_at DATETIME,
|
||||
returned_at DATETIME,
|
||||
payment_id TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Menu Items
|
||||
CREATE TABLE IF NOT EXISTS menu_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'DRINK',
|
||||
price INTEGER NOT NULL,
|
||||
image_url TEXT,
|
||||
is_available BOOLEAN DEFAULT 1,
|
||||
preparation_time INTEGER,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
-- Orders
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
booking_id TEXT NOT NULL,
|
||||
court_id TEXT NOT NULL,
|
||||
items TEXT NOT NULL, -- JSON array
|
||||
status TEXT DEFAULT 'PENDING',
|
||||
total_amount INTEGER NOT NULL,
|
||||
payment_status TEXT DEFAULT 'PENDING',
|
||||
payment_id TEXT,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (court_id) REFERENCES courts(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Notifications
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
data TEXT, -- JSON
|
||||
is_read BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User Activity (Wearables)
|
||||
CREATE TABLE IF NOT EXISTS user_activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'MANUAL',
|
||||
activity_type TEXT DEFAULT 'PADEL_GAME',
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration INTEGER NOT NULL,
|
||||
calories_burned INTEGER,
|
||||
heart_rate_avg INTEGER,
|
||||
heart_rate_max INTEGER,
|
||||
steps INTEGER,
|
||||
distance REAL,
|
||||
metadata TEXT, -- JSON
|
||||
booking_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_wof_event_date ON wall_of_fame_entries(event_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_wof_featured ON wall_of_fame_entries(featured);
|
||||
CREATE INDEX IF NOT EXISTS idx_achievements_category ON achievements(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_challenges_dates ON challenges(start_date, end_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_codes ON qr_codes(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_check_ins_booking ON check_ins(booking_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_check_ins_time ON check_ins(check_in_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_equipment_category ON equipment_items(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_rentals_user ON equipment_rentals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_user ON user_activities(user_id);
|
||||
@@ -90,6 +90,17 @@ model User {
|
||||
studentEnrollments StudentEnrollment[]
|
||||
coachReviews CoachReview[]
|
||||
|
||||
// Servicios del Club (Fase 6.3)
|
||||
orders Order[]
|
||||
notifications Notification[]
|
||||
userActivities UserActivity[]
|
||||
|
||||
// Check-ins (Fase 6.2)
|
||||
checkIns CheckIn[]
|
||||
|
||||
// Alquileres de equipamiento (Fase 6.2)
|
||||
equipmentRentals EquipmentRental[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -148,6 +159,9 @@ model Court {
|
||||
tournamentMatches TournamentMatch[]
|
||||
classBookings ClassBooking[]
|
||||
|
||||
// Servicios del Club (Fase 6.3)
|
||||
orders Order[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -211,6 +225,16 @@ model Booking {
|
||||
// Uso de bonos
|
||||
bonusUsages BonusUsage[]
|
||||
|
||||
// Servicios del Club (Fase 6.3)
|
||||
orders Order[]
|
||||
userActivities UserActivity[]
|
||||
|
||||
// Alquileres de equipamiento asociados (Fase 6.2)
|
||||
equipmentRentals EquipmentRental[]
|
||||
|
||||
// Check-ins asociados (Fase 6.2)
|
||||
checkIns CheckIn[]
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1221,3 +1245,320 @@ model CoachReview {
|
||||
@@index([rating])
|
||||
@@map("coach_reviews")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Modelos de Servicios del Club (Fase 6.3)
|
||||
// ============================================
|
||||
|
||||
// Modelo de Item del Menú (productos del bar/cafetería)
|
||||
model MenuItem {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// Información básica
|
||||
name String
|
||||
description String?
|
||||
|
||||
// Categoría: DRINK, SNACK, FOOD, OTHER
|
||||
category String @default("OTHER")
|
||||
|
||||
// Precio en centavos
|
||||
price Int
|
||||
|
||||
// Imagen
|
||||
imageUrl String?
|
||||
|
||||
// Disponibilidad y estado
|
||||
isAvailable Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// Tiempo de preparación en minutos
|
||||
preparationTime Int?
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([category])
|
||||
@@index([isAvailable])
|
||||
@@index([isActive])
|
||||
@@map("menu_items")
|
||||
}
|
||||
|
||||
// Modelo de Pedido (órdenes a la cancha)
|
||||
model Order {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// Usuario que realiza el pedido
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
// Reserva asociada (vinculado a reserva activa)
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
bookingId String
|
||||
|
||||
// Cancha donde se entregará
|
||||
court Court @relation(fields: [courtId], references: [id])
|
||||
courtId String
|
||||
|
||||
// Items del pedido (JSON array de {itemId, quantity, notes, price})
|
||||
items String
|
||||
|
||||
// Estado del pedido: PENDING, PREPARING, READY, DELIVERED, CANCELLED
|
||||
status String @default("PENDING")
|
||||
|
||||
// Monto total en centavos
|
||||
totalAmount Int
|
||||
|
||||
// Estado de pago: PENDING, PAID
|
||||
paymentStatus String @default("PENDING")
|
||||
|
||||
// ID de referencia de pago (MercadoPago u otro)
|
||||
paymentId String?
|
||||
|
||||
// Notas adicionales
|
||||
notes String?
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([bookingId])
|
||||
@@index([courtId])
|
||||
@@index([status])
|
||||
@@index([paymentStatus])
|
||||
@@index([createdAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
// Modelo de Notificación (push/in-app)
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// Usuario destinatario
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
|
||||
// Tipo de notificación: ORDER_READY, BOOKING_REMINDER, TOURNAMENT_START, etc.
|
||||
type String
|
||||
|
||||
// Contenido
|
||||
title String
|
||||
message String
|
||||
|
||||
// Datos adicionales (JSON)
|
||||
data String? // Puede contener orderId, bookingId, etc.
|
||||
|
||||
// Estado de lectura
|
||||
isRead Boolean @default(false)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([type])
|
||||
@@index([isRead])
|
||||
@@index([createdAt])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Modelos de Integración con Wearables (Fase 6.3)
|
||||
// ============================================
|
||||
|
||||
// Modelo de Actividad de Usuario (registro de actividad física)
|
||||
model UserActivity {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// Usuario
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
|
||||
// Fuente de datos: APPLE_HEALTH, GOOGLE_FIT, MANUAL
|
||||
source String
|
||||
|
||||
// Tipo de actividad: PADEL_GAME, WORKOUT
|
||||
activityType String @default("PADEL_GAME")
|
||||
|
||||
// Tiempos
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
duration Int // Duración en minutos
|
||||
|
||||
// Métricas de salud
|
||||
caloriesBurned Int
|
||||
heartRateAvg Int?
|
||||
heartRateMax Int?
|
||||
steps Int?
|
||||
distance Float? // km
|
||||
|
||||
// Metadatos adicionales (JSON)
|
||||
metadata String?
|
||||
|
||||
// Reserva asociada (opcional)
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||
bookingId String?
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([source])
|
||||
@@index([activityType])
|
||||
@@index([startTime])
|
||||
@@index([bookingId])
|
||||
@@map("user_activities")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Modelos de Check-in Digital QR (Fase 6.2)
|
||||
// ============================================
|
||||
|
||||
// Modelo de Código QR
|
||||
model QRCode {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
type String // BOOKING_CHECKIN, EVENT_ACCESS, etc.
|
||||
referenceId String // ID de la entidad (booking, etc.)
|
||||
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
usedBy String? // userId que usó el QR
|
||||
isActive Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relaciones
|
||||
checkIns CheckIn[]
|
||||
|
||||
@@index([code])
|
||||
@@index([referenceId])
|
||||
@@index([type])
|
||||
@@index([isActive])
|
||||
@@map("qr_codes")
|
||||
}
|
||||
|
||||
// Modelo de CheckIn (registro de asistencia)
|
||||
model CheckIn {
|
||||
id String @id @default(uuid())
|
||||
|
||||
bookingId String
|
||||
userId String
|
||||
qrCodeId String?
|
||||
|
||||
checkInTime DateTime
|
||||
checkOutTime DateTime?
|
||||
|
||||
method String // QR, MANUAL
|
||||
verifiedBy String? // admin que verificó
|
||||
notes String?
|
||||
|
||||
// Relaciones
|
||||
qrCode QRCode? @relation(fields: [qrCodeId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([bookingId])
|
||||
@@index([userId])
|
||||
@@index([checkInTime])
|
||||
@@index([method])
|
||||
@@map("check_ins")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Modelos de Gestión de Material (Fase 6.2)
|
||||
// ============================================
|
||||
|
||||
// Modelo de Item de Equipamiento
|
||||
model EquipmentItem {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String?
|
||||
|
||||
category String // RACKET, BALLS, ACCESSORIES, SHOES
|
||||
brand String?
|
||||
model String?
|
||||
size String? // talla si aplica
|
||||
condition String @default("NEW") // NEW, GOOD, FAIR, POOR
|
||||
|
||||
hourlyRate Int? // tarifa por hora (en centavos)
|
||||
dailyRate Int? // tarifa por día (en centavos)
|
||||
depositRequired Int @default(0) // depósito requerido (en centavos)
|
||||
|
||||
quantityTotal Int @default(1)
|
||||
quantityAvailable Int @default(1)
|
||||
|
||||
imageUrl String?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// Relaciones
|
||||
rentals EquipmentRentalItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([category])
|
||||
@@index([isActive])
|
||||
@@index([quantityAvailable])
|
||||
@@map("equipment_items")
|
||||
}
|
||||
|
||||
// Modelo de Alquiler de Equipamiento
|
||||
model EquipmentRental {
|
||||
id String @id @default(uuid())
|
||||
|
||||
userId String
|
||||
|
||||
bookingId String? // vinculado a reserva opcional
|
||||
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
|
||||
totalCost Int // en centavos
|
||||
depositAmount Int // en centavos
|
||||
depositReturned Int @default(0) // depósito devuelto (en centavos)
|
||||
|
||||
status String @default("RESERVED") // RESERVED, PICKED_UP, RETURNED, LATE, DAMAGED
|
||||
|
||||
pickedUpAt DateTime?
|
||||
returnedAt DateTime?
|
||||
|
||||
paymentId String?
|
||||
|
||||
notes String?
|
||||
|
||||
// Relaciones
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||
items EquipmentRentalItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([bookingId])
|
||||
@@index([status])
|
||||
@@index([startDate])
|
||||
@@index([endDate])
|
||||
@@map("equipment_rentals")
|
||||
}
|
||||
|
||||
// Modelo intermedio para items de un alquiler
|
||||
model EquipmentRentalItem {
|
||||
id String @id @default(uuid())
|
||||
|
||||
rentalId String
|
||||
itemId String
|
||||
quantity Int @default(1)
|
||||
|
||||
hourlyRate Int? // tarifa aplicada al momento del alquiler
|
||||
dailyRate Int? // tarifa aplicada al momento del alquiler
|
||||
|
||||
// Relaciones
|
||||
rental EquipmentRental @relation(fields: [rentalId], references: [id], onDelete: Cascade)
|
||||
item EquipmentItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([rentalId])
|
||||
@@index([itemId])
|
||||
@@map("equipment_rental_items")
|
||||
}
|
||||
|
||||
216
backend/src/controllers/achievement.controller.ts
Normal file
216
backend/src/controllers/achievement.controller.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AchievementService } from '../services/achievement.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { AchievementCategory } from '../utils/constants';
|
||||
|
||||
export class AchievementController {
|
||||
/**
|
||||
* Crear un nuevo logro (solo admin)
|
||||
*/
|
||||
static async createAchievement(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const achievement = await AchievementService.createAchievement(req.user.userId, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Logro creado exitosamente',
|
||||
data: achievement,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar todos los logros disponibles
|
||||
*/
|
||||
static async getAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const options = {
|
||||
category: req.query.category as AchievementCategory | undefined,
|
||||
activeOnly: req.query.activeOnly !== 'false',
|
||||
};
|
||||
|
||||
const achievements = await AchievementService.getAchievements(options);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: achievements.length,
|
||||
data: achievements,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener mis logros desbloqueados
|
||||
*/
|
||||
static async getMyAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const achievements = await AchievementService.getUserAchievements(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: achievements,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener el progreso de todos mis logros
|
||||
*/
|
||||
static async getMyAchievementsProgress(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const progress = await AchievementService.getUserAchievementsProgress(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: progress.length,
|
||||
data: progress,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener el progreso de un logro específico
|
||||
*/
|
||||
static async getAchievementProgress(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const progress = await AchievementService.getAchievementProgress(req.user.userId, id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: progress,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar y desbloquear logros para el usuario actual
|
||||
*/
|
||||
static async checkAndUnlockAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const result = await AchievementService.checkAndUnlockAchievements(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logros verificados',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un logro (solo admin)
|
||||
*/
|
||||
static async updateAchievement(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const achievement = await AchievementService.updateAchievement(id, req.body);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logro actualizado exitosamente',
|
||||
data: achievement,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar un logro (solo admin)
|
||||
*/
|
||||
static async deleteAchievement(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
await AchievementService.deleteAchievement(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logro eliminado exitosamente',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener ranking por puntos de logros
|
||||
*/
|
||||
static async getLeaderboard(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
|
||||
const leaderboard = await AchievementService.getLeaderboard(limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: leaderboard.length,
|
||||
data: leaderboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializar logros por defecto (solo admin)
|
||||
*/
|
||||
static async initializeDefaultAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const result = await AchievementService.initializeDefaultAchievements(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logros por defecto inicializados',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AchievementController;
|
||||
241
backend/src/controllers/challenge.controller.ts
Normal file
241
backend/src/controllers/challenge.controller.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ChallengeService } from '../services/challenge.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { ChallengeType } from '../utils/constants';
|
||||
|
||||
export class ChallengeController {
|
||||
/**
|
||||
* Crear un nuevo reto (solo admin)
|
||||
*/
|
||||
static async createChallenge(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const challenge = await ChallengeService.createChallenge(req.user.userId, {
|
||||
...req.body,
|
||||
startDate: new Date(req.body.startDate),
|
||||
endDate: new Date(req.body.endDate),
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Reto creado exitosamente',
|
||||
data: challenge,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar retos activos
|
||||
*/
|
||||
static async getActiveChallenges(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
type: req.query.type as ChallengeType | undefined,
|
||||
ongoing: req.query.ongoing === 'true',
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||
offset: req.query.offset ? parseInt(req.query.offset as string) : undefined,
|
||||
};
|
||||
|
||||
const result = await ChallengeService.getActiveChallenges(filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.challenges,
|
||||
meta: {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener un reto por ID
|
||||
*/
|
||||
static async getChallengeById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const challenge = await ChallengeService.getChallengeById(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: challenge,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unirse a un reto
|
||||
*/
|
||||
static async joinChallenge(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await ChallengeService.joinChallenge(req.user.userId, id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Te has unido al reto exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener mis retos
|
||||
*/
|
||||
static async getMyChallenges(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const challenges = await ChallengeService.getUserChallenges(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: challenges.length,
|
||||
data: challenges,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completar un reto y reclamar recompensa
|
||||
*/
|
||||
static async completeChallenge(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await ChallengeService.completeChallenge(req.user.userId, id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Recompensa reclamada exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener tabla de líderes de un reto
|
||||
*/
|
||||
static async getChallengeLeaderboard(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
|
||||
const leaderboard = await ChallengeService.getChallengeLeaderboard(id, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: leaderboard.length,
|
||||
data: leaderboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar retos expirados (endpoint de mantenimiento)
|
||||
*/
|
||||
static async checkExpiredChallenges(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const result = await ChallengeService.checkExpiredChallenges();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `${result.count} retos cerrados`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un reto (solo admin)
|
||||
*/
|
||||
static async updateChallenge(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updateData: any = { ...req.body };
|
||||
|
||||
if (req.body.startDate) {
|
||||
updateData.startDate = new Date(req.body.startDate);
|
||||
}
|
||||
|
||||
if (req.body.endDate) {
|
||||
updateData.endDate = new Date(req.body.endDate);
|
||||
}
|
||||
|
||||
const challenge = await ChallengeService.updateChallenge(id, updateData);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Reto actualizado exitosamente',
|
||||
data: challenge,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar un reto (solo admin)
|
||||
*/
|
||||
static async deleteChallenge(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await ChallengeService.deleteChallenge(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChallengeController;
|
||||
230
backend/src/controllers/equipment.controller.ts
Normal file
230
backend/src/controllers/equipment.controller.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { EquipmentService } from '../services/equipment.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { UserRole } from '../utils/constants';
|
||||
|
||||
export class EquipmentController {
|
||||
/**
|
||||
* Listar equipamiento disponible
|
||||
*/
|
||||
static async getEquipmentItems(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
category: req.query.category as any,
|
||||
isActive: req.query.isActive === 'true' ? true :
|
||||
req.query.isActive === 'false' ? false : undefined,
|
||||
available: req.query.available === 'true' ? true : undefined,
|
||||
search: req.query.search as string,
|
||||
};
|
||||
|
||||
const items = await EquipmentService.getEquipmentItems(filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: items.length,
|
||||
data: items,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener detalle de un item
|
||||
*/
|
||||
static async getEquipmentById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del equipamiento', 400);
|
||||
}
|
||||
|
||||
const item = await EquipmentService.getEquipmentById(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: item,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear nuevo equipamiento (admin)
|
||||
*/
|
||||
static async createEquipment(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para crear equipamiento', 403);
|
||||
}
|
||||
|
||||
const item = await EquipmentService.createEquipmentItem(
|
||||
req.user.userId,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Equipamiento creado exitosamente',
|
||||
data: item,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar equipamiento (admin)
|
||||
*/
|
||||
static async updateEquipment(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para actualizar equipamiento', 403);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del equipamiento', 400);
|
||||
}
|
||||
|
||||
const item = await EquipmentService.updateEquipment(
|
||||
id,
|
||||
req.user.userId,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Equipamiento actualizado exitosamente',
|
||||
data: item,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar equipamiento (admin)
|
||||
*/
|
||||
static async deleteEquipment(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para eliminar equipamiento', 403);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del equipamiento', 400);
|
||||
}
|
||||
|
||||
await EquipmentService.deleteEquipment(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Equipamiento eliminado exitosamente',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar disponibilidad de un item
|
||||
*/
|
||||
static async checkAvailability(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del equipamiento', 400);
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw new ApiError('Se requieren las fechas de inicio y fin', 400);
|
||||
}
|
||||
|
||||
const availability = await EquipmentService.checkAvailability(
|
||||
id,
|
||||
new Date(startDate as string),
|
||||
new Date(endDate as string)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: availability,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener reporte de inventario (admin)
|
||||
*/
|
||||
static async getInventoryReport(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para ver el reporte de inventario', 403);
|
||||
}
|
||||
|
||||
const report = await EquipmentService.getInventoryReport();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: report,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener items disponibles para una fecha específica
|
||||
*/
|
||||
static async getAvailableForDate(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { startDate, endDate, category } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw new ApiError('Se requieren las fechas de inicio y fin', 400);
|
||||
}
|
||||
|
||||
const items = await EquipmentService.getAvailableItemsForDate(
|
||||
category as any,
|
||||
new Date(startDate as string),
|
||||
new Date(endDate as string)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: items.length,
|
||||
data: items,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EquipmentController;
|
||||
296
backend/src/controllers/equipmentRental.controller.ts
Normal file
296
backend/src/controllers/equipmentRental.controller.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { EquipmentRentalService } from '../services/equipmentRental.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { UserRole } from '../utils/constants';
|
||||
|
||||
export class EquipmentRentalController {
|
||||
/**
|
||||
* Crear un nuevo alquiler
|
||||
*/
|
||||
static async createRental(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { items, startDate, endDate, bookingId } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
throw new ApiError('Se requiere al menos un item para alquilar', 400);
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw new ApiError('Se requieren las fechas de inicio y fin', 400);
|
||||
}
|
||||
|
||||
const result = await EquipmentRentalService.createRental(req.user.userId, {
|
||||
items,
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
bookingId,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Alquiler creado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener mis alquileres
|
||||
*/
|
||||
static async getMyRentals(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const rentals = await EquipmentRentalService.getMyRentals(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: rentals.length,
|
||||
data: rentals,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener detalle de un alquiler
|
||||
*/
|
||||
static async getRentalById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del alquiler', 400);
|
||||
}
|
||||
|
||||
const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN;
|
||||
const rental = await EquipmentRentalService.getRentalById(
|
||||
id,
|
||||
isAdmin ? undefined : req.user.userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: rental,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entregar material (pickup) - Admin
|
||||
*/
|
||||
static async pickUpRental(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para entregar material', 403);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del alquiler', 400);
|
||||
}
|
||||
|
||||
const result = await EquipmentRentalService.pickUpRental(
|
||||
id,
|
||||
req.user.userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Material entregado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Devolver material - Admin
|
||||
*/
|
||||
static async returnRental(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para recibir material', 403);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { condition, depositReturned } = req.body;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del alquiler', 400);
|
||||
}
|
||||
|
||||
const result = await EquipmentRentalService.returnRental(
|
||||
id,
|
||||
req.user.userId,
|
||||
condition,
|
||||
depositReturned
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Material devuelto exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar alquiler
|
||||
*/
|
||||
static async cancelRental(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ApiError('Se requiere el ID del alquiler', 400);
|
||||
}
|
||||
|
||||
const result = await EquipmentRentalService.cancelRental(
|
||||
id,
|
||||
req.user.userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Alquiler cancelado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener alquileres vencidos (admin)
|
||||
*/
|
||||
static async getOverdueRentals(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para ver alquileres vencidos', 403);
|
||||
}
|
||||
|
||||
const rentals = await EquipmentRentalService.getOverdueRentals();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: rentals.length,
|
||||
data: rentals,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todos los alquileres (admin)
|
||||
*/
|
||||
static async getAllRentals(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para ver todos los alquileres', 403);
|
||||
}
|
||||
|
||||
const filters = {
|
||||
status: req.query.status as any,
|
||||
userId: req.query.userId as string,
|
||||
};
|
||||
|
||||
const rentals = await EquipmentRentalService.getAllRentals(filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: rentals.length,
|
||||
data: rentals,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener estadísticas de alquileres (admin)
|
||||
*/
|
||||
static async getRentalStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para ver estadísticas', 403);
|
||||
}
|
||||
|
||||
const stats = await EquipmentRentalService.getRentalStats();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook para notificaciones de pago de MercadoPago
|
||||
*/
|
||||
static async paymentWebhook(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
// Responder inmediatamente a MercadoPago
|
||||
res.status(200).send('OK');
|
||||
|
||||
// Procesar el webhook de forma asíncrona
|
||||
await EquipmentRentalService.processPaymentWebhook(payload);
|
||||
} catch (error) {
|
||||
// No devolver error a MercadoPago, ya respondimos 200
|
||||
logger.error('Error procesando webhook de alquiler:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Importar logger para el webhook
|
||||
import logger from '../config/logger';
|
||||
|
||||
export default EquipmentRentalController;
|
||||
103
backend/src/controllers/extras/achievement.controller.ts
Normal file
103
backend/src/controllers/extras/achievement.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AchievementService } from '../../services/extras/achievement.service';
|
||||
import { ApiError } from '../../middleware/errorHandler';
|
||||
|
||||
export class AchievementController {
|
||||
// Crear logro
|
||||
static async createAchievement(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const achievement = await AchievementService.createAchievement(req.user.userId, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Logro creado exitosamente',
|
||||
data: achievement,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Listar logros
|
||||
static async getAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const achievements = await AchievementService.getAchievements();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: achievements.length,
|
||||
data: achievements,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mis logros
|
||||
static async getUserAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const achievements = await AchievementService.getUserAchievements(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: achievements,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Progreso de logro
|
||||
static async getAchievementProgress(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const progress = await AchievementService.getAchievementProgress(req.user.userId, req.params.id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: progress,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Leaderboard
|
||||
static async getLeaderboard(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const leaderboard = await AchievementService.getLeaderboard(
|
||||
req.query.limit ? parseInt(req.query.limit as string) : 10
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: leaderboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar logros
|
||||
static async checkAchievements(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const count = await AchievementService.checkAndUnlockAchievements(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `${count} nuevos logros verificados`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AchievementController;
|
||||
105
backend/src/controllers/extras/qrCheckin.controller.ts
Normal file
105
backend/src/controllers/extras/qrCheckin.controller.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { QRCheckinService } from '../../services/extras/qrCheckin.service';
|
||||
import { ApiError } from '../../middleware/errorHandler';
|
||||
|
||||
export class QRCheckinController {
|
||||
// Generar QR
|
||||
static async generateQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const result = await QRCheckinService.generateQRCode(req.params.bookingId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener mi QR
|
||||
static async getMyQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const result = await QRCheckinService.getQRCodeForBooking(req.params.bookingId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Validar QR (para escáner)
|
||||
static async validateQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const result = await QRCheckinService.validateQRCode(req.body.code);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar check-in
|
||||
static async processCheckIn(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const checkIn = await QRCheckinService.processCheckIn(
|
||||
req.body.code,
|
||||
req.user.role === 'ADMIN' || req.user.role === 'SUPERADMIN' ? req.user.userId : undefined
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Check-in realizado exitosamente',
|
||||
data: checkIn,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check-out
|
||||
static async processCheckOut(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const checkOut = await QRCheckinService.processCheckOut(req.params.checkInId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Check-out realizado exitosamente',
|
||||
data: checkOut,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check-ins del día
|
||||
static async getTodayCheckIns(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const checkIns = await QRCheckinService.getTodayCheckIns();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: checkIns.length,
|
||||
data: checkIns,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default QRCheckinController;
|
||||
104
backend/src/controllers/extras/wallOfFame.controller.ts
Normal file
104
backend/src/controllers/extras/wallOfFame.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { WallOfFameService } from '../../services/extras/wallOfFame.service';
|
||||
import { ApiError } from '../../middleware/errorHandler';
|
||||
|
||||
export class WallOfFameController {
|
||||
// Crear entrada
|
||||
static async createEntry(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const entry = await WallOfFameService.createEntry(req.user.userId, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Entrada creada exitosamente',
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Listar entradas
|
||||
static async getEntries(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const entries = await WallOfFameService.getEntries({
|
||||
category: req.query.category as string,
|
||||
featured: req.query.featured === 'true',
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: entries.length,
|
||||
data: entries,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Entradas destacadas
|
||||
static async getFeaturedEntries(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const entries = await WallOfFameService.getFeaturedEntries();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: entries,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Ver detalle
|
||||
static async getEntryById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const entry = await WallOfFameService.getEntryById(req.params.id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar
|
||||
static async updateEntry(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
const entry = await WallOfFameService.updateEntry(req.params.id, req.user.userId, req.body);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Entrada actualizada',
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar
|
||||
static async deleteEntry(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) throw new ApiError('No autenticado', 401);
|
||||
|
||||
await WallOfFameService.deleteEntry(req.params.id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Entrada eliminada',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WallOfFameController;
|
||||
207
backend/src/controllers/healthIntegration.controller.ts
Normal file
207
backend/src/controllers/healthIntegration.controller.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { HealthIntegrationService } from '../services/healthIntegration.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
|
||||
export class HealthIntegrationController {
|
||||
/**
|
||||
* Sincronizar datos de entrenamiento
|
||||
*/
|
||||
static async syncWorkoutData(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const activity = await HealthIntegrationService.syncWorkoutData(req.user.userId, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Datos de entrenamiento sincronizados exitosamente',
|
||||
data: activity,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener resumen de actividad
|
||||
*/
|
||||
static async getWorkoutSummary(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { period = 'WEEK' } = req.query;
|
||||
const summary = await HealthIntegrationService.getWorkoutSummary(
|
||||
req.user.userId,
|
||||
period as string
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: summary,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener calorías quemadas en un rango de fechas
|
||||
*/
|
||||
static async getCaloriesBurned(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
|
||||
}
|
||||
|
||||
const calories = await HealthIntegrationService.getCaloriesBurned(
|
||||
req.user.userId,
|
||||
new Date(startDate as string),
|
||||
new Date(endDate as string)
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: calories,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener tiempo total de juego
|
||||
*/
|
||||
static async getTotalPlayTime(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { period = 'MONTH' } = req.query;
|
||||
const playTime = await HealthIntegrationService.getTotalPlayTime(
|
||||
req.user.userId,
|
||||
period as string
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: playTime,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener actividades del usuario
|
||||
*/
|
||||
static async getUserActivities(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { activityType, source, limit, offset } = req.query;
|
||||
|
||||
const activities = await HealthIntegrationService.getUserActivities(
|
||||
req.user.userId,
|
||||
{
|
||||
activityType: activityType as string | undefined,
|
||||
source: source as string | undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
offset: offset ? parseInt(offset as string) : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: activities.length,
|
||||
data: activities,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizar con Apple Health (placeholder)
|
||||
*/
|
||||
static async syncWithAppleHealth(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { authToken } = req.body;
|
||||
|
||||
if (!authToken) {
|
||||
throw new ApiError('Se requiere el token de autenticación de Apple Health', 400);
|
||||
}
|
||||
|
||||
const result = await HealthIntegrationService.syncWithAppleHealth(
|
||||
req.user.userId,
|
||||
authToken
|
||||
);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizar con Google Fit (placeholder)
|
||||
*/
|
||||
static async syncWithGoogleFit(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { authToken } = req.body;
|
||||
|
||||
if (!authToken) {
|
||||
throw new ApiError('Se requiere el token de autenticación de Google Fit', 400);
|
||||
}
|
||||
|
||||
const result = await HealthIntegrationService.syncWithGoogleFit(
|
||||
req.user.userId,
|
||||
authToken
|
||||
);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una actividad
|
||||
*/
|
||||
static async deleteActivity(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await HealthIntegrationService.deleteActivity(id, req.user.userId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default HealthIntegrationController;
|
||||
146
backend/src/controllers/menu.controller.ts
Normal file
146
backend/src/controllers/menu.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { MenuService } from '../services/menu.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
|
||||
export class MenuController {
|
||||
/**
|
||||
* Crear un nuevo item del menú (solo admin)
|
||||
*/
|
||||
static async createMenuItem(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const menuItem = await MenuService.createMenuItem(req.user.userId, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Item del menú creado exitosamente',
|
||||
data: menuItem,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todos los items del menú (público - solo activos y disponibles)
|
||||
*/
|
||||
static async getMenuItems(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { category } = req.query;
|
||||
const menuItems = await MenuService.getMenuItems(category as string | undefined);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: menuItems.length,
|
||||
data: menuItems,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todos los items del menú (admin - incluye inactivos)
|
||||
*/
|
||||
static async getAllMenuItems(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { category } = req.query;
|
||||
const menuItems = await MenuService.getAllMenuItems(category as string | undefined);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: menuItems.length,
|
||||
data: menuItems,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener un item del menú por ID
|
||||
*/
|
||||
static async getMenuItemById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const menuItem = await MenuService.getMenuItemById(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: menuItem,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un item del menú (solo admin)
|
||||
*/
|
||||
static async updateMenuItem(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const menuItem = await MenuService.updateMenuItem(id, req.user.userId, req.body);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Item del menú actualizado exitosamente',
|
||||
data: menuItem,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar un item del menú (solo admin - soft delete)
|
||||
*/
|
||||
static async deleteMenuItem(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
await MenuService.deleteMenuItem(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Item del menú eliminado exitosamente',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar disponibilidad de un item (solo admin)
|
||||
*/
|
||||
static async toggleAvailability(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const menuItem = await MenuService.toggleAvailability(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Disponibilidad cambiada a: ${menuItem.isAvailable ? 'Disponible' : 'No disponible'}`,
|
||||
data: menuItem,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MenuController;
|
||||
153
backend/src/controllers/notification.controller.ts
Normal file
153
backend/src/controllers/notification.controller.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
|
||||
export class NotificationController {
|
||||
/**
|
||||
* Obtener mis notificaciones
|
||||
*/
|
||||
static async getMyNotifications(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||
const notifications = await NotificationService.getMyNotifications(req.user.userId, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: notifications.length,
|
||||
data: notifications,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar notificación como leída
|
||||
*/
|
||||
static async markAsRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const notification = await NotificationService.markAsRead(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Notificación marcada como leída',
|
||||
data: notification,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar todas las notificaciones como leídas
|
||||
*/
|
||||
static async markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const result = await NotificationService.markAllAsRead(req.user.userId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una notificación
|
||||
*/
|
||||
static async deleteNotification(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await NotificationService.deleteNotification(id, req.user.userId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener conteo de notificaciones no leídas
|
||||
*/
|
||||
static async getUnreadCount(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const result = await NotificationService.getUnreadCount(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar notificación masiva (solo admin)
|
||||
*/
|
||||
static async sendBulkNotification(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { userIds, type, title, message, data } = req.body;
|
||||
|
||||
const result = await NotificationService.createBulkNotification(
|
||||
req.user.userId,
|
||||
userIds,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
data
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Notificaciones enviadas exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar notificaciones antiguas (solo admin)
|
||||
*/
|
||||
static async cleanupOldNotifications(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const result = await NotificationService.cleanupOldNotifications(req.user.userId);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationController;
|
||||
204
backend/src/controllers/order.controller.ts
Normal file
204
backend/src/controllers/order.controller.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { OrderService } from '../services/order.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { UserRole } from '../utils/constants';
|
||||
|
||||
export class OrderController {
|
||||
/**
|
||||
* Crear un nuevo pedido
|
||||
*/
|
||||
static async createOrder(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const order = await OrderService.createOrder(req.user.userId, req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Pedido creado exitosamente',
|
||||
data: order,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener mis pedidos
|
||||
*/
|
||||
static async getMyOrders(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const orders = await OrderService.getMyOrders(req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: orders.length,
|
||||
data: orders,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener pedidos de una reserva
|
||||
*/
|
||||
static async getOrdersByBooking(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { bookingId } = req.params;
|
||||
const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN;
|
||||
|
||||
const orders = await OrderService.getOrdersByBooking(
|
||||
bookingId,
|
||||
isAdmin ? undefined : req.user.userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: orders.length,
|
||||
data: orders,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener pedidos pendientes (bar/admin)
|
||||
*/
|
||||
static async getPendingOrders(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const orders = await OrderService.getPendingOrders();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: orders.length,
|
||||
data: orders,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar estado del pedido (admin)
|
||||
*/
|
||||
static async updateOrderStatus(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
const order = await OrderService.updateOrderStatus(id, status, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Estado del pedido actualizado exitosamente',
|
||||
data: order,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar pedido como entregado (admin)
|
||||
*/
|
||||
static async markAsDelivered(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const order = await OrderService.markAsDelivered(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Pedido marcado como entregado',
|
||||
data: order,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar pedido
|
||||
*/
|
||||
static async cancelOrder(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const isAdmin = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SUPERADMIN;
|
||||
|
||||
const order = await OrderService.cancelOrder(id, req.user.userId, isAdmin);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Pedido cancelado exitosamente',
|
||||
data: order,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar pago del pedido
|
||||
*/
|
||||
static async processPayment(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const paymentInfo = await OrderService.processPayment(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Preferencia de pago creada',
|
||||
data: paymentInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook de MercadoPago para pedidos
|
||||
*/
|
||||
static async webhook(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const paymentData = req.body;
|
||||
const result = await OrderService.processWebhook(paymentData);
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default OrderController;
|
||||
276
backend/src/controllers/qrCheckin.controller.ts
Normal file
276
backend/src/controllers/qrCheckin.controller.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { QRCheckInService } from '../services/qrCheckin.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { UserRole } from '../utils/constants';
|
||||
|
||||
export class QRCheckInController {
|
||||
/**
|
||||
* Generar código QR para una reserva
|
||||
*/
|
||||
static async generateQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { bookingId } = req.body;
|
||||
const expiresInMinutes = req.body.expiresInMinutes || 15;
|
||||
|
||||
if (!bookingId) {
|
||||
throw new ApiError('Se requiere el ID de la reserva', 400);
|
||||
}
|
||||
|
||||
const result = await QRCheckInService.generateQRCode({
|
||||
bookingId,
|
||||
userId: req.user.userId,
|
||||
expiresInMinutes,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Código QR generado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener mi código QR para una reserva
|
||||
*/
|
||||
static async getMyQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { bookingId } = req.params;
|
||||
|
||||
if (!bookingId) {
|
||||
throw new ApiError('Se requiere el ID de la reserva', 400);
|
||||
}
|
||||
|
||||
const result = await QRCheckInService.getQRCodeForBooking(
|
||||
bookingId,
|
||||
req.user.userId
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'No hay código QR activo para esta reserva',
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar código QR (para escáner de recepción)
|
||||
*/
|
||||
static async validateQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
// Solo admins pueden validar QR
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para validar códigos QR', 403);
|
||||
}
|
||||
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code) {
|
||||
throw new ApiError('Se requiere el código QR', 400);
|
||||
}
|
||||
|
||||
const result = await QRCheckInService.validateQRCode(code);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar check-in (con QR o manual)
|
||||
*/
|
||||
static async processCheckIn(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
// Solo admins pueden procesar check-in
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para procesar check-ins', 403);
|
||||
}
|
||||
|
||||
const { bookingId } = req.params;
|
||||
const { code, notes } = req.body;
|
||||
|
||||
if (!bookingId) {
|
||||
throw new ApiError('Se requiere el ID de la reserva', 400);
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (code) {
|
||||
// Check-in con QR
|
||||
result = await QRCheckInService.processCheckIn({
|
||||
code,
|
||||
adminId: req.user.userId,
|
||||
notes,
|
||||
});
|
||||
} else {
|
||||
// Check-in manual
|
||||
result = await QRCheckInService.processManualCheckIn(
|
||||
bookingId,
|
||||
req.user.userId,
|
||||
notes
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Check-in procesado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesar check-out
|
||||
*/
|
||||
static async processCheckOut(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
// Solo admins pueden procesar check-out
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para procesar check-outs', 403);
|
||||
}
|
||||
|
||||
const { checkInId } = req.params;
|
||||
const { notes } = req.body;
|
||||
|
||||
if (!checkInId) {
|
||||
throw new ApiError('Se requiere el ID del check-in', 400);
|
||||
}
|
||||
|
||||
const result = await QRCheckInService.processCheckOut({
|
||||
checkInId,
|
||||
adminId: req.user.userId,
|
||||
notes,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Check-out procesado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener check-ins del día (admin)
|
||||
*/
|
||||
static async getTodayCheckIns(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
// Solo admins pueden ver todos los check-ins
|
||||
if (req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
|
||||
throw new ApiError('No tienes permiso para ver los check-ins', 403);
|
||||
}
|
||||
|
||||
const checkIns = await QRCheckInService.getTodayCheckIns();
|
||||
const stats = await QRCheckInService.getTodayStats();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
checkIns,
|
||||
stats,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener historial de check-ins de una reserva
|
||||
*/
|
||||
static async getCheckInsByBooking(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { bookingId } = req.params;
|
||||
|
||||
if (!bookingId) {
|
||||
throw new ApiError('Se requiere el ID de la reserva', 400);
|
||||
}
|
||||
|
||||
const checkIns = await QRCheckInService.getCheckInsByBooking(bookingId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: checkIns.length,
|
||||
data: checkIns,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelar código QR
|
||||
*/
|
||||
static async cancelQR(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code) {
|
||||
throw new ApiError('Se requiere el código QR', 400);
|
||||
}
|
||||
|
||||
const result = await QRCheckInService.cancelQRCode(code, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Código QR cancelado exitosamente',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default QRCheckInController;
|
||||
198
backend/src/controllers/wallOfFame.controller.ts
Normal file
198
backend/src/controllers/wallOfFame.controller.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { WallOfFameService } from '../services/wallOfFame.service';
|
||||
import { ApiError } from '../middleware/errorHandler';
|
||||
import { WallOfFameCategory } from '../utils/constants';
|
||||
|
||||
export class WallOfFameController {
|
||||
/**
|
||||
* Crear una nueva entrada en el Wall of Fame (solo admin)
|
||||
*/
|
||||
static async createEntry(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const entry = await WallOfFameService.createEntry(req.user.userId, {
|
||||
...req.body,
|
||||
eventDate: new Date(req.body.eventDate),
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Entrada creada exitosamente',
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar entradas del Wall of Fame (público)
|
||||
*/
|
||||
static async getEntries(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const filters = {
|
||||
category: req.query.category as WallOfFameCategory | undefined,
|
||||
featured: req.query.featured === 'true' ? true :
|
||||
req.query.featured === 'false' ? false : undefined,
|
||||
isActive: req.query.isActive === 'false' ? false : true,
|
||||
tournamentId: req.query.tournamentId as string | undefined,
|
||||
leagueId: req.query.leagueId as string | undefined,
|
||||
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
|
||||
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||
offset: req.query.offset ? parseInt(req.query.offset as string) : undefined,
|
||||
};
|
||||
|
||||
const result = await WallOfFameService.getEntries(filters);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.entries,
|
||||
meta: {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener entradas destacadas para el home (público)
|
||||
*/
|
||||
static async getFeaturedEntries(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 5;
|
||||
const entries = await WallOfFameService.getFeaturedEntries(limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: entries.length,
|
||||
data: entries,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener una entrada por ID (público)
|
||||
*/
|
||||
static async getEntryById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const entry = await WallOfFameService.getEntryById(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar una entrada (solo admin)
|
||||
*/
|
||||
static async updateEntry(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updateData: any = { ...req.body };
|
||||
|
||||
if (req.body.eventDate) {
|
||||
updateData.eventDate = new Date(req.body.eventDate);
|
||||
}
|
||||
|
||||
const entry = await WallOfFameService.updateEntry(id, req.user.userId, updateData);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Entrada actualizada exitosamente',
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una entrada (solo admin)
|
||||
*/
|
||||
static async deleteEntry(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await WallOfFameService.deleteEntry(id, req.user.userId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agregar ganadores a una entrada existente (solo admin)
|
||||
*/
|
||||
static async addWinners(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new ApiError('No autenticado', 401);
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { winners } = req.body;
|
||||
|
||||
const entry = await WallOfFameService.addWinners(id, winners);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Ganadores agregados exitosamente',
|
||||
data: entry,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar entradas por término (público)
|
||||
*/
|
||||
static async searchEntries(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || typeof q !== 'string') {
|
||||
throw new ApiError('Término de búsqueda requerido', 400);
|
||||
}
|
||||
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
|
||||
const entries = await WallOfFameService.searchEntries(q, limit);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
count: entries.length,
|
||||
data: entries,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WallOfFameController;
|
||||
186
backend/src/routes/achievement.routes.ts
Normal file
186
backend/src/routes/achievement.routes.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Router } from 'express';
|
||||
import { AchievementController } from '../controllers/achievement.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate, validateQuery } from '../middleware/validate';
|
||||
import { UserRole, AchievementCategory } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
import { RequirementType } from '../utils/constants';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema para crear logro
|
||||
const createAchievementSchema = z.object({
|
||||
code: z.string().min(1, 'El código es requerido').max(50),
|
||||
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||
description: z.string().min(1, 'La descripción es requerida').max(500),
|
||||
category: z.enum([
|
||||
AchievementCategory.GAMES,
|
||||
AchievementCategory.TOURNAMENTS,
|
||||
AchievementCategory.SOCIAL,
|
||||
AchievementCategory.STREAK,
|
||||
AchievementCategory.SPECIAL,
|
||||
]),
|
||||
icon: z.string().min(1, 'El icono es requerido').max(10),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser formato hex (#RRGGBB)'),
|
||||
requirementType: z.enum([
|
||||
RequirementType.MATCHES_PLAYED,
|
||||
RequirementType.MATCHES_WON,
|
||||
RequirementType.TOURNAMENTS_PLAYED,
|
||||
RequirementType.TOURNAMENTS_WON,
|
||||
RequirementType.FRIENDS_ADDED,
|
||||
RequirementType.STREAK_DAYS,
|
||||
RequirementType.BOOKINGS_MADE,
|
||||
RequirementType.GROUPS_JOINED,
|
||||
RequirementType.LEAGUES_WON,
|
||||
RequirementType.PERFECT_MATCH,
|
||||
RequirementType.COMEBACK_WIN,
|
||||
]),
|
||||
requirementValue: z.number().int().min(1, 'El valor debe ser al menos 1'),
|
||||
pointsReward: z.number().int().min(0, 'Los puntos no pueden ser negativos'),
|
||||
});
|
||||
|
||||
// Schema para actualizar logro
|
||||
const updateAchievementSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().min(1).max(500).optional(),
|
||||
category: z.enum([
|
||||
AchievementCategory.GAMES,
|
||||
AchievementCategory.TOURNAMENTS,
|
||||
AchievementCategory.SOCIAL,
|
||||
AchievementCategory.STREAK,
|
||||
AchievementCategory.SPECIAL,
|
||||
]).optional(),
|
||||
icon: z.string().min(1).max(10).optional(),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
|
||||
requirementType: z.enum([
|
||||
RequirementType.MATCHES_PLAYED,
|
||||
RequirementType.MATCHES_WON,
|
||||
RequirementType.TOURNAMENTS_PLAYED,
|
||||
RequirementType.TOURNAMENTS_WON,
|
||||
RequirementType.FRIENDS_ADDED,
|
||||
RequirementType.STREAK_DAYS,
|
||||
RequirementType.BOOKINGS_MADE,
|
||||
RequirementType.GROUPS_JOINED,
|
||||
RequirementType.LEAGUES_WON,
|
||||
RequirementType.PERFECT_MATCH,
|
||||
RequirementType.COMEBACK_WIN,
|
||||
]).optional(),
|
||||
requirementValue: z.number().int().min(1).optional(),
|
||||
pointsReward: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema para query params de listado
|
||||
const listAchievementsQuerySchema = z.object({
|
||||
category: z.enum([
|
||||
AchievementCategory.GAMES,
|
||||
AchievementCategory.TOURNAMENTS,
|
||||
AchievementCategory.SOCIAL,
|
||||
AchievementCategory.STREAK,
|
||||
AchievementCategory.SPECIAL,
|
||||
]).optional(),
|
||||
activeOnly: z.enum(['true', 'false']).optional(),
|
||||
});
|
||||
|
||||
// Schema para params de ID
|
||||
const achievementIdParamsSchema = z.object({
|
||||
id: z.string().uuid('ID inválido'),
|
||||
});
|
||||
|
||||
// Schema para query params del leaderboard
|
||||
const leaderboardQuerySchema = z.object({
|
||||
limit: z.string().regex(/^\d+$/).optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Rutas Públicas (lectura)
|
||||
// ============================================
|
||||
|
||||
// Listar logros disponibles
|
||||
router.get(
|
||||
'/',
|
||||
validateQuery(listAchievementsQuerySchema),
|
||||
AchievementController.getAchievements
|
||||
);
|
||||
|
||||
// Obtener ranking por puntos
|
||||
router.get(
|
||||
'/leaderboard',
|
||||
validateQuery(leaderboardQuerySchema),
|
||||
AchievementController.getLeaderboard
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas Protegidas (requieren autenticación)
|
||||
// ============================================
|
||||
|
||||
// Mis logros desbloqueados
|
||||
router.get(
|
||||
'/my',
|
||||
authenticate,
|
||||
AchievementController.getMyAchievements
|
||||
);
|
||||
|
||||
// Progreso de mis logros
|
||||
router.get(
|
||||
'/my/progress',
|
||||
authenticate,
|
||||
AchievementController.getMyAchievementsProgress
|
||||
);
|
||||
|
||||
// Progreso de un logro específico
|
||||
router.get(
|
||||
'/progress/:id',
|
||||
authenticate,
|
||||
validate(achievementIdParamsSchema),
|
||||
AchievementController.getAchievementProgress
|
||||
);
|
||||
|
||||
// Verificar y desbloquear logros
|
||||
router.post(
|
||||
'/check',
|
||||
authenticate,
|
||||
AchievementController.checkAndUnlockAchievements
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas de Admin
|
||||
// ============================================
|
||||
|
||||
// Crear logro
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(createAchievementSchema),
|
||||
AchievementController.createAchievement
|
||||
);
|
||||
|
||||
// Inicializar logros por defecto
|
||||
router.post(
|
||||
'/initialize',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
AchievementController.initializeDefaultAchievements
|
||||
);
|
||||
|
||||
// Actualizar logro
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(achievementIdParamsSchema),
|
||||
validate(updateAchievementSchema),
|
||||
AchievementController.updateAchievement
|
||||
);
|
||||
|
||||
// Eliminar logro
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(achievementIdParamsSchema),
|
||||
AchievementController.deleteAchievement
|
||||
);
|
||||
|
||||
export default router;
|
||||
178
backend/src/routes/challenge.routes.ts
Normal file
178
backend/src/routes/challenge.routes.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Router } from 'express';
|
||||
import { ChallengeController } from '../controllers/challenge.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate, validateQuery } from '../middleware/validate';
|
||||
import { UserRole, ChallengeType, RequirementType } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema para crear reto
|
||||
const createChallengeSchema = z.object({
|
||||
title: z.string().min(1, 'El título es requerido').max(200),
|
||||
description: z.string().min(1, 'La descripción es requerida').max(1000),
|
||||
type: z.enum([
|
||||
ChallengeType.WEEKLY,
|
||||
ChallengeType.MONTHLY,
|
||||
ChallengeType.SPECIAL,
|
||||
]),
|
||||
requirementType: z.enum([
|
||||
RequirementType.MATCHES_PLAYED,
|
||||
RequirementType.MATCHES_WON,
|
||||
RequirementType.TOURNAMENTS_PLAYED,
|
||||
RequirementType.TOURNAMENTS_WON,
|
||||
RequirementType.FRIENDS_ADDED,
|
||||
RequirementType.STREAK_DAYS,
|
||||
RequirementType.BOOKINGS_MADE,
|
||||
RequirementType.GROUPS_JOINED,
|
||||
RequirementType.LEAGUES_WON,
|
||||
RequirementType.PERFECT_MATCH,
|
||||
RequirementType.COMEBACK_WIN,
|
||||
]),
|
||||
requirementValue: z.number().int().min(1, 'El valor debe ser al menos 1'),
|
||||
startDate: z.string().datetime('Fecha de inicio inválida'),
|
||||
endDate: z.string().datetime('Fecha de fin inválida'),
|
||||
rewardPoints: z.number().int().min(0, 'Los puntos no pueden ser negativos'),
|
||||
});
|
||||
|
||||
// Schema para actualizar reto
|
||||
const updateChallengeSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().min(1).max(1000).optional(),
|
||||
requirementType: z.enum([
|
||||
RequirementType.MATCHES_PLAYED,
|
||||
RequirementType.MATCHES_WON,
|
||||
RequirementType.TOURNAMENTS_PLAYED,
|
||||
RequirementType.TOURNAMENTS_WON,
|
||||
RequirementType.FRIENDS_ADDED,
|
||||
RequirementType.STREAK_DAYS,
|
||||
RequirementType.BOOKINGS_MADE,
|
||||
RequirementType.GROUPS_JOINED,
|
||||
RequirementType.LEAGUES_WON,
|
||||
RequirementType.PERFECT_MATCH,
|
||||
RequirementType.COMEBACK_WIN,
|
||||
]).optional(),
|
||||
requirementValue: z.number().int().min(1).optional(),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
rewardPoints: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema para query params de listado
|
||||
const listChallengesQuerySchema = z.object({
|
||||
type: z.enum([
|
||||
ChallengeType.WEEKLY,
|
||||
ChallengeType.MONTHLY,
|
||||
ChallengeType.SPECIAL,
|
||||
]).optional(),
|
||||
ongoing: z.enum(['true']).optional(),
|
||||
limit: z.string().regex(/^\d+$/).optional(),
|
||||
offset: z.string().regex(/^\d+$/).optional(),
|
||||
});
|
||||
|
||||
// Schema para params de ID
|
||||
const challengeIdParamsSchema = z.object({
|
||||
id: z.string().uuid('ID inválido'),
|
||||
});
|
||||
|
||||
// Schema para query params del leaderboard
|
||||
const leaderboardQuerySchema = z.object({
|
||||
limit: z.string().regex(/^\d+$/).optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Rutas Públicas (lectura)
|
||||
// ============================================
|
||||
|
||||
// Listar retos activos
|
||||
router.get(
|
||||
'/',
|
||||
validateQuery(listChallengesQuerySchema),
|
||||
ChallengeController.getActiveChallenges
|
||||
);
|
||||
|
||||
// Obtener tabla de líderes de un reto
|
||||
router.get(
|
||||
'/:id/leaderboard',
|
||||
validate(challengeIdParamsSchema),
|
||||
validateQuery(leaderboardQuerySchema),
|
||||
ChallengeController.getChallengeLeaderboard
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas Protegidas (requieren autenticación)
|
||||
// ============================================
|
||||
|
||||
// Obtener un reto por ID
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticate,
|
||||
validate(challengeIdParamsSchema),
|
||||
ChallengeController.getChallengeById
|
||||
);
|
||||
|
||||
// Mis retos
|
||||
router.get(
|
||||
'/my/list',
|
||||
authenticate,
|
||||
ChallengeController.getMyChallenges
|
||||
);
|
||||
|
||||
// Unirse a un reto
|
||||
router.post(
|
||||
'/:id/join',
|
||||
authenticate,
|
||||
validate(challengeIdParamsSchema),
|
||||
ChallengeController.joinChallenge
|
||||
);
|
||||
|
||||
// Completar reto y reclamar recompensa
|
||||
router.post(
|
||||
'/:id/claim',
|
||||
authenticate,
|
||||
validate(challengeIdParamsSchema),
|
||||
ChallengeController.completeChallenge
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas de Admin
|
||||
// ============================================
|
||||
|
||||
// Crear reto
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(createChallengeSchema),
|
||||
ChallengeController.createChallenge
|
||||
);
|
||||
|
||||
// Verificar retos expirados
|
||||
router.post(
|
||||
'/check-expired',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
ChallengeController.checkExpiredChallenges
|
||||
);
|
||||
|
||||
// Actualizar reto
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(challengeIdParamsSchema),
|
||||
validate(updateChallengeSchema),
|
||||
ChallengeController.updateChallenge
|
||||
);
|
||||
|
||||
// Eliminar reto
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(challengeIdParamsSchema),
|
||||
ChallengeController.deleteChallenge
|
||||
);
|
||||
|
||||
export default router;
|
||||
106
backend/src/routes/checkin.routes.ts
Normal file
106
backend/src/routes/checkin.routes.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Router } from 'express';
|
||||
import { QRCheckInController } from '../controllers/qrCheckin.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schemas de validación
|
||||
const generateQRSchema = z.object({
|
||||
bookingId: z.string().uuid('ID de reserva inválido'),
|
||||
expiresInMinutes: z.number().min(5).max(120).optional(),
|
||||
});
|
||||
|
||||
const validateQRSchema = z.object({
|
||||
code: z.string().min(1, 'El código es requerido'),
|
||||
});
|
||||
|
||||
const checkInSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const checkOutSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const cancelQRSchema = z.object({
|
||||
code: z.string().min(1, 'El código es requerido'),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Rutas para usuarios (generar/ver sus QR)
|
||||
// ============================================
|
||||
|
||||
// Generar código QR para una reserva
|
||||
router.post(
|
||||
'/qr/generate',
|
||||
authenticate,
|
||||
validate(generateQRSchema),
|
||||
QRCheckInController.generateQR
|
||||
);
|
||||
|
||||
// Obtener mi código QR activo para una reserva
|
||||
router.get(
|
||||
'/qr/my-booking/:bookingId',
|
||||
authenticate,
|
||||
QRCheckInController.getMyQR
|
||||
);
|
||||
|
||||
// Cancelar mi código QR
|
||||
router.post(
|
||||
'/qr/cancel',
|
||||
authenticate,
|
||||
validate(cancelQRSchema),
|
||||
QRCheckInController.cancelQR
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas para administradores (escáner/recepción)
|
||||
// ============================================
|
||||
|
||||
// Validar código QR (para escáner)
|
||||
router.post(
|
||||
'/validate',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(validateQRSchema),
|
||||
QRCheckInController.validateQR
|
||||
);
|
||||
|
||||
// Procesar check-in (con QR o manual)
|
||||
router.post(
|
||||
'/:bookingId/checkin',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(checkInSchema),
|
||||
QRCheckInController.processCheckIn
|
||||
);
|
||||
|
||||
// Procesar check-out
|
||||
router.post(
|
||||
'/:checkInId/checkout',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(checkOutSchema),
|
||||
QRCheckInController.processCheckOut
|
||||
);
|
||||
|
||||
// Obtener check-ins del día (dashboard de recepción)
|
||||
router.get(
|
||||
'/today',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
QRCheckInController.getTodayCheckIns
|
||||
);
|
||||
|
||||
// Obtener historial de check-ins de una reserva
|
||||
router.get(
|
||||
'/booking/:bookingId',
|
||||
authenticate,
|
||||
QRCheckInController.getCheckInsByBooking
|
||||
);
|
||||
|
||||
export default router;
|
||||
96
backend/src/routes/equipment.routes.ts
Normal file
96
backend/src/routes/equipment.routes.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Router } from 'express';
|
||||
import { EquipmentController } from '../controllers/equipment.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schemas de validación
|
||||
const createEquipmentSchema = z.object({
|
||||
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||
description: z.string().optional(),
|
||||
category: z.enum(['RACKET', 'BALLS', 'ACCESSORIES', 'SHOES']),
|
||||
brand: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
size: z.string().optional(),
|
||||
condition: z.enum(['NEW', 'GOOD', 'FAIR', 'POOR']).optional(),
|
||||
hourlyRate: z.number().min(0).optional(),
|
||||
dailyRate: z.number().min(0).optional(),
|
||||
depositRequired: z.number().min(0).optional(),
|
||||
quantityTotal: z.number().min(1).optional(),
|
||||
imageUrl: z.string().url().optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
const updateEquipmentSchema = z.object({
|
||||
name: z.string().min(2).optional(),
|
||||
description: z.string().optional(),
|
||||
category: z.enum(['RACKET', 'BALLS', 'ACCESSORIES', 'SHOES']).optional(),
|
||||
brand: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
size: z.string().optional(),
|
||||
condition: z.enum(['NEW', 'GOOD', 'FAIR', 'POOR']).optional(),
|
||||
hourlyRate: z.number().min(0).optional(),
|
||||
dailyRate: z.number().min(0).optional(),
|
||||
depositRequired: z.number().min(0).optional(),
|
||||
quantityTotal: z.number().min(1).optional(),
|
||||
imageUrl: z.string().url().optional().or(z.literal('')),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Rutas públicas (lectura)
|
||||
// ============================================
|
||||
|
||||
// Listar equipamiento
|
||||
router.get('/', EquipmentController.getEquipmentItems);
|
||||
|
||||
// Obtener items disponibles para una fecha específica
|
||||
router.get('/available', EquipmentController.getAvailableForDate);
|
||||
|
||||
// Verificar disponibilidad de un item
|
||||
router.get('/:id/availability', EquipmentController.checkAvailability);
|
||||
|
||||
// Obtener detalle de un item
|
||||
router.get('/:id', EquipmentController.getEquipmentById);
|
||||
|
||||
// ============================================
|
||||
// Rutas de administración (requieren admin)
|
||||
// ============================================
|
||||
|
||||
// Crear equipamiento
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(createEquipmentSchema),
|
||||
EquipmentController.createEquipment
|
||||
);
|
||||
|
||||
// Actualizar equipamiento
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(updateEquipmentSchema),
|
||||
EquipmentController.updateEquipment
|
||||
);
|
||||
|
||||
// Eliminar equipamiento
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
EquipmentController.deleteEquipment
|
||||
);
|
||||
|
||||
// Reporte de inventario
|
||||
router.get(
|
||||
'/admin/inventory-report',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
EquipmentController.getInventoryReport
|
||||
);
|
||||
|
||||
export default router;
|
||||
116
backend/src/routes/equipmentRental.routes.ts
Normal file
116
backend/src/routes/equipmentRental.routes.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Router } from 'express';
|
||||
import { EquipmentRentalController } from '../controllers/equipmentRental.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schemas de validación
|
||||
const createRentalSchema = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
itemId: z.string().uuid('ID de item inválido'),
|
||||
quantity: z.number().min(1, 'La cantidad debe ser al menos 1'),
|
||||
})
|
||||
).min(1, 'Se requiere al menos un item'),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
bookingId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const returnRentalSchema = z.object({
|
||||
condition: z.enum(['GOOD', 'FAIR', 'DAMAGED']).optional(),
|
||||
depositReturned: z.number().min(0).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Rutas para usuarios
|
||||
// ============================================
|
||||
|
||||
// Crear alquiler
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
validate(createRentalSchema),
|
||||
EquipmentRentalController.createRental
|
||||
);
|
||||
|
||||
// Obtener mis alquileres
|
||||
router.get(
|
||||
'/my',
|
||||
authenticate,
|
||||
EquipmentRentalController.getMyRentals
|
||||
);
|
||||
|
||||
// Obtener detalle de un alquiler
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticate,
|
||||
EquipmentRentalController.getRentalById
|
||||
);
|
||||
|
||||
// Cancelar alquiler
|
||||
router.post(
|
||||
'/:id/cancel',
|
||||
authenticate,
|
||||
EquipmentRentalController.cancelRental
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas para administradores
|
||||
// ============================================
|
||||
|
||||
// Entregar material (pickup)
|
||||
router.post(
|
||||
'/:id/pickup',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
EquipmentRentalController.pickUpRental
|
||||
);
|
||||
|
||||
// Devolver material (return)
|
||||
router.post(
|
||||
'/:id/return',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(returnRentalSchema),
|
||||
EquipmentRentalController.returnRental
|
||||
);
|
||||
|
||||
// Obtener alquileres vencidos
|
||||
router.get(
|
||||
'/admin/overdue',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
EquipmentRentalController.getOverdueRentals
|
||||
);
|
||||
|
||||
// Obtener todos los alquileres
|
||||
router.get(
|
||||
'/admin/all',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
EquipmentRentalController.getAllRentals
|
||||
);
|
||||
|
||||
// Obtener estadísticas
|
||||
router.get(
|
||||
'/admin/stats',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
EquipmentRentalController.getRentalStats
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Webhook de MercadoPago (público)
|
||||
// ============================================
|
||||
|
||||
router.post(
|
||||
'/webhook',
|
||||
EquipmentRentalController.paymentWebhook
|
||||
);
|
||||
|
||||
export default router;
|
||||
44
backend/src/routes/extras.routes.ts
Normal file
44
backend/src/routes/extras.routes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { WallOfFameController } from '../controllers/extras/wallOfFame.controller';
|
||||
import { AchievementController } from '../controllers/extras/achievement.controller';
|
||||
import { QRCheckinController } from '../controllers/extras/qrCheckin.controller';
|
||||
import { UserRole } from '../utils/constants';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============================================
|
||||
// WALL OF FAME (público para lectura)
|
||||
// ============================================
|
||||
router.get('/wall-of-fame', WallOfFameController.getEntries);
|
||||
router.get('/wall-of-fame/featured', WallOfFameController.getFeaturedEntries);
|
||||
router.get('/wall-of-fame/:id', WallOfFameController.getEntryById);
|
||||
|
||||
// Admin
|
||||
router.post('/wall-of-fame', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), WallOfFameController.createEntry);
|
||||
router.put('/wall-of-fame/:id', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), WallOfFameController.updateEntry);
|
||||
router.delete('/wall-of-fame/:id', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), WallOfFameController.deleteEntry);
|
||||
|
||||
// ============================================
|
||||
// ACHIEVEMENTS / LOGROS
|
||||
// ============================================
|
||||
router.get('/achievements', AchievementController.getAchievements);
|
||||
router.get('/achievements/my', authenticate, AchievementController.getUserAchievements);
|
||||
router.get('/achievements/progress/:id', authenticate, AchievementController.getAchievementProgress);
|
||||
router.get('/achievements/leaderboard', AchievementController.getLeaderboard);
|
||||
router.post('/achievements/check', authenticate, AchievementController.checkAchievements);
|
||||
|
||||
// Admin
|
||||
router.post('/achievements', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), AchievementController.createAchievement);
|
||||
|
||||
// ============================================
|
||||
// QR CHECK-IN
|
||||
// ============================================
|
||||
router.post('/checkin/qr/generate/:bookingId', authenticate, QRCheckinController.generateQR);
|
||||
router.get('/checkin/qr/my-booking/:bookingId', authenticate, QRCheckinController.getMyQR);
|
||||
router.post('/checkin/validate', authenticate, QRCheckinController.validateQR);
|
||||
router.post('/checkin/:bookingId/checkin', authenticate, QRCheckinController.processCheckIn);
|
||||
router.post('/checkin/:checkInId/checkout', authenticate, QRCheckinController.processCheckOut);
|
||||
router.get('/checkin/today', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), QRCheckinController.getTodayCheckIns);
|
||||
|
||||
export default router;
|
||||
65
backend/src/routes/health.routes.ts
Normal file
65
backend/src/routes/health.routes.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Router } from 'express';
|
||||
import { HealthIntegrationController } from '../controllers/healthIntegration.controller';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema para sincronizar datos de salud
|
||||
const syncHealthDataSchema = z.object({
|
||||
source: z.enum(['APPLE_HEALTH', 'GOOGLE_FIT', 'MANUAL']),
|
||||
activityType: z.enum(['PADEL_GAME', 'WORKOUT']),
|
||||
workoutData: z.object({
|
||||
calories: z.number().min(0).max(5000),
|
||||
duration: z.number().int().min(1).max(300),
|
||||
heartRate: z.object({
|
||||
avg: z.number().int().min(30).max(220).optional(),
|
||||
max: z.number().int().min(30).max(220).optional(),
|
||||
}).optional(),
|
||||
startTime: z.string().datetime(),
|
||||
endTime: z.string().datetime(),
|
||||
steps: z.number().int().min(0).max(50000).optional(),
|
||||
distance: z.number().min(0).max(50).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
}),
|
||||
bookingId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
// Schema para autenticación con servicios de salud
|
||||
const healthAuthSchema = z.object({
|
||||
authToken: z.string().min(1, 'El token de autenticación es requerido'),
|
||||
});
|
||||
|
||||
// Rutas para sincronización de datos
|
||||
router.post(
|
||||
'/sync',
|
||||
authenticate,
|
||||
validate(syncHealthDataSchema),
|
||||
HealthIntegrationController.syncWorkoutData
|
||||
);
|
||||
|
||||
router.get('/summary', authenticate, HealthIntegrationController.getWorkoutSummary);
|
||||
router.get('/calories', authenticate, HealthIntegrationController.getCaloriesBurned);
|
||||
router.get('/playtime', authenticate, HealthIntegrationController.getTotalPlayTime);
|
||||
router.get('/activities', authenticate, HealthIntegrationController.getUserActivities);
|
||||
|
||||
// Rutas para integración con Apple Health y Google Fit (placeholders)
|
||||
router.post(
|
||||
'/apple-health/sync',
|
||||
authenticate,
|
||||
validate(healthAuthSchema),
|
||||
HealthIntegrationController.syncWithAppleHealth
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/google-fit/sync',
|
||||
authenticate,
|
||||
validate(healthAuthSchema),
|
||||
HealthIntegrationController.syncWithGoogleFit
|
||||
);
|
||||
|
||||
// Ruta para eliminar actividad
|
||||
router.delete('/activities/:id', authenticate, HealthIntegrationController.deleteActivity);
|
||||
|
||||
export default router;
|
||||
@@ -22,6 +22,11 @@ import paymentRoutes from './payment.routes';
|
||||
// Rutas de Sistema de Bonos (Fase 4.2) - Desactivado temporalmente
|
||||
// import bonusRoutes from './bonus.routes';
|
||||
|
||||
// Rutas de Wall of Fame y Logros (Fase 6.1)
|
||||
import wallOfFameRoutes from './wallOfFame.routes';
|
||||
import achievementRoutes from './achievement.routes';
|
||||
import challengeRoutes from './challenge.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Health check
|
||||
@@ -105,6 +110,38 @@ router.use('/', subscriptionRoutes);
|
||||
import analyticsRoutes from './analytics.routes';
|
||||
router.use('/analytics', analyticsRoutes);
|
||||
|
||||
// ============================================
|
||||
// Rutas de Extras - Fase 6
|
||||
// ============================================
|
||||
|
||||
import extrasRoutes from './extras.routes';
|
||||
router.use('/', extrasRoutes);
|
||||
|
||||
// Rutas individuales de Fase 6 (si existen archivos separados)
|
||||
// Wall of Fame - ganadores de torneos y ligas
|
||||
try {
|
||||
const wallOfFameRoutes = require('./wallOfFame.routes').default;
|
||||
router.use('/wall-of-fame', wallOfFameRoutes);
|
||||
} catch (e) {
|
||||
// Ya incluido en extrasRoutes
|
||||
}
|
||||
|
||||
// Logros/Achievements
|
||||
try {
|
||||
const achievementRoutes = require('./achievement.routes').default;
|
||||
router.use('/achievements', achievementRoutes);
|
||||
} catch (e) {
|
||||
// Ya incluido en extrasRoutes
|
||||
}
|
||||
|
||||
// Retos/Challenges
|
||||
try {
|
||||
const challengeRoutes = require('./challenge.routes').default;
|
||||
router.use('/challenges', challengeRoutes);
|
||||
} catch (e) {
|
||||
// Ya incluido en extrasRoutes
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente
|
||||
// ============================================
|
||||
|
||||
76
backend/src/routes/menu.routes.ts
Normal file
76
backend/src/routes/menu.routes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Router } from 'express';
|
||||
import { MenuController } from '../controllers/menu.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema de validación para crear item del menú
|
||||
const createMenuItemSchema = z.object({
|
||||
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||
description: z.string().optional(),
|
||||
category: z.enum(['DRINK', 'SNACK', 'FOOD', 'OTHER']),
|
||||
price: z.number().int().min(0, 'El precio no puede ser negativo'),
|
||||
imageUrl: z.string().url().optional(),
|
||||
preparationTime: z.number().int().min(0).optional(),
|
||||
isAvailable: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema para actualizar item del menú
|
||||
const updateMenuItemSchema = z.object({
|
||||
name: z.string().min(2).optional(),
|
||||
description: z.string().optional(),
|
||||
category: z.enum(['DRINK', 'SNACK', 'FOOD', 'OTHER']).optional(),
|
||||
price: z.number().int().min(0).optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
preparationTime: z.number().int().min(0).optional(),
|
||||
isAvailable: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Rutas públicas (solo items activos y disponibles)
|
||||
router.get('/', MenuController.getMenuItems);
|
||||
router.get('/:id', MenuController.getMenuItemById);
|
||||
|
||||
// Rutas de administración (solo admin)
|
||||
router.get(
|
||||
'/admin/all',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
MenuController.getAllMenuItems
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(createMenuItemSchema),
|
||||
MenuController.createMenuItem
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(updateMenuItemSchema),
|
||||
MenuController.updateMenuItem
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
MenuController.deleteMenuItem
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id/toggle-availability',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
MenuController.toggleAvailability
|
||||
);
|
||||
|
||||
export default router;
|
||||
54
backend/src/routes/notification.routes.ts
Normal file
54
backend/src/routes/notification.routes.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Router } from 'express';
|
||||
import { NotificationController } from '../controllers/notification.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema para notificación masiva
|
||||
const bulkNotificationSchema = z.object({
|
||||
userIds: z.array(z.string().uuid()).min(1, 'Debe especificar al menos un usuario'),
|
||||
type: z.enum([
|
||||
'ORDER_READY',
|
||||
'BOOKING_REMINDER',
|
||||
'TOURNAMENT_START',
|
||||
'TOURNAMENT_MATCH_READY',
|
||||
'LEAGUE_MATCH_SCHEDULED',
|
||||
'FRIEND_REQUEST',
|
||||
'GROUP_INVITATION',
|
||||
'SUBSCRIPTION_EXPIRING',
|
||||
'PAYMENT_CONFIRMED',
|
||||
'CLASS_REMINDER',
|
||||
'GENERAL',
|
||||
]),
|
||||
title: z.string().min(1, 'El título es requerido'),
|
||||
message: z.string().min(1, 'El mensaje es requerido'),
|
||||
data: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
// Rutas para usuarios autenticados
|
||||
router.get('/', authenticate, NotificationController.getMyNotifications);
|
||||
router.get('/unread-count', authenticate, NotificationController.getUnreadCount);
|
||||
router.put('/:id/read', authenticate, NotificationController.markAsRead);
|
||||
router.put('/read-all', authenticate, NotificationController.markAllAsRead);
|
||||
router.delete('/:id', authenticate, NotificationController.deleteNotification);
|
||||
|
||||
// Rutas para admin
|
||||
router.post(
|
||||
'/bulk',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(bulkNotificationSchema),
|
||||
NotificationController.sendBulkNotification
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cleanup',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
NotificationController.cleanupOldNotifications
|
||||
);
|
||||
|
||||
export default router;
|
||||
68
backend/src/routes/order.routes.ts
Normal file
68
backend/src/routes/order.routes.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from 'express';
|
||||
import { OrderController } from '../controllers/order.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema para items del pedido
|
||||
const orderItemSchema = z.object({
|
||||
itemId: z.string().uuid('ID de item inválido'),
|
||||
quantity: z.number().int().min(1, 'La cantidad debe ser al menos 1'),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema para crear pedido
|
||||
const createOrderSchema = z.object({
|
||||
bookingId: z.string().uuid('ID de reserva inválido'),
|
||||
items: z.array(orderItemSchema).min(1, 'El pedido debe tener al menos un item'),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema para actualizar estado del pedido
|
||||
const updateOrderStatusSchema = z.object({
|
||||
status: z.enum(['PENDING', 'PREPARING', 'READY', 'DELIVERED', 'CANCELLED']),
|
||||
});
|
||||
|
||||
// Rutas para usuarios autenticados
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
validate(createOrderSchema),
|
||||
OrderController.createOrder
|
||||
);
|
||||
|
||||
router.get('/my', authenticate, OrderController.getMyOrders);
|
||||
router.get('/booking/:bookingId', authenticate, OrderController.getOrdersByBooking);
|
||||
router.post('/:id/pay', authenticate, OrderController.processPayment);
|
||||
router.post('/:id/cancel', authenticate, OrderController.cancelOrder);
|
||||
|
||||
// Rutas para bar/admin
|
||||
router.get(
|
||||
'/pending',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
OrderController.getPendingOrders
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id/status',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
validate(updateOrderStatusSchema),
|
||||
OrderController.updateOrderStatus
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id/deliver',
|
||||
authenticate,
|
||||
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
|
||||
OrderController.markAsDelivered
|
||||
);
|
||||
|
||||
// Webhook de MercadoPago (público)
|
||||
router.post('/webhook', OrderController.webhook);
|
||||
|
||||
export default router;
|
||||
171
backend/src/routes/wallOfFame.routes.ts
Normal file
171
backend/src/routes/wallOfFame.routes.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Router } from 'express';
|
||||
import { WallOfFameController } from '../controllers/wallOfFame.controller';
|
||||
import { authenticate, authorize } from '../middleware/auth';
|
||||
import { validate, validateQuery } from '../middleware/validate';
|
||||
import { UserRole } from '../utils/constants';
|
||||
import { z } from 'zod';
|
||||
import { WallOfFameCategory } from '../utils/constants';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Schema para crear entrada
|
||||
const createEntrySchema = z.object({
|
||||
title: z.string().min(1, 'El título es requerido').max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
tournamentId: z.string().uuid('ID de torneo inválido').optional(),
|
||||
leagueId: z.string().uuid('ID de liga inválido').optional(),
|
||||
winners: z.array(z.object({
|
||||
userId: z.string().uuid('ID de usuario inválido'),
|
||||
name: z.string(),
|
||||
position: z.number().int().min(1),
|
||||
avatarUrl: z.string().url().optional(),
|
||||
})).min(1, 'Debe haber al menos un ganador'),
|
||||
category: z.enum([
|
||||
WallOfFameCategory.TOURNAMENT,
|
||||
WallOfFameCategory.LEAGUE,
|
||||
WallOfFameCategory.SPECIAL,
|
||||
]),
|
||||
imageUrl: z.string().url().optional(),
|
||||
eventDate: z.string().datetime('Fecha inválida'),
|
||||
featured: z.boolean().optional(),
|
||||
}).refine(
|
||||
(data) => data.tournamentId || data.leagueId || data.category === WallOfFameCategory.SPECIAL,
|
||||
{
|
||||
message: 'Debe especificar un torneo o liga (excepto para logros especiales)',
|
||||
path: ['tournamentId'],
|
||||
}
|
||||
);
|
||||
|
||||
// Schema para actualizar entrada
|
||||
const updateEntrySchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
winners: z.array(z.object({
|
||||
userId: z.string().uuid(),
|
||||
name: z.string(),
|
||||
position: z.number().int().min(1),
|
||||
avatarUrl: z.string().url().optional(),
|
||||
})).optional(),
|
||||
category: z.enum([
|
||||
WallOfFameCategory.TOURNAMENT,
|
||||
WallOfFameCategory.LEAGUE,
|
||||
WallOfFameCategory.SPECIAL,
|
||||
]).optional(),
|
||||
imageUrl: z.string().url().optional().nullable(),
|
||||
eventDate: z.string().datetime().optional(),
|
||||
featured: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Schema para query params de listado
|
||||
const listEntriesQuerySchema = z.object({
|
||||
category: z.enum([
|
||||
WallOfFameCategory.TOURNAMENT,
|
||||
WallOfFameCategory.LEAGUE,
|
||||
WallOfFameCategory.SPECIAL,
|
||||
]).optional(),
|
||||
featured: z.enum(['true', 'false']).optional(),
|
||||
isActive: z.enum(['true', 'false']).optional(),
|
||||
tournamentId: z.string().uuid().optional(),
|
||||
leagueId: z.string().uuid().optional(),
|
||||
fromDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
toDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
limit: z.string().regex(/^\d+$/).optional(),
|
||||
offset: z.string().regex(/^\d+$/).optional(),
|
||||
});
|
||||
|
||||
// Schema para params de ID
|
||||
const entryIdParamsSchema = z.object({
|
||||
id: z.string().uuid('ID inválido'),
|
||||
});
|
||||
|
||||
// Schema para agregar ganadores
|
||||
const addWinnersSchema = z.object({
|
||||
winners: z.array(z.object({
|
||||
userId: z.string().uuid('ID de usuario inválido'),
|
||||
name: z.string(),
|
||||
position: z.number().int().min(1),
|
||||
avatarUrl: z.string().url().optional(),
|
||||
})).min(1, 'Debe haber al menos un ganador'),
|
||||
});
|
||||
|
||||
// Schema para búsqueda
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().min(1, 'Término de búsqueda requerido'),
|
||||
limit: z.string().regex(/^\d+$/).optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Rutas Públicas (lectura)
|
||||
// ============================================
|
||||
|
||||
// Listar entradas
|
||||
router.get(
|
||||
'/',
|
||||
validateQuery(listEntriesQuerySchema),
|
||||
WallOfFameController.getEntries
|
||||
);
|
||||
|
||||
// Obtener entradas destacadas
|
||||
router.get(
|
||||
'/featured',
|
||||
WallOfFameController.getFeaturedEntries
|
||||
);
|
||||
|
||||
// Buscar entradas
|
||||
router.get(
|
||||
'/search',
|
||||
validateQuery(searchQuerySchema),
|
||||
WallOfFameController.searchEntries
|
||||
);
|
||||
|
||||
// Obtener una entrada por ID
|
||||
router.get(
|
||||
'/:id',
|
||||
validate(entryIdParamsSchema),
|
||||
WallOfFameController.getEntryById
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Rutas Protegidas (admin)
|
||||
// ============================================
|
||||
|
||||
// Crear entrada
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(createEntrySchema),
|
||||
WallOfFameController.createEntry
|
||||
);
|
||||
|
||||
// Actualizar entrada
|
||||
router.put(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(entryIdParamsSchema),
|
||||
validate(updateEntrySchema),
|
||||
WallOfFameController.updateEntry
|
||||
);
|
||||
|
||||
// Eliminar entrada
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(entryIdParamsSchema),
|
||||
WallOfFameController.deleteEntry
|
||||
);
|
||||
|
||||
// Agregar ganadores a entrada
|
||||
router.post(
|
||||
'/:id/winners',
|
||||
authenticate,
|
||||
authorize([UserRole.ADMIN, UserRole.SUPERADMIN]),
|
||||
validate(entryIdParamsSchema),
|
||||
validate(addWinnersSchema),
|
||||
WallOfFameController.addWinners
|
||||
);
|
||||
|
||||
export default router;
|
||||
563
backend/src/services/achievement.service.ts
Normal file
563
backend/src/services/achievement.service.ts
Normal file
@@ -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<AchievementProgress[]> {
|
||||
// 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<AchievementProgress> {
|
||||
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<string, {
|
||||
user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatarUrl: string | null;
|
||||
playerLevel: string;
|
||||
};
|
||||
totalPoints: number;
|
||||
achievementsCount: number;
|
||||
}>();
|
||||
|
||||
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;
|
||||
615
backend/src/services/challenge.service.ts
Normal file
615
backend/src/services/challenge.service.ts
Normal file
@@ -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;
|
||||
471
backend/src/services/equipment.service.ts
Normal file
471
backend/src/services/equipment.service.ts
Normal file
@@ -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;
|
||||
753
backend/src/services/equipmentRental.service.ts
Normal file
753
backend/src/services/equipmentRental.service.ts
Normal file
@@ -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;
|
||||
187
backend/src/services/extras/achievement.service.ts
Normal file
187
backend/src/services/extras/achievement.service.ts
Normal file
@@ -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;
|
||||
212
backend/src/services/extras/qrCheckin.service.ts
Normal file
212
backend/src/services/extras/qrCheckin.service.ts
Normal file
@@ -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;
|
||||
101
backend/src/services/extras/wallOfFame.service.ts
Normal file
101
backend/src/services/extras/wallOfFame.service.ts
Normal file
@@ -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<CreateWallOfFameInput>) {
|
||||
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;
|
||||
444
backend/src/services/healthIntegration.service.ts
Normal file
444
backend/src/services/healthIntegration.service.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, { count: number; duration: number; calories: number }>,
|
||||
bySource: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
// 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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
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;
|
||||
258
backend/src/services/menu.service.ts
Normal file
258
backend/src/services/menu.service.ts
Normal file
@@ -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;
|
||||
294
backend/src/services/notification.service.ts
Normal file
294
backend/src/services/notification.service.ts
Normal file
@@ -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;
|
||||
600
backend/src/services/order.service.ts
Normal file
600
backend/src/services/order.service.ts
Normal file
@@ -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;
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
766
backend/src/services/qrCheckin.service.ts
Normal file
766
backend/src/services/qrCheckin.service.ts
Normal file
@@ -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;
|
||||
509
backend/src/services/wallOfFame.service.ts
Normal file
509
backend/src/services/wallOfFame.service.ts
Normal file
@@ -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;
|
||||
@@ -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];
|
||||
|
||||
119
backend/src/utils/qr.ts
Normal file
119
backend/src/utils/qr.ts
Normal file
@@ -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<QRCodeData, 'checksum'>): 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<QRCodeData, 'checksum'> = {
|
||||
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<string> {
|
||||
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)));
|
||||
}
|
||||
154
backend/src/validators/services.validator.ts
Normal file
154
backend/src/validators/services.validator.ts
Normal file
@@ -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<typeof createMenuItemSchema>;
|
||||
export type UpdateMenuItemInput = z.infer<typeof updateMenuItemSchema>;
|
||||
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
|
||||
export type UpdateOrderStatusInput = z.infer<typeof updateOrderStatusSchema>;
|
||||
export type SyncHealthDataInput = z.infer<typeof syncHealthDataSchema>;
|
||||
export type BulkNotificationInput = z.infer<typeof bulkNotificationSchema>;
|
||||
223
backend/src/validators/wallOfFame.validator.ts
Normal file
223
backend/src/validators/wallOfFame.validator.ts
Normal file
@@ -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<typeof createEntrySchema>;
|
||||
export type UpdateEntryInput = z.infer<typeof updateEntrySchema>;
|
||||
export type WinnerInput = z.infer<typeof winnerSchema>;
|
||||
export type CreateAchievementInput = z.infer<typeof createAchievementSchema>;
|
||||
export type UpdateAchievementInput = z.infer<typeof updateAchievementSchema>;
|
||||
export type CreateChallengeInput = z.infer<typeof createChallengeSchema>;
|
||||
export type UpdateChallengeInput = z.infer<typeof updateChallengeSchema>;
|
||||
Reference in New Issue
Block a user