independencia del provider

This commit is contained in:
2026-07-09 16:55:39 +02:00
parent 61a43ef481
commit 6400485b6d
5 changed files with 164 additions and 17 deletions
+5
View File
@@ -13,3 +13,8 @@ MYSQL_ROOT_PASSWORD=changeme-contrasena-fuerte
# URL pública del panel de EasyAppointments (cámbiala en producción). # URL pública del panel de EasyAppointments (cámbiala en producción).
EA_BASE_URL=http://localhost:8888 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
+2
View File
@@ -8,6 +8,8 @@ services:
environment: environment:
# Token de API de EasyAppointments. Definido en .env (ver .env.example). # Token de API de EasyAppointments. Definido en .env (ver .env.example).
- EA_API_KEY=${EA_API_KEY} - EA_API_KEY=${EA_API_KEY}
# Opcional: fija el provider; vacío = autodescubierto en EA.
- EA_PROVIDER_ID=${EA_PROVIDER_ID:-}
depends_on: depends_on:
- easyappointments - easyappointments
+67 -7
View File
@@ -163,6 +163,10 @@ type eaCustomer struct {
Notes string `json:"notes"` Notes string `json:"notes"`
} }
type eaProvider struct {
ID flexInt `json:"id"`
}
type eaAppointment struct { type eaAppointment struct {
Start string `json:"start"` Start string `json:"start"`
End string `json:"end"` End string `json:"end"`
@@ -181,7 +185,6 @@ type bookRequest struct {
Email string `json:"email"` Email string `json:"email"`
Telefono string `json:"telefono"` Telefono string `json:"telefono"`
Mensaje string `json:"mensaje"` Mensaje string `json:"mensaje"`
ProviderID int `json:"providerId"`
ServiceIDs []int `json:"serviceIds"` ServiceIDs []int `json:"serviceIds"`
Date string `json:"date"` // YYYY-MM-DD Date string `json:"date"` // YYYY-MM-DD
Time string `json:"time"` // HH:MM Time string `json:"time"` // HH:MM
@@ -195,8 +198,9 @@ var (
// handleBook orquesta la reserva contra EasyAppointments: // handleBook orquesta la reserva contra EasyAppointments:
// 1. valida la entrada, // 1. valida la entrada,
// 2. lee los servicios de EA (fuente de verdad de nombre/duración), // 2. lee los servicios de EA (fuente de verdad de nombre/duración),
// 3. busca o crea el cliente por email, // 3. resuelve el provider (server-side, ver providerID),
// 4. crea UNA cita con el servicio de mayor duración y el bloque total en notas. // 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 { func handleBook(ea *eaClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -216,7 +220,7 @@ func handleBook(ea *eaClient) http.HandlerFunc {
req.Mensaje = strings.TrimSpace(req.Mensaje) req.Mensaje = strings.TrimSpace(req.Mensaje)
if req.Nombre == "" || req.Apellidos == "" || req.Email == "" || req.Telefono == "" || 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) { !reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) {
writeJSONError(w, http.StatusBadRequest, "datos_incompletos") writeJSONError(w, http.StatusBadRequest, "datos_incompletos")
return return
@@ -284,7 +288,15 @@ func handleBook(ea *eaClient) http.HandlerFunc {
} }
notes := strings.Join(notesLines, "\n") 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) customerID, err := ea.findOrCreateCustomer(req)
if err != nil { if err != nil {
log.Printf("book: error con cliente: %v", err) log.Printf("book: error con cliente: %v", err)
@@ -292,12 +304,12 @@ func handleBook(ea *eaClient) http.HandlerFunc {
return return
} }
// 6. crear la cita (en EA). // 7. crear la cita (en EA).
appt := eaAppointment{ appt := eaAppointment{
Start: start.Format(layout), Start: start.Format(layout),
End: end.Format(layout), End: end.Format(layout),
ServiceID: int(primary.ID), ServiceID: int(primary.ID),
ProviderID: req.ProviderID, ProviderID: providerID,
CustomerID: customerID, CustomerID: customerID,
Notes: notes, Notes: notes,
Status: "booked", 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. // findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea.
func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) {
var found []eaCustomer var found []eaCustomer
@@ -408,6 +464,10 @@ func main() {
// Reservas: orquestadas server-side contra EasyAppointments. // Reservas: orquestadas server-side contra EasyAppointments.
http.HandleFunc("/api/book", handleBook(ea)) 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))) http.Handle("/", http.FileServer(http.FS(staticFS)))
addr := fmt.Sprintf(":%d", *port) addr := fmt.Sprintf(":%d", *port)
+75 -5
View File
@@ -93,13 +93,17 @@ func TestHandleBook(t *testing.T) {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":5}`)) 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) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()} ea := &eaClient{base: srv.URL, http: srv.Client()}
body := `{"nombre":"Ana","apellidos":"López Gil","email":"ana@x.com","telefono":"600","mensaje":"hola", 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)) req := httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handleBook(ea)(rec, req) 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" { 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) 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) t.Errorf("appt = %+v", gotAppt)
} }
// Nombre y apellidos viajan por separado hasta EA, sin adivinar cuál es cuál. // 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. // Toda la gestión pasó por EA y nada más.
sort.Strings(hits) 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, ",") { if strings.Join(hits, ",") != strings.Join(want, ",") {
t.Errorf("llamadas a EA = %v, want %v", hits, want) t.Errorf("llamadas a EA = %v, want %v", hits, want)
} }
@@ -148,7 +153,7 @@ func TestHandleBookRejectsUnknownService(t *testing.T) {
defer srv.Close() defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()} 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() rec := httptest.NewRecorder()
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) 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. // Sin apellidos ⇒ 400 sin llegar a llamar a EA.
func TestHandleBookRequiresApellidos(t *testing.T) { func TestHandleBookRequiresApellidos(t *testing.T) {
called := false called := false
@@ -169,7 +239,7 @@ func TestHandleBookRequiresApellidos(t *testing.T) {
defer srv.Close() defer srv.Close()
ea := &eaClient{base: srv.URL, http: srv.Client()} 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() rec := httptest.NewRecorder()
handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)))
+15 -5
View File
@@ -211,9 +211,16 @@
} }
// === EasyAppointments config === // === EasyAppointments config ===
// IMPORTANTE: Después de configurar EasyAppointments, ve al admin (http://localhost:8888) // El id del provider lo resuelve el servidor (/api/config): autodescubierto
// y crea al menos un Provider. Anota su ID y ponlo aquí. // en EasyAppointments o fijado con EA_PROVIDER_ID en .env. Aquí nunca va
const PROVIDER_ID = 5; // 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) ---- // ---- Horario desde EasyAppointments (Settings → Business, company_working_plan) ----
// Si la petición falla se mantiene el horario estático del HTML. // Si la petición falla se mantiene el horario estático del HTML.
@@ -655,7 +662,6 @@
email: email, email: email,
telefono: form.telefono.value.trim(), telefono: form.telefono.value.trim(),
mensaje: form.mensaje.value.trim(), mensaje: form.mensaje.value.trim(),
providerId: PROVIDER_ID,
serviceIds: selectedServices.map(s => s.id).filter(Boolean), serviceIds: selectedServices.map(s => s.id).filter(Boolean),
date: selectedDate, date: selectedDate,
time: selectedHora time: selectedHora
@@ -696,7 +702,11 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadSchedule(); 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(); initCalendar();
initBookingForm(); initBookingForm();
}); });