- services.module: import NotificationsModule - services.service: inject NotificationsService, notify the other party (user or professional) when service status changes to accepted/denied/ cancelled/active/completed - ARQUITECTURA.md: updated to reflect current migration state (all migrated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
import { ServiceStatus, VALID_TRANSITIONS } from './dto/service.dto';
|
|
|
|
const STATUS_MESSAGES: Partial<Record<ServiceStatus, { title: string; body: string }>> = {
|
|
[ServiceStatus.ACCEPTED]: { title: 'Servicio aceptado', body: 'El profesional aceptó tu solicitud.' },
|
|
[ServiceStatus.DENIED]: { title: 'Servicio rechazado', body: 'El profesional rechazó tu solicitud.' },
|
|
[ServiceStatus.CANCELLED]:{ title: 'Servicio cancelado', body: 'El servicio fue cancelado.' },
|
|
[ServiceStatus.ACTIVE]: { title: 'Servicio iniciado', body: 'El profesional inició el servicio.' },
|
|
[ServiceStatus.COMPLETED]:{ title: 'Servicio completado', body: '¡El servicio ha finalizado! Puedes dejar tu puntuación.' },
|
|
};
|
|
|
|
@Injectable()
|
|
export class ServicesService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private notifications: NotificationsService,
|
|
) {}
|
|
|
|
async create(data: {
|
|
professional_id: string;
|
|
user_id: string;
|
|
day: string;
|
|
description?: string;
|
|
rate?: number;
|
|
range1_hour1?: string;
|
|
range1_hour2?: string;
|
|
address?: string;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
location_preference?: 'office' | 'delivery';
|
|
}) {
|
|
// Accept either professional UUID or user_id (legacy Flutter behavior)
|
|
let prof = await this.prisma.professionals.findUnique({ where: { id: data.professional_id } });
|
|
if (!prof) prof = await this.prisma.professionals.findUnique({ where: { user_id: data.professional_id } });
|
|
if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible');
|
|
|
|
return this.prisma.services.create({
|
|
data: {
|
|
professional_id: prof.id,
|
|
user_id: data.user_id,
|
|
day: new Date(data.day),
|
|
description: data.description,
|
|
rate: data.rate ?? prof.rate,
|
|
range1_hour1: data.range1_hour1 ? new Date(`1970-01-01T${data.range1_hour1}:00`) : null,
|
|
range1_hour2: data.range1_hour2 ? new Date(`1970-01-01T${data.range1_hour2}:00`) : null,
|
|
address: data.address,
|
|
latitude: data.latitude,
|
|
longitude: data.longitude,
|
|
location_preference: data.location_preference as any,
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateStatus(serviceId: string, newStatus: ServiceStatus, userId: string) {
|
|
const service = await this.prisma.services.findUnique({ where: { id: serviceId } });
|
|
if (!service) throw new NotFoundException('Servicio no encontrado');
|
|
|
|
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
|
|
|
const allowedTransitions = VALID_TRANSITIONS[service.status];
|
|
if (!allowedTransitions || !allowedTransitions.includes(newStatus)) {
|
|
throw new BadRequestException(
|
|
`Transición inválida: ${service.status} → ${newStatus}. Permitidas: ${allowedTransitions?.join(', ') || 'ninguna'}`,
|
|
);
|
|
}
|
|
|
|
if (newStatus === ServiceStatus.ACCEPTED || newStatus === ServiceStatus.DENIED) {
|
|
if (!prof || prof.id !== service.professional_id) {
|
|
throw new ForbiddenException('Solo el profesional puede aceptar/rechazar');
|
|
}
|
|
}
|
|
|
|
if (newStatus === ServiceStatus.CANCELLED) {
|
|
if (service.user_id !== userId && (!prof || prof.id !== service.professional_id)) {
|
|
throw new ForbiddenException('Solo el usuario o el profesional pueden cancelar');
|
|
}
|
|
}
|
|
|
|
if (newStatus === ServiceStatus.COMPLETED) {
|
|
if (!prof || prof.id !== service.professional_id) {
|
|
throw new ForbiddenException('Solo el profesional puede marcar como completado');
|
|
}
|
|
}
|
|
|
|
const updated = await this.prisma.services.update({
|
|
where: { id: serviceId },
|
|
data: { status: newStatus as any },
|
|
});
|
|
|
|
// Notify the other party
|
|
const msg = STATUS_MESSAGES[newStatus];
|
|
if (msg) {
|
|
const targetUserId = userId === service.user_id
|
|
? (await this.prisma.professionals.findUnique({ where: { id: service.professional_id } }))?.user_id
|
|
: service.user_id;
|
|
if (targetUserId) {
|
|
const target = await this.prisma.users.findUnique({ where: { id: targetUserId }, select: { fcm_token: true } });
|
|
if (target?.fcm_token) {
|
|
this.notifications.send(target.fcm_token, msg.title, msg.body).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
async findByUser(userId: string, page = 1, limit = 20) {
|
|
const skip = (page - 1) * limit;
|
|
const where = { user_id: userId, status: { notIn: ['completed' as const, 'cancelled' as const, 'denied' as const] } };
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.services.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
include: { professionals: { include: { users: true } } },
|
|
orderBy: { created_at: 'desc' },
|
|
}),
|
|
this.prisma.services.count({ where }),
|
|
]);
|
|
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
|
}
|
|
|
|
async findByProfessional(userId: string, page = 1, limit = 20) {
|
|
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
|
if (!prof) throw new NotFoundException('No eres un profesional');
|
|
const where = { professional_id: prof.id };
|
|
const skip = (page - 1) * limit;
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.services.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
|
orderBy: { created_at: 'desc' },
|
|
}),
|
|
this.prisma.services.count({ where }),
|
|
]);
|
|
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
|
}
|
|
|
|
async findRequestsByProfessional(userId: string, page = 1, limit = 20) {
|
|
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
|
if (!prof) throw new NotFoundException('No eres un profesional');
|
|
const where = { professional_id: prof.id, status: 'pending' as const };
|
|
const skip = (page - 1) * limit;
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.services.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
|
orderBy: { created_at: 'desc' },
|
|
}),
|
|
this.prisma.services.count({ where }),
|
|
]);
|
|
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
|
}
|
|
|
|
async findById(id: string) {
|
|
const service = await this.prisma.services.findUnique({
|
|
where: { id },
|
|
include: {
|
|
users: { select: { id: true, name: true, picture: true, phone: true } },
|
|
professionals: { include: { users: { select: { name: true, picture: true } } } },
|
|
},
|
|
});
|
|
if (!service) throw new NotFoundException('Servicio no encontrado');
|
|
return service;
|
|
}
|
|
|
|
async getHistoryByUser(userId: string, page = 1, limit = 20) {
|
|
const skip = (page - 1) * limit;
|
|
const where = { user_id: userId, status: { in: ['completed' as const, 'cancelled' as const] } };
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.services.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
include: { professionals: { include: { users: { select: { name: true, picture: true } } } } },
|
|
orderBy: { day: 'desc' },
|
|
}),
|
|
this.prisma.services.count({ where }),
|
|
]);
|
|
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
|
}
|
|
|
|
async getHistoryByProfessional(userId: string, page = 1, limit = 20) {
|
|
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
|
if (!prof) throw new NotFoundException('No eres un profesional');
|
|
const where = { professional_id: prof.id, status: { in: ['completed' as const, 'cancelled' as const] } };
|
|
const skip = (page - 1) * limit;
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.services.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
include: { users: { select: { id: true, name: true, picture: true } } },
|
|
orderBy: { day: 'desc' },
|
|
}),
|
|
this.prisma.services.count({ where }),
|
|
]);
|
|
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
|
}
|
|
|
|
async getCalendarByProfessional(userId: string) {
|
|
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
|
if (!prof) throw new NotFoundException('No eres un profesional');
|
|
return this.prisma.services.findMany({
|
|
where: { professional_id: prof.id, status: { notIn: ['denied', 'cancelled'] } },
|
|
orderBy: { day: 'asc' },
|
|
});
|
|
}
|
|
|
|
async getPublicCalendar(professionalId: string) {
|
|
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
|
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
|
|
|
const schedules = await this.prisma.schedules.findMany({
|
|
where: { professional_id: professionalId, enabled: true },
|
|
});
|
|
const services = await this.prisma.services.findMany({
|
|
where: { professional_id: professionalId, status: { notIn: ['denied', 'cancelled'] } },
|
|
});
|
|
return { schedules, services };
|
|
}
|
|
|
|
async findAll(page = 1, limit = 20) {
|
|
const skip = (page - 1) * limit;
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.services.findMany({
|
|
skip,
|
|
take: limit,
|
|
include: {
|
|
users: { select: { id: true, name: true } },
|
|
professionals: { include: { users: { select: { name: true } } } },
|
|
},
|
|
orderBy: { created_at: 'desc' },
|
|
}),
|
|
this.prisma.services.count(),
|
|
]);
|
|
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
|
}
|
|
}
|