From 6400485b6d1369165d4a8a4b8f7d5b66946a797c Mon Sep 17 00:00:00 2001 From: Juan Marquez Date: Thu, 9 Jul 2026 16:55:39 +0200 Subject: [PATCH] independencia del provider --- .env.example | 5 +++ docker-compose.yml | 2 ++ main.go | 74 ++++++++++++++++++++++++++++++++++++++---- main_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++--- static/index.html | 20 +++++++++--- 5 files changed, 164 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 9f182f8..47b8e64 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ MYSQL_ROOT_PASSWORD=changeme-contrasena-fuerte # URL pública del panel de EasyAppointments (cámbiala en producción). EA_BASE_URL=http://localhost:8888 + +# Opcional: id del provider contra el que se reserva. Si no se define, +# el servidor lo autodescubre preguntando a EA (usa el primero, y en este +# salón solo hay uno). Defínelo solo si algún día hay varios providers. +#EA_PROVIDER_ID=5 diff --git a/docker-compose.yml b/docker-compose.yml index a879d2d..fc2a1c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: environment: # Token de API de EasyAppointments. Definido en .env (ver .env.example). - EA_API_KEY=${EA_API_KEY} + # Opcional: fija el provider; vacío = autodescubierto en EA. + - EA_PROVIDER_ID=${EA_PROVIDER_ID:-} depends_on: - easyappointments diff --git a/main.go b/main.go index 72c3f22..f1dd79b 100644 --- a/main.go +++ b/main.go @@ -163,6 +163,10 @@ type eaCustomer struct { Notes string `json:"notes"` } +type eaProvider struct { + ID flexInt `json:"id"` +} + type eaAppointment struct { Start string `json:"start"` End string `json:"end"` @@ -181,7 +185,6 @@ type bookRequest struct { Email string `json:"email"` Telefono string `json:"telefono"` Mensaje string `json:"mensaje"` - ProviderID int `json:"providerId"` ServiceIDs []int `json:"serviceIds"` Date string `json:"date"` // YYYY-MM-DD Time string `json:"time"` // HH:MM @@ -195,8 +198,9 @@ var ( // handleBook orquesta la reserva contra EasyAppointments: // 1. valida la entrada, // 2. lee los servicios de EA (fuente de verdad de nombre/duración), -// 3. busca o crea el cliente por email, -// 4. crea UNA cita con el servicio de mayor duración y el bloque total en notas. +// 3. resuelve el provider (server-side, ver providerID), +// 4. busca o crea el cliente por email, +// 5. crea UNA cita con el servicio de mayor duración y el bloque total en notas. func handleBook(ea *eaClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -216,7 +220,7 @@ func handleBook(ea *eaClient) http.HandlerFunc { req.Mensaje = strings.TrimSpace(req.Mensaje) if req.Nombre == "" || req.Apellidos == "" || req.Email == "" || req.Telefono == "" || - len(req.ServiceIDs) == 0 || req.ProviderID <= 0 || + len(req.ServiceIDs) == 0 || !reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) { writeJSONError(w, http.StatusBadRequest, "datos_incompletos") return @@ -284,7 +288,15 @@ func handleBook(ea *eaClient) http.HandlerFunc { } notes := strings.Join(notesLines, "\n") - // 5. cliente: buscar por email o crear (en EA). + // 5. provider resuelto server-side: el navegador no elige contra quién reserva. + providerID, err := ea.providerID() + if err != nil { + log.Printf("book: error resolviendo provider: %v", err) + writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible") + return + } + + // 6. cliente: buscar por email o crear (en EA). customerID, err := ea.findOrCreateCustomer(req) if err != nil { log.Printf("book: error con cliente: %v", err) @@ -292,12 +304,12 @@ func handleBook(ea *eaClient) http.HandlerFunc { return } - // 6. crear la cita (en EA). + // 7. crear la cita (en EA). appt := eaAppointment{ Start: start.Format(layout), End: end.Format(layout), ServiceID: int(primary.ID), - ProviderID: req.ProviderID, + ProviderID: providerID, CustomerID: customerID, Notes: notes, Status: "booked", @@ -314,6 +326,50 @@ func handleBook(ea *eaClient) http.HandlerFunc { } } +// providerID resuelve el id del provider contra el que se reserva. Con +// EA_PROVIDER_ID definido se usa ese valor; si no, se pregunta a EA y se toma +// el primero (el salón tiene un único provider). Así el id no va hardcodeado +// y cada entorno (desarrollo, producción) resuelve el suyo. +func (c *eaClient) providerID() (int, error) { + if v := os.Getenv("EA_PROVIDER_ID"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return 0, fmt.Errorf("EA_PROVIDER_ID inválido: %q", v) + } + return n, nil + } + var provs []eaProvider + if err := c.do(http.MethodGet, "/providers", nil, &provs); err != nil { + return 0, err + } + if len(provs) == 0 { + return 0, fmt.Errorf("EA no tiene ningún provider") + } + if len(provs) > 1 { + log.Printf("aviso: EA tiene %d providers; se usa el primero (id %d). Fija EA_PROVIDER_ID para elegir otro.", len(provs), int(provs[0].ID)) + } + return int(provs[0].ID), nil +} + +// handleConfig expone al frontend la configuración mínima que necesita para +// consultar disponibilidad por el proxy: solo el id del provider. +func handleConfig(ea *eaClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + id, err := ea.providerID() + if err != nil { + log.Printf("config: error resolviendo provider: %v", err) + writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible") + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"providerId": %d}`, id) + } +} + // findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea. func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { var found []eaCustomer @@ -408,6 +464,10 @@ func main() { // Reservas: orquestadas server-side contra EasyAppointments. http.HandleFunc("/api/book", handleBook(ea)) + // Config para el frontend: el id del provider se resuelve aquí (EA o + // EA_PROVIDER_ID), nunca va hardcodeado en el HTML. + http.HandleFunc("/api/config", handleConfig(ea)) + http.Handle("/", http.FileServer(http.FS(staticFS))) addr := fmt.Sprintf(":%d", *port) diff --git a/main_test.go b/main_test.go index 39f9f55..5a70d0b 100644 --- a/main_test.go +++ b/main_test.go @@ -93,13 +93,17 @@ func TestHandleBook(t *testing.T) { w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"id":5}`)) }) + mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) { + hits = append(hits, "GET providers") + json.NewEncoder(w).Encode([]eaProvider{{ID: 7}}) + }) srv := httptest.NewServer(mux) defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} body := `{"nombre":"Ana","apellidos":"López Gil","email":"ana@x.com","telefono":"600","mensaje":"hola", - "providerId":1,"serviceIds":[1,2],"date":"2026-07-01","time":"10:00"}` + "serviceIds":[1,2],"date":"2026-07-01","time":"10:00"}` req := httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)) rec := httptest.NewRecorder() handleBook(ea)(rec, req) @@ -115,7 +119,8 @@ func TestHandleBook(t *testing.T) { if gotAppt.Start != "2026-07-01 10:00:00" || gotAppt.End != "2026-07-01 11:15:00" { t.Errorf("start/end = %q/%q, want 10:00:00/11:15:00", gotAppt.Start, gotAppt.End) } - if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 1 || gotAppt.Status != "booked" { + // El provider lo resuelve el servidor contra EA, no el navegador. + if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 7 || gotAppt.Status != "booked" { t.Errorf("appt = %+v", gotAppt) } // Nombre y apellidos viajan por separado hasta EA, sin adivinar cuál es cuál. @@ -128,7 +133,7 @@ func TestHandleBook(t *testing.T) { } // Toda la gestión pasó por EA y nada más. sort.Strings(hits) - want := []string{"GET customers", "GET services", "POST appointments", "POST customers"} + want := []string{"GET customers", "GET providers", "GET services", "POST appointments", "POST customers"} if strings.Join(hits, ",") != strings.Join(want, ",") { t.Errorf("llamadas a EA = %v, want %v", hits, want) } @@ -148,7 +153,7 @@ func TestHandleBookRejectsUnknownService(t *testing.T) { defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} - body := `{"nombre":"Ana","apellidos":"López","email":"a@x.com","telefono":"600","providerId":1,"serviceIds":[999],"date":"2026-07-01","time":"10:00"}` + body := `{"nombre":"Ana","apellidos":"López","email":"a@x.com","telefono":"600","serviceIds":[999],"date":"2026-07-01","time":"10:00"}` rec := httptest.NewRecorder() handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) @@ -160,6 +165,71 @@ func TestHandleBookRejectsUnknownService(t *testing.T) { } } +// El id del provider se resuelve contra EA (autodescubierto) o vía +// EA_PROVIDER_ID, nunca hardcodeado: es distinto en cada entorno. +func TestProviderID(t *testing.T) { + calls := 0 + mux := http.NewServeMux() + mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) { + calls++ + json.NewEncoder(w).Encode([]eaProvider{{ID: 5}, {ID: 9}}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + ea := &eaClient{base: srv.URL, http: srv.Client()} + + t.Run("autodescubre el primero de EA", func(t *testing.T) { + id, err := ea.providerID() + if err != nil || id != 5 { + t.Errorf("providerID() = %d, %v; want 5, nil", id, err) + } + }) + + t.Run("EA_PROVIDER_ID tiene prioridad y no llama a EA", func(t *testing.T) { + t.Setenv("EA_PROVIDER_ID", "9") + before := calls + id, err := ea.providerID() + if err != nil || id != 9 { + t.Errorf("providerID() = %d, %v; want 9, nil", id, err) + } + if calls != before { + t.Error("con EA_PROVIDER_ID definido no debería consultar EA") + } + }) + + t.Run("EA_PROVIDER_ID inválido es error", func(t *testing.T) { + t.Setenv("EA_PROVIDER_ID", "mayra") + if _, err := ea.providerID(); err == nil { + t.Error("EA_PROVIDER_ID no numérico debería dar error") + } + }) + + t.Run("sin providers en EA es error", func(t *testing.T) { + empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`[]`)) + })) + defer empty.Close() + ea := &eaClient{base: empty.URL, http: empty.Client()} + if _, err := ea.providerID(); err == nil { + t.Error("sin providers debería dar error") + } + }) + + t.Run("/api/config lo expone al frontend", func(t *testing.T) { + rec := httptest.NewRecorder() + handleConfig(ea)(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got struct { + ProviderID int `json:"providerId"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.ProviderID != 5 { + t.Errorf("body = %s (err %v), want providerId 5", rec.Body.String(), err) + } + }) +} + // Sin apellidos ⇒ 400 sin llegar a llamar a EA. func TestHandleBookRequiresApellidos(t *testing.T) { called := false @@ -169,7 +239,7 @@ func TestHandleBookRequiresApellidos(t *testing.T) { defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} - body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","providerId":1,"serviceIds":[1],"date":"2026-07-01","time":"10:00"}` + body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","serviceIds":[1],"date":"2026-07-01","time":"10:00"}` rec := httptest.NewRecorder() handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) diff --git a/static/index.html b/static/index.html index 76d2b32..aaeac73 100644 --- a/static/index.html +++ b/static/index.html @@ -211,9 +211,16 @@ } // === EasyAppointments config === - // IMPORTANTE: Después de configurar EasyAppointments, ve al admin (http://localhost:8888) - // y crea al menos un Provider. Anota su ID y ponlo aquí. - const PROVIDER_ID = 5; + // El id del provider lo resuelve el servidor (/api/config): autodescubierto + // en EasyAppointments o fijado con EA_PROVIDER_ID en .env. Aquí nunca va + // hardcodeado, así el mismo HTML sirve en desarrollo y en producción. + let PROVIDER_ID = null; + + async function loadConfig() { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error('HTTP ' + res.status); + PROVIDER_ID = (await res.json()).providerId; + } // ---- Horario desde EasyAppointments (Settings → Business, company_working_plan) ---- // Si la petición falla se mantiene el horario estático del HTML. @@ -655,7 +662,6 @@ email: email, telefono: form.telefono.value.trim(), mensaje: form.mensaje.value.trim(), - providerId: PROVIDER_ID, serviceIds: selectedServices.map(s => s.id).filter(Boolean), date: selectedDate, time: selectedHora @@ -696,7 +702,11 @@ document.addEventListener('DOMContentLoaded', function() { loadSchedule(); - loadServices(); + // loadServices filtra por provider y las availabilities usan su id: + // primero la config. Si falla, los servicios se cargan igual (sin filtro). + loadConfig().catch(function(e) { + console.error('Error cargando config:', e); + }).finally(loadServices); initCalendar(); initBookingForm(); });