Compare commits

..
24 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 23db497730 fix: include id and phone in professionals.users for findById
The findById endpoint only selected {name, picture} from professionals.users,
so when a patient viewed a service detail the professional's id and phone
were missing. This caused:
- The call button to be hidden (phone was null)
- The chat route to be built with an empty professional ID ('/chat/')
  making the Firestore stream unable to find the chat → spinner forever

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:16:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b4857dbc75 fix: notify professional via FCM when service is created
Backend create() now looks up the professional's fcm_token and sends a
push notification after the service is persisted. This replaces the
unreliable client-side notification in the Flutter app (which couldn't
use the server FCM key).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 09:33:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d7a0769b46 feat: filter professionals by city and proximity
- findAllActive now accepts city, lat, lng params
- City filter: case-insensitive contains on users.city
- Proximity sort: Haversine within city before services/score ranking
- Controller exposes ?city=&lat=&lng= query params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:35:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 00a6f637f6 feat: rank professionals by completed services + score, search by name/profession/city
- GET /professionals now accepts ?search= instead of ?city=/page/limit
- Filters each word across profession, user name, and user city (AND of OR)
- Counts completed services per professional via Prisma _count filter
- Sorts: most completed services first, then highest average_score
- Always returns top 7 results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:18:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 74be21eb4e fix: use 'as any[]' for notIn array to avoid readonly tuple type error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:02:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7d481b3451 Exclude self_booked/denied/cancelled from professional services list
findByProfessional had no status filter, so blocked slots (self_booked)
and historical cancelled/denied services appeared in the active view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:54:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c720911172 add configurable appointment slot duration per professional
DB: slot_duration_minutes INT DEFAULT 30 on professionals table
Backend DTO: slot_duration_minutes (optional int, 5-480 min) in UpdateProfessionalDto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:24:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 afbf4d537e show gender on admin professional detail page
Add gender: true to users select in findById() (both lookup paths)
and render it in the personal data card in the admin frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:03:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2180e0480c add admin services-by-professional endpoint + calendar view in admin panel
Backend:
- GET /services/admin/professional/:id (JWT-guarded) returns paginated
  services for any professional with embedded user (client) info

Admin professional detail page:
- Replace broken all-services-then-filter with new endpoint
- Add month calendar with colored dots per day (click to filter table)
- Services table shows client name/phone, time, colored status badges
- Fix DAY_NAMES array (was 0=Domingo, now 0=Lunes per DB convention)
- Schedule row shows both ranges + continuous_day flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:58:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8a97b52eb7 feat(services): add POST /services/block endpoint for slot blocking
- BlockSlotDto: validates day (ISO date) and range1_hour1 (HH:MM)
- ServicesService.blockSlot(): finds professional by userId, checks for
  conflicts (any non-cancelled/denied service at that slot), then creates
  a service with status=self_booked using the professional's own user_id
- ServicesController: POST /services/block (JWT-guarded) wired to blockSlot

Existing PATCH /services/:id/status → cancelled handles unblocking since
VALID_TRANSITIONS already allows self_booked → cancelled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:26:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c8b2c0c901 fix(services): add double-booking check and schedule validation on create
Before creating a service, now:
1. Checks if the requested slot (day + range1_hour1) is already occupied by
   a non-cancelled/denied service for that professional — rejects with 400
2. Validates the requested day has an enabled schedule for the professional;
   rejects if the professional does not work that weekday
3. Validates the requested hour falls within the configured hour ranges
   (continuous day: range1_hour1→range2_hour2; split day: morning + afternoon
   blocks) — rejects if outside configured hours

day_of_week conversion: JS Date.getDay() (0=Sun) → DB convention (0=Mon)
via (getDay()+6)%7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 17:55:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 72777df52c fix: save and display professional profile address, payment methods, and docs
- DTO: add payment_methods field so whitelist pipe no longer strips it
- Service upsert(): extract payment_methods and write as Prisma nested upsert instead of passing flat object
- Admin detail page: fix image regex to handle Firebase URLs with query params; fix payment_methods display for 1:1 Prisma relation (object, not array)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 21:42:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e0de51ec5 feat(settings): add domicilios and tarifas toggles in admin panel
Both flags are read by prosappweb from /settings to enable/disable
delivery service and rate display. Saves via the existing PATCH /settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 20:48:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4d98c7cb70 feat(auth): reject login and invalidate token for blocked users
Checks is_active on email login, phone OTP login, and /auth/me so
existing tokens also stop working immediately after a user is blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:56:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a8842a2728 feat(users): add block/unblock and delete from admin panel
- Migration 0004: adds is_active column to users table
- Backend: DELETE /users/:id endpoint + is_active field in UpdateUserDto
- Admin UI: block/unblock toggle and delete with confirmation on user detail
- Users list: shows "Bloqueado" badge for inactive users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:55:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 04f1801b89 fix(entrypoint): let 0001 migration run so rethus columns are created on deploy
Baselining 0001 caused rethus_code/rethus_validated to never be added
automatically. The SQL already uses IF NOT EXISTS so it is safe to deploy
on every fresh container even if the columns already exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:34:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 57c159e91f fix(migrations): baseline existing tables + add 0003 for missing suggestions table
- entrypoint.sh: resolve --applied 0000 and 0001 before deploying,
  so prisma skips them and only runs 0002 (message_logs) and 0003 (suggestions)
- migration 0003: CREATE TABLE IF NOT EXISTS suggestions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:17:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9703b1f08e fix(db): use postgres container name as host instead of IP, fix space in user
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:10:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f6e34807ce fix(network): revert to bridge network — host mode breaks Docker internal routing to 10.0.1.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:59:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 46f3085432 fix(network): use host network mode so backend can reach LAN postgres at 10.0.1.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:42:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 54bc39bb34 fix(config): hardcode DATABASE_URL in entrypoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:28:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e9e3d8141d fix(entrypoint): remove set -e, add verbose logging, don't block on migration failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:24:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6288b9e43b chore(config): add DATABASE_URL to backend service in docker-compose
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:14:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e63ed1873 feat(db): idempotent migrations + auto-deploy on startup
- Rewrite 0000_init with IF NOT EXISTS on all tables, indexes and enums;
  wrap FK constraints in DO/EXCEPTION blocks so re-runs never fail
- Add missing suggestions table to init migration
- Add migration 0002 to create message_logs table (IF NOT EXISTS)
- Add entrypoint.sh: runs prisma migrate deploy before starting the server
- Update Dockerfile to copy entrypoint.sh and use it as CMD
- Add prisma:migrate script to package.json for manual runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:10:33 -05:00
24 changed files with 859 additions and 277 deletions
+295 -84
View File
@@ -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>
)}
+31
View File
@@ -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>
+97 -17
View File
@@ -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>
+8 -1
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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
+1
View File
@@ -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": [],
+122 -138
View File
@@ -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;
+4 -2
View File
@@ -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")
+4
View File
@@ -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 };
}
+5 -1
View File
@@ -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[]) {
+8
View File
@@ -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;
}
+22 -2
View File
@@ -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()
+106 -3
View File
@@ -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([
+8 -1
View File
@@ -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);
}
}
+4
View File
@@ -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 } });
}
}
+1
View File
@@ -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