full project: admin panel, backend modules, docs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user