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).
|
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user