feat: SMS OTP auth, phone verification gate, admin comments/edit/status pages
Backend: - Add SmsService + SmsModule: send OTP via u-site.app provider, 5-min TTL - Auth endpoints: POST /auth/send-otp, POST /auth/phone (login by phone+code), POST /auth/verify-phone (link), PATCH /auth/change-password - is_phone_verified included in JWT token response - GET /comments (admin, JWT-protected) with author/destination names Admin: - Users list: link to detail page per row - User detail: inline edit form (name, city, phone) with PATCH /users/:id - Services list: link to detail page per row - Service detail: status change dropdown (PATCH /services/:id/status) - New Comments page: summary stats + full table with star ratings - New SMS settings page: configure API key + send test SMS - Sidebar: added Comments and SMS entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1c2f0ca71a
commit
7783cac3fe
@@ -12,6 +12,7 @@ import { SettingsModule } from './settings/settings.module';
|
||||
import { ProfessionsModule } from './professions/professions.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { SmsModule } from './sms/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,6 +29,7 @@ import { NotificationsModule } from './notifications/notifications.module';
|
||||
ProfessionsModule,
|
||||
StorageModule,
|
||||
NotificationsModule,
|
||||
SmsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +1,43 @@
|
||||
import { Controller, Post, Body, UseGuards, Get, Req, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, MinLength } from 'class-validator';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { RegisterDto, LoginDto, PhoneDto, UpdateUserDto, FcmTokenDto } from './dto/auth.dto';
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
|
||||
class SendOtpDto {
|
||||
@IsString()
|
||||
phone: string;
|
||||
}
|
||||
|
||||
class PhoneLoginDto {
|
||||
@IsString()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
class VerifyPhoneDto {
|
||||
@IsString()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
}
|
||||
|
||||
class ChangePasswordDto {
|
||||
@IsString()
|
||||
current_password: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
@@ -19,16 +54,21 @@ export class AuthController {
|
||||
return this.auth.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Post('send-otp')
|
||||
sendOtp(@Body() dto: SendOtpDto) {
|
||||
return this.auth.sendPhoneOtp(dto.phone);
|
||||
}
|
||||
|
||||
@Post('phone')
|
||||
phone(@Body() dto: PhoneDto) {
|
||||
return this.auth.loginOrCreateByPhone(dto.phone, dto.name);
|
||||
phone(@Body() dto: PhoneLoginDto) {
|
||||
return this.auth.loginOrCreateByPhone(dto.phone, dto.code, dto.name);
|
||||
}
|
||||
|
||||
@Post('verify-phone')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
verifyPhone(@Req() req, @Body() dto: { phone: string }) {
|
||||
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone);
|
||||
verifyPhone(@Req() req, @Body() dto: VerifyPhoneDto) {
|
||||
return this.auth.verifyOtpAndLinkPhone(req.user.sub, dto.phone, dto.code);
|
||||
}
|
||||
|
||||
@Post('link-email')
|
||||
@@ -38,6 +78,13 @@ export class AuthController {
|
||||
return this.auth.linkEmail(req.user.sub, dto.email, dto.password);
|
||||
}
|
||||
|
||||
@Patch('change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
changePassword(@Req() req, @Body() dto: ChangePasswordDto) {
|
||||
return this.auth.changePassword(req.user.sub, dto.current_password, dto.new_password);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -17,6 +18,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
SmsModule,
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Injectable, UnauthorizedException, ConflictException, BadRequestExcepti
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private sms: SmsService,
|
||||
) {}
|
||||
|
||||
async register(email: string, password: string, name: string) {
|
||||
@@ -32,17 +34,32 @@ export class AuthService {
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
async loginOrCreateByPhone(phone: string, name?: string) {
|
||||
async sendPhoneOtp(phone: string): Promise<void> {
|
||||
await this.sms.sendOtp(phone);
|
||||
}
|
||||
|
||||
async loginOrCreateByPhone(phone: string, code: string, name?: string) {
|
||||
const valid = this.sms.verifyOtp(phone, code);
|
||||
if (!valid) throw new BadRequestException('Código OTP inválido o expirado');
|
||||
|
||||
let user = await this.prisma.users.findUnique({ where: { phone } });
|
||||
if (!user) {
|
||||
user = await this.prisma.users.create({
|
||||
data: { phone, name: name || phone },
|
||||
data: { phone, name: name || phone, is_phone_verified: true },
|
||||
});
|
||||
} else {
|
||||
user = await this.prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: { is_phone_verified: true },
|
||||
});
|
||||
}
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
async verifyOtpAndLinkPhone(userId: string, phone: string) {
|
||||
async verifyOtpAndLinkPhone(userId: string, phone: string, code: string) {
|
||||
const valid = this.sms.verifyOtp(phone, code);
|
||||
if (!valid) throw new BadRequestException('Código OTP inválido o expirado');
|
||||
|
||||
const existing = await this.prisma.users.findUnique({ where: { phone } });
|
||||
if (existing && existing.id !== userId) {
|
||||
throw new ConflictException('Teléfono ya registrado por otro usuario');
|
||||
@@ -64,6 +81,18 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.prisma.users.findUnique({ where: { id: userId } });
|
||||
if (!user || !user.password_hash) throw new BadRequestException('El usuario no tiene contraseña configurada');
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!valid) throw new UnauthorizedException('Contraseña actual incorrecta');
|
||||
|
||||
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||
await this.prisma.users.update({ where: { id: userId }, data: { password_hash } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.prisma.users.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -92,6 +121,7 @@ export class AuthService {
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
name: user.name,
|
||||
is_phone_verified: user.is_phone_verified ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CommentsService } from './comments.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -9,6 +9,13 @@ import { CreateCommentDto } from './dto/comment.dto';
|
||||
export class CommentsController {
|
||||
constructor(private comments: CommentsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
findAll(@Query('page') page = '1', @Query('limit') limit = '50') {
|
||||
return this.comments.findAll(+page, +limit);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -104,6 +104,23 @@ export class CommentsService {
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async findAll(page = 1, limit = 50) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.comments.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users_comments_author_idTousers: { select: { name: true, picture: true } },
|
||||
users_comments_destination_idTousers: { select: { name: true } },
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
}),
|
||||
this.prisma.comments.count(),
|
||||
]);
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Controller, Get, Patch, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
import { SmsService } from './sms.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
class SaveConfigDto {
|
||||
@IsString()
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
class TestSmsDto {
|
||||
@IsString()
|
||||
numero: string;
|
||||
|
||||
@IsString()
|
||||
mensaje: string;
|
||||
}
|
||||
|
||||
@ApiTags('SMS')
|
||||
@Controller('sms')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class SmsController {
|
||||
constructor(private sms: SmsService) {}
|
||||
|
||||
@Get('config')
|
||||
async getConfig() {
|
||||
const config = await this.sms.getConfig();
|
||||
if (!config?.api_key) return { configured: false, api_key_preview: '' };
|
||||
const k = config.api_key;
|
||||
const preview = k.length > 8 ? `${k.slice(0, 8)}••••••••${k.slice(-4)}` : '••••••••';
|
||||
return { configured: true, api_key_preview: preview };
|
||||
}
|
||||
|
||||
@Patch('config')
|
||||
async saveConfig(@Body() dto: SaveConfigDto) {
|
||||
await this.sms.saveConfig(dto.api_key);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
test(@Body() dto: TestSmsDto) {
|
||||
return this.sms.send(dto.numero, dto.mensaje);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
import { SmsController } from './sms.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [SmsController],
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
Reference in New Issue
Block a user