full project: admin panel, backend modules, docs

This commit is contained in:
Lizandro Guarnizo
2026-06-03 22:11:01 -05:00
parent 1635723035
commit afc096d552
94 changed files with 15994 additions and 240 deletions
+66
View File
@@ -0,0 +1,66 @@
import { IsString, IsOptional, IsNumber, IsDateString, IsEnum, Matches } from 'class-validator';
export enum ServiceStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
DENIED = 'denied',
ACTIVE = 'active',
CANCELLED = 'cancelled',
COMPLETED = 'completed',
SELF_BOOKED = 'self_booked',
}
export const VALID_TRANSITIONS: Record<string, string[]> = {
[ServiceStatus.PENDING]: [ServiceStatus.ACCEPTED, ServiceStatus.DENIED, ServiceStatus.CANCELLED],
[ServiceStatus.ACCEPTED]: [ServiceStatus.ACTIVE, ServiceStatus.CANCELLED],
[ServiceStatus.ACTIVE]: [ServiceStatus.COMPLETED, ServiceStatus.CANCELLED],
[ServiceStatus.DENIED]: [],
[ServiceStatus.CANCELLED]: [],
[ServiceStatus.COMPLETED]: [],
[ServiceStatus.SELF_BOOKED]: [ServiceStatus.CANCELLED],
};
export class CreateServiceDto {
@IsString()
professional_id: string;
@IsDateString()
day: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
rate?: number;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
range1_hour1?: string;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
range1_hour2?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsEnum(['office', 'delivery'])
location_preference?: 'office' | 'delivery';
}
export class UpdateServiceStatusDto {
@IsEnum(ServiceStatus)
status: ServiceStatus;
}
+43 -11
View File
@@ -1,7 +1,8 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ServicesService } from './services.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateServiceDto, UpdateServiceStatusDto } from './dto/service.dto';
@ApiTags('Services')
@Controller('services')
@@ -11,43 +12,74 @@ export class ServicesController {
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
create(@Req() req, @Body() data: any) {
return this.services.create({ ...data, user_id: req.user.sub });
create(@Req() req, @Body() dto: CreateServiceDto) {
return this.services.create({ ...dto, user_id: req.user.sub });
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
findAll(@Req() req) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.services.findAll(page, limit);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
findByMe(@Req() req) {
return this.services.findByUser(req.user.sub);
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.services.findByUser(req.user.sub, page, limit);
}
@Get('professional')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
findByProfessional(@Req() req) {
return this.services.findByProfessional(req.user.sub);
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.services.findByProfessional(req.user.sub, page, limit);
}
@Get('professional/requests')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
requestsByProfessional(@Req() req) {
return this.services.findRequestsByProfessional(req.user.sub);
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.services.findRequestsByProfessional(req.user.sub, page, limit);
}
@Get('professional/history')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
historyByProfessional(@Req() req) {
return this.services.getHistoryByProfessional(req.user.sub);
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.services.getHistoryByProfessional(req.user.sub, page, limit);
}
@Get('me/history')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
historyByUser(@Req() req) {
return this.services.getHistoryByUser(req.user.sub);
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.services.getHistoryByUser(req.user.sub, page, limit);
}
@Get('professional/calendar')
@@ -72,7 +104,7 @@ export class ServicesController {
@Patch(':id/status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
updateStatus(@Param('id') id: string, @Body() dto: { status: string }) {
return this.services.updateStatus(id, dto.status);
updateStatus(@Req() req, @Param('id') id: string, @Body() dto: UpdateServiceStatusDto) {
return this.services.updateStatus(id, dto.status, req.user.sub);
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ServicesService } from './services.service';
import { ServicesController } from './services.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
providers: [ServicesService],
controllers: [ServicesController],
exports: [ServicesService],
+175 -52
View File
@@ -1,11 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ServiceStatus, VALID_TRANSITIONS } from './dto/service.dto';
@Injectable()
export class ServicesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
) {}
create(data: {
async create(data: {
professional_id: string;
user_id: string;
day: string;
@@ -18,71 +21,174 @@ export class ServicesService {
longitude?: number;
location_preference?: 'office' | 'delivery';
}) {
return this.prisma.services.create({ data: { ...data, day: new Date(data.day) } as any });
}
const prof = await this.prisma.professionals.findUnique({ where: { id: data.professional_id } });
if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible');
findByUser(userId: string) {
return this.prisma.services.findMany({
where: { user_id: userId },
include: { professionals: { include: { users: true } } },
orderBy: { created_at: 'desc' },
});
}
findByProfessional(professionalId: string) {
return this.prisma.services.findMany({
where: { professional_id: professionalId },
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
orderBy: { created_at: 'desc' },
});
}
findRequestsByProfessional(professionalId: string) {
return this.prisma.services.findMany({
where: { professional_id: professionalId, status: 'pending' },
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
orderBy: { created_at: 'desc' },
});
}
updateStatus(id: string, status: string) {
return this.prisma.services.update({ where: { id }, data: { status: status as any } });
}
findById(id: string) {
return this.prisma.services.findUnique({
where: { id },
include: {
users: { select: { id: true, name: true, picture: true, phone: true } },
professionals: { include: { users: true } },
return this.prisma.services.create({
data: {
professional_id: data.professional_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,
},
});
}
getHistoryByUser(userId: string) {
return this.prisma.services.findMany({
where: { user_id: userId, status: { in: ['completed', 'cancelled'] } },
include: { professionals: { include: { users: true } } },
orderBy: { day: 'desc' },
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');
}
}
return this.prisma.services.update({
where: { id: serviceId },
data: { status: newStatus as any },
});
}
getHistoryByProfessional(professionalId: string) {
return this.prisma.services.findMany({
where: { professional_id: professionalId, status: { in: ['completed', 'cancelled'] } },
include: { users: { select: { id: true, name: true, picture: true } } },
orderBy: { day: 'desc' },
});
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 } };
}
getCalendarByProfessional(professionalId: string) {
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: professionalId, status: { notIn: ['denied', 'cancelled'] } },
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 },
});
@@ -91,4 +197,21 @@ export class ServicesService {
});
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 } };
}
}