Compare commits
24
Commits
749a1e860d
..
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,11 +197,11 @@ 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);
|
||||
@@ -58,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);
|
||||
@@ -75,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));
|
||||
@@ -178,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">
|
||||
@@ -187,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">
|
||||
@@ -219,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}>
|
||||
@@ -252,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>
|
||||
|
||||
@@ -341,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>
|
||||
@@ -355,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>
|
||||
@@ -364,36 +533,20 @@ export default function ProfessionalDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={consultRethus}
|
||||
disabled={loadingRethus}
|
||||
>
|
||||
<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 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>
|
||||
@@ -416,7 +569,6 @@ export default function ProfessionalDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!professional.rethus_validated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Consulta automáticamente con Verifik o búscalo en el portal de Minsalud. Una vez verificado, haz clic en "Marcar como validado".
|
||||
@@ -429,7 +581,7 @@ export default function ProfessionalDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documentos subidos */}
|
||||
{/* Documentos */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
@@ -440,11 +592,12 @@ export default function ProfessionalDetailPage() {
|
||||
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 ? (
|
||||
url.match(/\.(jpg|jpeg|png|webp)$/i) ? (
|
||||
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>
|
||||
@@ -463,23 +616,27 @@ export default function ProfessionalDetailPage() {
|
||||
</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>
|
||||
@@ -489,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>
|
||||
@@ -517,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>
|
||||
)}
|
||||
|
||||
@@ -315,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>
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
+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")
|
||||
|
||||
@@ -34,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);
|
||||
}
|
||||
|
||||
@@ -51,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 },
|
||||
@@ -107,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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -80,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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -20,25 +20,64 @@ export class ProfessionalsService {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async findAllActive(page = 1, limit = 20, city?: string) {
|
||||
const skip = (page - 1) * limit;
|
||||
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) {
|
||||
@@ -46,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,
|
||||
@@ -56,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,
|
||||
@@ -83,11 +122,21 @@ 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 } });
|
||||
const prof = await 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.');
|
||||
@@ -97,8 +146,10 @@ export class ProfessionalsService {
|
||||
// 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: { ...data, is_active: false } }),
|
||||
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(() => {});
|
||||
@@ -106,7 +157,9 @@ export class ProfessionalsService {
|
||||
return this.prisma.professionals.findUnique({ where: { user_id: userId } });
|
||||
}
|
||||
|
||||
return this.prisma.professionals.update({ where: { user_id: userId }, data });
|
||||
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[]) {
|
||||
|
||||
@@ -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,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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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