diff --git a/server/routes/booking.ts b/server/routes/booking.ts index b93ef46..3d43f26 100644 --- a/server/routes/booking.ts +++ b/server/routes/booking.ts @@ -28,9 +28,30 @@ bookingRouter.get("/:slug", (req, res) => { const services = db .prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`) .all(biz.id); - const employees = db + const employeeRows = db .prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`) - .all(biz.id); + .all(biz.id) as { id: number; name: string; role: string; color: string }[]; + // Qué servicios ofrece cada especialista. Sin esto el cliente no puede filtrar el + // paso "Elige un especialista" y deja escoger a alguien que no da ese servicio: el + // siguiente paso responde 400 y la UI lo muestra como "no pudimos cargar los + // horarios, intenta otra fecha", que nunca lo arregla. + // El JOIN contra employees filtra por negocio: employee_services no tiene + // business_id propio, así que es la única forma de no cruzar tenants aquí. + const links = db + .prepare( + `SELECT es.employee_id, es.service_id + FROM employee_services es + JOIN employees e ON e.id = es.employee_id + WHERE e.business_id = ? AND e.active = 1` + ) + .all(biz.id) as { employee_id: number; service_id: number }[]; + const byEmployee = new Map(); + for (const l of links) { + const arr = byEmployee.get(l.employee_id) ?? []; + arr.push(l.service_id); + byEmployee.set(l.employee_id, arr); + } + const employees = employeeRows.map((e) => ({ ...e, service_ids: byEmployee.get(e.id) ?? [] })); res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees }); }); diff --git a/server/scripts/booking-e2e.mjs b/server/scripts/booking-e2e.mjs index fd198e8..9072e5e 100644 --- a/server/scripts/booking-e2e.mjs +++ b/server/scripts/booking-e2e.mjs @@ -28,6 +28,27 @@ const { json: pub } = await req("GET", `/public/${slug}`); check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0); const svc = pub.services[0]; +// El paso "Elige un especialista" solo puede ofrecer a quien realmente da el servicio: +// si ofrece a otro, /slots responde 400 y el cliente queda atascado. Estas dos +// comprobaciones fijan el contrato que hace posible ese filtro en el cliente. +check( + "cada especialista publica sus service_ids", + pub.employees.every((e) => Array.isArray(e.service_ids)), + JSON.stringify(pub.employees.map((e) => e.service_ids)).slice(0, 120) +); +{ + const target = pub.services.find((x) => pub.employees.some((e) => e.service_ids.includes(x.id))); + const offers = pub.employees.filter((e) => e.service_ids.includes(target.id)); + const denies = pub.employees.filter((e) => !e.service_ids.includes(target.id)); + const probe = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + const okRes = await req("GET", `/public/${slug}/slots?service_id=${target.id}&employee_id=${offers[0].id}&date=${probe}`); + check("especialista que SÍ ofrece el servicio → 200", okRes.status === 200, `status=${okRes.status}`); + if (denies.length) { + const badRes = await req("GET", `/public/${slug}/slots?service_id=${target.id}&employee_id=${denies[0].id}&date=${probe}`); + check("especialista que NO lo ofrece → 400 (por eso se filtra en la UI)", badRes.status === 400, `status=${badRes.status}`); + } +} + // 3) Slots disponibles. // Se busca el próximo día ABIERTO en vez de asumir "mañana": el negocio demo cierra // sábado y domingo, así que fijar mañana hacía que la suite fallara cada viernes y diff --git a/src/lib/publicApi.ts b/src/lib/publicApi.ts index 6d68142..b5bb961 100644 --- a/src/lib/publicApi.ts +++ b/src/lib/publicApi.ts @@ -35,6 +35,8 @@ export interface PublicEmployee { name: string; role: string; color: string; + /** Servicios que este especialista sí ofrece. El paso 2 filtra por aquí. */ + service_ids: number[]; } export interface PublicBusinessResponse { diff --git a/src/pages/public/BookingPage.tsx b/src/pages/public/BookingPage.tsx index afd7ae2..bb0695a 100644 --- a/src/pages/public/BookingPage.tsx +++ b/src/pages/public/BookingPage.tsx @@ -95,6 +95,14 @@ export default function BookingPage() { [services, serviceId] ); + // Un especialista que no ofrece el servicio hace que /slots responda 400. Antes se + // listaban todos, así que el cliente podía elegir a alguien imposible y solo se + // enteraba un paso después, con un mensaje que le pedía cambiar la fecha. + const eligibleEmployees = useMemo( + () => (serviceId == null ? [] : employees.filter((e) => e.service_ids.includes(serviceId))), + [employees, serviceId] + ); + const slotsQuery = useQuery({ queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId], queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined), @@ -214,6 +222,10 @@ export default function BookingPage() { setServiceId(id); setSelectedSlot(null); setBookError(null); + // El especialista elegido puede no ofrecer el servicio nuevo: soltarlo en vez + // de arrastrar una combinación que el servidor rechazará. + const stillValid = employees.some((e) => e.id === employeeId && e.service_ids.includes(id)); + if (!stillValid) setEmployeeId(null); } }; @@ -288,9 +300,10 @@ export default function BookingPage() { subtitle="¿Tienes una preferencia? O déjalo en cualquiera." > )} @@ -313,6 +326,7 @@ export default function BookingPage() { slots={slots} loading={slotsQuery.isLoading} isError={slotsQuery.isError} + errorMessage={slotsQuery.error instanceof Error ? slotsQuery.error.message : undefined} selectedSlot={selectedSlot} onPick={pickSlot} tz={tz} @@ -568,10 +582,12 @@ function EmployeeList({ employees, selectedId, onPick, + serviceName, }: { employees: { id: number; name: string; role: string; color: string }[]; selectedId: number | null; onPick: (id: number | null) => void; + serviceName?: string; }) { const anyActive = selectedId === null; return ( @@ -600,6 +616,14 @@ function EmployeeList({ )} + {employees.length === 0 && ( +

+ {serviceName + ? `Para ${serviceName} asignamos al especialista disponible.` + : "Asignamos al especialista disponible."} +

+ )} + {employees.map((e) => { const active = e.id === selectedId; return ( @@ -644,6 +668,7 @@ function DateTimePicker({ slots, loading, isError, + errorMessage, selectedSlot, onPick, tz, @@ -655,6 +680,7 @@ function DateTimePicker({ slots: PublicSlot[]; loading: boolean; isError: boolean; + errorMessage?: string; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; tz: string; @@ -700,7 +726,13 @@ function DateTimePicker({ {!loading && isError && (
-

No pudimos cargar los horarios. Intenta otra fecha.

+ {/* Se muestra el motivo real del servidor. El texto fijo anterior + ("intenta otra fecha") mandaba al cliente a probar días al azar + cuando el problema era el especialista, y ninguna fecha lo resolvía. */} +

+ {errorMessage || "No pudimos cargar los horarios."} +

+

Prueba con otro especialista u otra fecha.

)} {!loading && !isError && slots.length === 0 && (