From 521c206ba717e0631a9d5f74e4eec0529e10dc82 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Fri, 28 Aug 2026 15:24:10 -0600 Subject: [PATCH] fix: el paso de especialista solo ofrece a quien da ese servicio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elegir "Corte + arreglo de barba" y luego cualquier especialista que no fuera el barbero dejaba el asistente atascado: /slots responde 400 "El especialista no ofrece este servicio" y la UI lo pintaba como "No pudimos cargar los horarios. Intenta otra fecha" — un mensaje que manda a probar días al azar cuando ninguna fecha lo resuelve. La raíz es que el payload público listaba todos los empleados activos sin decir qué servicios ofrece cada uno, así que el cliente no tenía con qué filtrar. - GET /api/public/:slug devuelve ahora `service_ids` por empleado. El JOIN contra employees filtra por negocio: employee_services no tiene business_id propio. - El paso 2 lista solo a los elegibles, y si nadie ofrece el servicio en concreto lo dice en vez de mostrar una lista vacía. - Cambiar de servicio suelta un especialista que ya no aplique, en lugar de arrastrar una combinación que el servidor va a rechazar. - El estado de error muestra el motivo real del servidor. booking-e2e.mjs cubre el contrato: cada empleado publica service_ids, quien ofrece el servicio responde 200 y quien no responde 400. 15/15. Co-Authored-By: Claude Opus 5 (1M context) --- server/routes/booking.ts | 25 ++++++++++++++++++++-- server/scripts/booking-e2e.mjs | 21 +++++++++++++++++++ src/lib/publicApi.ts | 2 ++ src/pages/public/BookingPage.tsx | 36 ++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 4 deletions(-) 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 && (