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:
Lizandro Guarnizo
2026-06-25 15:53:32 -05:00
co-authored by Claude Sonnet 4.6
parent 1c2f0ca71a
commit 7783cac3fe
15 changed files with 675 additions and 234 deletions
+33 -3
View File
@@ -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,
},
};
}