Compare commits
34
Commits
3fd7a33fc1
..
main
@@ -10,10 +10,18 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Pencil, X, Check, ShieldCheck, ShieldAlert, ExternalLink, Copy } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft, Pencil, X, Check, ShieldCheck, ShieldAlert,
|
||||
ExternalLink, Copy, ChevronLeft, ChevronRight, Calendar,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface User { id: string; name: string; email?: string; phone?: string; city?: string; picture?: string; }
|
||||
interface Schedule { id: string; day_of_week: number; enabled: boolean; range1_hour1?: string; range1_hour2?: string; }
|
||||
interface User { id: string; name: string; email?: string; phone?: string; city?: string; picture?: string; gender?: string | null; }
|
||||
// DB convention: day_of_week 0=Mon … 6=Sun
|
||||
interface Schedule {
|
||||
id: string; day_of_week: number; enabled: boolean; continuous_day?: boolean;
|
||||
range1_hour1?: string; range1_hour2?: string;
|
||||
range2_hour1?: string; range2_hour2?: string;
|
||||
}
|
||||
interface Specialization { id: string; name: string; }
|
||||
interface PaymentMethod { id: string; nequi: boolean; datafono: boolean; transferencia: boolean; }
|
||||
interface Professional {
|
||||
@@ -23,13 +31,165 @@ interface Professional {
|
||||
rate?: number; average_score?: number;
|
||||
identification_picture?: string; certificate_picture?: string; banner_picture?: string;
|
||||
users?: User; schedules?: Schedule[];
|
||||
specializations?: Specialization[]; payment_methods?: PaymentMethod[];
|
||||
specializations?: Specialization[];
|
||||
payment_methods?: PaymentMethod | PaymentMethod[] | null;
|
||||
}
|
||||
interface ServiceItem {
|
||||
id: string; description?: string; rate?: number; status: string;
|
||||
day: string; range1_hour1?: string; range1_hour2?: string;
|
||||
users?: { id: string; name: string; phone?: string; picture?: string } | null;
|
||||
}
|
||||
interface Service { id: string; description?: string; rate?: number; status: string; day: string; }
|
||||
|
||||
const DAY_NAMES = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
|
||||
// DB: 0=Mon … 6=Sun
|
||||
const DAY_NAMES = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
||||
const PAYMENT_LABELS: Record<string, string> = { nequi: 'Nequi', datafono: 'Datáfono', transferencia: 'Transferencia' };
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'Pendiente', accepted: 'Aceptado', active: 'Activo',
|
||||
completed: 'Completado', cancelled: 'Cancelado', denied: 'Rechazado', self_booked: 'Bloqueado',
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
accepted: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
active: 'bg-green-100 text-green-800 border-green-200',
|
||||
completed: 'bg-emerald-700 text-white border-emerald-800',
|
||||
cancelled: 'bg-red-100 text-red-800 border-red-200',
|
||||
denied: 'bg-red-200 text-red-900 border-red-300',
|
||||
self_booked: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
};
|
||||
|
||||
function fmtTime(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
// raw may be ISO date-time string from prisma (Date stored as time)
|
||||
const match = raw.match(/T(\d{2}:\d{2})/);
|
||||
if (match) return match[1];
|
||||
// already "HH:MM"
|
||||
if (/^\d{2}:\d{2}/.test(raw)) return raw.slice(0, 5);
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ── Compact month calendar ──────────────────────────────────────────────────
|
||||
function MonthCalendar({
|
||||
services,
|
||||
selectedDay,
|
||||
onSelect,
|
||||
}: {
|
||||
services: ServiceItem[];
|
||||
selectedDay: string | null;
|
||||
onSelect: (day: string | null) => void;
|
||||
}) {
|
||||
const [cursor, setCursor] = useState(() => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
});
|
||||
|
||||
const year = cursor.getFullYear();
|
||||
const month = cursor.getMonth();
|
||||
const firstDow = new Date(year, month, 1).getDay(); // 0=Sun
|
||||
// shift so Mon=0
|
||||
const startOffset = (firstDow + 6) % 7;
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const servicesByDay = new Map<string, string[]>();
|
||||
for (const s of services) {
|
||||
const key = s.day.slice(0, 10);
|
||||
if (!servicesByDay.has(key)) servicesByDay.set(key, []);
|
||||
servicesByDay.get(key)!.push(s.status);
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
'Enero','Febrero','Marzo','Abril','Mayo','Junio',
|
||||
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre',
|
||||
];
|
||||
|
||||
function dotColor(statuses: string[]): string {
|
||||
if (statuses.includes('active')) return 'bg-green-500';
|
||||
if (statuses.includes('accepted')) return 'bg-blue-500';
|
||||
if (statuses.includes('pending')) return 'bg-amber-500';
|
||||
if (statuses.includes('self_booked')) return 'bg-orange-400';
|
||||
if (statuses.includes('completed')) return 'bg-emerald-600';
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array(startOffset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => setCursor(new Date(year, month - 1, 1))} className="p-1 hover:bg-muted rounded">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="font-semibold text-sm">{monthNames[month]} {year}</span>
|
||||
<button onClick={() => setCursor(new Date(year, month + 1, 1))} className="p-1 hover:bg-muted rounded">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5 text-xs">
|
||||
{['L','M','X','J','V','S','D'].map((d) => (
|
||||
<div key={d} className="text-center text-muted-foreground font-medium py-1">{d}</div>
|
||||
))}
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={`e-${i}`} />;
|
||||
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const statuses = servicesByDay.get(key);
|
||||
const isSelected = selectedDay === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelect(isSelected ? null : key)}
|
||||
className={`relative flex flex-col items-center justify-center rounded py-1 text-xs font-medium transition-colors
|
||||
${isSelected ? 'bg-primary text-primary-foreground' : statuses ? 'hover:bg-muted bg-muted/50' : 'hover:bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{day}
|
||||
{statuses && !isSelected && (
|
||||
<span className={`absolute bottom-0.5 h-1.5 w-1.5 rounded-full ${dotColor(statuses)}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedDay && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Filtrando: {selectedDay} —{' '}
|
||||
<button className="text-primary underline" onClick={() => onSelect(null)}>ver todos</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schedule display helpers ────────────────────────────────────────────────
|
||||
function ScheduleRow({ s }: { s: Schedule }) {
|
||||
if (!s.enabled) {
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
|
||||
<TableCell className="text-muted-foreground">Descanso</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
let hours = '';
|
||||
if (s.continuous_day) {
|
||||
hours = `${fmtTime(s.range1_hour1)} – ${fmtTime(s.range2_hour2)} (continuo)`;
|
||||
} else {
|
||||
const r1 = s.range1_hour1 ? `${fmtTime(s.range1_hour1)} – ${fmtTime(s.range1_hour2)}` : '';
|
||||
const r2 = s.range2_hour1 ? `${fmtTime(s.range2_hour1)} – ${fmtTime(s.range2_hour2)}` : '';
|
||||
hours = [r1, r2].filter(Boolean).join(' · ') || '—';
|
||||
}
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
|
||||
<TableCell>{hours}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
interface ProForm { profession: string; identification: string; address: string; rate: string; rethus_code: string; }
|
||||
interface UserForm { name: string; email: string; phone: string; city: string; }
|
||||
|
||||
@@ -37,16 +197,18 @@ export default function ProfessionalDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id;
|
||||
const [professional, setProfessional] = useState<Professional | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
||||
|
||||
// Edit state
|
||||
const [editingPro, setEditingPro] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(false);
|
||||
const [savingPro, setSavingPro] = useState(false);
|
||||
const [savingUser, setSavingUser] = useState(false);
|
||||
const [validatingRethus, setValidatingRethus] = useState(false);
|
||||
const [rethusResult, setRethusResult] = useState<any>(null);
|
||||
const [loadingRethus, setLoadingRethus] = useState(false);
|
||||
const [proForm, setProForm] = useState<ProForm>({ profession: '', identification: '', address: '', rate: '', rethus_code: '' });
|
||||
const [userForm, setUserForm] = useState<UserForm>({ name: '', email: '', phone: '', city: '' });
|
||||
|
||||
@@ -56,7 +218,7 @@ export default function ProfessionalDetailPage() {
|
||||
setError(null);
|
||||
Promise.all([
|
||||
api.get<Professional>(`/professionals/${id}`),
|
||||
api.get<{ data: Service[] }>('/services?page=1&limit=50'),
|
||||
api.get<{ data: ServiceItem[]; meta: any }>(`/services/admin/professional/${id}?limit=200`),
|
||||
])
|
||||
.then(([prof, svcRes]) => {
|
||||
setProfessional(prof);
|
||||
@@ -73,7 +235,7 @@ export default function ProfessionalDetailPage() {
|
||||
phone: prof.users?.phone || '',
|
||||
city: prof.users?.city || '',
|
||||
});
|
||||
setServices(svcRes.data.filter((s: any) => s.professional_id === id));
|
||||
setServices(svcRes.data);
|
||||
})
|
||||
.catch(() => setError('Error al cargar el profesional'))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -101,6 +263,19 @@ export default function ProfessionalDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const consultRethus = async () => {
|
||||
setLoadingRethus(true);
|
||||
setRethusResult(null);
|
||||
try {
|
||||
const data = await api.get<any>(`/verifik/rethus/professional/${id}`);
|
||||
setRethusResult(data);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al consultar RETHUS');
|
||||
} finally {
|
||||
setLoadingRethus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateRethus = async () => {
|
||||
setValidatingRethus(true);
|
||||
try {
|
||||
@@ -163,6 +338,13 @@ export default function ProfessionalDetailPage() {
|
||||
);
|
||||
|
||||
const user = professional.users;
|
||||
const visibleServices = selectedDay
|
||||
? services.filter((s) => s.day.slice(0, 10) === selectedDay)
|
||||
: services;
|
||||
|
||||
const sortedSchedules = professional.schedules
|
||||
? [...professional.schedules].sort((a, b) => a.day_of_week - b.day_of_week)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -172,7 +354,7 @@ export default function ProfessionalDetailPage() {
|
||||
<Link href="/professionals"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{user?.name || 'Profesional'}</h1>
|
||||
<p className="text-sm text-muted-foreground">ID profesional: {id}</p>
|
||||
<p className="text-sm text-muted-foreground">ID: {id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -204,7 +386,7 @@ export default function ProfessionalDetailPage() {
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditingUser(false); }} disabled={savingUser}>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditingUser(false)} disabled={savingUser}>
|
||||
<X className="mr-1 h-4 w-4" /> Cancelar
|
||||
</Button>
|
||||
<Button size="sm" onClick={saveUser} disabled={savingUser}>
|
||||
@@ -237,6 +419,10 @@ export default function ProfessionalDetailPage() {
|
||||
? <Input value={userForm.city} onChange={(e) => setUserForm({ ...userForm, city: e.target.value })} />
|
||||
: <p className="font-medium">{user?.city || '—'}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">Género</span>
|
||||
<p className="font-medium capitalize">{user?.gender || '—'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -326,7 +512,6 @@ export default function ProfessionalDetailPage() {
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(professional.rethus_code!); toast.success('Código copiado'); }}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Copiar código"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -340,7 +525,6 @@ export default function ProfessionalDetailPage() {
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(professional.identification!); toast.success('Cédula copiada'); }}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Copiar cédula"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -349,29 +533,45 @@ export default function ProfessionalDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Abrir portal RETHUS (Minsalud)
|
||||
<Button size="sm" variant="outline" onClick={consultRethus} disabled={loadingRethus}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{loadingRethus ? 'Consultando...' : 'Consultar en RETHUS'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" /> Portal Minsalud
|
||||
</Button>
|
||||
{!professional.rethus_validated && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={validateRethus}
|
||||
disabled={validatingRethus}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Button size="sm" onClick={validateRethus} disabled={validatingRethus} className="bg-green-600 hover:bg-green-700 text-white">
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{validatingRethus ? 'Guardando...' : 'Marcar como validado'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{rethusResult && (
|
||||
<div className="mt-3 rounded-lg border bg-muted/40 p-4 space-y-2 text-sm">
|
||||
<p className="font-semibold">{rethusResult.fullName || `${rethusResult.firstName} ${rethusResult.lastName}`}</p>
|
||||
<p><span className="text-muted-foreground">Estado: </span>
|
||||
<span className={rethusResult.rethus?.status?.includes('ACTIVO') ? 'text-green-600 font-medium' : 'text-destructive font-medium'}>
|
||||
{rethusResult.rethus?.status || '—'}
|
||||
</span>
|
||||
</p>
|
||||
{rethusResult.rethus?.academic?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground font-medium">Títulos registrados:</p>
|
||||
{rethusResult.rethus.academic.map((a: any, i: number) => (
|
||||
<div key={i} className="pl-3 border-l-2 border-muted-foreground/30">
|
||||
<p className="font-medium">{a.profession}</p>
|
||||
<p className="text-xs text-muted-foreground">{a.type} · {a.originDegree} · {a.startDate}</p>
|
||||
{a.entity && <p className="text-xs text-muted-foreground">{a.entity}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!professional.rethus_validated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copia el código o cédula, búscalo en el portal de Minsalud y una vez verificado haz clic en "Marcar como validado".
|
||||
Consulta automáticamente con Verifik o búscalo en el portal de Minsalud. Una vez verificado, haz clic en "Marcar como validado".
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -381,69 +581,62 @@ export default function ProfessionalDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documentos subidos */}
|
||||
{(professional.identification_picture || professional.certificate_picture || professional.banner_picture) && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{professional.identification_picture && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Foto de cédula</p>
|
||||
{professional.identification_picture.match(/\.(jpg|jpeg|png|webp)$/i) ? (
|
||||
<a href={professional.identification_picture} target="_blank" rel="noreferrer">
|
||||
<img src={professional.identification_picture} alt="Cédula" className="rounded-lg border object-cover w-full max-h-48" />
|
||||
</a>
|
||||
{/* Documentos */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{(['identification_picture', 'certificate_picture', 'banner_picture'] as const).map((field) => {
|
||||
const labels: Record<string, string> = {
|
||||
identification_picture: 'Foto de cédula',
|
||||
certificate_picture: 'Certificado / Diploma',
|
||||
banner_picture: 'Foto de perfil / Banner',
|
||||
};
|
||||
const url = professional[field];
|
||||
const isImage = url && /\.(jpg|jpeg|png|webp|gif)(\?|$)/i.test(url);
|
||||
return (
|
||||
<div key={field} className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{labels[field]}</p>
|
||||
{url ? (
|
||||
isImage ? (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<img src={url} alt={labels[field]} className="rounded-lg border object-cover w-full max-h-48" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
|
||||
<ExternalLink className="h-4 w-4" /> Ver documento
|
||||
</a>
|
||||
)
|
||||
) : (
|
||||
<a href={professional.identification_picture} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
|
||||
<ExternalLink className="h-4 w-4" /> Ver documento
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground italic">No subido</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{professional.certificate_picture && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Certificado / Diploma</p>
|
||||
{professional.certificate_picture.match(/\.(jpg|jpeg|png|webp)$/i) ? (
|
||||
<a href={professional.certificate_picture} target="_blank" rel="noreferrer">
|
||||
<img src={professional.certificate_picture} alt="Certificado" className="rounded-lg border object-cover w-full max-h-48" />
|
||||
</a>
|
||||
) : (
|
||||
<a href={professional.certificate_picture} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
|
||||
<ExternalLink className="h-4 w-4" /> Ver documento
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{professional.banner_picture && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Foto de perfil / Banner</p>
|
||||
<a href={professional.banner_picture} target="_blank" rel="noreferrer">
|
||||
<img src={professional.banner_picture} alt="Banner" className="rounded-lg border object-cover w-full max-h-48" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Métodos de pago */}
|
||||
{professional.payment_methods && professional.payment_methods.length > 0 && (
|
||||
{professional.payment_methods && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Métodos de pago aceptados</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['nequi', 'datafono', 'transferencia']
|
||||
.filter((k) => (professional.payment_methods![0] as any)[k])
|
||||
.map((k) => <Badge key={k} variant="outline">{PAYMENT_LABELS[k]}</Badge>)}
|
||||
</div>
|
||||
{(() => {
|
||||
const pm = Array.isArray(professional.payment_methods)
|
||||
? (professional.payment_methods as PaymentMethod[])[0]
|
||||
: professional.payment_methods as PaymentMethod;
|
||||
const active = pm ? ['nequi', 'datafono', 'transferencia'].filter((k) => (pm as any)[k]) : [];
|
||||
return active.length > 0
|
||||
? <div className="flex flex-wrap gap-2">{active.map((k) => <Badge key={k} variant="outline">{PAYMENT_LABELS[k]}</Badge>)}</div>
|
||||
: <p className="text-sm text-muted-foreground">Ninguno configurado</p>;
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Horarios */}
|
||||
{professional.schedules && professional.schedules.length > 0 && (
|
||||
{sortedSchedules.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Horarios</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>Horarios configurados</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -453,16 +646,7 @@ export default function ProfessionalDetailPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{professional.schedules.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
|
||||
<TableCell>
|
||||
{s.enabled
|
||||
? [s.range1_hour1, s.range1_hour2].filter(Boolean).join(' – ') || '—'
|
||||
: <span className="text-muted-foreground">Descanso</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{sortedSchedules.map((s) => <ScheduleRow key={s.id} s={s} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
@@ -481,39 +665,102 @@ export default function ProfessionalDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Servicios */}
|
||||
{services.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Servicios recientes</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fecha</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
<TableHead>Tarifa</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{new Date(s.day).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{s.description || '—'}</TableCell>
|
||||
<TableCell>{s.rate != null ? `$${s.rate}` : '—'}</TableCell>
|
||||
<TableCell>{s.status}</TableCell>
|
||||
</TableRow>
|
||||
{/* Calendario + Servicios agendados */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Servicios agendados
|
||||
<Badge variant="outline" className="ml-auto text-xs">{services.length} total</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Calendar */}
|
||||
<div className="lg:w-64 shrink-0">
|
||||
<MonthCalendar
|
||||
services={services}
|
||||
selectedDay={selectedDay}
|
||||
onSelect={setSelectedDay}
|
||||
/>
|
||||
{/* Legend */}
|
||||
<div className="mt-4 space-y-1 text-xs text-muted-foreground">
|
||||
{[
|
||||
{ color: 'bg-amber-500', label: 'Pendiente' },
|
||||
{ color: 'bg-blue-500', label: 'Aceptado' },
|
||||
{ color: 'bg-green-500', label: 'Activo' },
|
||||
{ color: 'bg-emerald-600', label: 'Completado' },
|
||||
{ color: 'bg-orange-400', label: 'Bloqueado' },
|
||||
{ color: 'bg-gray-400', label: 'Cancelado / Rechazado' },
|
||||
].map(({ color, label }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${color}`} />
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aprobar / Rechazar */}
|
||||
{/* Services table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{visibleServices.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
{selectedDay ? 'Sin servicios este día.' : 'Sin servicios registrados.'}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Fecha</TableHead>
|
||||
<TableHead>Hora</TableHead>
|
||||
<TableHead>Cliente</TableHead>
|
||||
<TableHead>Tarifa</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleServices.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(s.day).toLocaleDateString('es-CO', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-sm">
|
||||
{fmtTime(s.range1_hour1) || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.users ? (
|
||||
<div>
|
||||
<p className="font-medium text-sm">{s.users.name}</p>
|
||||
{s.users.phone && <p className="text-xs text-muted-foreground">{s.users.phone}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{s.rate != null ? `$${s.rate}` : '—'}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center rounded border px-2 py-0.5 text-xs font-medium ${STATUS_CLASS[s.status] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
|
||||
{STATUS_LABEL[s.status] || s.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-48 truncate">
|
||||
{s.description || '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Approve / Deny bottom CTA */}
|
||||
{!professional.is_active && (
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button onClick={approve}>Aprobar profesional</Button>
|
||||
<Button onClick={approve} className="bg-green-600 hover:bg-green-700 text-white">Aprobar profesional</Button>
|
||||
<Button variant="destructive" onClick={deny}>Rechazar solicitud</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Save, ExternalLink, Copy, FileText, Shield, Map,
|
||||
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server,
|
||||
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server, Link,
|
||||
} from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
@@ -57,10 +57,20 @@ export default function SettingsPage() {
|
||||
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
|
||||
const [savingPolicy, setSavingPolicy] = useState<string | null>(null);
|
||||
|
||||
// Verifik
|
||||
const [verifikConnected, setVerifikConnected] = useState(false);
|
||||
const [verifikEmail, setVerifikEmail] = useState('');
|
||||
const [verifikOtp, setVerifikOtp] = useState('');
|
||||
const [verifikOtpSent, setVerifikOtpSent] = useState(false);
|
||||
const [verifikLoading, setVerifikLoading] = useState(false);
|
||||
|
||||
// SMTP
|
||||
const [smtp, setSmtp] = useState<SmtpConfig>({ host: '', port: '587', user: '', pass: '', from: '' });
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const [savingSmtp, setSavingSmtp] = useState(false);
|
||||
const [testEmailTo, setTestEmailTo] = useState('');
|
||||
const [sendingTest, setSendingTest] = useState(false);
|
||||
const [mailLogs, setMailLogs] = useState<{ id: string; recipient: string; body: string; status: string; error?: string; created_at: string }[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -69,12 +79,16 @@ export default function SettingsPage() {
|
||||
api.get<{ configured: boolean }>('/settings/maps-key'),
|
||||
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
|
||||
api.get<SmtpConfig | null>('/settings/smtp').catch(() => null),
|
||||
api.get<{ connected: boolean }>('/verifik/status').catch(() => ({ connected: false })),
|
||||
api.get<{ data: any[] }>('/settings/message-logs?limit=20&channel=email').catch(() => ({ data: [] })),
|
||||
])
|
||||
.then(([globalData, mapsData, policiesData, smtpData]) => {
|
||||
.then(([globalData, mapsData, policiesData, smtpData, verifikStatus, logsData]) => {
|
||||
setGlobal(globalData || {});
|
||||
setMapsConfigured(mapsData.configured);
|
||||
setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' });
|
||||
if (smtpData) setSmtp({ host: smtpData.host || '', port: smtpData.port || '587', user: smtpData.user || '', pass: smtpData.pass || '', from: smtpData.from || '' });
|
||||
setVerifikConnected(verifikStatus.connected);
|
||||
setMailLogs(logsData.data || []);
|
||||
})
|
||||
.catch(() => toast.error('Error al cargar configuración'))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -134,6 +148,62 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
if (!testEmailTo.trim()) return toast.error('Ingresa un correo destino');
|
||||
setSendingTest(true);
|
||||
try {
|
||||
await api.post('/settings/test-email', { to: testEmailTo.trim() });
|
||||
toast.success(`Correo de prueba enviado a ${testEmailTo}`);
|
||||
load(); // refresh logs
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al enviar correo de prueba');
|
||||
} finally {
|
||||
setSendingTest(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendVerifikOtp = async () => {
|
||||
if (!verifikEmail.trim()) return toast.error('Ingresa tu email de Verifik');
|
||||
setVerifikLoading(true);
|
||||
try {
|
||||
await api.post('/verifik/send-otp', { email: verifikEmail.trim() });
|
||||
setVerifikOtpSent(true);
|
||||
toast.success('OTP enviado a tu correo');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al enviar OTP');
|
||||
} finally {
|
||||
setVerifikLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmVerifikOtp = async () => {
|
||||
if (!verifikOtp.trim()) return toast.error('Ingresa el código OTP');
|
||||
setVerifikLoading(true);
|
||||
try {
|
||||
await api.post('/verifik/confirm', { email: verifikEmail.trim(), otp: verifikOtp.trim() });
|
||||
setVerifikConnected(true);
|
||||
setVerifikOtpSent(false);
|
||||
setVerifikOtp('');
|
||||
toast.success('Verifik conectado correctamente');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al confirmar OTP');
|
||||
} finally {
|
||||
setVerifikLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshVerifikToken = async () => {
|
||||
setVerifikLoading(true);
|
||||
try {
|
||||
await api.post('/verifik/refresh', {});
|
||||
toast.success('Token renovado');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al renovar token');
|
||||
} finally {
|
||||
setVerifikLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = (key: string) => {
|
||||
navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`);
|
||||
toast.success('Enlace copiado');
|
||||
@@ -245,6 +315,37 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Funcionalidades de la app</CardTitle>
|
||||
<CardDescription>Activa o desactiva funciones para todos los usuarios.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[
|
||||
{ key: 'domicilios', label: 'Servicio a domicilio', description: 'Permite que los profesionales ofrezcan y los usuarios soliciten servicios a domicilio.' },
|
||||
{ key: 'tarifas', label: 'Mostrar tarifas', description: 'Muestra las tarifas de los profesionales en su perfil y en las búsquedas.' },
|
||||
].map(({ key, label, description }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-4 py-1">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => setG(key, !global[key])}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${global[key] ? 'bg-[#42A4EF]' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${global[key] ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<Badge variant={global[key] ? 'default' : 'secondary'} className={global[key] ? 'bg-[#42A4EF]' : ''}>
|
||||
{global[key] ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Modo mantenimiento</CardTitle>
|
||||
@@ -263,6 +364,32 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Solicitudes de profesional */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Clock size={18} className="text-muted-foreground" />
|
||||
Solicitudes de profesional
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">Días de espera para reintentar tras rechazo</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-28"
|
||||
value={global.rejection_wait_days ?? 7}
|
||||
onChange={(e) => setG('rejection_wait_days', Number(e.target.value))}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">días</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Con 0 el usuario puede reintentar inmediatamente.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={saveGlobal} disabled={savingGlobal}>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
{savingGlobal ? 'Guardando...' : 'Guardar ajustes generales'}
|
||||
@@ -392,6 +519,130 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Test de correo + Logs ── */}
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Mail size={18} className="text-muted-foreground" />
|
||||
Probar configuración SMTP
|
||||
</CardTitle>
|
||||
<CardDescription>Envía un correo de prueba para verificar que el servidor SMTP funciona.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="destino@ejemplo.com"
|
||||
value={testEmailTo}
|
||||
onChange={(e) => setTestEmailTo(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={sendTestEmail} disabled={sendingTest}>
|
||||
{sendingTest ? 'Enviando...' : 'Enviar prueba'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mailLogs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Últimos 20 correos enviados</p>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 font-medium">Destinatario</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Asunto</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Estado</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Fecha</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mailLogs.map((log) => (
|
||||
<tr key={log.id} className="border-t hover:bg-muted/30">
|
||||
<td className="px-3 py-2 text-muted-foreground">{log.recipient}</td>
|
||||
<td className="px-3 py-2 max-w-[200px] truncate">{log.body}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
log.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{log.status === 'sent' ? '✓ enviado' : '✗ error'}
|
||||
</span>
|
||||
{log.error && <p className="text-xs text-destructive mt-0.5 truncate max-w-[180px]" title={log.error}>{log.error}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{new Date(log.created_at).toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Verifik ── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Verifik — Validación de profesionales</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Link size={18} className="text-muted-foreground" />
|
||||
Cuenta Verifik
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Permite consultar RETHUS (registro de profesionales de salud) directamente desde el perfil de cada profesional.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{verifikConnected
|
||||
? <><CheckCircle2 className="text-green-500 h-5 w-5" /><span className="text-sm font-medium text-green-600">Conectado</span></>
|
||||
: <><XCircle className="text-destructive h-5 w-5" /><span className="text-sm font-medium text-destructive">No conectado</span></>}
|
||||
</div>
|
||||
|
||||
{!verifikOtpSent ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="tu@email.com (cuenta Verifik)"
|
||||
value={verifikEmail}
|
||||
onChange={(e) => setVerifikEmail(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={sendVerifikOtp} disabled={verifikLoading || !verifikEmail.trim()}>
|
||||
{verifikLoading ? 'Enviando...' : 'Enviar OTP'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Ingresa el código que llegó a <strong>{verifikEmail}</strong></p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Código OTP"
|
||||
value={verifikOtp}
|
||||
onChange={(e) => setVerifikOtp(e.target.value)}
|
||||
className="w-40 font-mono"
|
||||
/>
|
||||
<Button onClick={confirmVerifikOtp} disabled={verifikLoading}>
|
||||
{verifikLoading ? 'Confirmando...' : 'Confirmar'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setVerifikOtpSent(false)}>Cancelar</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verifikConnected && (
|
||||
<Button variant="outline" size="sm" onClick={refreshVerifikToken} disabled={verifikLoading}>
|
||||
{verifikLoading ? 'Renovando...' : 'Renovar token (30 días)'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── Documentos legales ── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Documentos legales</h2>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { ArrowLeft, Pencil, X, Check } from 'lucide-react';
|
||||
import { ArrowLeft, Pencil, X, Check, ShieldOff, Shield, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Professional { id: string; profession?: string; rate?: number; identification?: string; }
|
||||
@@ -16,7 +16,7 @@ interface Reputation { total: number; average: number; total_pro: number; averag
|
||||
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;
|
||||
is_phone_verified?: boolean; is_active?: boolean; pro_state?: number; created_at: string;
|
||||
professionals?: Professional | null; reputations?: Reputation | null;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,16 @@ interface Form { name: string; city: string; phone: string; gender: string; }
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const id = params?.id;
|
||||
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 [toggling, setToggling] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [form, setForm] = useState<Form>({ name: '', city: '', phone: '', gender: '' });
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -71,6 +75,34 @@ export default function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBlock = async () => {
|
||||
if (!user) return;
|
||||
setToggling(true);
|
||||
try {
|
||||
const updated = await api.patch<UserDetail>(`/users/${id}`, { is_active: !user.is_active });
|
||||
setUser(updated);
|
||||
toast.success(updated.is_active ? 'Usuario desbloqueado' : 'Usuario bloqueado');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al cambiar estado');
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/users/${id}`);
|
||||
toast.success('Usuario eliminado');
|
||||
router.push('/users');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'No se puede eliminar: el usuario tiene datos asociados');
|
||||
setConfirmDelete(false);
|
||||
} finally {
|
||||
setDeleting(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 gap-2">
|
||||
@@ -81,32 +113,72 @@ export default function UserDetailPage() {
|
||||
if (!user) return null;
|
||||
|
||||
const proState = user.pro_state ?? 0;
|
||||
const isActive = user.is_active !== false;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
{!isActive && <Badge variant="destructive">Bloqueado</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">ID: {user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1 h-4 w-4" /> Editar
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Bloquear / Desbloquear */}
|
||||
<Button
|
||||
variant={isActive ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={toggleBlock}
|
||||
disabled={toggling}
|
||||
className={isActive ? 'border-orange-300 text-orange-600 hover:bg-orange-50' : ''}
|
||||
>
|
||||
{isActive
|
||||
? <><ShieldOff className="mr-1 h-4 w-4" />{toggling ? 'Bloqueando...' : 'Bloquear'}</>
|
||||
: <><Shield className="mr-1 h-4 w-4" />{toggling ? 'Desbloqueando...' : 'Desbloquear'}</>
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(false); setForm({ name: user.name, city: user.city || '', phone: user.phone || '', gender: user.gender || '' }); }} disabled={saving}>
|
||||
<X className="mr-1 h-4 w-4" /> Cancelar
|
||||
|
||||
{/* Eliminar */}
|
||||
{!confirmDelete ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(true)}
|
||||
className="border-red-300 text-red-600 hover:bg-red-50">
|
||||
<Trash2 className="mr-1 h-4 w-4" /> Eliminar
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<Check className="mr-1 h-4 w-4" /> {saving ? 'Guardando...' : 'Guardar'}
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded-md border border-red-300 bg-red-50 px-3 py-1.5">
|
||||
<span className="text-xs text-red-600 font-medium mr-1">¿Confirmar?</span>
|
||||
<Button variant="destructive" size="sm" onClick={deleteUser} disabled={deleting} className="h-7 px-2 text-xs">
|
||||
{deleting ? 'Eliminando...' : 'Sí, eliminar'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmDelete(false)} className="h-7 px-2 text-xs">
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editar */}
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1 h-4 w-4" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(false); setForm({ name: user.name, city: user.city || '', phone: user.phone || '', gender: user.gender || '' }); }} 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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
@@ -170,6 +242,14 @@ export default function UserDetailPage() {
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Estado</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Cuenta</span>
|
||||
<div className="mt-2">
|
||||
<Badge variant={isActive ? 'default' : 'destructive'}>
|
||||
{isActive ? 'Activo' : 'Bloqueado'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
||||
<div className="mt-2">
|
||||
@@ -201,7 +281,7 @@ export default function UserDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Perfil profesional (solo lectura, con link) */}
|
||||
{/* Perfil profesional */}
|
||||
{user.professionals && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Eye } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface User {
|
||||
@@ -15,6 +16,7 @@ interface User {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
is_active?: boolean;
|
||||
pro_state?: number;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -91,7 +93,12 @@ export default function UsersPage() {
|
||||
<TableCell>{u.email || '—'}</TableCell>
|
||||
<TableCell>{u.phone || '—'}</TableCell>
|
||||
<TableCell>{u.city || '—'}</TableCell>
|
||||
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell>
|
||||
<TableCell>
|
||||
{u.is_active === false
|
||||
? <Badge variant="destructive">Bloqueado</Badge>
|
||||
: <span>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/users/${u.id}`}>
|
||||
|
||||
@@ -24,7 +24,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
throw new ApiError(res.status, body.message || 'Error de servidor');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
|
||||
+4
-1
@@ -12,5 +12,8 @@ COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.js ./
|
||||
COPY --from=builder /app/entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
CMD ["sh", "entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
|
||||
DATABASE_URL="postgresql://prosapp_user:ProsappPass123!@wkuvmdsy39relyhnugk87eqb:5432/prosapp"
|
||||
export DATABASE_URL
|
||||
|
||||
PRISMA="node_modules/.bin/prisma"
|
||||
|
||||
echo "=== ProsApp Backend Starting ==="
|
||||
|
||||
# Baseline: mark existing migrations as applied without running their SQL.
|
||||
# This creates _prisma_migrations if it doesn't exist, then records each
|
||||
# migration so prisma migrate deploy skips them and only runs new ones.
|
||||
echo "Baselining existing migrations..."
|
||||
$PRISMA migrate resolve --applied "0000_init" 2>/dev/null || true
|
||||
|
||||
# Apply only new/pending migrations (0002, 0003, ...)
|
||||
echo "Applying pending migrations..."
|
||||
$PRISMA migrate deploy
|
||||
|
||||
echo "Starting server..."
|
||||
exec node dist/main.js
|
||||
@@ -11,6 +11,7 @@
|
||||
"start:prod": "node dist/main",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:pull": "prisma db pull",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
Loaded Prisma config from prisma.config.js.
|
||||
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "service_location" AS ENUM ('office', 'delivery');
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "service_location" AS ENUM ('office', 'delivery');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "service_status" AS ENUM ('pending', 'accepted', 'denied', 'active', 'cancelled', 'completed', 'self_booked');
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "service_status" AS ENUM ('pending', 'accepted', 'denied', 'active', 'cancelled', 'completed', 'self_booked');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "chats" (
|
||||
CREATE TABLE IF NOT EXISTS "chats" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"professional_id" UUID NOT NULL,
|
||||
@@ -19,8 +22,7 @@ CREATE TABLE "chats" (
|
||||
CONSTRAINT "chats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cities" (
|
||||
CREATE TABLE IF NOT EXISTS "cities" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"region_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
@@ -30,8 +32,7 @@ CREATE TABLE "cities" (
|
||||
CONSTRAINT "cities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "comments" (
|
||||
CREATE TABLE IF NOT EXISTS "comments" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"author_id" UUID NOT NULL,
|
||||
"destination_id" UUID NOT NULL,
|
||||
@@ -44,16 +45,14 @@ CREATE TABLE "comments" (
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
CREATE TABLE IF NOT EXISTS "countries" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "messages" (
|
||||
CREATE TABLE IF NOT EXISTS "messages" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"chat_id" UUID NOT NULL,
|
||||
"sender_id" UUID NOT NULL,
|
||||
@@ -63,8 +62,7 @@ CREATE TABLE "messages" (
|
||||
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payment_methods" (
|
||||
CREATE TABLE IF NOT EXISTS "payment_methods" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"nequi" BOOLEAN DEFAULT false,
|
||||
@@ -74,8 +72,7 @@ CREATE TABLE "payment_methods" (
|
||||
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professionals" (
|
||||
CREATE TABLE IF NOT EXISTS "professionals" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" UUID NOT NULL,
|
||||
"identification" VARCHAR(50),
|
||||
@@ -98,16 +95,14 @@ CREATE TABLE "professionals" (
|
||||
CONSTRAINT "professionals_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "professions" (
|
||||
CREATE TABLE IF NOT EXISTS "professions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "professions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "regions" (
|
||||
CREATE TABLE IF NOT EXISTS "regions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"country_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
@@ -115,8 +110,7 @@ CREATE TABLE "regions" (
|
||||
CONSTRAINT "regions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reputations" (
|
||||
CREATE TABLE IF NOT EXISTS "reputations" (
|
||||
"user_id" UUID NOT NULL,
|
||||
"total" INTEGER NOT NULL DEFAULT 0,
|
||||
"average" DECIMAL(3,2) NOT NULL DEFAULT 0,
|
||||
@@ -127,8 +121,7 @@ CREATE TABLE "reputations" (
|
||||
CONSTRAINT "reputations_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "schedules" (
|
||||
CREATE TABLE IF NOT EXISTS "schedules" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"day_of_week" SMALLINT NOT NULL,
|
||||
@@ -142,8 +135,7 @@ CREATE TABLE "schedules" (
|
||||
CONSTRAINT "schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "services" (
|
||||
CREATE TABLE IF NOT EXISTS "services" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
@@ -166,8 +158,7 @@ CREATE TABLE "services" (
|
||||
CONSTRAINT "services_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "settings" (
|
||||
CREATE TABLE IF NOT EXISTS "settings" (
|
||||
"key" VARCHAR(100) NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -175,8 +166,7 @@ CREATE TABLE "settings" (
|
||||
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "specializations" (
|
||||
CREATE TABLE IF NOT EXISTS "specializations" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"professional_id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
@@ -185,8 +175,16 @@ CREATE TABLE "specializations" (
|
||||
CONSTRAINT "specializations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
CREATE TABLE IF NOT EXISTS "suggestions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255),
|
||||
"message" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "suggestions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"email" VARCHAR(255),
|
||||
"phone" VARCHAR(20),
|
||||
@@ -208,126 +206,113 @@ CREATE TABLE "users" (
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_chats_professional_id" ON "chats"("professional_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_chats_professional_id" ON "chats"("professional_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_chats_user_id" ON "chats"("user_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "chats_user_id_professional_id_key" ON "chats"("user_id", "professional_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_cities_region_id" ON "cities"("region_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_comments_author_id" ON "comments"("author_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_comments_destination_id" ON "comments"("destination_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_comments_service_id" ON "comments"("service_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "countries_name_key" ON "countries"("name");
|
||||
CREATE INDEX IF NOT EXISTS "idx_messages_chat_id" ON "messages"("chat_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_messages_created_at" ON "messages"("created_at");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "payment_methods_professional_id_key" ON "payment_methods"("professional_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "professionals_user_id_key" ON "professionals"("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_professionals_user_id" ON "professionals"("user_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "professions_name_key" ON "professions"("name");
|
||||
CREATE INDEX IF NOT EXISTS "idx_regions_country_id" ON "regions"("country_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_schedules_professional_id" ON "schedules"("professional_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "schedules_professional_id_day_of_week_key" ON "schedules"("professional_id", "day_of_week");
|
||||
CREATE INDEX IF NOT EXISTS "idx_services_day" ON "services"("day");
|
||||
CREATE INDEX IF NOT EXISTS "idx_services_professional_id" ON "services"("professional_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_services_status" ON "services"("status");
|
||||
CREATE INDEX IF NOT EXISTS "idx_services_user_id" ON "services"("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_specializations_professional_id" ON "specializations"("professional_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_key" ON "users"("email");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "users_phone_key" ON "users"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_chats_user_id" ON "chats"("user_id");
|
||||
-- AddForeignKey (idempotent via DO blocks)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "chats_user_id_professional_id_key" ON "chats"("user_id", "professional_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_cities_region_id" ON "cities"("region_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "cities" ADD CONSTRAINT "cities_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_author_id" ON "comments"("author_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_destination_id" ON "comments"("destination_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_destination_id_fkey" FOREIGN KEY ("destination_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_comments_service_id" ON "comments"("service_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_name_key" ON "countries"("name");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_fkey" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_messages_chat_id" ON "messages"("chat_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_messages_created_at" ON "messages"("created_at");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "payment_methods_professional_id_key" ON "payment_methods"("professional_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "professionals" ADD CONSTRAINT "professionals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "professionals_user_id_key" ON "professionals"("user_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "regions" ADD CONSTRAINT "regions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_professionals_user_id" ON "professionals"("user_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "reputations" ADD CONSTRAINT "reputations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "professions_name_key" ON "professions"("name");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "schedules" ADD CONSTRAINT "schedules_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_regions_country_id" ON "regions"("country_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_schedules_professional_id" ON "schedules"("professional_id");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "schedules_professional_id_day_of_week_key" ON "schedules"("professional_id", "day_of_week");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "specializations" ADD CONSTRAINT "specializations_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_day" ON "services"("day");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_professional_id" ON "services"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_status" ON "services"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_services_user_id" ON "services"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_specializations_professional_id" ON "specializations"("professional_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cities" ADD CONSTRAINT "cities_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_destination_id_fkey" FOREIGN KEY ("destination_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_fkey" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "professionals" ADD CONSTRAINT "professionals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "regions" ADD CONSTRAINT "regions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reputations" ADD CONSTRAINT "reputations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "schedules" ADD CONSTRAINT "schedules_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "services" ADD CONSTRAINT "services_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "specializations" ADD CONSTRAINT "specializations_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- CreateTrigger: update reputation on comment insert/update
|
||||
-- Trigger: update reputation on comment insert/update
|
||||
CREATE OR REPLACE FUNCTION update_reputation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
@@ -360,4 +345,3 @@ CREATE OR REPLACE TRIGGER trg_update_reputation
|
||||
AFTER INSERT OR UPDATE ON comments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_reputation();
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "message_logs" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"channel" VARCHAR(10) NOT NULL,
|
||||
"recipient" VARCHAR(255) NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"error" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "message_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "suggestions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" VARCHAR(255),
|
||||
"message" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "suggestions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "is_active" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "professionals" ADD COLUMN IF NOT EXISTS "slot_duration_minutes" INTEGER NOT NULL DEFAULT 30;
|
||||
@@ -96,6 +96,7 @@ model professionals {
|
||||
latitude Decimal? @db.Decimal(10, 7)
|
||||
longitude Decimal? @db.Decimal(10, 7)
|
||||
average_score Decimal? @default(0) @db.Decimal(3, 2)
|
||||
slot_duration_minutes Int @default(30)
|
||||
is_active Boolean? @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
@@ -194,10 +195,10 @@ model suggestions {
|
||||
|
||||
model message_logs {
|
||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
channel String @db.VarChar(10) // 'sms' | 'email'
|
||||
channel String @db.VarChar(10)
|
||||
recipient String @db.VarChar(255)
|
||||
body String
|
||||
status String @db.VarChar(20) // 'sent' | 'error'
|
||||
status String @db.VarChar(20)
|
||||
error String?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
}
|
||||
@@ -227,6 +228,7 @@ model users {
|
||||
fcm_token String?
|
||||
is_phone_verified Boolean? @default(false)
|
||||
is_email_verified Boolean? @default(false)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
chats_chats_professional_idTousers chats[] @relation("chats_professional_idTousers")
|
||||
|
||||
@@ -14,6 +14,8 @@ import { StorageModule } from './storage/storage.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { SmsModule } from './sms/sms.module';
|
||||
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { VerifikModule } from './verifik/verifik.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +34,8 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
NotificationsModule,
|
||||
SmsModule,
|
||||
SuggestionsModule,
|
||||
VerifikModule,
|
||||
MailModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -10,6 +11,7 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private sms: SmsService,
|
||||
private mail: MailService,
|
||||
) {}
|
||||
|
||||
async register(email: string, password: string, name: string) {
|
||||
@@ -21,6 +23,7 @@ export class AuthService {
|
||||
data: { email, password_hash, name },
|
||||
});
|
||||
|
||||
this.mail.sendWelcome(name, email).catch(() => {});
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
@@ -31,6 +34,8 @@ export class AuthService {
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) throw new UnauthorizedException('Credenciales inválidas');
|
||||
|
||||
if (user.is_active === false) throw new UnauthorizedException('Tu cuenta ha sido bloqueada');
|
||||
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
@@ -48,6 +53,7 @@ export class AuthService {
|
||||
data: { phone, name: name || phone, is_phone_verified: true },
|
||||
});
|
||||
} else {
|
||||
if (user.is_active === false) throw new UnauthorizedException('Tu cuenta ha sido bloqueada');
|
||||
user = await this.prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: { is_phone_verified: true },
|
||||
@@ -104,6 +110,7 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('Usuario no encontrado');
|
||||
if (user.is_active === false) throw new UnauthorizedException('Tu cuenta ha sido bloqueada');
|
||||
return { ...user, professional_state: user.pro_state };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches, IsBoolean } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
@@ -54,6 +54,10 @@ export class UpdateUserDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
birthday?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export class FcmTokenDto {
|
||||
|
||||
@@ -46,6 +46,7 @@ export class EmailOtpService {
|
||||
create: { key: 'smtp_config', value: config as any },
|
||||
update: { value: config as any },
|
||||
});
|
||||
return { message: 'Configuración SMTP guardada' };
|
||||
}
|
||||
|
||||
async sendOtp(email: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const BRAND_COLOR = '#42A4EF';
|
||||
const LOGO = 'https://prosapp.co/img/logo_prosapp.png';
|
||||
const APP_URL = 'https://app.prosapp.co';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ── transport ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async createTransport(): Promise<{ transport: nodemailer.Transporter; from: string } | null> {
|
||||
const dbConfig = await this.prisma.settings
|
||||
.findUnique({ where: { key: 'smtp_config' } })
|
||||
.then(r => r?.value as any)
|
||||
.catch(() => null);
|
||||
|
||||
const host = dbConfig?.host || process.env.SMTP_HOST;
|
||||
const port = parseInt(dbConfig?.port || process.env.SMTP_PORT || '587');
|
||||
const user = dbConfig?.user || process.env.SMTP_USER;
|
||||
const pass = dbConfig?.pass || process.env.SMTP_PASS;
|
||||
const from = dbConfig?.from || process.env.EMAIL_FROM || user;
|
||||
|
||||
if (!host || !user || !pass) return null;
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user, pass },
|
||||
});
|
||||
return { transport, from: from || user };
|
||||
}
|
||||
|
||||
// Silent send — never throws, logs errors
|
||||
async tryMail(to: string, subject: string, html: string) {
|
||||
const ctx = await this.createTransport();
|
||||
if (!ctx) return; // SMTP not configured — silently skip
|
||||
|
||||
try {
|
||||
await ctx.transport.sendMail({ from: `ProsApp <${ctx.from}>`, to, subject, html });
|
||||
this.logger.log(`Mail enviado a ${to}: ${subject}`);
|
||||
await this.prisma.message_logs.create({
|
||||
data: { channel: 'email', recipient: to, body: subject, status: 'sent' },
|
||||
}).catch(() => {});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Mail falló a ${to}: ${err}`);
|
||||
await this.prisma.message_logs.create({
|
||||
data: { channel: 'email', recipient: to, body: subject, status: 'error', error: String(err) },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async adminEmail(): Promise<string | null> {
|
||||
const g = await this.prisma.settings.findUnique({ where: { key: 'global' } }).then(r => r?.value as any).catch(() => null);
|
||||
if (g?.admin_email) return g.admin_email;
|
||||
const smtp = await this.prisma.settings.findUnique({ where: { key: 'smtp_config' } }).then(r => r?.value as any).catch(() => null);
|
||||
return smtp?.user || null;
|
||||
}
|
||||
|
||||
// ── base template ────────────────────────────────────────────────────────────
|
||||
|
||||
private wrap(content: string, preheader = '') {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ProsApp</title>
|
||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#F0F4F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||
${preheader ? `<div style="display:none;max-height:0;overflow:hidden;color:#F0F4F8">${preheader}</div>` : ''}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F4F8;padding:40px 16px">
|
||||
<tr><td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px">
|
||||
|
||||
<!-- Header -->
|
||||
<tr><td style="background:linear-gradient(135deg,${BRAND_COLOR},#1565C0);border-radius:16px 16px 0 0;padding:32px 40px;text-align:center">
|
||||
<img src="${LOGO}" height="36" alt="ProsApp" style="display:block;margin:0 auto 16px">
|
||||
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:0.5px">ProsApp — Profesionales de salud</p>
|
||||
</td></tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr><td style="background:#ffffff;padding:40px;border-left:1px solid #E2E8F0;border-right:1px solid #E2E8F0">
|
||||
${content}
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr><td style="background:#F8FAFC;border:1px solid #E2E8F0;border-top:0;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
|
||||
<p style="margin:0;color:#94A3B8;font-size:12px">© ${new Date().getFullYear()} ProsApp · <a href="${APP_URL}" style="color:${BRAND_COLOR};text-decoration:none">app.prosapp.co</a></p>
|
||||
<p style="margin:8px 0 0;color:#CBD5E1;font-size:11px">Si no esperabas este correo, puedes ignorarlo.</p>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private badge(text: string, color: string, bg: string) {
|
||||
return `<span style="display:inline-block;padding:4px 12px;background:${bg};color:${color};border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.3px">${text}</span>`;
|
||||
}
|
||||
|
||||
private btn(text: string, href: string) {
|
||||
return `<a href="${href}" style="display:inline-block;padding:14px 32px;background:${BRAND_COLOR};color:#ffffff;border-radius:10px;font-size:15px;font-weight:600;text-decoration:none;margin-top:24px">${text}</a>`;
|
||||
}
|
||||
|
||||
// ── templates ────────────────────────────────────────────────────────────────
|
||||
|
||||
async sendWelcome(name: string, email: string) {
|
||||
const html = this.wrap(`
|
||||
<h1 style="margin:0 0 8px;color:#1E293B;font-size:24px">¡Bienvenido a ProsApp, ${name}! 👋</h1>
|
||||
<p style="margin:0 0 24px;color:#64748B;font-size:15px;line-height:1.6">
|
||||
Nos alegra tenerte aquí. Ya puedes explorar profesionales de salud, agendar citas y mucho más.
|
||||
</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F8FF;border:1px solid #BAE3FF;border-radius:12px;padding:20px 24px;margin-bottom:24px">
|
||||
<tr>
|
||||
<td style="padding:8px 0">
|
||||
<p style="margin:0;color:#1E293B;font-size:14px">✅ <strong>Cuenta creada</strong> con correo <strong>${email}</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
|
||||
<p style="margin:0;color:#475569;font-size:14px">📋 Completa tu perfil para una mejor experiencia</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
|
||||
<p style="margin:0;color:#475569;font-size:14px">🔍 Encuentra y agenda profesionales de salud</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="text-align:center">${this.btn('Ir a ProsApp', APP_URL)}</div>
|
||||
`, `Bienvenido a ProsApp, ${name}`);
|
||||
|
||||
await this.tryMail(email, `¡Bienvenido a ProsApp, ${name}!`, html);
|
||||
}
|
||||
|
||||
async sendProfessionalSubmitted(profName: string, profEmail: string, profession: string) {
|
||||
const admin = await this.adminEmail();
|
||||
if (!admin) return;
|
||||
|
||||
const html = this.wrap(`
|
||||
<p style="margin:0 0 4px;color:#64748B;font-size:13px;text-transform:uppercase;letter-spacing:0.5px">Nueva solicitud pendiente</p>
|
||||
<h1 style="margin:0 0 24px;color:#1E293B;font-size:22px">Solicitud de profesional recibida</h1>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #E2E8F0;border-radius:12px;overflow:hidden;margin-bottom:24px">
|
||||
<tr style="background:#F8FAFC">
|
||||
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;width:40%">Nombre</td>
|
||||
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500">${profName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Correo</td>
|
||||
<td style="padding:16px 20px;color:#1E293B;font-size:14px;border-top:1px solid #E2E8F0">${profEmail}</td>
|
||||
</tr>
|
||||
<tr style="background:#F8FAFC">
|
||||
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Profesión</td>
|
||||
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500;border-top:1px solid #E2E8F0">${profession || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Estado</td>
|
||||
<td style="padding:16px 20px;border-top:1px solid #E2E8F0">${this.badge('En revisión', '#92400E', '#FEF3C7')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 24px;color:#64748B;font-size:14px;line-height:1.6">
|
||||
Revisa los documentos adjuntos y aprueba o rechaza la solicitud desde el panel de administración.
|
||||
</p>
|
||||
<div style="text-align:center">${this.btn('Revisar en el panel', 'https://admin.prosapp.co/professionals')}</div>
|
||||
`, `${profName} ha enviado una solicitud de profesional`);
|
||||
|
||||
await this.tryMail(admin, `Nueva solicitud de profesional — ${profName}`, html);
|
||||
}
|
||||
|
||||
async sendStatusChanged(
|
||||
profName: string,
|
||||
email: string,
|
||||
status: 'approved' | 'rejected' | 'pending' | 'deactivated',
|
||||
) {
|
||||
const configs = {
|
||||
approved: {
|
||||
subject: '¡Tu solicitud fue aprobada! 🎉',
|
||||
badge: this.badge('Aprobado', '#065F46', '#D1FAE5'),
|
||||
title: '¡Felicidades, ya eres profesional en ProsApp!',
|
||||
body: 'Tu solicitud fue revisada y <strong>aprobada</strong>. Ya puedes recibir clientes, gestionar tu agenda y ofrecer tus servicios de salud en la plataforma.',
|
||||
cta: this.btn('Ver mi perfil profesional', APP_URL),
|
||||
accent: '#10B981',
|
||||
accentBg: '#D1FAE5',
|
||||
icon: '✅',
|
||||
},
|
||||
rejected: {
|
||||
subject: 'Actualización sobre tu solicitud de profesional',
|
||||
badge: this.badge('No aprobada', '#991B1B', '#FEE2E2'),
|
||||
title: 'Tu solicitud no fue aprobada',
|
||||
body: 'Lamentablemente tu solicitud <strong>no fue aprobada</strong> en esta ocasión. Puedes corregir los documentos y volver a intentarlo desde la aplicación.',
|
||||
cta: this.btn('Volver a intentarlo', APP_URL),
|
||||
accent: '#EF4444',
|
||||
accentBg: '#FEE2E2',
|
||||
icon: '📋',
|
||||
},
|
||||
pending: {
|
||||
subject: 'Tu solicitud está en revisión',
|
||||
badge: this.badge('En revisión', '#92400E', '#FEF3C7'),
|
||||
title: 'Tu solicitud volvió a revisión',
|
||||
body: 'Un administrador ha puesto tu solicitud nuevamente <strong>en revisión</strong>. Te notificaremos cuando haya una actualización.',
|
||||
cta: this.btn('Ver estado', APP_URL),
|
||||
accent: '#F59E0B',
|
||||
accentBg: '#FEF3C7',
|
||||
icon: '🔍',
|
||||
},
|
||||
deactivated: {
|
||||
subject: 'Tu cuenta profesional fue desactivada',
|
||||
badge: this.badge('Desactivada', '#374151', '#F3F4F6'),
|
||||
title: 'Tu cuenta profesional fue desactivada',
|
||||
body: 'Tu perfil profesional ha sido <strong>desactivado</strong>. Si crees que es un error, comunícate con el soporte de ProsApp.',
|
||||
cta: this.btn('Contactar soporte', APP_URL),
|
||||
accent: '#6B7280',
|
||||
accentBg: '#F3F4F6',
|
||||
icon: '⚠️',
|
||||
},
|
||||
};
|
||||
|
||||
const c = configs[status];
|
||||
const html = this.wrap(`
|
||||
<div style="text-align:center;margin-bottom:28px">
|
||||
<div style="display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;background:${c.accentBg};border-radius:50%;font-size:28px;margin-bottom:16px">${c.icon}</div>
|
||||
<br>${c.badge}
|
||||
</div>
|
||||
<h1 style="margin:0 0 12px;color:#1E293B;font-size:22px;text-align:center">${c.title}</h1>
|
||||
<p style="margin:0 0 28px;color:#64748B;font-size:15px;line-height:1.7;text-align:center">
|
||||
Hola <strong>${profName}</strong>, ${c.body}
|
||||
</p>
|
||||
<div style="background:#F8FAFC;border-left:4px solid ${c.accent};border-radius:0 8px 8px 0;padding:16px 20px;margin-bottom:28px">
|
||||
<p style="margin:0;color:#475569;font-size:13px;line-height:1.6">
|
||||
Si tienes preguntas, escríbenos a través del soporte en la app o responde este correo.
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align:center">${c.cta}</div>
|
||||
`, c.title);
|
||||
|
||||
await this.tryMail(email, c.subject, html);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, MinLength, Matches } from 'class-validator';
|
||||
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, IsInt, Min, Max, MinLength, Matches, IsObject } from 'class-validator';
|
||||
|
||||
export class CreateProfessionalDto {
|
||||
@IsString()
|
||||
@@ -57,6 +57,14 @@ export class UpdateProfessionalDto {
|
||||
@IsNumber()
|
||||
rate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
identification_picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
certificate_picture?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
banner_picture?: string;
|
||||
@@ -72,6 +80,20 @@ export class UpdateProfessionalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
location_preferences?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(480)
|
||||
slot_duration_minutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
payment_methods?: {
|
||||
nequi?: boolean;
|
||||
datafono?: boolean;
|
||||
transferencia?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class ScheduleDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ProfessionalsService } from './professionals.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -15,10 +15,11 @@ export class ProfessionalsController {
|
||||
|
||||
@Get()
|
||||
findAllActive(@Req() req) {
|
||||
const page = +(req.query.page || 1);
|
||||
const limit = +(req.query.limit || 20);
|
||||
const search = req.query.search as string | undefined;
|
||||
const city = req.query.city as string | undefined;
|
||||
return this.pros.findAllActive(page, limit, city);
|
||||
const lat = req.query.lat ? parseFloat(req.query.lat as string) : undefined;
|
||||
const lng = req.query.lng ? parseFloat(req.query.lng as string) : undefined;
|
||||
return this.pros.findAllActive(search, city, lat, lng);
|
||||
}
|
||||
|
||||
@Get('pending')
|
||||
@@ -76,6 +77,13 @@ export class ProfessionalsController {
|
||||
return this.pros.requestProfessional(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Delete('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
async deleteMe(@Req() req) {
|
||||
return this.pros.resetRejected(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch('me/schedules')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { ProfessionalsService } from './professionals.service';
|
||||
import { ProfessionalsController } from './professionals.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
providers: [ProfessionalsService],
|
||||
controllers: [ProfessionalsController],
|
||||
exports: [ProfessionalsService],
|
||||
|
||||
@@ -1,29 +1,83 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
@Injectable()
|
||||
export class ProfessionalsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private mail: MailService,
|
||||
private notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async findAllActive(page = 1, limit = 20, city?: string) {
|
||||
const skip = (page - 1) * limit;
|
||||
private async tryPush(userId: string, title: string, body: string) {
|
||||
try {
|
||||
const u = await this.prisma.users.findUnique({
|
||||
where: { id: userId }, select: { fcm_token: true },
|
||||
});
|
||||
if (u?.fcm_token) await this.notifications.send(u.fcm_token, title, body);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async findAllActive(search?: string, city?: string, lat?: number, lng?: number) {
|
||||
const where: any = { is_active: true };
|
||||
if (city) where.users = { city: { contains: city, mode: 'insensitive' } };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.professionals.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } },
|
||||
schedules: true,
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.professionals.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
|
||||
if (city && city.trim()) {
|
||||
where.users = { city: { contains: city.trim(), mode: 'insensitive' } };
|
||||
}
|
||||
|
||||
if (search && search.trim()) {
|
||||
const words = search.trim().split(/\s+/).filter(w => w.length > 1);
|
||||
if (words.length > 0) {
|
||||
where.AND = words.map(word => ({
|
||||
OR: [
|
||||
{ profession: { contains: word, mode: 'insensitive' } },
|
||||
{ users: { name: { contains: word, mode: 'insensitive' } } },
|
||||
{ users: { city: { contains: word, mode: 'insensitive' } } },
|
||||
],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const professionals = await this.prisma.professionals.findMany({
|
||||
where,
|
||||
include: {
|
||||
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } },
|
||||
schedules: true,
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
_count: { select: { services: { where: { status: 'completed' as any } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const haversineKm = (lat1: number, lng1: number, lat2: number, lng2: number) => {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const sorted = [...professionals]
|
||||
.sort((a, b) => {
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
const aLat = a.latitude ? Number(a.latitude) : null;
|
||||
const aLng = a.longitude ? Number(a.longitude) : null;
|
||||
const bLat = b.latitude ? Number(b.latitude) : null;
|
||||
const bLng = b.longitude ? Number(b.longitude) : null;
|
||||
if (aLat && aLng && bLat && bLng) {
|
||||
const distDiff = haversineKm(lat, lng, aLat, aLng) - haversineKm(lat, lng, bLat, bLng);
|
||||
if (Math.abs(distDiff) > 0.5) return distDiff;
|
||||
}
|
||||
}
|
||||
const countDiff = (b._count?.services ?? 0) - (a._count?.services ?? 0);
|
||||
if (countDiff !== 0) return countDiff;
|
||||
return Number(b.average_score ?? 0) - Number(a.average_score ?? 0);
|
||||
})
|
||||
.slice(0, 7);
|
||||
|
||||
return { data: sorted, meta: { total: sorted.length, page: 1, limit: 7, totalPages: 1 } };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
@@ -31,7 +85,7 @@ export class ProfessionalsService {
|
||||
let prof = await this.prisma.professionals.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } },
|
||||
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true, gender: true } },
|
||||
schedules: { orderBy: { day_of_week: 'asc' } },
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
@@ -41,7 +95,7 @@ export class ProfessionalsService {
|
||||
prof = await this.prisma.professionals.findUnique({
|
||||
where: { user_id: id },
|
||||
include: {
|
||||
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } },
|
||||
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true, gender: true } },
|
||||
schedules: { orderBy: { day_of_week: 'asc' } },
|
||||
specializations: true,
|
||||
payment_methods: true,
|
||||
@@ -68,14 +122,44 @@ export class ProfessionalsService {
|
||||
}
|
||||
|
||||
async upsert(userId: string, data: any) {
|
||||
const { payment_methods: pm, ...profData } = data;
|
||||
|
||||
const buildPm = (forCreate = false) => {
|
||||
if (!pm) return undefined;
|
||||
const pmFields = { nequi: pm.nequi ?? false, datafono: pm.datafono ?? false, transferencia: pm.transferencia ?? false };
|
||||
return forCreate ? { create: pmFields } : { upsert: { update: pmFields, create: pmFields } };
|
||||
};
|
||||
|
||||
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
|
||||
if (!existing) {
|
||||
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
|
||||
return this.prisma.professionals.create({ data: { user_id: userId, is_active: false, ...data } });
|
||||
const createData: any = { user_id: userId, is_active: false, ...profData };
|
||||
if (pm) createData.payment_methods = buildPm(true);
|
||||
const prof = await this.prisma.professionals.create({ data: createData });
|
||||
const u = await this.prisma.users.findUnique({ where: { id: userId }, select: { name: true, email: true } });
|
||||
if (u?.email) this.mail.sendProfessionalSubmitted(u.name || 'Usuario', u.email, data.profession || '').catch(() => {});
|
||||
this.tryPush(userId, 'Solicitud recibida', 'Hemos recibido tu solicitud profesional. Te avisaremos cuando sea revisada.');
|
||||
return prof;
|
||||
}
|
||||
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data });
|
||||
// Re-submitting after rejection reset (pro_state=0): move back to pending
|
||||
const user = await this.prisma.users.findUnique({ where: { id: userId }, select: { pro_state: true, name: true, email: true } });
|
||||
if (user?.pro_state === 0) {
|
||||
const resubData: any = { ...profData, is_active: false };
|
||||
if (pm) resubData.payment_methods = buildPm();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.update({ where: { user_id: userId }, data: resubData }),
|
||||
this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } }),
|
||||
]);
|
||||
if (user.email) this.mail.sendProfessionalSubmitted(user.name || 'Usuario', user.email, data.profession || '').catch(() => {});
|
||||
this.tryPush(userId, 'Solicitud recibida', 'Hemos recibido tu solicitud profesional. Te avisaremos cuando sea revisada.');
|
||||
return this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
}
|
||||
|
||||
const updateData: any = { ...profData };
|
||||
if (pm) updateData.payment_methods = buildPm();
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data: updateData });
|
||||
}
|
||||
|
||||
async updateSchedules(professionalId: string, schedules: any[]) {
|
||||
@@ -145,15 +229,12 @@ export class ProfessionalsService {
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.update({
|
||||
where: { id: professionalId },
|
||||
data: { is_active: true },
|
||||
}),
|
||||
this.prisma.users.update({
|
||||
where: { id: prof.user_id },
|
||||
data: { pro_state: 2 },
|
||||
}),
|
||||
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: true } }),
|
||||
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 2 } }),
|
||||
]);
|
||||
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
|
||||
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'approved').catch(() => {});
|
||||
this.tryPush(prof.user_id, '¡Solicitud aprobada! 🎉', 'Tu perfil profesional ha sido aprobado en ProsApp.');
|
||||
return { message: 'Profesional aprobado' };
|
||||
}
|
||||
|
||||
@@ -162,12 +243,21 @@ export class ProfessionalsService {
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false } }),
|
||||
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false, updated_at: new Date() } }),
|
||||
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
|
||||
]);
|
||||
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
|
||||
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'rejected').catch(() => {});
|
||||
this.tryPush(prof.user_id, 'Actualización de tu solicitud', 'Tu solicitud profesional fue rechazada. Puedes volver a intentarlo.');
|
||||
return { message: 'Solicitud rechazada' };
|
||||
}
|
||||
|
||||
async resetRejected(userId: string) {
|
||||
// Always reset pro_state regardless of whether a professional record exists
|
||||
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 0 } });
|
||||
return { message: 'Solicitud reiniciada' };
|
||||
}
|
||||
|
||||
async deactivate(id: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id } });
|
||||
if (!prof) throw new NotFoundException('Profesional no encontrado');
|
||||
@@ -175,6 +265,9 @@ export class ProfessionalsService {
|
||||
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
|
||||
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
|
||||
]);
|
||||
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
|
||||
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'deactivated').catch(() => {});
|
||||
this.tryPush(prof.user_id, 'Cuenta desactivada', 'Tu cuenta profesional ha sido desactivada.');
|
||||
return { message: 'Profesional desactivado' };
|
||||
}
|
||||
|
||||
@@ -185,6 +278,9 @@ export class ProfessionalsService {
|
||||
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
|
||||
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 1 } }),
|
||||
]);
|
||||
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
|
||||
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'pending').catch(() => {});
|
||||
this.tryPush(prof.user_id, 'Solicitud en revisión', 'Tu solicitud está siendo revisada por el equipo de ProsApp.');
|
||||
return { message: 'Profesional puesto en revisión' };
|
||||
}
|
||||
|
||||
|
||||
@@ -64,3 +64,11 @@ export class UpdateServiceStatusDto {
|
||||
@IsEnum(ServiceStatus)
|
||||
status: ServiceStatus;
|
||||
}
|
||||
|
||||
export class BlockSlotDto {
|
||||
@IsDateString()
|
||||
day: string;
|
||||
|
||||
@Matches(/^\d{2}:\d{2}$/)
|
||||
range1_hour1: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query, ParseIntPipe, Optional } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ServicesService } from './services.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateServiceDto, UpdateServiceStatusDto } from './dto/service.dto';
|
||||
import { CreateServiceDto, UpdateServiceStatusDto, BlockSlotDto } from './dto/service.dto';
|
||||
|
||||
@ApiTags('Services')
|
||||
@Controller('services')
|
||||
@@ -16,6 +16,13 @@ export class ServicesController {
|
||||
return this.services.create({ ...dto, user_id: req.user.sub });
|
||||
}
|
||||
|
||||
@Post('block')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
blockSlot(@Req() req, @Body() dto: BlockSlotDto) {
|
||||
return this.services.blockSlot(req.user.sub, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@@ -94,6 +101,19 @@ export class ServicesController {
|
||||
return this.services.getPublicCalendar(id);
|
||||
}
|
||||
|
||||
@Get('admin/professional/:professionalId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
getByProfessionalAdmin(
|
||||
@Param('professionalId') professionalId: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.services.getServicesByProfessionalAdmin(professionalId, +(page || 1), +(limit || 50));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -36,7 +36,51 @@ export class ServicesService {
|
||||
if (!prof) prof = await this.prisma.professionals.findUnique({ where: { user_id: data.professional_id } });
|
||||
if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible');
|
||||
|
||||
return this.prisma.services.create({
|
||||
if (data.range1_hour1) {
|
||||
const slotTime = new Date(`1970-01-01T${data.range1_hour1}:00`);
|
||||
|
||||
// Double-booking check: reject if this slot already has a non-cancelled/denied service
|
||||
const conflict = await this.prisma.services.findFirst({
|
||||
where: {
|
||||
professional_id: prof.id,
|
||||
day: new Date(data.day),
|
||||
range1_hour1: slotTime,
|
||||
status: { notIn: ['denied', 'cancelled'] },
|
||||
},
|
||||
});
|
||||
if (conflict) throw new BadRequestException('Este horario ya está ocupado');
|
||||
|
||||
// Schedule validation: confirm the slot is within the professional's configured hours
|
||||
// JS getDay() → 0=Sun…6=Sat; DB convention → 0=Mon…6=Sun, so: (getDay()+6)%7
|
||||
const jsDay = new Date(data.day).getDay();
|
||||
const dayOfWeek = (jsDay + 6) % 7;
|
||||
const schedule = await this.prisma.schedules.findFirst({
|
||||
where: { professional_id: prof.id, day_of_week: dayOfWeek, enabled: true },
|
||||
});
|
||||
if (!schedule) throw new BadRequestException('El profesional no atiende ese día');
|
||||
|
||||
const toMins = (d: Date | null) => d ? d.getUTCHours() * 60 + d.getUTCMinutes() : null;
|
||||
const [sh, sm] = data.range1_hour1.split(':').map(Number);
|
||||
const reqMins = sh * 60 + sm;
|
||||
|
||||
let inRange = false;
|
||||
if (schedule.continuous_day) {
|
||||
// Full day: range1_hour1 → range2_hour2
|
||||
const start = toMins(schedule.range1_hour1);
|
||||
const end = toMins(schedule.range2_hour2);
|
||||
if (start !== null && end !== null) inRange = reqMins >= start && reqMins < end;
|
||||
} else {
|
||||
// Morning block: range1_hour1 → range1_hour2
|
||||
const s1 = toMins(schedule.range1_hour1), e1 = toMins(schedule.range1_hour2);
|
||||
if (s1 !== null && e1 !== null) inRange = inRange || (reqMins >= s1 && reqMins < e1);
|
||||
// Afternoon block: range2_hour1 → range2_hour2
|
||||
const s2 = toMins(schedule.range2_hour1), e2 = toMins(schedule.range2_hour2);
|
||||
if (s2 !== null && e2 !== null) inRange = inRange || (reqMins >= s2 && reqMins < e2);
|
||||
}
|
||||
if (!inRange) throw new BadRequestException('La hora seleccionada está fuera del horario del profesional');
|
||||
}
|
||||
|
||||
const created = await this.prisma.services.create({
|
||||
data: {
|
||||
professional_id: prof.id,
|
||||
user_id: data.user_id,
|
||||
@@ -51,6 +95,21 @@ export class ServicesService {
|
||||
location_preference: data.location_preference as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the professional of the new service request
|
||||
const profUser = await this.prisma.users.findUnique({
|
||||
where: { id: prof.user_id },
|
||||
select: { fcm_token: true },
|
||||
});
|
||||
if (profUser?.fcm_token) {
|
||||
this.notifications.send(
|
||||
profUser.fcm_token,
|
||||
'Nueva solicitud de servicio',
|
||||
'Tienes una nueva solicitud pendiente de un cliente.',
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateStatus(serviceId: string, newStatus: ServiceStatus, userId: string) {
|
||||
@@ -106,6 +165,32 @@ export class ServicesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async blockSlot(userId: string, data: { day: string; range1_hour1: string }) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
|
||||
const slotTime = new Date(`1970-01-01T${data.range1_hour1}:00`);
|
||||
const conflict = await this.prisma.services.findFirst({
|
||||
where: {
|
||||
professional_id: prof.id,
|
||||
day: new Date(data.day),
|
||||
range1_hour1: slotTime,
|
||||
status: { notIn: ['denied', 'cancelled'] },
|
||||
},
|
||||
});
|
||||
if (conflict) throw new BadRequestException('Este horario ya está ocupado o bloqueado');
|
||||
|
||||
return this.prisma.services.create({
|
||||
data: {
|
||||
professional_id: prof.id,
|
||||
user_id: prof.user_id,
|
||||
day: new Date(data.day),
|
||||
range1_hour1: slotTime,
|
||||
status: 'self_booked' as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByUser(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { user_id: userId, status: { notIn: ['completed' as const, 'cancelled' as const, 'denied' as const] } };
|
||||
@@ -125,7 +210,7 @@ export class ServicesService {
|
||||
async findByProfessional(userId: string, page = 1, limit = 20) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
if (!prof) throw new NotFoundException('No eres un profesional');
|
||||
const where = { professional_id: prof.id };
|
||||
const where = { professional_id: prof.id, status: { notIn: ['self_booked', 'denied', 'cancelled'] as any[] } };
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
@@ -162,8 +247,10 @@ export class ServicesService {
|
||||
const service = await this.prisma.services.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
// Patient data (for professional viewing the service)
|
||||
users: { select: { id: true, name: true, picture: true, phone: true } },
|
||||
professionals: { include: { users: { select: { name: true, picture: true } } } },
|
||||
// Professional data (for patient viewing the service — needs id and phone for chat/call)
|
||||
professionals: { include: { users: { select: { id: true, name: true, picture: true, phone: true } } } },
|
||||
},
|
||||
});
|
||||
if (!service) throw new NotFoundException('Servicio no encontrado');
|
||||
@@ -226,6 +313,22 @@ export class ServicesService {
|
||||
return { schedules, services };
|
||||
}
|
||||
|
||||
async getServicesByProfessionalAdmin(professionalId: string, page = 1, limit = 50) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = { professional_id: professionalId };
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.services.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
|
||||
orderBy: { day: 'desc' },
|
||||
}),
|
||||
this.prisma.services.count({ where }),
|
||||
]);
|
||||
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
|
||||
}
|
||||
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
const [data, total] = await Promise.all([
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { EmailOtpService } from '../auth/email-otp.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
|
||||
@ApiTags('Settings')
|
||||
@Controller('settings')
|
||||
export class SettingsController {
|
||||
constructor(private settings: SettingsService, private emailOtp: EmailOtpService) {}
|
||||
constructor(private settings: SettingsService, private emailOtp: EmailOtpService, private mail: MailService) {}
|
||||
|
||||
@Get()
|
||||
getGlobal() { return this.settings.getGlobal(); }
|
||||
@@ -44,6 +45,21 @@ export class SettingsController {
|
||||
return this.emailOtp.saveSmtpConfig(body);
|
||||
}
|
||||
|
||||
@Post('test-email')
|
||||
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||
async testEmail(@Body() body: { to: string }) {
|
||||
await this.mail.tryMail(
|
||||
body.to,
|
||||
'Correo de prueba — ProsApp',
|
||||
`<div style="font-family:sans-serif;padding:32px;max-width:480px;margin:auto">
|
||||
<h2 style="color:#1e293b">✅ Configuración SMTP correcta</h2>
|
||||
<p style="color:#64748b">Si recibes este correo, el servidor SMTP está funcionando correctamente en ProsApp.</p>
|
||||
<p style="color:#94a3b8;font-size:12px">Enviado desde el panel de administración.</p>
|
||||
</div>`,
|
||||
);
|
||||
return { message: `Correo de prueba enviado a ${body.to}` };
|
||||
}
|
||||
|
||||
// Message logs
|
||||
@Get('message-logs')
|
||||
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Patch, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -48,4 +48,11 @@ export class UsersController {
|
||||
findById(@Param('id') id: string) {
|
||||
return this.users.findById(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
deleteById(@Param('id') id: string) {
|
||||
return this.users.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,8 @@ export class UsersService {
|
||||
updateFcmToken(id: string, fcm_token: string) {
|
||||
return this.prisma.users.update({ where: { id }, data: { fcm_token } });
|
||||
}
|
||||
|
||||
deleteById(id: string) {
|
||||
return this.prisma.users.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Controller, Post, Get, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { VerifikService } from './verifik.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Verifik')
|
||||
@Controller('verifik')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class VerifikController {
|
||||
constructor(private verifik: VerifikService) {}
|
||||
|
||||
@Get('status')
|
||||
status() { return this.verifik.getStatus(); }
|
||||
|
||||
@Post('send-otp')
|
||||
sendOtp(@Body() body: { email: string }) {
|
||||
return this.verifik.sendOtp(body.email);
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@Body() body: { email: string; otp: string }) {
|
||||
return this.verifik.confirmOtp(body.email, body.otp);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh() { return this.verifik.refreshToken(); }
|
||||
|
||||
// Query by professional ID — looks up cedula from DB
|
||||
@Get('rethus/professional/:id')
|
||||
rethusByProfessional(@Param('id') id: string) {
|
||||
return this.verifik.queryRethusByProfessional(id);
|
||||
}
|
||||
|
||||
// Direct query by document
|
||||
@Get('rethus')
|
||||
rethus(
|
||||
@Query('documentType') documentType: string,
|
||||
@Query('documentNumber') documentNumber: string,
|
||||
) {
|
||||
return this.verifik.queryRethus(documentType, documentNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VerifikService } from './verifik.service';
|
||||
import { VerifikController } from './verifik.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
providers: [VerifikService],
|
||||
controllers: [VerifikController],
|
||||
exports: [VerifikService],
|
||||
})
|
||||
export class VerifikModule {}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const BASE = 'https://api.verifik.co';
|
||||
|
||||
@Injectable()
|
||||
export class VerifikService {
|
||||
private readonly logger = new Logger(VerifikService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// ── token storage ──────────────────────────────────────────────────────────
|
||||
|
||||
private async getStoredToken(): Promise<string | null> {
|
||||
const s = await this.prisma.settings.findUnique({ where: { key: 'verifik_config' } });
|
||||
return (s?.value as any)?.token ?? null;
|
||||
}
|
||||
|
||||
private async saveToken(token: string) {
|
||||
await this.prisma.settings.upsert({
|
||||
where: { key: 'verifik_config' },
|
||||
create: { key: 'verifik_config', value: { token } },
|
||||
update: { value: { token } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── auth flow ──────────────────────────────────────────────────────────────
|
||||
|
||||
async sendOtp(email: string) {
|
||||
const res = await fetch(`${BASE}/v2/projects/email-login?email=${encodeURIComponent(email)}`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as any).message || `Error ${res.status}`);
|
||||
}
|
||||
return { message: 'OTP enviado al correo' };
|
||||
}
|
||||
|
||||
async confirmOtp(email: string, otp: string) {
|
||||
const res = await fetch(`${BASE}/v2/projects/email-login/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, otp }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({})) as any;
|
||||
if (!res.ok) throw new Error(body.message || `Error ${res.status}`);
|
||||
const token: string = body.data?.accessToken ?? body.accessToken;
|
||||
if (!token) throw new Error('No se recibió token');
|
||||
await this.saveToken(token);
|
||||
return { message: 'Conectado correctamente' };
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
const token = await this.getStoredToken();
|
||||
if (!token) throw new Error('No hay token guardado');
|
||||
const res = await fetch(`${BASE}/v2/auth/session?origin=refresh&expiresIn=1`, {
|
||||
headers: { Accept: 'application/json', Authorization: token },
|
||||
});
|
||||
const body = await res.json().catch(() => ({})) as any;
|
||||
if (!res.ok) throw new Error(body.message || `Error ${res.status}`);
|
||||
const newToken: string = body.accessToken ?? body.data?.accessToken;
|
||||
if (!newToken) throw new Error('No se recibió token renovado');
|
||||
await this.saveToken(newToken);
|
||||
return { message: 'Token renovado' };
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
const token = await this.getStoredToken();
|
||||
if (!token) return { connected: false };
|
||||
// Quick validation — session endpoint with no origin param just validates
|
||||
const res = await fetch(`${BASE}/v2/auth/session`, {
|
||||
headers: { Accept: 'application/json', Authorization: token },
|
||||
});
|
||||
return { connected: res.ok };
|
||||
}
|
||||
|
||||
// ── RETHUS lookup ──────────────────────────────────────────────────────────
|
||||
|
||||
async queryRethusByProfessional(professionalId: string) {
|
||||
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
|
||||
if (!prof) throw new Error('Profesional no encontrado');
|
||||
if (!prof.identification) throw new Error('El profesional no tiene número de cédula registrado');
|
||||
return this.queryRethus('CC', prof.identification);
|
||||
}
|
||||
|
||||
async queryRethus(documentType: string, documentNumber: string) {
|
||||
let token = await this.getStoredToken();
|
||||
if (!token) throw new Error('Verifik no está configurado. Conecta tu cuenta en Configuración.');
|
||||
|
||||
const call = async (t: string) =>
|
||||
fetch(`${BASE}/v2/co/cedula/rethus?documentType=${documentType}&documentNumber=${documentNumber}`, {
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${t}` },
|
||||
});
|
||||
|
||||
let res = await call(token);
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (res.status === 401) {
|
||||
this.logger.log('Token Verifik expirado, renovando…');
|
||||
await this.refreshToken();
|
||||
token = await this.getStoredToken();
|
||||
res = await call(token!);
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({})) as any;
|
||||
if (!res.ok) throw new Error(body.message || `Error Verifik ${res.status}`);
|
||||
return body.data ?? body;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
environment:
|
||||
- PORT=3001
|
||||
- STORAGE_URL=https://backend.prosapp.co/uploads
|
||||
- DATABASE_URL=postgresql://prosapp_user:ProsappPass123!@wkuvmdsy39relyhnugk87eqb:5432/prosapp
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
|
||||
|
||||
Reference in New Issue
Block a user