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.
754 lines
18 KiB
TypeScript
754 lines
18 KiB
TypeScript
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;
|