revision de cloud code
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user