diff --git a/admin/src/app/comments/page.tsx b/admin/src/app/comments/page.tsx new file mode 100644 index 0000000..634dfbf --- /dev/null +++ b/admin/src/app/comments/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Star } from 'lucide-react'; + +interface Comment { + id: string; + score: number; + content?: string; + is_from_user: boolean; + created_at: string; + users_comments_author_idTousers?: { name: string }; + users_comments_destination_idTousers?: { name: string }; +} + +function Stars({ score }: { score: number }) { + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} + {score} +
+ ); +} + +export default function CommentsPage() { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(() => { + setLoading(true); + setError(null); + api.get<{ data: Comment[]; meta: any }>('/comments?limit=100') + .then((res) => setComments(res.data)) + .catch(() => setError('Error al cargar los comentarios')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const fromUser = comments.filter((c) => c.is_from_user); + const fromPro = comments.filter((c) => !c.is_from_user); + const avg = comments.length > 0 ? (comments.reduce((s, c) => s + c.score, 0) / comments.length).toFixed(1) : '—'; + + return ( +
+

Comentarios y Reseñas

+ +
+

{comments.length}

Total

+

{avg}

Promedio

+

{fromUser.length}

De usuarios

+

{fromPro.length}

De profesionales

+
+ + {error ? ( +
+

{error}

+ +
+ ) : ( + + Últimas reseñas + + + + + Autor + Destinatario + Tipo + Puntuación + Comentario + Fecha + + + + {loading ? ( + Cargando... + ) : comments.length === 0 ? ( + Sin comentarios + ) : ( + comments.map((c) => ( + + {c.users_comments_author_idTousers?.name || '—'} + {c.users_comments_destination_idTousers?.name || '—'} + + + {c.is_from_user ? 'Usuario → Pro' : 'Pro → Usuario'} + + + + {c.content || '—'} + {new Date(c.created_at).toLocaleDateString()} + + )) + )} + +
+
+
+ )} +
+ ); +} diff --git a/admin/src/app/services/[id]/page.tsx b/admin/src/app/services/[id]/page.tsx index 08586f6..ba31a35 100644 --- a/admin/src/app/services/[id]/page.tsx +++ b/admin/src/app/services/[id]/page.tsx @@ -7,7 +7,9 @@ import { api } from '@/lib/api'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ArrowLeft, Star } from 'lucide-react'; +import { toast } from 'sonner'; interface Service { id: string; @@ -23,19 +25,8 @@ interface Service { updated_at: string; professional_scored: boolean; user_scored: boolean; - users?: { - id: string; - name: string; - phone?: string; - picture?: string; - }; - professionals?: { - id: string; - users?: { - name: string; - picture?: string; - }; - }; + users?: { id: string; name: string; phone?: string; picture?: string; }; + professionals?: { id: string; users?: { name: string; picture?: string; }; }; } const statusColors: Record = { @@ -45,6 +36,7 @@ const statusColors: Record = { completed: 'bg-gray-100 text-gray-800', cancelled: 'bg-red-100 text-red-800', denied: 'bg-red-100 text-red-800', + self_booked: 'bg-purple-100 text-purple-800', }; const statusLabels: Record = { @@ -71,8 +63,8 @@ export default function ServiceDetailPage() { const [service, setService] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [retryCounter, setRetryCounter] = useState(0); + const [updatingStatus, setUpdatingStatus] = useState(false); useEffect(() => { let cancelled = false; @@ -82,35 +74,44 @@ export default function ServiceDetailPage() { return () => { cancelled = true; }; }, [params.id, retryCounter]); - if (loading) { - return ( -
- - Volver - -
Cargando...
-
- ); - } + const changeStatus = async (newStatus: string) => { + if (!service || newStatus === service.status) return; + setUpdatingStatus(true); + try { + await api.patch(`/services/${params.id}/status`, { status: newStatus }); + setService({ ...service, status: newStatus }); + toast.success(`Estado cambiado a "${statusLabels[newStatus] || newStatus}"`); + } catch (e: any) { + toast.error(e?.message || 'Error al cambiar estado'); + } finally { + setUpdatingStatus(false); + } + }; - if (error) { - return ( -
- - Volver - -
-

{error}

- -
+ if (loading) return ( +
+ + Volver + +
Cargando...
+
+ ); + + if (error) return ( +
+ + Volver + +
+

{error}

+
- ); - } +
+ ); if (!service) return null; - const s = service; return ( @@ -119,39 +120,43 @@ export default function ServiceDetailPage() { Volver -
+

Servicio #{s.id.slice(0, 8)}

- - {statusLabels[s.status] || s.status} - +
+ + {statusLabels[s.status] || s.status} + +
+ Cambiar estado: + +
+
- - Cliente - + Cliente {s.users ? ( <>
{s.users.picture && ( - {s.users.name} + {s.users.name} )} - + {s.users.name}
- - {s.users.phone || '—'} - + {s.users.phone} ) : (

Sin información

@@ -160,79 +165,52 @@ export default function ServiceDetailPage() {
- - Profesional - + Profesional {s.professionals ? (
{s.professionals.users?.picture && ( - {s.professionals.users.name} + {s.professionals.users.name} )} - + {s.professionals.users?.name || '—'}
) : ( -

Sin información

+

Sin profesional asignado

)}
- - Detalles del servicio - + Detalles del servicio - - {new Date(s.day).toLocaleDateString()} - + {new Date(s.day).toLocaleDateString()} {s.address} {s.description} ${s.rate} - - {s.location_preference} - + {s.location_preference} - {s.range1_hour1 && s.range1_hour2 - ? `${s.range1_hour1} — ${s.range1_hour2}` - : '—'} + {s.range1_hour1 && s.range1_hour2 ? `${s.range1_hour1} — ${s.range1_hour2}` : undefined} - - Información adicional - + Información adicional - - {new Date(s.created_at).toLocaleString()} - - - {new Date(s.updated_at).toLocaleString()} - + {new Date(s.created_at).toLocaleString()} + {new Date(s.updated_at).toLocaleString()}
- + {s.user_scored ? 'Sí' : 'No'}
- + {s.professional_scored ? 'Sí' : 'No'}
diff --git a/admin/src/app/services/page.tsx b/admin/src/app/services/page.tsx index 4e44883..f080bd3 100644 --- a/admin/src/app/services/page.tsx +++ b/admin/src/app/services/page.tsx @@ -7,6 +7,8 @@ import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import Link from 'next/link'; +import { Eye } from 'lucide-react'; interface Service { id: string; @@ -97,13 +99,14 @@ export default function ServicesPage() { Dirección Tarifa Estado + {loading ? ( - Cargando... + Cargando... ) : filtered.length === 0 ? ( - Sin resultados + Sin resultados ) : ( filtered.map((s) => ( @@ -118,6 +121,11 @@ export default function ServicesPage() { {statusLabels[s.status] || s.status} + + + + + )) )} diff --git a/admin/src/app/sms/page.tsx b/admin/src/app/sms/page.tsx new file mode 100644 index 0000000..775ced3 --- /dev/null +++ b/admin/src/app/sms/page.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { Save, Send, Eye, EyeOff, CheckCircle2, XCircle } from 'lucide-react'; + +interface SmsConfig { + configured: boolean; + api_key_preview: string; +} + +export default function SmsPage() { + const [config, setConfig] = useState(null); + const [newKey, setNewKey] = useState(''); + const [showKey, setShowKey] = useState(false); + const [savingKey, setSavingKey] = useState(false); + + const [testPhone, setTestPhone] = useState(''); + const [testMessage, setTestMessage] = useState('Hola, este es un mensaje de prueba desde ProsApp.'); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; id?: string; error?: string } | null>(null); + + const loadConfig = useCallback(() => { + api.get('/sms/config') + .then(setConfig) + .catch(() => toast.error('Error al cargar configuración SMS')); + }, []); + + useEffect(() => { loadConfig(); }, [loadConfig]); + + const saveKey = async () => { + if (!newKey.trim()) return toast.error('Ingresa una API Key'); + setSavingKey(true); + try { + await api.patch('/sms/config', { api_key: newKey.trim() }); + toast.success('API Key guardada'); + setNewKey(''); + loadConfig(); + } catch (e: any) { + toast.error(e?.message || 'Error al guardar'); + } finally { + setSavingKey(false); + } + }; + + const sendTest = async () => { + if (!testPhone.trim()) return toast.error('Ingresa un número de teléfono'); + if (!testMessage.trim()) return toast.error('Ingresa un mensaje'); + setTesting(true); + setTestResult(null); + try { + const result = await api.post<{ ok: boolean; id: string }>('/sms/test', { + numero: testPhone.trim(), + mensaje: testMessage.trim(), + }); + setTestResult(result); + toast.success('SMS enviado correctamente'); + } catch (e: any) { + setTestResult({ ok: false, error: e?.message || 'Error desconocido' }); + toast.error(e?.message || 'Error al enviar SMS'); + } finally { + setTesting(false); + } + }; + + return ( +
+
+

Proveedor SMS

+

+ Configuración del servicio de envío de mensajes de texto (OTP y notificaciones). +

+
+ + {/* Estado */} + + + Estado del proveedor + + + {config === null ? ( + Cargando... + ) : config.configured ? ( + <> + + Configurado + + {config.api_key_preview} + + + ) : ( + <> + + No configurado + + )} + + + + {/* Configurar API Key */} + + + API Key + + Encuentra tu API Key en la sección API Key (envío externo) del panel de U-Site. + + + +
+
+ setNewKey(e.target.value)} + className="pr-10 font-mono" + /> + +
+ +
+

+ La clave se almacena cifrada. Si ya tienes una configurada, ingresa una nueva para reemplazarla. +

+
+
+ + {/* Prueba de envío */} + + + Probar envío + + Envía un SMS de prueba para verificar que la configuración funciona correctamente. + + + +
+ + setTestPhone(e.target.value)} + /> +

+ Formato internacional sin + (ej: 573001234567 para Colombia) +

+
+
+ + setTestMessage(e.target.value.slice(0, 160))} + /> +

+ {testMessage.length}/160 caracteres +

+
+ + {!config?.configured && ( +

Debes configurar la API Key antes de probar.

+ )} + {testResult && ( +
+ {testResult.ok ? ( + ✓ Enviado — ID: {testResult.id} + ) : ( + ✗ Error: {testResult.error} + )} +
+ )} +
+
+
+ ); +} diff --git a/admin/src/app/users/[id]/page.tsx b/admin/src/app/users/[id]/page.tsx index ac9f8d2..b961abf 100644 --- a/admin/src/app/users/[id]/page.tsx +++ b/admin/src/app/users/[id]/page.tsx @@ -7,37 +7,17 @@ import { api } from '@/lib/api'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { ArrowLeft } from 'lucide-react'; - -interface Professional { - id: string; - profession?: string; - rate?: number; - identification?: string; - is_active?: boolean; -} - -interface Reputation { - total: number; - average: number; - total_pro: number; - average_pro: number; -} +import { Input } from '@/components/ui/input'; +import { ArrowLeft, Pencil, X, Check } from 'lucide-react'; +import { toast } from 'sonner'; +interface Professional { id: string; profession?: string; rate?: number; identification?: string; } +interface Reputation { total: number; average: number; total_pro: number; average_pro: number; } interface UserDetail { - id: string; - name: string; - email?: string; - phone?: string; - city?: string; - gender?: string; - birthday?: string; - is_email_verified?: boolean; - is_phone_verified?: boolean; - pro_state?: number; - created_at: string; - professionals?: Professional | null; - reputations?: Reputation | null; + id: string; name: string; email?: string; phone?: string; city?: string; + gender?: string; birthday?: string; is_email_verified?: boolean; + is_phone_verified?: boolean; pro_state?: number; created_at: string; + professionals?: Professional | null; reputations?: Reputation | null; } const PRO_STATE_LABELS = ['Usuario', 'Solicitó', 'Profesional', 'Rechazado']; @@ -47,87 +27,107 @@ export default function UserDetailPage() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({ name: '', city: '', phone: '', email: '' }); const load = useCallback(() => { if (!id) return; setLoading(true); setError(null); api.get(`/users/${id}`) - .then(setUser) + .then((u) => { setUser(u); setForm({ name: u.name, city: u.city || '', phone: u.phone || '', email: u.email || '' }); }) .catch(() => setError('Error al cargar el usuario')) .finally(() => setLoading(false)); }, [id]); useEffect(() => { load(); }, [load]); - if (loading) { - return ( -
-

Cargando...

-
- ); - } - - if (error) { - return ( -
-

{error}

- -
- ); - } + const save = async () => { + setSaving(true); + try { + const updated = await api.patch(`/users/${id}`, { + name: form.name || undefined, + city: form.city || undefined, + phone: form.phone || undefined, + }); + setUser(updated); + setEditing(false); + toast.success('Usuario actualizado'); + } catch (e: any) { + toast.error(e?.message || 'Error al guardar'); + } finally { + setSaving(false); + } + }; + if (loading) return
Cargando...
; + if (error) return ( +
+

{error}

+
+ ); if (!user) return null; return (
-
- - +

{user.name}

+
+ {!editing ? ( + - -

{user.name}

+ ) : ( +
+ + +
+ )}
- - Información general - - + Información general + +
+ Nombre + {editing + ? setForm({ ...form, name: e.target.value })} className="mt-1" /> + :

{user.name}

} +
Email

{user.email || '—'} - {user.is_email_verified && ( - Email verificado - )} + {user.is_email_verified && Verificado}

Teléfono -

- {user.phone || '—'} - {user.is_phone_verified && ( - Teléfono verificado - )} -

+ {editing + ? setForm({ ...form, phone: e.target.value })} className="mt-1" placeholder="+57300..." /> + :

+ {user.phone || '—'} + {user.is_phone_verified && Verificado} +

}
Ciudad -

{user.city || '—'}

+ {editing + ? setForm({ ...form, city: e.target.value })} className="mt-1" /> + :

{user.city || '—'}

}
Género

{user.gender || '—'}

-
- Cumpleaños -

{user.birthday ? new Date(user.birthday).toLocaleDateString() : '—'}

-
Registro

{new Date(user.created_at).toLocaleDateString()}

@@ -136,69 +136,37 @@ export default function UserDetailPage() { - - Estado - + Estado
Estado profesional -

- {PRO_STATE_LABELS[user.pro_state ?? 0] || '—'} -

+

{PRO_STATE_LABELS[user.pro_state ?? 0]}

+ {user.reputations && ( +
+
Puntuación prom.

{user.reputations.average.toFixed(1)}

+
Total reseñas

{user.reputations.total}

+
Prom. como pro

{user.reputations.average_pro.toFixed(1)}

+
Reseñas como pro

{user.reputations.total_pro}

+
+ )}
{user.professionals && ( - - Profesional - - -
- {user.professionals.profession || '—'} + Perfil profesional + +
+
Profesión

{user.professionals.profession || '—'}

+
Tarifa

{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}

+
Identificación

{user.professionals.identification || '—'}

+
+
- - -
-
-
- Tarifa -

{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}

-
-
- Identificación -

{user.professionals.identification || '—'}

-
-
-
- - )} - - {user.reputations && ( - - - Reputación - - -
-
- Total -

{user.reputations.total}

-
-
- Promedio -

{user.reputations.average.toFixed(1)}

-
-
- Total Pro -

{user.reputations.total_pro}

-
-
- Promedio Pro -

{user.reputations.average_pro.toFixed(1)}

-
+ +
diff --git a/admin/src/app/users/page.tsx b/admin/src/app/users/page.tsx index c606d1c..4d1fc51 100644 --- a/admin/src/app/users/page.tsx +++ b/admin/src/app/users/page.tsx @@ -6,7 +6,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Search } from 'lucide-react'; +import { Search, Eye } from 'lucide-react'; +import Link from 'next/link'; interface User { id: string; @@ -75,6 +76,7 @@ export default function UsersPage() { Ciudad Estado Registro + @@ -91,6 +93,11 @@ export default function UsersPage() { {u.city || '—'} {['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'} {new Date(u.created_at).toLocaleDateString()} + + + + + )) )} diff --git a/admin/src/components/sidebar.tsx b/admin/src/components/sidebar.tsx index cc06549..6ed4fe6 100644 --- a/admin/src/components/sidebar.tsx +++ b/admin/src/components/sidebar.tsx @@ -11,6 +11,8 @@ import { MapPin, Wrench, Settings, + MessageSquare, + Star, LogOut, ChevronLeft, Menu, @@ -27,6 +29,8 @@ const menu = [ { href: '/services', label: 'Servicios', icon: ClipboardList }, { href: '/cities', label: 'Ciudades', icon: MapPin }, { href: '/professions', label: 'Profesiones', icon: Wrench }, + { href: '/comments', label: 'Comentarios', icon: Star }, + { href: '/sms', label: 'SMS', icon: MessageSquare }, { href: '/settings', label: 'Configuración', icon: Settings }, ]; diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 38c9ddf..6bdee9b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 0c1757e..3eda804 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -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() diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index f2f29d7..d66c95b 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -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], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index db9e630..80df0b3 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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 { + 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, }, }; } diff --git a/backend/src/comments/comments.controller.ts b/backend/src/comments/comments.controller.ts index b3cbd0a..944c3e9 100644 --- a/backend/src/comments/comments.controller.ts +++ b/backend/src/comments/comments.controller.ts @@ -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() diff --git a/backend/src/comments/comments.service.ts b/backend/src/comments/comments.service.ts index f350e8d..31f9f5c 100644 --- a/backend/src/comments/comments.service.ts +++ b/backend/src/comments/comments.service.ts @@ -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 }; diff --git a/backend/src/sms/sms.controller.ts b/backend/src/sms/sms.controller.ts new file mode 100644 index 0000000..863acb5 --- /dev/null +++ b/backend/src/sms/sms.controller.ts @@ -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); + } +} diff --git a/backend/src/sms/sms.module.ts b/backend/src/sms/sms.module.ts new file mode 100644 index 0000000..e37298a --- /dev/null +++ b/backend/src/sms/sms.module.ts @@ -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 {}