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:
2026-01-31 21:59:36 +00:00
parent 5e50dd766f
commit e135e7ad24
51 changed files with 11323 additions and 4 deletions

View File

@@ -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",

View File

@@ -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.

Binary file not shown.

View File

@@ -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");

View File

@@ -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");

View 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);

View File

@@ -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")
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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
// ============================================ // ============================================

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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}`);
} }

View 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;

View 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;

View File

@@ -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
View 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)));
}

View 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>;

View 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>;