independencia del provider
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+75
-5
@@ -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)))
|
||||
|
||||
|
||||
+15
-5
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user