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
+2
View File
@@ -11,6 +11,7 @@ import { LocationsModule } from './locations/locations.module';
import { SettingsModule } from './settings/settings.module';
import { ProfessionsModule } from './professions/professions.module';
import { StorageModule } from './storage/storage.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { StorageModule } from './storage/storage.module';
SettingsModule,
ProfessionsModule,
StorageModule,
NotificationsModule,
],
})
export class AppModule {}
+19 -4
View File
@@ -1,7 +1,8 @@
import { Controller, Post, Body, UseGuards, Get, Req } from '@nestjs/common';
import { Controller, Post, Body, UseGuards, Get, Req, Patch } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RegisterDto, LoginDto, PhoneDto, UpdateUserDto, FcmTokenDto } from './dto/auth.dto';
@ApiTags('Auth')
@Controller('auth')
@@ -9,20 +10,34 @@ export class AuthController {
constructor(private auth: AuthService) {}
@Post('register')
register(@Body() dto: { email: string; password: string; name: string }) {
register(@Body() dto: RegisterDto) {
return this.auth.register(dto.email, dto.password, dto.name);
}
@Post('login')
login(@Body() dto: { email: string; password: string }) {
login(@Body() dto: LoginDto) {
return this.auth.login(dto.email, dto.password);
}
@Post('phone')
phone(@Body() dto: { phone: string; name?: string }) {
phone(@Body() dto: PhoneDto) {
return this.auth.loginOrCreateByPhone(dto.phone, dto.name);
}
@Post('verify-phone')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
verifyPhone(@Req() req, @Body() dto: { phone: string }) {
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone);
}
@Post('link-email')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
linkEmail(@Req() req, @Body() dto: { email: string; password: string }) {
return this.auth.linkEmail(req.user.sub, dto.email, dto.password);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
+8 -3
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@@ -8,9 +9,13 @@ import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_SECRET || 'prosapp-secret-dev',
signOptions: { expiresIn: '30d' },
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '7d' },
}),
}),
],
providers: [AuthService, JwtStrategy],
+32 -3
View File
@@ -16,7 +16,7 @@ export class AuthService {
const password_hash = await bcrypt.hash(password, 10);
const user = await this.prisma.users.create({
data: { email, password_hash, name, is_email_verified: true },
data: { email, password_hash, name },
});
return this.generateToken(user);
@@ -36,14 +36,36 @@ export class AuthService {
let user = await this.prisma.users.findUnique({ where: { phone } });
if (!user) {
user = await this.prisma.users.create({
data: { phone, name: name || phone, is_phone_verified: true },
data: { phone, name: name || phone },
});
}
return this.generateToken(user);
}
async verifyOtpAndLinkPhone(userId: string, phone: string) {
const existing = await this.prisma.users.findUnique({ where: { phone } });
if (existing && existing.id !== userId) {
throw new ConflictException('Teléfono ya registrado por otro usuario');
}
return this.prisma.users.update({
where: { id: userId },
data: { phone, is_phone_verified: true },
});
}
async linkEmail(userId: string, email: string, password: string) {
const existing = await this.prisma.users.findUnique({ where: { email } });
if (existing) throw new ConflictException('Email ya registrado');
const password_hash = await bcrypt.hash(password, 10);
return this.prisma.users.update({
where: { id: userId },
data: { email, password_hash },
});
}
async me(userId: string) {
return this.prisma.users.findUnique({
const user = await this.prisma.users.findUnique({
where: { id: userId },
include: {
professionals: {
@@ -52,6 +74,13 @@ export class AuthService {
reputations: true,
},
});
if (!user) throw new UnauthorizedException('Usuario no encontrado');
return user;
}
async getProfessionalId(userId: string): Promise<string | null> {
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
return prof?.id || null;
}
private generateToken(user: any) {
+62
View File
@@ -0,0 +1,62 @@
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(6)
password: string;
@IsString()
@MinLength(2)
name: string;
}
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
export class PhoneDto {
@IsString()
phone: string;
@IsOptional()
@IsString()
name?: string;
}
export class UpdateUserDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
nickname?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
picture?: string;
@IsOptional()
@IsString()
gender?: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
@IsOptional()
birthday?: string;
}
export class FcmTokenDto {
@IsString()
token: string;
}
+9 -5
View File
@@ -1,21 +1,25 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private prisma: PrismaService) {
constructor(
config: ConfigService,
private prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'prosapp-secret-dev',
secretOrKey: config.get<string>('JWT_SECRET')!,
});
}
async validate(payload: { sub: string }) {
const user = await this.prisma.users.findUnique({ where: { id: payload.sub } });
if (!user) return null;
return { sub: user.id, email: user.email, phone: user.phone };
if (!user) throw new UnauthorizedException('Token inválido');
return { sub: user.id, email: user.email, phone: user.phone, role: user.pro_state >= 2 ? 'professional' : 'user' };
}
}
+18 -9
View File
@@ -1,38 +1,47 @@
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Get, Post, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ChatService } from './chat.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { SendMessageDto } from './dto/chat.dto';
@ApiTags('Chat')
@Controller('chat')
export class ChatController {
constructor(private chat: ChatService) {}
@Post('start/:professionalId')
@Post('start/:professionalUserId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
start(@Req() req, @Param('professionalId') professionalId: string) {
return this.chat.getOrCreateChat(req.user.sub, professionalId);
start(@Req() req, @Param('professionalUserId') professionalUserId: string) {
return this.chat.getOrCreateChat(req.user.sub, professionalUserId);
}
@Post(':chatId/message')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
sendMessage(@Req() req, @Param('chatId') chatId: string, @Body() dto: { content: string }) {
sendMessage(@Req() req, @Param('chatId') chatId: string, @Body() dto: SendMessageDto) {
return this.chat.sendMessage(chatId, req.user.sub, dto.content);
}
@Get('my')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
myChats(@Req() req) {
return this.chat.getUserChats(req.user.sub);
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.chat.getUserChats(req.user.sub, page, limit);
}
@Get(':chatId/messages')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
messages(@Param('chatId') chatId: string) {
return this.chat.getChatMessages(chatId);
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
messages(@Req() req, @Param('chatId') chatId: string) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 50);
return this.chat.getChatMessages(chatId, req.user.sub, page, limit);
}
}
+33 -17
View File
@@ -10,6 +10,7 @@ import {
import { Server, Socket } from 'socket.io';
import { ChatService } from './chat.service';
import { PrismaService } from '../prisma/prisma.service';
import * as jwt from 'jsonwebtoken';
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@@ -18,39 +19,54 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private userSockets = new Map<string, string>();
constructor(private chat: ChatService, private prisma: PrismaService) {}
constructor(
private chat: ChatService,
private prisma: PrismaService,
) {}
handleConnection(client: Socket) {
const userId = client.handshake.query.userId as string;
if (userId) {
this.userSockets.set(userId, client.id);
client.join(`user:${userId}`);
async handleConnection(client: Socket) {
const token = client.handshake.auth?.token || client.handshake.query?.token as string;
if (!token) {
client.disconnect();
return;
}
try {
const secret = process.env.JWT_SECRET;
if (!secret) { client.disconnect(); return; }
const payload = jwt.verify(token, secret) as { sub: string };
const user = await this.prisma.users.findUnique({ where: { id: payload.sub } });
if (!user) {
client.disconnect();
return;
}
(client as any).userId = payload.sub;
this.userSockets.set(payload.sub, client.id);
client.join(`user:${payload.sub}`);
} catch {
client.disconnect();
}
}
handleDisconnect(client: Socket) {
for (const [userId, socketId] of this.userSockets) {
if (socketId === client.id) {
this.userSockets.delete(userId);
break;
}
const userId = (client as any).userId;
if (userId) {
this.userSockets.delete(userId);
}
}
@SubscribeMessage('sendMessage')
async handleMessage(@ConnectedSocket() client: Socket, @MessageBody() data: { chatId: string; content: string }) {
const userId = client.handshake.query.userId as string;
const message = await this.chat.sendMessage(data.chatId, userId, data.content);
const userId = (client as any).userId;
if (!userId) return;
const chat = await this.prisma.chats.findUnique({
where: { id: data.chatId },
});
const message = await this.chat.sendMessage(data.chatId, userId, data.content);
const chat = await this.prisma.chats.findUnique({ where: { id: data.chatId } });
if (chat) {
this.server.to(`user:${chat.user_id}`).emit('newMessage', message);
this.server.to(`user:${chat.professional_id}`).emit('newMessage', message);
}
return message;
}
+3
View File
@@ -2,8 +2,11 @@ import { Module } from '@nestjs/common';
import { ChatService } from './chat.service';
import { ChatController } from './chat.controller';
import { ChatGateway } from './chat.gateway';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [AuthModule, PrismaModule],
providers: [ChatService, ChatGateway],
controllers: [ChatController],
exports: [ChatService],
+61 -30
View File
@@ -1,53 +1,84 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ChatService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
) {}
async getOrCreateChat(userId: string, professionalUserId: string) {
const prof = await this.prisma.professionals.findUnique({ where: { user_id: professionalUserId } });
if (!prof) throw new NotFoundException('Profesional no encontrado');
async getOrCreateChat(userId: string, professionalId: string) {
const existing = await this.prisma.chats.findUnique({
where: { user_id_professional_id: { user_id: userId, professional_id: professionalId } },
include: { messages: { orderBy: { created_at: 'asc' } } },
where: { user_id_professional_id: { user_id: userId, professional_id: professionalUserId } },
include: { messages: { orderBy: { created_at: 'asc' }, take: 50 } },
});
if (existing) return existing;
return this.prisma.chats.create({
data: { user_id: userId, professional_id: professionalId },
data: { user_id: userId, professional_id: professionalUserId },
include: { messages: true },
});
}
async sendMessage(chatId: string, senderId: string, content: string) {
const message = await this.prisma.messages.create({
const chat = await this.prisma.chats.findUnique({ where: { id: chatId } });
if (!chat) throw new NotFoundException('Chat no encontrado');
if (chat.user_id !== senderId && chat.professional_id !== senderId) {
throw new ForbiddenException('No eres participante de este chat');
}
return this.prisma.messages.create({
data: { chat_id: chatId, sender_id: senderId, content },
});
await this.prisma.chats.update({
where: { id: chatId },
data: {},
});
return message;
}
getUserChats(userId: string) {
return this.prisma.chats.findMany({
where: {
OR: [{ user_id: userId }, { professional_id: userId }],
},
include: {
users_chats_user_idTousers: { select: { id: true, name: true, picture: true } },
users_chats_professional_idTousers: { select: { id: true, name: true, picture: true } },
messages: { orderBy: { created_at: 'desc' }, take: 1 },
},
});
async getUserChats(userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const where = {
OR: [
{ user_id: userId },
{ professional_id: userId },
],
};
const [data, total] = await Promise.all([
this.prisma.chats.findMany({
where,
skip,
take: limit,
include: {
users_chats_user_idTousers: { select: { id: true, name: true, picture: true } },
users_chats_professional_idTousers: { select: { id: true, name: true, picture: true } },
messages: { orderBy: { created_at: 'desc' }, take: 1 },
},
}),
this.prisma.chats.count({ where }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
getChatMessages(chatId: string) {
return this.prisma.messages.findMany({
where: { chat_id: chatId },
orderBy: { created_at: 'asc' },
});
async getChatMessages(chatId: string, userId: string, page = 1, limit = 50) {
const chat = await this.prisma.chats.findUnique({ where: { id: chatId } });
if (!chat) throw new NotFoundException('Chat no encontrado');
if (chat.user_id !== userId && chat.professional_id !== userId) {
throw new ForbiddenException('No tienes acceso a este chat');
}
const skip = (page - 1) * limit;
const where = { chat_id: chatId };
const [data, total] = await Promise.all([
this.prisma.messages.findMany({
where,
skip,
take: limit,
orderBy: { created_at: 'asc' },
}),
this.prisma.messages.count({ where }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class SendMessageDto {
@IsString()
content: string;
}
+11 -6
View File
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/comm
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { CommentsService } from './comments.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateCommentDto } from './dto/comment.dto';
@ApiTags('Comments')
@Controller('comments')
@@ -11,18 +12,22 @@ export class CommentsController {
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
create(@Req() req, @Body() data: any) {
return this.comments.create({ ...data, author_id: req.user.sub });
create(@Req() req, @Body() dto: CreateCommentDto) {
return this.comments.create({ ...dto, author_id: req.user.sub });
}
@Get('user/:userId')
getScoresForUser(@Param('userId') id: string) {
return this.comments.getScoresForUser(id);
getScoresForUser(@Param('userId') id: string, @Req() req) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.comments.getScoresForUser(id, page, limit);
}
@Get('professional/:userId')
getScoresForProfessional(@Param('userId') id: string) {
return this.comments.getScoresForProfessional(id);
getScoresForProfessional(@Param('userId') id: string, @Req() req) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.comments.getScoresForProfessional(id, page, limit);
}
@Get('reputation/:userId')
+91 -17
View File
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class CommentsService {
constructor(private prisma: PrismaService) {}
create(data: {
async create(data: {
author_id: string;
destination_id: string;
service_id?: string;
@@ -13,26 +13,100 @@ export class CommentsService {
score: number;
is_from_user: boolean;
}) {
return this.prisma.comments.create({ data });
}
if (data.service_id) {
const service = await this.prisma.services.findUnique({ where: { id: data.service_id } });
if (!service) throw new NotFoundException('Servicio no encontrado');
if (service.status !== 'completed') throw new BadRequestException('Solo puedes calificar servicios completados');
getScoresForUser(userId: string) {
return this.prisma.comments.findMany({
where: { destination_id: userId, is_from_user: false },
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
orderBy: { created_at: 'desc' },
const alreadyScored = await this.prisma.comments.findFirst({
where: { author_id: data.author_id, service_id: data.service_id },
});
if (alreadyScored) throw new BadRequestException('Ya calificaste este servicio');
const field = data.is_from_user ? 'user_scored' : 'professional_scored';
await this.prisma.services.update({
where: { id: data.service_id },
data: { [field]: true },
});
const service2 = await this.prisma.services.findUnique({ where: { id: data.service_id } });
if (service2?.user_scored && service2?.professional_scored) {
await this.prisma.services.update({
where: { id: data.service_id },
data: { status: 'completed' as any },
});
}
}
const comment = await this.prisma.comments.create({ data });
const stats = await this.prisma.comments.aggregate({
where: { destination_id: data.destination_id, is_from_user: data.is_from_user },
_count: true,
_avg: { score: true },
});
}
getScoresForProfessional(userId: string) {
return this.prisma.comments.findMany({
where: { destination_id: userId, is_from_user: true },
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
orderBy: { created_at: 'desc' },
const oppositeStats = await this.prisma.comments.aggregate({
where: { destination_id: data.destination_id, is_from_user: !data.is_from_user },
_count: true,
_avg: { score: true },
});
await this.prisma.reputations.upsert({
where: { user_id: data.destination_id },
create: {
user_id: data.destination_id,
total: data.is_from_user ? 0 : stats._count,
average: data.is_from_user ? 0 : (stats._avg.score || 0),
total_pro: data.is_from_user ? stats._count : 0,
average_pro: data.is_from_user ? (stats._avg.score || 0) : 0,
},
update: {
total: data.is_from_user ? undefined : stats._count,
average: data.is_from_user ? undefined : (stats._avg.score || 0),
total_pro: data.is_from_user ? stats._count : oppositeStats._count,
average_pro: data.is_from_user ? (stats._avg.score || 0) : (oppositeStats._avg.score || 0),
},
});
return comment;
}
getReputation(userId: string) {
return this.prisma.reputations.findUnique({ where: { user_id: userId } });
async getScoresForUser(userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const where = { destination_id: userId, is_from_user: false };
const [data, total] = await Promise.all([
this.prisma.comments.findMany({
where,
skip,
take: limit,
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
orderBy: { created_at: 'desc' },
}),
this.prisma.comments.count({ where }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
async getScoresForProfessional(userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const where = { destination_id: userId, is_from_user: true };
const [data, total] = await Promise.all([
this.prisma.comments.findMany({
where,
skip,
take: limit,
include: { users_comments_author_idTousers: { select: { name: true, picture: true } } },
orderBy: { created_at: 'desc' },
}),
this.prisma.comments.count({ where }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
async getReputation(userId: string) {
const rep = await this.prisma.reputations.findUnique({ where: { user_id: userId } });
if (!rep) return { total: 0, average: 0, total_pro: 0, average_pro: 0 };
return rep;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { IsString, IsNumber, IsBoolean, IsOptional, Min, Max } from 'class-validator';
export class CreateCommentDto {
@IsString()
destination_id: string;
@IsOptional()
@IsString()
service_id?: string;
@IsOptional()
@IsString()
content?: string;
@IsNumber()
@Min(1)
@Max(5)
score: number;
@IsBoolean()
is_from_user: boolean;
}
+47
View File
@@ -0,0 +1,47 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function paginate<T>(
data: T[],
total: number,
page: number,
limit: number,
): PaginatedResult<T> {
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit) || 1,
},
};
}
+33
View File
@@ -0,0 +1,33 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PrismaService } from '../prisma/prisma.service';
export const OWNERSHIP_KEY = 'ownership';
export const Ownership = (param: string, model: string, field: string) =>
SetMetadata(OWNERSHIP_KEY, { param, model, field });
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const metadata = this.reflector.get(OWNERSHIP_KEY, context.getHandler());
if (!metadata) return true;
const request = context.switchToHttp().getRequest();
const resourceId = request.params[metadata.param];
const userId = request.user.sub;
const record = await (this.prisma as any)[metadata.model].findUnique({
where: { id: resourceId },
});
if (!record || record[metadata.field] !== userId) {
throw new ForbiddenException('No tienes permiso para modificar este recurso');
}
return true;
}
}
+4
View File
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
+18
View File
@@ -0,0 +1,18 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}
+10 -2
View File
@@ -1,6 +1,7 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { LocationsService } from './locations.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ApiTags('Locations')
@Controller('locations')
@@ -21,4 +22,11 @@ export class LocationsController {
getCities(@Param('regionId') id: string) {
return this.locations.getCities(id);
}
@Post('cities')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
createCity(@Body() body: { region_id: string; name: string; latitude?: number; longitude?: number }) {
return this.locations.createCity(body);
}
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
@@ -16,4 +16,18 @@ export class LocationsService {
getCities(regionId: string) {
return this.prisma.cities.findMany({ where: { region_id: regionId } });
}
async createCity(data: { region_id: string; name: string; latitude?: number; longitude?: number }) {
const region = await this.prisma.regions.findUnique({ where: { id: data.region_id } });
if (!region) throw new NotFoundException('Región no encontrada');
const existing = await this.prisma.cities.findFirst({
where: { region_id: data.region_id, name: data.name },
});
if (existing) throw new ConflictException('Ya existe esta ciudad en la región');
return this.prisma.cities.create({
data: { region_id: data.region_id, name: data.name, latitude: data.latitude, longitude: data.longitude },
});
}
}
+5 -1
View File
@@ -1,10 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const logger = new Logger('Bootstrap');
app.enableCors({
@@ -16,6 +18,8 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useStaticAssets(join(__dirname, '..', 'uploads'), { prefix: '/uploads' });
const config = new DocumentBuilder()
.setTitle('ProsApp API')
.setDescription('API de ProsApp - Migración Firebase a PostgreSQL')
@@ -0,0 +1,30 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsString, IsOptional, IsObject } from 'class-validator';
import { NotificationsService } from './notifications.service';
class SendNotificationDto {
@IsString()
to: string;
@IsString()
title: string;
@IsString()
body: string;
@IsOptional()
@IsObject()
data?: Record<string, any>;
}
@ApiTags('Notifications')
@Controller('notifications')
export class NotificationsController {
constructor(private notifications: NotificationsService) {}
@Post('send')
send(@Body() dto: SendNotificationDto) {
return this.notifications.send(dto.to, dto.title, dto.body, dto.data);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
@Module({
providers: [NotificationsService],
controllers: [NotificationsController],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,40 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly fcmServerKey: string;
constructor(private config: ConfigService) {
this.fcmServerKey = this.config.getOrThrow<string>('FCM_SERVER_KEY');
}
async send(to: string, title: string, body: string, data?: Record<string, any>) {
const message = {
notification: { title, body },
priority: 'high' as const,
data: data ?? { click_action: 'FLUTTER_NOTIFICATION_CLICK', id: '1', status: 'done' },
to,
};
const response = await fetch('https://fcm.googleapis.com/fcm/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
Authorization: `key=${this.fcmServerKey}`,
},
body: JSON.stringify(message),
});
if (!response.ok) {
const text = await response.text();
this.logger.error(`FCM error ${response.status}: ${text}`);
throw new Error(`FCM request failed: ${response.status}`);
}
const result = await response.json();
this.logger.log(`FCM success: ${JSON.stringify(result)}`);
return result;
}
}
@@ -0,0 +1,97 @@
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, MinLength, Matches } from 'class-validator';
export class CreateProfessionalDto {
@IsString()
@MinLength(5)
identification: string;
@IsString()
profession: string;
@IsString()
address: string;
@IsOptional()
@IsString()
additional_address?: string;
@IsOptional()
@IsString()
identification_picture?: string;
@IsOptional()
@IsString()
certificate_picture?: string;
}
export class UpdateProfessionalDto {
@IsOptional()
@IsString()
identification?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
additional_address?: string;
@IsOptional()
@IsString()
profession?: string;
@IsOptional()
@IsNumber()
rate?: number;
@IsOptional()
@IsString()
banner_picture?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsString()
location_preferences?: string;
}
export class ScheduleDto {
@IsNumber()
day_of_week: number;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
continuous_day?: boolean;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/, { message: 'time must be HH:MM' })
range1_hour1?: string;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
range1_hour2?: string;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
range2_hour1?: string;
@IsOptional()
@Matches(/^\d{2}:\d{2}$/)
range2_hour2?: string;
}
export class UpdateSchedulesDto {
@IsArray()
schedules: ScheduleDto[];
}
@@ -1,22 +1,54 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ProfessionalsService } from './professionals.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateProfessionalDto, UpdateProfessionalDto, UpdateSchedulesDto } from './dto/professional.dto';
import { AuthService } from '../auth/auth.service';
@ApiTags('Professionals')
@Controller('professionals')
export class ProfessionalsController {
constructor(private pros: ProfessionalsService) {}
constructor(
private pros: ProfessionalsService,
private auth: AuthService,
) {}
@Get()
findAllActive() {
return this.pros.findAllActive();
findAllActive(@Req() req) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.pros.findAllActive(page, limit);
}
@Get('pending')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
findPending(@Req() req) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.pros.findPendingApprovals(page, limit);
}
@Post(':id/approve')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
approve(@Param('id') id: string) {
return this.pros.approve(id);
}
@Post(':id/deny')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
deny(@Param('id') id: string) {
return this.pros.deny(id);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
findByMe(@Req() req) {
async findByMe(@Req() req) {
const profId = await this.auth.getProfessionalId(req.user.sub);
if (!profId) throw new NotFoundException('No eres un profesional');
return this.pros.findByUserId(req.user.sub);
}
@@ -28,21 +60,22 @@ export class ProfessionalsController {
@Post('request')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
request(@Req() req, @Body() data: any) {
return this.pros.requestProfessional(req.user.sub, data);
request(@Req() req, @Body() dto: CreateProfessionalDto) {
return this.pros.requestProfessional(req.user.sub, dto);
}
@Patch('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
update(@Req() req, @Body() data: any) {
return this.pros.upsert(req.user.sub, data);
async update(@Req() req, @Body() dto: UpdateProfessionalDto) {
return this.pros.upsert(req.user.sub, dto);
}
@Patch('me/schedules')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
updateSchedules(@Req() req, @Body() data: { schedules: any[] }) {
return this.pros.updateSchedules(req.user.sub, data.schedules);
async updateSchedules(@Req() req, @Body() dto: UpdateSchedulesDto) {
const prof = await this.pros.findByUserId(req.user.sub);
return this.pros.updateSchedules(prof.id, dto.schedules);
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ProfessionalsService } from './professionals.service';
import { ProfessionalsController } from './professionals.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
providers: [ProfessionalsService],
controllers: [ProfessionalsController],
exports: [ProfessionalsService],
@@ -1,24 +1,31 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ProfessionalsService {
constructor(private prisma: PrismaService) {}
findAllActive() {
return this.prisma.professionals.findMany({
where: { is_active: true },
include: {
users: { select: { id: true, name: true, picture: true, city: true } },
schedules: true,
specializations: true,
payment_methods: true,
},
});
async findAllActive(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.professionals.findMany({
where: { is_active: true },
skip,
take: limit,
include: {
users: { select: { id: true, name: true, picture: true, city: true } },
schedules: true,
specializations: true,
payment_methods: true,
},
}),
this.prisma.professionals.count({ where: { is_active: true } }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
findById(id: string) {
return this.prisma.professionals.findUnique({
async findById(id: string) {
const prof = await this.prisma.professionals.findUnique({
where: { id },
include: {
users: { select: { id: true, name: true, picture: true, city: true } },
@@ -27,43 +34,106 @@ export class ProfessionalsService {
payment_methods: true,
},
});
if (!prof) throw new NotFoundException('Profesional no encontrado');
return prof;
}
findByUserId(userId: string) {
return this.prisma.professionals.findUnique({
async findByUserId(userId: string) {
const prof = await this.prisma.professionals.findUnique({
where: { user_id: userId },
include: { schedules: true, specializations: true, payment_methods: true },
});
if (!prof) throw new NotFoundException('No eres un profesional registrado');
return prof;
}
async upsert(userId: string, data: any) {
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
if (!prof) throw new NotFoundException('Debes solicitar ser profesional primero');
if (existing) {
return this.prisma.professionals.update({ where: { user_id: userId }, data });
}
return this.prisma.professionals.create({ data: { ...data, user_id: userId } });
return this.prisma.professionals.update({ where: { user_id: userId }, data });
}
async updateSchedules(professionalId: string, schedules: any[]) {
await this.prisma.schedules.deleteMany({ where: { professional_id: professionalId } });
return this.prisma.schedules.createMany({
data: schedules.map((s: any) => ({
professional_id: professionalId,
day_of_week: s.day_of_week,
enabled: s.enabled,
continuous_day: s.continuous_day,
range1_hour1: s.range1_hour1,
range1_hour2: s.range1_hour2,
range2_hour1: s.range2_hour1,
range2_hour2: s.range2_hour2,
})),
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
if (!prof) throw new NotFoundException('Profesional no encontrado');
for (const s of schedules) {
if (s.range1_hour1 && s.range1_hour2 && s.range1_hour1 >= s.range1_hour2) {
throw new BadRequestException(`Día ${s.day_of_week}: range1_hour1 debe ser menor que range1_hour2`);
}
}
const result = await this.prisma.$transaction(async (tx: any) => {
await tx.schedules.deleteMany({ where: { professional_id: professionalId } });
if (schedules.length > 0) {
await tx.schedules.createMany({
data: schedules.map((s: any) => ({
professional_id: professionalId,
day_of_week: s.day_of_week,
enabled: s.enabled ?? false,
continuous_day: s.continuous_day ?? false,
range1_hour1: s.range1_hour1 ? new Date(`1970-01-01T${s.range1_hour1}:00`) : null,
range1_hour2: s.range1_hour2 ? new Date(`1970-01-01T${s.range1_hour2}:00`) : null,
range2_hour1: s.range2_hour1 ? new Date(`1970-01-01T${s.range2_hour1}:00`) : null,
range2_hour2: s.range2_hour2 ? new Date(`1970-01-01T${s.range2_hour2}:00`) : null,
})),
});
}
return tx.schedules.findMany({ where: { professional_id: professionalId } });
});
return result;
}
async findPendingApprovals(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.professionals.findMany({
where: { is_active: false },
skip,
take: limit,
include: { users: { select: { id: true, name: true, email: true, phone: true, created_at: true } } },
}),
this.prisma.professionals.count({ where: { is_active: false } }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
async approve(professionalId: string) {
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([
this.prisma.professionals.update({
where: { id: professionalId },
data: { is_active: true },
}),
this.prisma.users.update({
where: { id: prof.user_id },
data: { pro_state: 2 },
}),
]);
return { message: 'Profesional aprobado' };
}
async deny(professionalId: string) {
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([
this.prisma.professionals.delete({ where: { id: professionalId } }),
this.prisma.users.update({
where: { id: prof.user_id },
data: { pro_state: 3 },
}),
]);
return { message: 'Solicitud rechazada' };
}
async requestProfessional(userId: string, data: any) {
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
if (existing) throw new Error('Ya tienes una solicitud de profesional');
if (existing) throw new BadRequestException('Ya tienes una solicitud de profesional');
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
return this.prisma.professionals.create({
@@ -72,6 +142,7 @@ export class ProfessionalsService {
identification: data.identification,
profession: data.profession,
address: data.address,
additional_address: data.additional_address,
identification_picture: data.identification_picture,
certificate_picture: data.certificate_picture,
},
@@ -1,6 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ProfessionsService } from './professions.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ApiTags('Professions')
@Controller('professions')
@@ -11,4 +12,18 @@ export class ProfessionsController {
findAll() {
return this.professions.findAll();
}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
create(@Body() body: { name: string }) {
return this.professions.create(body.name);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
remove(@Param('id') id: string) {
return this.professions.remove(id);
}
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
@@ -8,4 +8,17 @@ export class ProfessionsService {
findAll() {
return this.prisma.professions.findMany({ orderBy: { name: 'asc' } });
}
async create(name: string) {
const existing = await this.prisma.professions.findUnique({ where: { name } });
if (existing) throw new ConflictException('Ya existe esta profesión');
return this.prisma.professions.create({ data: { name } });
}
async remove(id: string) {
const prof = await this.prisma.professions.findUnique({ where: { id } });
if (!prof) throw new NotFoundException('Profesión no encontrada');
await this.prisma.professions.delete({ where: { id } });
return { message: 'Profesión eliminada' };
}
}
+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 } };
}
}
+10 -2
View File
@@ -1,6 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ApiTags('Settings')
@Controller('settings')
@@ -11,4 +12,11 @@ export class SettingsController {
getGlobal() {
return this.settings.getGlobal();
}
@Patch()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
updateGlobal(@Body() body: Record<string, any>) {
return this.settings.updateGlobal(body);
}
}
+8
View File
@@ -9,4 +9,12 @@ export class SettingsService {
const setting = await this.prisma.settings.findUnique({ where: { key: 'global' } });
return setting?.value;
}
async updateGlobal(value: Record<string, any>) {
return this.prisma.settings.upsert({
where: { key: 'global' },
create: { key: 'global', value },
update: { value },
});
}
}
+9 -5
View File
@@ -1,8 +1,8 @@
import { Controller, Post, UseGuards, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
import { Controller, Post, UseGuards, UploadedFile, UseInterceptors, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { StorageService } from './storage.service';
import { StorageService, StoredFile } from './storage.service';
@ApiTags('Storage')
@Controller('storage')
@@ -12,8 +12,12 @@ export class StorageController {
@Post('upload')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiConsumes('multipart/form-data')
@ApiBody({ schema: { type: 'object', properties: { file: { type: 'string', format: 'binary' } } } })
@UseInterceptors(FileInterceptor('file'))
upload(@UploadedFile() file: any) {
return { url: this.storage.getUploadUrl(file?.originalname) };
async upload(@UploadedFile() file: StoredFile) {
if (!file) throw new BadRequestException('Archivo requerido');
const url = await this.storage.save(file);
return { url };
}
}
+34 -3
View File
@@ -1,14 +1,45 @@
import { Injectable } from '@nestjs/common';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { join } from 'path';
import { randomUUID } from 'crypto';
export interface StoredFile {
originalname: string;
buffer: Buffer;
mimetype: string;
size: number;
}
@Injectable()
export class StorageService {
private uploadDir: string;
private baseUrl: string;
constructor() {
this.baseUrl = process.env.STORAGE_URL || 'http://localhost:9000';
this.uploadDir = process.env.UPLOAD_DIR || join(process.cwd(), 'uploads');
this.baseUrl = process.env.STORAGE_URL || `http://localhost:3000/uploads`;
}
getUploadUrl(fileName: string) {
return `${this.baseUrl}/uploads/${fileName}`;
async save(file: StoredFile, subfolder = 'general'): Promise<string> {
const dir = join(this.uploadDir, subfolder);
await mkdir(dir, { recursive: true });
const ext = file.originalname.split('.').pop() || 'bin';
const filename = `${randomUUID()}.${ext}`;
const filepath = join(dir, filename);
await writeFile(filepath, file.buffer);
return `${this.baseUrl}/${subfolder}/${filename}`;
}
async delete(url: string): Promise<void> {
const relativePath = url.replace(this.baseUrl, '');
const filepath = join(this.uploadDir, relativePath);
await unlink(filepath).catch(() => {});
}
getUploadUrl(fileName: string): string {
return `${this.baseUrl}/${fileName}`;
}
}
+17 -11
View File
@@ -2,6 +2,7 @@ import { Controller, Get, Patch, Param, Body, UseGuards, Req } from '@nestjs/com
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { UpdateUserDto, FcmTokenDto } from '../auth/dto/auth.dto';
@ApiTags('Users')
@Controller('users')
@@ -9,30 +10,35 @@ export class UsersController {
constructor(private users: UsersService) {}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
findAll() {
return this.users.findAll();
findAll(@Req() req) {
const page = +(req.query.page || 1);
const limit = +(req.query.limit || 20);
return this.users.findAll(page, limit);
}
@Get(':id')
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
findById(@Param('id') id: string) {
return this.users.findById(id);
findMe(@Req() req) {
return this.users.findById(req.user.sub);
}
@Patch('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
update(@Req() req, @Body() data: any) {
return this.users.update(req.user.sub, data);
update(@Req() req, @Body() dto: UpdateUserDto) {
return this.users.update(req.user.sub, dto);
}
@Patch('fcm-token')
@Patch('me/fcm-token')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
updateFcmToken(@Req() req, @Body() dto: { token: string }) {
updateFcmToken(@Req() req, @Body() dto: FcmTokenDto) {
return this.users.updateFcmToken(req.user.sub, dto.token);
}
@Get(':id')
findById(@Param('id') id: string) {
return this.users.findById(id);
}
}
+7 -2
View File
@@ -5,8 +5,13 @@ import { PrismaService } from '../prisma/prisma.service';
export class UsersService {
constructor(private prisma: PrismaService) {}
findAll() {
return this.prisma.users.findMany({ orderBy: { created_at: 'desc' } });
async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.users.findMany({ skip, take: limit, orderBy: { created_at: 'desc' } }),
this.prisma.users.count(),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
findById(id: string) {