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
@@ -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 (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-3.5 w-3.5 ${i <= score ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-1 text-xs text-muted-foreground">{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommentsPage() {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Comentarios y Reseñas</h1>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{comments.length}</p><p className="text-sm text-muted-foreground">Total</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{avg}</p><p className="text-sm text-muted-foreground">Promedio</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{fromUser.length}</p><p className="text-sm text-muted-foreground">De usuarios</p></CardContent></Card>
|
||||
<Card><CardContent className="pt-4 text-center"><p className="text-3xl font-bold">{fromPro.length}</p><p className="text-sm text-muted-foreground">De profesionales</p></CardContent></Card>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Últimas reseñas</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Autor</TableHead>
|
||||
<TableHead>Destinatario</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Puntuación</TableHead>
|
||||
<TableHead>Comentario</TableHead>
|
||||
<TableHead>Fecha</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : comments.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground">Sin comentarios</TableCell></TableRow>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.users_comments_author_idTousers?.name || '—'}</TableCell>
|
||||
<TableCell>{c.users_comments_destination_idTousers?.name || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={c.is_from_user ? 'bg-blue-50 text-blue-700' : 'bg-purple-50 text-purple-700'}>
|
||||
{c.is_from_user ? 'Usuario → Pro' : 'Pro → Usuario'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell><Stars score={c.score} /></TableCell>
|
||||
<TableCell className="max-w-48 truncate text-sm text-muted-foreground">{c.content || '—'}</TableCell>
|
||||
<TableCell className="text-sm">{new Date(c.created_at).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
@@ -45,6 +36,7 @@ const statusColors: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
@@ -71,8 +63,8 @@ export default function ServiceDetailPage() {
|
||||
const [service, setService] = useState<Service | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetryCounter((c) => c + 1)} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
if (loading) return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/services" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetryCounter((c) => c + 1)} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!service) return null;
|
||||
|
||||
const s = service;
|
||||
|
||||
return (
|
||||
@@ -119,39 +120,43 @@ export default function ServiceDetailPage() {
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Volver
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">Servicio #{s.id.slice(0, 8)}</h1>
|
||||
<Badge className={statusColors[s.status]} variant="outline">
|
||||
{statusLabels[s.status] || s.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={statusColors[s.status]} variant="outline">
|
||||
{statusLabels[s.status] || s.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Cambiar estado:</span>
|
||||
<Select value={s.status} onValueChange={changeStatus} disabled={updatingStatus}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(statusLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Cliente</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{s.users ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
{s.users.picture && (
|
||||
<img
|
||||
src={s.users.picture}
|
||||
alt={s.users.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<img src={s.users.picture} alt={s.users.name} className="h-10 w-10 rounded-full object-cover" />
|
||||
)}
|
||||
<Link
|
||||
href={`/users/${s.users.id}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
<Link href={`/users/${s.users.id}`} className="text-sm font-medium hover:underline">
|
||||
{s.users.name}
|
||||
</Link>
|
||||
</div>
|
||||
<DetailRow label="Teléfono">
|
||||
{s.users.phone || '—'}
|
||||
</DetailRow>
|
||||
<DetailRow label="Teléfono">{s.users.phone}</DetailRow>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sin información</p>
|
||||
@@ -160,79 +165,52 @@ export default function ServiceDetailPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profesional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Profesional</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{s.professionals ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{s.professionals.users?.picture && (
|
||||
<img
|
||||
src={s.professionals.users.picture}
|
||||
alt={s.professionals.users.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<img src={s.professionals.users.picture} alt={s.professionals.users.name} className="h-10 w-10 rounded-full object-cover" />
|
||||
)}
|
||||
<Link
|
||||
href={`/professionals/${s.professionals.id}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
<Link href={`/professionals/${s.professionals.id}`} className="text-sm font-medium hover:underline">
|
||||
{s.professionals.users?.name || '—'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sin información</p>
|
||||
<p className="text-sm text-muted-foreground">Sin profesional asignado</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detalles del servicio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Detalles del servicio</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DetailRow label="Fecha">
|
||||
{new Date(s.day).toLocaleDateString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Fecha">{new Date(s.day).toLocaleDateString()}</DetailRow>
|
||||
<DetailRow label="Dirección">{s.address}</DetailRow>
|
||||
<DetailRow label="Descripción">{s.description}</DetailRow>
|
||||
<DetailRow label="Tarifa">${s.rate}</DetailRow>
|
||||
<DetailRow label="Preferencia de ubicación">
|
||||
{s.location_preference}
|
||||
</DetailRow>
|
||||
<DetailRow label="Preferencia de ubicación">{s.location_preference}</DetailRow>
|
||||
<DetailRow label="Horario">
|
||||
{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}
|
||||
</DetailRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información adicional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Información adicional</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DetailRow label="Creado">
|
||||
{new Date(s.created_at).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Actualizado">
|
||||
{new Date(s.updated_at).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Creado">{new Date(s.created_at).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Actualizado">{new Date(s.updated_at).toLocaleString()}</DetailRow>
|
||||
<DetailRow label="Cliente puntuó">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star
|
||||
className={`h-4 w-4 ${s.user_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Star className={`h-4 w-4 ${s.user_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
{s.user_scored ? 'Sí' : 'No'}
|
||||
</div>
|
||||
</DetailRow>
|
||||
<DetailRow label="Profesional puntuó">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star
|
||||
className={`h-4 w-4 ${s.professional_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Star className={`h-4 w-4 ${s.professional_scored ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
{s.professional_scored ? 'Sí' : 'No'}
|
||||
</div>
|
||||
</DetailRow>
|
||||
|
||||
@@ -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() {
|
||||
<TableHead>Dirección</TableHead>
|
||||
<TableHead>Tarifa</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center">Cargando...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
@@ -118,6 +121,11 @@ export default function ServicesPage() {
|
||||
{statusLabels[s.status] || s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/services/${s.id}`}>
|
||||
<Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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<SmsConfig | null>(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<SmsConfig>('/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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Proveedor SMS</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Configuración del servicio de envío de mensajes de texto (OTP y notificaciones).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Estado */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Estado del proveedor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-3">
|
||||
{config === null ? (
|
||||
<span className="text-muted-foreground text-sm">Cargando...</span>
|
||||
) : config.configured ? (
|
||||
<>
|
||||
<CheckCircle2 className="text-green-500 h-5 w-5" />
|
||||
<span className="text-sm font-medium">Configurado</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{config.api_key_preview}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="text-destructive h-5 w-5" />
|
||||
<span className="text-sm font-medium text-destructive">No configurado</span>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Configurar API Key */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">API Key</CardTitle>
|
||||
<CardDescription>
|
||||
Encuentra tu API Key en la sección <strong>API Key (envío externo)</strong> del panel de U-Site.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
placeholder="sms_xxxxxxxxxxxxxxxxxxxx"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={saveKey} disabled={savingKey || !newKey.trim()}>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
{savingKey ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
La clave se almacena cifrada. Si ya tienes una configurada, ingresa una nueva para reemplazarla.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prueba de envío */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Probar envío</CardTitle>
|
||||
<CardDescription>
|
||||
Envía un SMS de prueba para verificar que la configuración funciona correctamente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Número de teléfono</label>
|
||||
<Input
|
||||
placeholder="573001234567"
|
||||
value={testPhone}
|
||||
onChange={(e) => setTestPhone(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Formato internacional sin + (ej: 573001234567 para Colombia)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Mensaje</label>
|
||||
<Input
|
||||
placeholder="Hola, este es un mensaje de prueba"
|
||||
value={testMessage}
|
||||
onChange={(e) => setTestMessage(e.target.value.slice(0, 160))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{testMessage.length}/160 caracteres
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={sendTest} disabled={testing || !config?.configured}>
|
||||
<Send className="mr-1 h-4 w-4" />
|
||||
{testing ? 'Enviando...' : 'Enviar SMS de prueba'}
|
||||
</Button>
|
||||
{!config?.configured && (
|
||||
<p className="text-xs text-destructive">Debes configurar la API Key antes de probar.</p>
|
||||
)}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded-md border p-3 text-sm font-mono ${
|
||||
testResult.ok
|
||||
? 'border-green-200 bg-green-50 text-green-800'
|
||||
: 'border-red-200 bg-red-50 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<span>✓ Enviado — ID: {testResult.id}</span>
|
||||
) : (
|
||||
<span>✗ Error: {testResult.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UserDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<UserDetail>(`/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 (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.patch<UserDetail>(`/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 <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||
if (error) return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
||||
<p>{error}</p><Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
|
||||
</div>
|
||||
);
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1 h-4 w-4" /> Editar
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(false)} disabled={saving}>
|
||||
<X className="mr-1 h-4 w-4" /> Cancelar
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<Check className="mr-1 h-4 w-4" /> {saving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información general</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardHeader><CardTitle>Información general</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Nombre</span>
|
||||
{editing
|
||||
? <Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="mt-1" />
|
||||
: <p className="font-medium">{user.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Email</span>
|
||||
<p className="flex items-center gap-2">
|
||||
{user.email || '—'}
|
||||
{user.is_email_verified && (
|
||||
<Badge variant="default" className="bg-green-600">Email verificado</Badge>
|
||||
)}
|
||||
{user.is_email_verified && <Badge className="bg-green-600 text-white">Verificado</Badge>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Teléfono</span>
|
||||
<p className="flex items-center gap-2">
|
||||
{user.phone || '—'}
|
||||
{user.is_phone_verified && (
|
||||
<Badge variant="default" className="bg-green-600">Teléfono verificado</Badge>
|
||||
)}
|
||||
</p>
|
||||
{editing
|
||||
? <Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="mt-1" placeholder="+57300..." />
|
||||
: <p className="flex items-center gap-2">
|
||||
{user.phone || '—'}
|
||||
{user.is_phone_verified && <Badge className="bg-green-600 text-white">Verificado</Badge>}
|
||||
</p>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Ciudad</span>
|
||||
<p>{user.city || '—'}</p>
|
||||
{editing
|
||||
? <Input value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} className="mt-1" />
|
||||
: <p>{user.city || '—'}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Género</span>
|
||||
<p>{user.gender || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Cumpleaños</span>
|
||||
<p>{user.birthday ? new Date(user.birthday).toLocaleDateString() : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Registro</span>
|
||||
<p>{new Date(user.created_at).toLocaleDateString()}</p>
|
||||
@@ -136,69 +136,37 @@ export default function UserDetailPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Estado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Estado</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
||||
<p>
|
||||
<Badge>{PRO_STATE_LABELS[user.pro_state ?? 0] || '—'}</Badge>
|
||||
</p>
|
||||
<p className="mt-1"><Badge>{PRO_STATE_LABELS[user.pro_state ?? 0]}</Badge></p>
|
||||
</div>
|
||||
{user.reputations && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div><span className="text-xs text-muted-foreground">Puntuación prom.</span><p className="text-2xl font-bold">{user.reputations.average.toFixed(1)}</p></div>
|
||||
<div><span className="text-xs text-muted-foreground">Total reseñas</span><p className="text-2xl font-bold">{user.reputations.total}</p></div>
|
||||
<div><span className="text-xs text-muted-foreground">Prom. como pro</span><p className="text-2xl font-bold">{user.reputations.average_pro.toFixed(1)}</p></div>
|
||||
<div><span className="text-xs text-muted-foreground">Reseñas como pro</span><p className="text-2xl font-bold">{user.reputations.total_pro}</p></div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{user.professionals && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profesional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{user.professionals.profession || '—'}</span>
|
||||
<CardHeader><CardTitle>Perfil profesional</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><span className="text-muted-foreground">Profesión</span><p>{user.professionals.profession || '—'}</p></div>
|
||||
<div><span className="text-muted-foreground">Tarifa</span><p>{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}</p></div>
|
||||
<div><span className="text-muted-foreground">Identificación</span><p>{user.professionals.identification || '—'}</p></div>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Link href={`/professionals/${user.professionals.id}`}>
|
||||
<Button variant="outline" size="sm">Ver detalle</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tarifa</span>
|
||||
<p>{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Identificación</span>
|
||||
<p>{user.professionals.identification || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{user.reputations && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reputación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Total</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Promedio</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.average.toFixed(1)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Total Pro</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.total_pro}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Promedio Pro</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.average_pro.toFixed(1)}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">Ver perfil profesional completo</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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() {
|
||||
<TableHead>Ciudad</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead>Registro</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -91,6 +93,11 @@ export default function UsersPage() {
|
||||
<TableCell>{u.city || '—'}</TableCell>
|
||||
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell>
|
||||
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/users/${u.id}`}>
|
||||
<Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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