Files
prosapp-migration/backend/src/services/services.service.ts
T
Lizandro GuarnizoandClaude Sonnet 4.6 6e4cc7436d feat: send FCM push notification on service status change + update docs
- 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>
2026-06-18 12:49:27 -05:00

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