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

@@ -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
// import bonusRoutes from './bonus.routes';
// Rutas de Wall of Fame y Logros (Fase 6.1)
import wallOfFameRoutes from './wallOfFame.routes';
import achievementRoutes from './achievement.routes';
import challengeRoutes from './challenge.routes';
const router = Router();
// Health check
@@ -105,6 +110,38 @@ router.use('/', subscriptionRoutes);
import analyticsRoutes from './analytics.routes';
router.use('/analytics', analyticsRoutes);
// ============================================
// Rutas de Extras - Fase 6
// ============================================
import extrasRoutes from './extras.routes';
router.use('/', extrasRoutes);
// Rutas individuales de Fase 6 (si existen archivos separados)
// Wall of Fame - ganadores de torneos y ligas
try {
const wallOfFameRoutes = require('./wallOfFame.routes').default;
router.use('/wall-of-fame', wallOfFameRoutes);
} catch (e) {
// Ya incluido en extrasRoutes
}
// Logros/Achievements
try {
const achievementRoutes = require('./achievement.routes').default;
router.use('/achievements', achievementRoutes);
} catch (e) {
// Ya incluido en extrasRoutes
}
// Retos/Challenges
try {
const challengeRoutes = require('./challenge.routes').default;
router.use('/challenges', challengeRoutes);
} catch (e) {
// Ya incluido en extrasRoutes
}
// ============================================
// Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente
// ============================================

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',
SUBSCRIPTION: 'SUBSCRIPTION',
CLASS: 'CLASS',
EQUIPMENT_RENTAL: 'EQUIPMENT_RENTAL',
} as const;
export type PaymentTypeType = typeof PaymentType[keyof typeof PaymentType];
@@ -333,6 +334,13 @@ export class PaymentService {
logger.info(`Bono ${payment.referenceId} activado`);
break;
case PaymentType.EQUIPMENT_RENTAL:
// Confirmar alquiler de equipamiento
const { EquipmentRentalService } = await import('./equipmentRental.service');
await EquipmentRentalService.confirmRentalPayment(payment.referenceId);
logger.info(`Alquiler de equipamiento ${payment.referenceId} confirmado`);
break;
default:
logger.info(`Pago completado para ${payment.type}: ${payment.referenceId}`);
}

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