✅ 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",
|
"mercadopago": "^2.12.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
@@ -923,6 +925,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
@@ -1281,7 +1293,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -1496,6 +1507,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/cfb": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
@@ -1535,6 +1555,17 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
@@ -1561,7 +1592,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -1574,7 +1604,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-string": {
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1778,6 +1816,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
@@ -2490,6 +2534,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -3495,6 +3548,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3521,7 +3583,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3575,6 +3636,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -3628,6 +3698,23 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
@@ -3702,6 +3789,21 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -4337,6 +4439,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
@@ -4410,6 +4518,20 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -4437,12 +4559,105 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"mercadopago": "^2.12.0",
|
"mercadopago": "^2.12.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
"eslint": "^8.56.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[]
|
studentEnrollments StudentEnrollment[]
|
||||||
coachReviews CoachReview[]
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -148,6 +159,9 @@ model Court {
|
|||||||
tournamentMatches TournamentMatch[]
|
tournamentMatches TournamentMatch[]
|
||||||
classBookings ClassBooking[]
|
classBookings ClassBooking[]
|
||||||
|
|
||||||
|
// Servicios del Club (Fase 6.3)
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -211,6 +225,16 @@ model Booking {
|
|||||||
// Uso de bonos
|
// Uso de bonos
|
||||||
bonusUsages BonusUsage[]
|
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
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1221,3 +1245,320 @@ model CoachReview {
|
|||||||
@@index([rating])
|
@@index([rating])
|
||||||
@@map("coach_reviews")
|
@@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
|
// Rutas de Sistema de Bonos (Fase 4.2) - Desactivado temporalmente
|
||||||
// import bonusRoutes from './bonus.routes';
|
// 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();
|
const router = Router();
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
@@ -105,6 +110,38 @@ router.use('/', subscriptionRoutes);
|
|||||||
import analyticsRoutes from './analytics.routes';
|
import analyticsRoutes from './analytics.routes';
|
||||||
router.use('/analytics', analyticsRoutes);
|
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
|
// 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',
|
BONUS: 'BONUS',
|
||||||
SUBSCRIPTION: 'SUBSCRIPTION',
|
SUBSCRIPTION: 'SUBSCRIPTION',
|
||||||
CLASS: 'CLASS',
|
CLASS: 'CLASS',
|
||||||
|
EQUIPMENT_RENTAL: 'EQUIPMENT_RENTAL',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PaymentTypeType = typeof PaymentType[keyof typeof PaymentType];
|
export type PaymentTypeType = typeof PaymentType[keyof typeof PaymentType];
|
||||||
@@ -333,6 +334,13 @@ export class PaymentService {
|
|||||||
logger.info(`Bono ${payment.referenceId} activado`);
|
logger.info(`Bono ${payment.referenceId} activado`);
|
||||||
break;
|
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:
|
default:
|
||||||
logger.info(`Pago completado para ${payment.type}: ${payment.referenceId}`);
|
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;
|
} as const;
|
||||||
|
|
||||||
export type ReportFormatType = typeof ReportFormat[keyof typeof ReportFormat];
|
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