611 lines
18 KiB
Go
611 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"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+/?$`)},
|
|
// Solo este ajuste concreto (horario del negocio); el resto de /settings
|
|
// sigue bloqueado porque contiene datos sensibles.
|
|
{http.MethodGet, regexp.MustCompile(`^/ea-api/v1/settings/company_working_plan/?$`)},
|
|
}
|
|
|
|
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,omitempty"`
|
|
Phone string `json:"phone"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
type eaProvider struct {
|
|
ID flexInt `json:"id"`
|
|
}
|
|
|
|
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"`
|
|
Apellidos string `json:"apellidos"`
|
|
Email string `json:"email"`
|
|
Telefono string `json:"telefono"`
|
|
Mensaje string `json:"mensaje"`
|
|
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. resuelve el provider (server-side, ver providerID),
|
|
// 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) {
|
|
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.Apellidos = strings.TrimSpace(req.Apellidos)
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
req.Telefono = strings.TrimSpace(req.Telefono)
|
|
req.Mensaje = strings.TrimSpace(req.Mensaje)
|
|
|
|
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")
|
|
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 + " " + req.Apellidos,
|
|
"Teléfono: " + req.Telefono,
|
|
}
|
|
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)
|
|
}
|
|
notes := strings.Join(notesLines, "\n")
|
|
|
|
// 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 teléfono 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
|
|
}
|
|
|
|
// 7. crear la cita (en EA).
|
|
appt := eaAppointment{
|
|
Start: start.Format(layout),
|
|
End: end.Format(layout),
|
|
ServiceID: int(primary.ID),
|
|
ProviderID: 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}`)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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(q), nil, &found); err == nil {
|
|
for _, cust := range found {
|
|
if samePhone(cust.Phone, phone) {
|
|
return int(cust.ID), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
var created eaCustomer
|
|
body := eaCustomer{
|
|
FirstName: req.Nombre,
|
|
LastName: req.Apellidos,
|
|
Email: req.Email,
|
|
Phone: phone,
|
|
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 main() {
|
|
port := flag.Int("port", 8080, "puerto del servidor")
|
|
flag.Parse()
|
|
|
|
staticFS, err := fs.Sub(static, "static")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
eaProxy := httputil.NewSingleHostReverseProxy(eaURL)
|
|
|
|
// Evita que el navegador muestre el popup de credenciales.
|
|
// Nunca exponemos WWW-Authenticate ni mensajes de auth del backend.
|
|
eaProxy.ModifyResponse = func(resp *http.Response) error {
|
|
resp.Header.Del("Www-Authenticate")
|
|
// Evitamos que cookies de sesión de EA lleguen al navegador del usuario público
|
|
resp.Header.Del("Set-Cookie")
|
|
// Limpiamos cabeceras que revelan el backend
|
|
resp.Header.Del("Server")
|
|
resp.Header.Del("X-Powered-By")
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
resp.StatusCode = http.StatusBadGateway
|
|
resp.Status = http.StatusText(http.StatusBadGateway)
|
|
// Reemplazamos el body para no filtrar detalles del backend
|
|
resp.Body.Close()
|
|
resp.Body = io.NopCloser(strings.NewReader(`{"error": "service_unavailable"}`))
|
|
resp.ContentLength = -1
|
|
resp.Header.Set("Content-Type", "application/json")
|
|
resp.Header.Del("Content-Length")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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 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))
|
|
|
|
// 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))
|
|
|
|
// 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)
|
|
log.Printf("Servidor iniciado en http://localhost%s", addr)
|
|
log.Fatal(http.ListenAndServe(addr, nil))
|
|
}
|