diff --git a/README.md b/README.md index 70d0d7b..0071f56 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,11 @@ docker compose up -d --build ni clientes, ni citas, ni servicios. Todo se lee y se escribe contra la API de EA. - Los servicios y la disponibilidad los lee el frontend de EA (a través del proxy de solo lectura). +- El calendario deshabilita los días sin ningún hueco vía `GET /api/month-availability` + (agregado server-side día a día contra EA, con caché de 10 min por mes+servicio). - Al reservar, el navegador envía la selección a `POST /api/book` y **el servidor**: - lee los servicios de EA (fuente de verdad de nombre y duración), - - busca o crea el cliente por **email**, + - busca o crea el cliente por **teléfono** (identifica al cliente; el email es opcional), - crea **una** cita con el servicio de mayor duración y el bloque total en las notas. - La gestión completa (clientes, servicios, citas, proveedores, etc.) se hace desde http://localhost:8888 @@ -96,8 +98,8 @@ Cómo funciona actualmente con EasyAppointments: - En el campo `notes` se guarda el detalle completo: ``` Nombre: ... - Email: ... Teléfono: ... + Email: ... (solo si el cliente lo dio) Servicios: Corte + Tinte + ... Duración total: 120 min Mensaje: ... diff --git a/main.go b/main.go index f1dd79b..f79b6eb 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" ) @@ -158,7 +159,7 @@ type eaCustomer struct { ID flexInt `json:"id,omitempty"` FirstName string `json:"firstName"` LastName string `json:"lastName"` - Email string `json:"email"` + Email string `json:"email,omitempty"` Phone string `json:"phone"` Notes string `json:"notes"` } @@ -199,7 +200,7 @@ var ( // 1. valida la entrada, // 2. lee los servicios de EA (fuente de verdad de nombre/duración), // 3. resuelve el provider (server-side, ver providerID), -// 4. busca o crea el cliente por email, +// 4. busca o crea el cliente por teléfono (es el dato que lo identifica; el email es opcional), // 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) { @@ -219,7 +220,7 @@ func handleBook(ea *eaClient) http.HandlerFunc { req.Telefono = strings.TrimSpace(req.Telefono) req.Mensaje = strings.TrimSpace(req.Mensaje) - if req.Nombre == "" || req.Apellidos == "" || req.Email == "" || req.Telefono == "" || + if req.Nombre == "" || req.Apellidos == "" || normPhone(req.Telefono) == "" || len(req.ServiceIDs) == 0 || !reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) { writeJSONError(w, http.StatusBadRequest, "datos_incompletos") @@ -278,11 +279,15 @@ func handleBook(ea *eaClient) http.HandlerFunc { } notesLines := []string{ "Nombre: " + req.Nombre + " " + req.Apellidos, - "Email: " + req.Email, "Teléfono: " + req.Telefono, - "Servicios: " + strings.Join(names, " + "), - fmt.Sprintf("Duración total: %d min", total), } + if req.Email != "" { + notesLines = append(notesLines, "Email: "+req.Email) + } + notesLines = append(notesLines, + "Servicios: "+strings.Join(names, " + "), + fmt.Sprintf("Duración total: %d min", total), + ) if req.Mensaje != "" { notesLines = append(notesLines, "Mensaje: "+req.Mensaje) } @@ -296,7 +301,7 @@ func handleBook(ea *eaClient) http.HandlerFunc { return } - // 6. cliente: buscar por email o crear (en EA). + // 6. cliente: buscar por teléfono o crear (en EA). customerID, err := ea.findOrCreateCustomer(req) if err != nil { log.Printf("book: error con cliente: %v", err) @@ -370,12 +375,137 @@ func handleConfig(ea *eaClient) http.HandlerFunc { } } -// findOrCreateCustomer busca un cliente por email en EA y, si no existe, lo crea. +// normPhone deja solo los dígitos ("690 91 88 15" → "690918815") para que el +// mismo número escrito de formas distintas identifique al mismo cliente. +func normPhone(s string) string { + var b strings.Builder + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +// samePhone compara dos teléfonos por sus dígitos, tolerando prefijos de país: +// "+34 690918815" y "690918815" son el mismo número (sufijo común de ≥9 dígitos). +func samePhone(a, b string) bool { + a, b = normPhone(a), normPhone(b) + if a == "" || b == "" { + return false + } + if len(a) > len(b) { + a, b = b, a + } + return a == b || (len(a) >= 9 && strings.HasSuffix(b, a)) +} + +// handleMonthAvailability devuelve los días de un mes sin NINGÚN hueco libre +// para un servicio ("unavailableDays"), para que el calendario los deshabilite +// sin que el usuario tenga que pulsarlos. Consulta las availabilities de EA +// día a día (en paralelo, saltando los pasados) y cachea el resultado 10 min +// por mes+servicio para no repetir ~30 llamadas a EA en cada render. +func handleMonthAvailability(ea *eaClient) http.HandlerFunc { + type cacheEntry struct { + expires time.Time + days []string + } + var ( + mu sync.Mutex + cache = make(map[string]cacheEntry) + ) + + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + month := r.URL.Query().Get("month") + first, errMonth := time.Parse("2006-01", month) + serviceID, errService := strconv.Atoi(r.URL.Query().Get("serviceId")) + + // Solo del mes actual a 12 meses vista: es lo único que el calendario + // puede pedir y acota el abanico de llamadas a EA. + now := time.Now() + currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + if errMonth != nil || errService != nil || serviceID <= 0 || + first.Before(currentMonth) || first.After(currentMonth.AddDate(1, 0, 0)) { + writeJSONError(w, http.StatusBadRequest, "parametros_invalidos") + return + } + + key := fmt.Sprintf("%s:%d", month, serviceID) + mu.Lock() + e, hit := cache[key] + mu.Unlock() + + if !hit || !now.Before(e.expires) { + providerID, err := ea.providerID() + if err != nil { + log.Printf("month-availability: error resolviendo provider: %v", err) + writeJSONError(w, http.StatusBadGateway, "proveedor_no_disponible") + return + } + + daysInMonth := first.AddDate(0, 1, -1).Day() + today := now.Format("2006-01-02") + unavailable := make([]bool, daysInMonth+1) + var wg sync.WaitGroup + sem := make(chan struct{}, 6) // máx. 6 llamadas a EA en paralelo + for d := 1; d <= daysInMonth; d++ { + date := fmt.Sprintf("%s-%02d", month, d) + if date < today { // los días pasados ya los bloquea el navegador + continue + } + wg.Add(1) + go func(d int, date string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + var slots []string + path := fmt.Sprintf("/availabilities?providerId=%d&serviceId=%d&date=%s", providerID, serviceID, date) + // Si EA falla, el día se deja clicable: al seleccionarlo + // se consulta de nuevo y allí se informa del error. + if err := ea.do(http.MethodGet, path, nil, &slots); err == nil && len(slots) == 0 { + unavailable[d] = true + } + }(d, date) + } + wg.Wait() + + days := []string{} + for d := 1; d <= daysInMonth; d++ { + if unavailable[d] { + days = append(days, fmt.Sprintf("%s-%02d", month, d)) + } + } + e = cacheEntry{expires: time.Now().Add(10 * time.Minute), days: days} + mu.Lock() + cache[key] = e + mu.Unlock() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string][]string{"unavailableDays": e.days}) + } +} + +// findOrCreateCustomer busca el cliente por TELÉFONO en EA (es el dato que lo +// identifica; el email es opcional) y, si no existe, lo crea. Así todas las +// citas hechas con el mismo teléfono quedan asignadas al mismo cliente. func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { + phone := normPhone(req.Telefono) + + // La búsqueda usa los últimos 9 dígitos (número nacional) para encontrar + // también registros guardados con prefijo de país. + q := phone + if len(q) > 9 { + q = q[len(q)-9:] + } var found []eaCustomer - if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil { + if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(q), nil, &found); err == nil { for _, cust := range found { - if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) { + if samePhone(cust.Phone, phone) { return int(cust.ID), nil } } @@ -386,7 +516,7 @@ func (c *eaClient) findOrCreateCustomer(req bookRequest) (int, error) { FirstName: req.Nombre, LastName: req.Apellidos, Email: req.Email, - Phone: req.Telefono, + Phone: phone, Notes: "Creado desde web MAY Studio", } if err := c.do(http.MethodPost, "/customers", body, &created); err != nil { @@ -468,6 +598,10 @@ func main() { // EA_PROVIDER_ID), nunca va hardcodeado en el HTML. http.HandleFunc("/api/config", handleConfig(ea)) + // Días del mes visible sin ningún hueco libre: el calendario los pinta + // deshabilitados para ahorrar clics en días completos o festivos. + http.HandleFunc("/api/month-availability", handleMonthAvailability(ea)) + http.Handle("/", http.FileServer(http.FS(staticFS))) addr := fmt.Sprintf(":%d", *port) diff --git a/main_test.go b/main_test.go index 5a70d0b..0028e52 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,9 @@ import ( "net/http/httptest" "sort" "strings" + "sync/atomic" "testing" + "time" ) // La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda @@ -139,6 +141,82 @@ func TestHandleBook(t *testing.T) { } } +// El teléfono identifica al cliente: si ya existe en EA se reutiliza (no se +// duplica) y se le pueden crear varias citas. El email ya no es obligatorio. +func TestHandleBookReusesCustomerByPhone(t *testing.T) { + customersCreated := 0 + apptsCreated := 0 + var lastAppt eaAppointment + + mux := http.NewServeMux() + mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]eaService{{ID: 1, Name: "Corte", Duration: 30}}) + }) + mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]eaProvider{{ID: 7}}) + }) + mux.HandleFunc("/index.php/api/v1/customers", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + if q := r.URL.Query().Get("q"); q != "690918815" { + t.Errorf("q = %q, want los últimos 9 dígitos del teléfono", q) + } + // En EA está guardado con prefijo y espacios: debe reconocerse igual. + json.NewEncoder(w).Encode([]eaCustomer{{ID: 42, Phone: "+34 690 91 88 15"}}) + return + } + customersCreated++ + json.NewEncoder(w).Encode(eaCustomer{ID: 99}) + }) + mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) { + apptsCreated++ + json.NewDecoder(r.Body).Decode(&lastAppt) + w.WriteHeader(http.StatusCreated) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + ea := &eaClient{base: srv.URL, http: srv.Client()} + + // Dos reservas sin email y con el teléfono escrito distinto cada vez. + for _, tel := range []string{"690 918 815", "+34690918815"} { + body := `{"nombre":"Ana","apellidos":"López","telefono":"` + tel + `","serviceIds":[1],"date":"2026-07-01","time":"10:00"}` + rec := httptest.NewRecorder() + handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + } + + if customersCreated != 0 { + t.Errorf("se crearon %d clientes nuevos; ese teléfono ya existía en EA", customersCreated) + } + if apptsCreated != 2 { + t.Errorf("citas creadas = %d, want 2 (varias citas para el mismo cliente)", apptsCreated) + } + if lastAppt.CustomerID != 42 { + t.Errorf("customerId = %d, want 42 (el cliente existente)", lastAppt.CustomerID) + } +} + +func TestSamePhone(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"690918815", "690 91 88 15", true}, + {"+34 690918815", "690918815", true}, + {"0034690918815", "690.91.88.15", true}, + {"690918815", "690918816", false}, + {"600", "600", true}, + {"600", "222600", false}, // sufijos cortos no identifican + {"", "690918815", false}, + } + for _, c := range cases { + if got := samePhone(c.a, c.b); got != c.want { + t.Errorf("samePhone(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + // Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita. func TestHandleBookRejectsUnknownService(t *testing.T) { created := false @@ -230,6 +308,82 @@ func TestProviderID(t *testing.T) { }) } +// /api/month-availability agrega las availabilities de EA día a día: devuelve +// los días sin ningún hueco para que el calendario los deshabilite, y cachea +// el resultado (una sola pasada de llamadas a EA por mes+servicio). +func TestHandleMonthAvailability(t *testing.T) { + // Mes siguiente al actual: así ningún día es pasado y se consultan todos. + now := time.Now() + first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0) + month := first.Format("2006-01") + daysInMonth := first.AddDate(0, 1, -1).Day() + full1, full2 := month+"-15", month+"-16" + + var eaCalls atomic.Int64 + mux := http.NewServeMux() + mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]eaProvider{{ID: 7}}) + }) + mux.HandleFunc("/index.php/api/v1/availabilities", func(w http.ResponseWriter, r *http.Request) { + eaCalls.Add(1) + if r.URL.Query().Get("serviceId") != "3" || r.URL.Query().Get("providerId") != "7" { + t.Errorf("query inesperada: %s", r.URL.RawQuery) + } + if date := r.URL.Query().Get("date"); date == full1 || date == full2 { + w.Write([]byte(`[]`)) // día completo: sin ningún hueco + return + } + w.Write([]byte(`["10:00","10:30"]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + handler := handleMonthAvailability(&eaClient{base: srv.URL, http: srv.Client()}) + + get := func() *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodGet, "/api/month-availability?month="+month+"&serviceId=3", nil)) + return rec + } + + rec := get() + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got struct { + UnavailableDays []string `json:"unavailableDays"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("json: %v", err) + } + if strings.Join(got.UnavailableDays, ",") != full1+","+full2 { + t.Errorf("unavailableDays = %v, want [%s %s]", got.UnavailableDays, full1, full2) + } + if n := eaCalls.Load(); n != int64(daysInMonth) { + t.Errorf("llamadas a EA = %d, want %d (una por día del mes)", n, daysInMonth) + } + + // Segunda petición: servida desde la caché, sin más llamadas a EA. + if rec := get(); rec.Code != http.StatusOK { + t.Fatalf("status con caché = %d, want 200", rec.Code) + } + if n := eaCalls.Load(); n != int64(daysInMonth) { + t.Errorf("la caché no se usó: %d llamadas a EA", n) + } + + // Parámetros inválidos ⇒ 400 (meses pasados, sin servicio, formato malo). + for _, qs := range []string{ + "month=2020-01&serviceId=3", + "month=" + month, + "month=13-2026&serviceId=3", + } { + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodGet, "/api/month-availability?"+qs, nil)) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (%s)", rec.Code, qs) + } + } +} + // Sin apellidos ⇒ 400 sin llegar a llamar a EA. func TestHandleBookRequiresApellidos(t *testing.T) { called := false @@ -250,3 +404,26 @@ func TestHandleBookRequiresApellidos(t *testing.T) { t.Error("se llamó a EA con datos incompletos") } } + +// Sin teléfono (o sin ningún dígito) ⇒ 400 sin llamar a EA: es el dato que +// identifica al cliente. El email en cambio ya no es obligatorio. +func TestHandleBookRequiresTelefono(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) + defer srv.Close() + ea := &eaClient{base: srv.URL, http: srv.Client()} + + for _, body := range []string{ + `{"nombre":"Ana","apellidos":"López","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`, + `{"nombre":"Ana","apellidos":"López","telefono":"--","serviceIds":[1],"date":"2026-07-01","time":"10:00"}`, + } { + rec := httptest.NewRecorder() + handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %s)", rec.Code, body) + } + } + if called { + t.Error("se llamó a EA con datos incompletos") + } +} diff --git a/static/index.html b/static/index.html index aaeac73..33e7ff1 100644 --- a/static/index.html +++ b/static/index.html @@ -3,38 +3,39 @@
-
+
Cortes, color, peinados y tratamientos capilares para toda la familia. Un espacio pensado para ti.
+
Peluquera & fundadora
-Soy Mayra, fundadora de MAY Studio, un espacio dedicado al cuidado del cabello con un enfoque personalizado.
+Soy Mayra, fundadora de MAY STUDIO, un espacio dedicado al cuidado del cabello con un enfoque personalizado.
Estoy especializada en color, mechas y cortes, buscando siempre resultados naturales, equilibrados y elegantes.
Cada cliente recibe una atención individualizada, analizando su cabello y su estilo para crear un resultado que sea bonito, favorecedor y fácil de mantener día a día.
-MAY Studio nace con la idea de ofrecer un ambiente tranquilo donde el cuidado del cabello y la atención al detalle son lo más importante.
+MAY STUDIO nace con la idea de ofrecer un ambiente tranquilo donde el cuidado del cabello y la atención al detalle son lo más importante.
Explora algunos de los estilos y transformaciones que creamos en MAY Studio. Haz clic en cualquier imagen para ampliar.
+Explora algunos de los estilos y transformaciones que creamos en MAY STUDIO. Haz clic en cualquier imagen para ampliar.


Carrer d'Horta, 12
08032 Barcelona (Horta)
Carrer Pintor Josep Pinos, 22
08031 Barcelona
Metro L5 — Horta
Bus V19, 45, 102
Metro: L3 (verde), parada Valldaura; L5 (azul), parada Horta
Bus: 102, V21, V23, V25 (Pl. Botticelli); 102, N4, V25 (Av. l'Estatut de Catalunya - Campoamor)