diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..50c1c77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +.dockerignore +.env +.env.example +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f182f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copia este archivo a .env y rellena los valores reales: +# cp .env.example .env +# .env está en .gitignore: nunca se sube al repositorio. + +# Token de API (admin) de EasyAppointments. +# Créalo en el panel de EA → Settings → API. +# IMPORTANTE: si reutilizas un token que estuvo en git, ROTALO primero. +EA_API_KEY=changeme-token-de-easyappointments + +# Contraseña de MySQL (usuario root, usado por EasyAppointments). +# Usa una cadena larga y aleatoria. +MYSQL_ROOT_PASSWORD=changeme-contrasena-fuerte + +# URL pública del panel de EasyAppointments (cámbiala en producción). +EA_BASE_URL=http://localhost:8888 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b92bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Secretos locales (usar .env.example como plantilla) +.env + +# Binario compilado +/server + +# Runbook con secretos a purgar (NO commitear) +SIGUIENTES-PASOS.md diff --git a/README.md b/README.md index 153bbd2..70d0d7b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,16 @@ El frontend (HTML/JS puro) sigue siendo el de la landing, pero **las reservas, c ## Cómo ejecutar (Desarrollo y Producción) -### 1. Levantar todo +### 1. Configurar secretos + +```sh +cp .env.example .env +# Edita .env: pon tu EA_API_KEY y una MYSQL_ROOT_PASSWORD fuerte. +``` + +`.env` está en `.gitignore` y nunca se sube al repositorio. + +### 2. Levantar todo ```sh docker compose up -d --build @@ -22,7 +31,7 @@ docker compose up -d --build - Landing + reservas: **http://localhost:8234** - Panel de administración EasyAppointments: **http://localhost:8888** -### 2. Primer arranque (importante) +### 3. Primer arranque (importante) 1. Abre http://localhost:8888 2. Completa el asistente de instalación de EasyAppointments. @@ -33,13 +42,11 @@ docker compose up -d --build 7. Configura el **Working Plan** del proveedor (horario). 8. **Anota el ID del Provider** (normalmente 1) y edita `static/index.html` → línea `const PROVIDER_ID = 1;` -### 3. Variables importantes en compose +### Variables (en `.env`) -Edita `docker-compose.yml` antes de levantar: - -- `EA_API_USERNAME` + `EA_API_PASSWORD` (o mejor: configura **API Key** en Settings de EA y usa `EA_API_KEY`) -- `BASE_URL` del servicio `easyappointments` -- Contraseña de MySQL +- `EA_API_KEY` — token de API de EasyAppointments (Settings → API en el panel). +- `MYSQL_ROOT_PASSWORD` — contraseña de la base de datos. +- `EA_BASE_URL` — URL pública del panel de EA (cámbiala en producción). ### Actualizar @@ -50,12 +57,27 @@ docker compose up -d --build ## Notas sobre la integración -- Los servicios y la disponibilidad los obtiene el frontend directamente de la API de EasyAppointments. -- Al reservar: - - Busca o crea automáticamente el cliente usando el **email**. - - Crea la cita en EasyAppointments (con todos los servicios seleccionados en las notas). +**EasyAppointments es la única fuente de verdad.** El servidor Go no guarda nada: +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). +- 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**, + - 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 -- La API antigua (carpeta `api/` + admin.html antigua + services.yml/mail.yml) ha sido eliminada completamente. + +## Seguridad del proxy + +El servidor Go **no** expone la API de EasyAppointments tal cual: + +- El proxy `/ea-api/` solo deja pasar **lecturas no sensibles** (categorías, servicios, + proveedor, disponibilidad), añadiendo la auth admin en el servidor. La lista blanca + está en `main.go` (`eaAllowedRoutes`); cualquier otra ruta/método recibe `404` + **antes** de inyectar credenciales. +- Las **escrituras y la búsqueda de clientes** no pasan por el proxy: se orquestan + server-side en `POST /api/book`. Así el navegador nunca puede listar/borrar/modificar + clientes ni citas, ni volcar datos personales de EA. ## Comportamiento multiservicio diff --git a/docker-compose.yml b/docker-compose.yml index 930d69e..a879d2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,8 @@ services: ports: - "8234:8080" environment: - - EA_API_USERNAME=may # Usuario de EA (ver ea_user_settings.username del admin). Usa tu password real. - # EA_API_PASSWORD=yourpassword - - EA_API_KEY=t9T6IdUkAVGRoB1Shm8MgLcoIuMvgkWJSFGde4trAUEUIOC4iRtc57TvFO6gXMVr # Recomendado: en el panel EA ve a Settings y configura "API Token", luego usa esta variable (mejor que user/pass) + # Token de API de EasyAppointments. Definido en .env (ver .env.example). + - EA_API_KEY=${EA_API_KEY} depends_on: - easyappointments @@ -20,22 +19,29 @@ services: ports: - "8888:80" # Panel admin: http://localhost:8888 depends_on: - - mysql + mysql: + condition: service_healthy environment: - - BASE_URL=http://localhost:8888 # Cambia en producción + - BASE_URL=${EA_BASE_URL:-http://localhost:8888} - DEBUG_MODE=FALSE - DB_HOST=mysql - DB_NAME=easyappointments - DB_USERNAME=root - - DB_PASSWORD=secret # Cambia por contraseña fuerte + - DB_PASSWORD=${MYSQL_ROOT_PASSWORD} mysql: image: mysql:8.0 container_name: may-ea-mysql restart: unless-stopped environment: - - MYSQL_ROOT_PASSWORD=secret + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - MYSQL_DATABASE=easyappointments + healthcheck: + # EA solo arranca bien si MySQL ya está listo (no solo "iniciado"). + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 10 volumes: - ea-mysql-data:/var/lib/mysql diff --git a/go.mod b/go.mod index 9e605b4..bfe16db 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module barbershop +module may go 1.24.7 diff --git a/main.go b/main.go index d624cb9..c6d68d0 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "embed" + "encoding/json" "flag" "fmt" "io" @@ -11,12 +13,343 @@ import ( "net/http/httputil" "net/url" "os" + "regexp" + "strconv" "strings" + "time" ) //go:embed all:static var static embed.FS +// Endpoints de EasyAppointments de SOLO LECTURA y no sensibles que el frontend +// puede consultar a través del proxy. Las escrituras (crear cliente y cita) y la +// búsqueda de clientes NO están aquí: se orquestan server-side en /api/book, de +// modo que el navegador nunca puede listar/borrar/modificar datos de EA. +var eaAllowedRoutes = []struct { + method string + path *regexp.Regexp +}{ + {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/categories/?$`)}, + {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/service_categories/?$`)}, + {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/services/?$`)}, + {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/availabilities/?$`)}, + {http.MethodGet, regexp.MustCompile(`^/ea-api/v1/providers/\d+/?$`)}, +} + +func eaRouteAllowed(method, path string) bool { + for _, route := range eaAllowedRoutes { + if route.method == method && route.path.MatchString(path) { + return true + } + } + return false +} + +func writeJSONError(w http.ResponseWriter, status int, code string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprintf(w, `{"error": %q}`, code) +} + +// flexInt acepta enteros que llegan como número JSON o como string ("30", 30, +// 30.0). La API de EasyAppointments mezcla ambos formatos según el campo. +type flexInt int + +func (f *flexInt) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" || s == "null" { + *f = 0 + return nil + } + if n, err := strconv.Atoi(s); err == nil { + *f = flexInt(n) + return nil + } + ff, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("flexInt: valor no numérico %q", s) + } + *f = flexInt(ff) + return nil +} + +// eaClient hace las llamadas server-side a EasyAppointments con las credenciales +// admin. EasyAppointments es la ÚNICA fuente de verdad: este servidor no almacena +// clientes ni citas, solo orquesta llamadas a su API REST. +type eaClient struct { + base string + apiKey string + user string + pass string + http *http.Client +} + +func newEAClient() *eaClient { + base := "http://easyappointments:80" + if t := os.Getenv("EA_TARGET"); t != "" { + base = t + } + return &eaClient{ + base: strings.TrimRight(base, "/"), + apiKey: os.Getenv("EA_API_KEY"), + user: os.Getenv("EA_API_USERNAME"), + pass: os.Getenv("EA_API_PASSWORD"), + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *eaClient) auth(req *http.Request) { + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } else if c.user != "" && c.pass != "" { + req.SetBasicAuth(c.user, c.pass) + } +} + +// do llama a la API v1 de EA y decodifica la respuesta JSON en out (si no es nil). +// Devuelve error si el status no es 2xx. +func (c *eaClient) do(method, path string, body, out interface{}) error { + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(buf) + } + req, err := http.NewRequest(method, c.base+"/index.php/api/v1"+path, reader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + c.auth(req) + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("EA %s %s -> %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet))) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +// ---- Tipos de EA usados en la orquestación ---- + +type eaService struct { + ID flexInt `json:"id"` + Name string `json:"name"` + Duration flexInt `json:"duration"` +} + +type eaCustomer struct { + ID flexInt `json:"id,omitempty"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Phone string `json:"phone"` + Notes string `json:"notes"` +} + +type eaAppointment struct { + Start string `json:"start"` + End string `json:"end"` + ServiceID int `json:"serviceId"` + ProviderID int `json:"providerId"` + CustomerID int `json:"customerId"` + Notes string `json:"notes"` + Status string `json:"status"` +} + +// ---- Petición de reserva desde el navegador ---- + +type bookRequest struct { + Nombre string `json:"nombre"` + 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 +} + +var ( + reDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + reTime = regexp.MustCompile(`^\d{2}:\d{2}$`) +) + +// 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. +func handleBook(ea *eaClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + + var req bookRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_json") + return + } + req.Nombre = strings.TrimSpace(req.Nombre) + req.Email = strings.TrimSpace(req.Email) + req.Telefono = strings.TrimSpace(req.Telefono) + req.Mensaje = strings.TrimSpace(req.Mensaje) + + if req.Nombre == "" || req.Email == "" || req.Telefono == "" || + len(req.ServiceIDs) == 0 || req.ProviderID <= 0 || + !reDate.MatchString(req.Date) || !reTime.MatchString(req.Time) { + writeJSONError(w, http.StatusBadRequest, "datos_incompletos") + return + } + + // 2. Servicios desde EA: no confiamos en nombres/duraciones del cliente. + var services []eaService + if err := ea.do(http.MethodGet, "/services", nil, &services); err != nil { + log.Printf("book: error leyendo servicios: %v", err) + writeJSONError(w, http.StatusBadGateway, "servicios_no_disponibles") + return + } + byID := make(map[int]eaService, len(services)) + for _, s := range services { + byID[int(s.ID)] = s + } + + var ( + chosen []eaService + total int + primary eaService + maxDur = -1 + ) + for _, id := range req.ServiceIDs { + s, ok := byID[id] + if !ok { + writeJSONError(w, http.StatusBadRequest, "servicio_invalido") + return + } + dur := int(s.Duration) + if dur <= 0 { + dur = 30 + } + total += dur + chosen = append(chosen, s) + if dur > maxDur { + maxDur = dur + primary = s + } + } + + // 3. start/end calculados a partir de la duración total REAL de EA. + start, err := time.Parse("2006-01-02 15:04", req.Date+" "+req.Time) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "fecha_invalida") + return + } + const layout = "2006-01-02 15:04:05" + end := start.Add(time.Duration(total) * time.Minute) + + // 4. notas con el detalle completo (multiservicio). + names := make([]string, len(chosen)) + for i, s := range chosen { + names[i] = s.Name + } + notesLines := []string{ + "Nombre: " + req.Nombre, + "Email: " + req.Email, + "Teléfono: " + req.Telefono, + "Servicios: " + strings.Join(names, " + "), + fmt.Sprintf("Duración total: %d min", total), + } + if req.Mensaje != "" { + notesLines = append(notesLines, "Mensaje: "+req.Mensaje) + } + notes := strings.Join(notesLines, "\n") + + // 5. cliente: buscar por email o crear (en EA). + customerID, err := ea.findOrCreateCustomer(req) + if err != nil { + log.Printf("book: error con cliente: %v", err) + writeJSONError(w, http.StatusBadGateway, "cliente_no_disponible") + return + } + + // 6. crear la cita (en EA). + appt := eaAppointment{ + Start: start.Format(layout), + End: end.Format(layout), + ServiceID: int(primary.ID), + ProviderID: req.ProviderID, + CustomerID: customerID, + Notes: notes, + Status: "booked", + } + if err := ea.do(http.MethodPost, "/appointments", appt, nil); err != nil { + log.Printf("book: error creando cita: %v", err) + writeJSONError(w, http.StatusBadGateway, "cita_no_creada") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"ok": true}`) + } +} + +// 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 + if err := c.do(http.MethodGet, "/customers?q="+url.QueryEscape(req.Email), nil, &found); err == nil { + for _, cust := range found { + if strings.EqualFold(strings.TrimSpace(cust.Email), req.Email) { + return int(cust.ID), nil + } + } + } + + first, last := splitName(req.Nombre) + var created eaCustomer + body := eaCustomer{ + FirstName: first, + LastName: last, + Email: req.Email, + Phone: req.Telefono, + Notes: "Creado desde web MAY Studio", + } + if err := c.do(http.MethodPost, "/customers", body, &created); err != nil { + return 0, err + } + if int(created.ID) == 0 { + return 0, fmt.Errorf("EA no devolvió id de cliente") + } + return int(created.ID), nil +} + +func splitName(full string) (first, last string) { + parts := strings.Fields(full) + switch len(parts) { + case 0: + return full, "" + case 1: + return parts[0], "" + default: + return parts[0], strings.Join(parts[1:], " ") + } +} + func main() { port := flag.Int("port", 8080, "puerto del servidor") flag.Parse() @@ -26,13 +359,12 @@ func main() { log.Fatal(err) } - // Proxy a EasyAppointments API - // El frontend llama a /ea-api/v1/... y aquí se reescribe y se añade auth - eaTarget := "http://easyappointments:80" - if t := os.Getenv("EA_TARGET"); t != "" { - eaTarget = t - } - eaURL, err := url.Parse(eaTarget) + ea := newEAClient() + + // Proxy de SOLO LECTURA a EasyAppointments. + // El frontend llama a /ea-api/v1/... (servicios, categorías, proveedor, + // disponibilidad) y aquí se reescribe la ruta y se añade la auth admin. + eaURL, err := url.Parse(ea.base) if err != nil { log.Fatal(err) } @@ -62,21 +394,28 @@ func main() { } http.HandleFunc("/ea-api/", func(w http.ResponseWriter, r *http.Request) { + // Allowlist método+ruta ANTES de inyectar credenciales: solo lecturas. + if !eaRouteAllowed(r.Method, r.URL.Path) { + writeJSONError(w, http.StatusNotFound, "not_found") + return + } + // /ea-api/v1/services → /index.php/api/v1/services r.URL.Path = strings.Replace(r.URL.Path, "/ea-api", "/index.php/api", 1) // Auth (server-side, nunca se expone al navegador) - if apiKey := os.Getenv("EA_API_KEY"); apiKey != "" { - r.Header.Set("Authorization", "Bearer " + apiKey) - } else if user := os.Getenv("EA_API_USERNAME"); user != "" { - if pass := os.Getenv("EA_API_PASSWORD"); pass != "" { - r.SetBasicAuth(user, pass) - } + if ea.apiKey != "" { + r.Header.Set("Authorization", "Bearer "+ea.apiKey) + } else if ea.user != "" && ea.pass != "" { + r.SetBasicAuth(ea.user, ea.pass) } eaProxy.ServeHTTP(w, r) }) + // Reservas: orquestadas server-side contra EasyAppointments. + http.HandleFunc("/api/book", handleBook(ea)) + http.Handle("/", http.FileServer(http.FS(staticFS))) addr := fmt.Sprintf(":%d", *port) diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..b9b3fec --- /dev/null +++ b/main_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" +) + +// La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda +// de clientes se hace server-side en /api/book, nunca por el proxy. +func TestEaRouteAllowed(t *testing.T) { + cases := []struct { + method string + path string + want bool + }{ + // Lecturas permitidas (las que usa el frontend) + {"GET", "/ea-api/v1/categories", true}, + {"GET", "/ea-api/v1/service_categories", true}, + {"GET", "/ea-api/v1/services", true}, + {"GET", "/ea-api/v1/availabilities", true}, + {"GET", "/ea-api/v1/providers/1", true}, + + // Bloqueadas: clientes y citas ya NO pasan por el proxy + {"GET", "/ea-api/v1/customers", false}, + {"POST", "/ea-api/v1/customers", false}, + {"POST", "/ea-api/v1/appointments", false}, + {"GET", "/ea-api/v1/appointments", false}, + {"DELETE", "/ea-api/v1/appointments/3", false}, + {"PUT", "/ea-api/v1/customers/5", false}, + {"GET", "/ea-api/v1/providers", false}, // listar todos + {"GET", "/ea-api/v1/providers/abc", false}, // id no numérico + {"GET", "/ea-api/v1/settings", false}, + {"POST", "/ea-api/v1/services", false}, + } + for _, c := range cases { + if got := eaRouteAllowed(c.method, c.path); got != c.want { + t.Errorf("eaRouteAllowed(%q, %q) = %v, want %v", c.method, c.path, got, c.want) + } + } +} + +func TestSplitName(t *testing.T) { + cases := []struct{ in, first, last string }{ + {"", "", ""}, + {"Ana", "Ana", ""}, + {"Ana López", "Ana", "López"}, + {"Ana María López Gil", "Ana", "María López Gil"}, + {" Ana López ", "Ana", "López"}, + } + for _, c := range cases { + f, l := splitName(c.in) + if f != c.first || l != c.last { + t.Errorf("splitName(%q) = (%q,%q), want (%q,%q)", c.in, f, l, c.first, c.last) + } + } +} + +func TestFlexIntUnmarshal(t *testing.T) { + var s struct { + A flexInt `json:"a"` + B flexInt `json:"b"` + C flexInt `json:"c"` + D flexInt `json:"d"` + } + if err := json.Unmarshal([]byte(`{"a":30,"b":"45","c":"60.0","d":null}`), &s); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if s.A != 30 || s.B != 45 || s.C != 60 || s.D != 0 { + t.Errorf("got %d,%d,%d,%d want 30,45,60,0", s.A, s.B, s.C, s.D) + } +} + +// TestHandleBook comprueba que una reserva se orquesta ENTERAMENTE contra la API +// de EasyAppointments: lee servicios, crea el cliente y crea la cita, calculando +// duración total y servicio principal en el servidor. +func TestHandleBook(t *testing.T) { + var gotAppt eaAppointment + var hits []string + + mux := http.NewServeMux() + mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) { + hits = append(hits, "GET services") + json.NewEncoder(w).Encode([]eaService{ + {ID: 1, Name: "Corte", Duration: 30}, + {ID: 2, Name: "Tinte", Duration: 45}, + }) + }) + mux.HandleFunc("/index.php/api/v1/customers", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + hits = append(hits, "GET customers") + json.NewEncoder(w).Encode([]eaCustomer{}) // no existe → se crea + return + } + hits = append(hits, "POST customers") + json.NewEncoder(w).Encode(eaCustomer{ID: 99}) + }) + mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) { + hits = append(hits, "POST appointments") + json.NewDecoder(r.Body).Decode(&gotAppt) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":5}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + ea := &eaClient{base: srv.URL, http: srv.Client()} + + body := `{"nombre":"Ana López","email":"ana@x.com","telefono":"600","mensaje":"hola", + "providerId":1,"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) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) + } + // Servicio principal = el de mayor duración (Tinte, id 2). + if gotAppt.ServiceID != 2 { + t.Errorf("serviceId = %d, want 2 (mayor duración)", gotAppt.ServiceID) + } + // Fin = inicio + 30 + 45 = 75 min. + 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" { + t.Errorf("appt = %+v", gotAppt) + } + if !strings.Contains(gotAppt.Notes, "Duración total: 75 min") || + !strings.Contains(gotAppt.Notes, "Servicios: Corte + Tinte") { + t.Errorf("notes = %q", gotAppt.Notes) + } + // Toda la gestión pasó por EA y nada más. + sort.Strings(hits) + want := []string{"GET customers", "GET services", "POST appointments", "POST customers"} + if strings.Join(hits, ",") != strings.Join(want, ",") { + t.Errorf("llamadas a EA = %v, want %v", hits, want) + } +} + +// Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita. +func TestHandleBookRejectsUnknownService(t *testing.T) { + created := false + 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/appointments", func(w http.ResponseWriter, r *http.Request) { + created = true + }) + srv := httptest.NewServer(mux) + defer srv.Close() + ea := &eaClient{base: srv.URL, http: srv.Client()} + + body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","providerId":1,"serviceIds":[999],"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", rec.Code) + } + if created { + t.Error("se creó una cita con un servicio inexistente") + } +} diff --git a/media/work/IMG-20260304-WA0001.jpg b/media/work/IMG-20260304-WA0001.jpg deleted file mode 100644 index 76d690b..0000000 Binary files a/media/work/IMG-20260304-WA0001.jpg and /dev/null differ diff --git a/media/work/IMG-20260304-WA0002.jpg b/media/work/IMG-20260304-WA0002.jpg deleted file mode 100644 index 1b185f8..0000000 Binary files a/media/work/IMG-20260304-WA0002.jpg and /dev/null differ diff --git a/media/work/IMG-20260304-WA0003.jpg b/media/work/IMG-20260304-WA0003.jpg deleted file mode 100644 index db3e08a..0000000 Binary files a/media/work/IMG-20260304-WA0003.jpg and /dev/null differ diff --git a/media/work/IMG-20260304-WA0004.jpg b/media/work/IMG-20260304-WA0004.jpg deleted file mode 100644 index 121b379..0000000 Binary files a/media/work/IMG-20260304-WA0004.jpg and /dev/null differ diff --git a/media/work/IMG-20260304-WA0005.jpg b/media/work/IMG-20260304-WA0005.jpg deleted file mode 100644 index f3722ac..0000000 Binary files a/media/work/IMG-20260304-WA0005.jpg and /dev/null differ diff --git a/media/work/IMG-20260304-WA0006.jpg b/media/work/IMG-20260304-WA0006.jpg deleted file mode 100644 index 53b1cfa..0000000 Binary files a/media/work/IMG-20260304-WA0006.jpg and /dev/null differ diff --git a/static/assets/.gitkeep b/static/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/static/assets/servicio-caballero.jpg b/static/assets/servicio-caballero.jpg deleted file mode 100644 index f08c747..0000000 Binary files a/static/assets/servicio-caballero.jpg and /dev/null differ diff --git a/static/assets/servicio-ninos.jpg b/static/assets/servicio-ninos.jpg deleted file mode 100644 index 1507300..0000000 Binary files a/static/assets/servicio-ninos.jpg and /dev/null differ diff --git a/static/assets/servicio-senora.jpg b/static/assets/servicio-senora.jpg deleted file mode 100644 index 33fa213..0000000 Binary files a/static/assets/servicio-senora.jpg and /dev/null differ diff --git a/static/favicon-48x48.png b/static/favicon-48x48.png deleted file mode 100644 index 64d9e90..0000000 Binary files a/static/favicon-48x48.png and /dev/null differ diff --git a/static/favicon.png b/static/favicon.png deleted file mode 100644 index 7f47bb0..0000000 Binary files a/static/favicon.png and /dev/null differ diff --git a/static/index.html b/static/index.html index 56ab546..41a1fb5 100644 --- a/static/index.html +++ b/static/index.html @@ -5,6 +5,9 @@ MAY Studio — Peluquería en Barcelona + + + @@ -211,7 +214,6 @@ // ---- Service selector state ---- let selectedServices = []; // [{id, nombre, duracion}] let totalDuration = 0; - let allServices = []; // cache de servicios EA // ---- Categories and Services from EasyAppointments ---- async function loadCategories() { @@ -237,7 +239,6 @@ ]); if (!servicesRes.ok) throw new Error('HTTP ' + servicesRes.status); const services = await servicesRes.json(); - allServices = services; // Filter services to those assigned to this provider let displayServices = services; @@ -264,7 +265,6 @@ const catInfo = catMap[catId] || {}; grouped[catId] = { nombre: catInfo.name || catInfo.title || 'Otros servicios', - imagen: '', servicios: [] }; } @@ -570,45 +570,6 @@ document.getElementById('booking-form').style.display = 'none'; } - // Busca cliente por email o lo crea en EasyAppointments - async function findOrCreateCustomer(nombre, email, telefono) { - // Buscar - try { - const searchRes = await fetch(`/ea-api/v1/customers?q=${encodeURIComponent(email)}`); - if (searchRes.ok) { - const list = await searchRes.json(); - if (Array.isArray(list) && list.length > 0) { - return list[0].id; - } - } - } catch (e) { console.warn('Búsqueda de cliente falló', e); } - - // Crear nuevo - const parts = nombre.trim().split(/\s+/); - const firstName = parts[0] || nombre; - const lastName = parts.slice(1).join(' ') || ''; - - const createRes = await fetch('/ea-api/v1/customers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: firstName, - lastName: lastName, - email: email, - phone: telefono || '', - notes: 'Creado desde web MAY Studio' - }) - }); - - if (!createRes.ok) { - const errText = await createRes.text().catch(() => ''); - throw new Error('No se pudo crear el cliente en EasyAppointments: ' + errText); - } - - const created = await createRes.json(); - return created.id; - } - function initBookingForm() { document.getElementById('booking-form').addEventListener('submit', async function(e) { e.preventDefault(); @@ -627,49 +588,23 @@ result.style.display = 'none'; try { - // 1. Cliente (por email) - const customerId = await findOrCreateCustomer( - form.nombre.value.trim(), - email, - form.telefono.value.trim() - ); - - // 2. Preparar appointment (usamos el servicio principal - el de mayor duración - + notas con todos) - const primaryServiceId = getPrimaryServiceId(); - const primaryService = selectedServices.find(s => s.id === primaryServiceId) || selectedServices[0]; - if (!primaryService || !primaryService.id) { - throw new Error('No se encontró ID de servicio válido'); - } - - const start = `${selectedDate} ${selectedHora}:00`; - // Duración total (EA usará la del servicio, pero ponemos nota) - const endDate = new Date(`${selectedDate}T${selectedHora}`); - endDate.setMinutes(endDate.getMinutes() + totalDuration); - const endHour = pad(endDate.getHours()) + ':' + pad(endDate.getMinutes()); - - const notes = [ - `Nombre: ${form.nombre.value.trim()}`, - `Email: ${email}`, - `Teléfono: ${form.telefono.value.trim()}`, - `Servicios: ${selectedServices.map(s => s.nombre).join(' + ')}`, - `Duración total: ${totalDuration} min`, - form.mensaje.value.trim() ? `Mensaje: ${form.mensaje.value.trim()}` : '' - ].filter(Boolean).join('\n'); - - const appointment = { - start: start, - end: `${selectedDate} ${endHour}:00`, - serviceId: primaryService.id, + // El servidor (server-side) busca/crea el cliente y crea la cita + // contra EasyAppointments. El navegador solo envía la selección. + const payload = { + nombre: form.nombre.value.trim(), + email: email, + telefono: form.telefono.value.trim(), + mensaje: form.mensaje.value.trim(), providerId: PROVIDER_ID, - customerId: customerId, - notes: notes, - status: 'booked' + serviceIds: selectedServices.map(s => s.id).filter(Boolean), + date: selectedDate, + time: selectedHora }; - const res = await fetch('/ea-api/v1/appointments', { + const res = await fetch('/api/book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(appointment), + body: JSON.stringify(payload), }); if (res.ok) { @@ -685,7 +620,7 @@ } else { const err = await res.json().catch(() => ({})); result.className = 'booking-result booking-error'; - result.textContent = (err.message || err.error || JSON.stringify(err)) || 'Error creando la cita en EasyAppointments.'; + result.textContent = err.error || 'No se pudo crear la reserva. Inténtalo de nuevo o llámanos.'; } } catch (err) { console.error(err); diff --git a/static/style.css b/static/style.css index 4e88d2e..c05f49c 100644 --- a/static/style.css +++ b/static/style.css @@ -108,15 +108,6 @@ img { max-width: 100%; display: block; } ); } -/* Fallback gradient when no image is loaded */ -.hero-bg img[src=""]::before, -.hero-bg img:not([src])::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(160deg, var(--bg) 40%, var(--bg-alt) 100%); -} - .hero-content { position: relative; z-index: 1; @@ -190,72 +181,6 @@ img { max-width: 100%; display: block; } margin-right: auto; } -/* ============ SERVICES ============ */ -.services-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 24px; -} - -.service-card { - background: var(--white); - border-radius: var(--radius); - overflow: hidden; - border: 1px solid rgba(0,0,0,0.05); - transition: transform 0.2s, box-shadow 0.2s; -} -.service-card:hover { - transform: translateY(-3px); - box-shadow: 0 8px 24px rgba(0,0,0,0.06); -} - -.service-img { - width: 100%; - height: 220px; - overflow: hidden; - background: var(--bg-alt); -} - -.service-img img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.service-body { - padding: 24px; -} - -.service-body h3 { - font-family: 'DM Serif Display', serif; - font-size: 1.3rem; - margin-bottom: 16px; -} - -.service-list { - list-style: none; -} - -.service-list li { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid rgba(0,0,0,0.06); - font-size: 0.9rem; - color: var(--text-mid); -} - -.service-list li:last-child { - border-bottom: none; -} - -.price { - font-weight: 600; - color: var(--accent); - white-space: nowrap; -} - /* ============ GALLERY ============ */ .gallery-grid { display: grid; @@ -538,14 +463,6 @@ img { max-width: 100%; display: block; } color: var(--accent); } -.slot-occupied { - background: var(--bg-alt); - color: rgba(0,0,0,0.25); - text-decoration: line-through; - cursor: default; - border-color: transparent; -} - .slot-selected { background: var(--accent) !important; color: var(--white) !important; @@ -624,11 +541,6 @@ img { max-width: 100%; display: block; } transition: opacity 0.2s; } -.svc-cat-card.svc-cat-locked { - opacity: 0.35; - pointer-events: none; -} - .svc-cat-title { font-family: 'DM Serif Display', serif; font-size: 1rem; @@ -714,9 +626,6 @@ img { max-width: 100%; display: block; } /* ============ RESPONSIVE ============ */ @media (max-width: 768px) { - .services-grid { - grid-template-columns: 1fr; - } .gallery-grid { grid-template-columns: repeat(2, 1fr); }