From c33ec8450e5a1cb7f5eca783591c646eaaaba9eb Mon Sep 17 00:00:00 2001 From: Carlo Mancosu Date: Sun, 12 Apr 2026 17:46:08 +0000 Subject: [PATCH] =?UTF-8?q?v1.3.0=20=E2=80=94=20pannello=20admin=20complet?= =?UTF-8?q?o,=20auth=20localStorage,=20Baileys=20WA,=20customers,=20calend?= =?UTF-8?q?ario,=20paginazione,=20dashboard=207gg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + Dockerfile | 13 + app/__init__.py | 0 app/config.py | 52 +++ app/database.py | 19 + app/main.py | 55 +++ app/models.py | 71 ++++ app/routers/__init__.py | 0 app/routers/admin.py | 258 ++++++++++++ app/routers/auth.py | 135 ++++++ app/routers/customers.py | 161 +++++++ app/routers/public.py | 113 +++++ app/routers/settings.py | 85 ++++ app/schemas.py | 90 ++++ app/services/__init__.py | 0 app/services/availability.py | 100 +++++ app/services/notifications.py | 114 +++++ docker-compose.yml | 52 +++ frontend/admin.html | 773 ++++++++++++++++++++++++++++++++++ frontend/favicon.svg | 5 + frontend/index.html | 339 +++++++++++++++ frontend/logo-white.svg | 150 +++++++ frontend/logo.jpg | Bin 0 -> 10796 bytes frontend/logo.png | Bin 0 -> 6196 bytes frontend/logo.svg | 150 +++++++ init.sql | 69 +++ nginx-booking.conf | 29 ++ requirements.txt | 15 + wa-service/Dockerfile | 13 + wa-service/package.json | 12 + wa-service/server.js | 194 +++++++++ 31 files changed, 3072 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/admin.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/customers.py create mode 100644 app/routers/public.py create mode 100644 app/routers/settings.py create mode 100644 app/schemas.py create mode 100644 app/services/__init__.py create mode 100644 app/services/availability.py create mode 100644 app/services/notifications.py create mode 100644 docker-compose.yml create mode 100644 frontend/admin.html create mode 100644 frontend/favicon.svg create mode 100644 frontend/index.html create mode 100644 frontend/logo-white.svg create mode 100644 frontend/logo.jpg create mode 100644 frontend/logo.png create mode 100644 frontend/logo.svg create mode 100644 init.sql create mode 100644 nginx-booking.conf create mode 100644 requirements.txt create mode 100644 wa-service/Dockerfile create mode 100644 wa-service/package.json create mode 100644 wa-service/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66640d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.env +*.log +wa-service/node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54772c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ app/ +COPY frontend/ frontend/ + +EXPOSE 8020 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e0ceabc --- /dev/null +++ b/app/config.py @@ -0,0 +1,52 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Database + database_url: str = "postgresql://booking:booking2026@db:5432/booking" + + # App + app_name: str = "BookingService" + app_url: str = "https://booking.scan360.app" + api_key: str = "changeme" + cors_origins: list[str] = ["https://farmaciaianni.it", "http://localhost:3000"] + + # Google OAuth + google_client_id: str = "" + google_client_secret: str = "" + google_redirect_uri: str = "https://booking.scan360.app/auth/callback" + allowed_domains: str = "kitzanos.com,farmaciaianni.it" + + # JWT + jwt_secret: str = "xab-booking-jwt-secret-2026" + jwt_algorithm: str = "HS256" + jwt_expire_hours: int = 24 + + # Google Calendar + google_credentials_json: str = "" + + # Notifications + smtp_host: str = "smtp.gmail.com" + smtp_port: int = 587 + smtp_user: str = "" + smtp_pass: str = "" + smtp_from: str = "prenotazioni@farmaciaianni.it" + + # WhatsApp gateway + wa_gateway_url: str = "http://host.docker.internal:18800/api/wa/send" + wa_enabled: bool = True + + # Timezone + timezone: str = "Europe/Rome" + + model_config = {"env_prefix": "BOOKING_", "env_file": ".env", "extra": "ignore"} + + @property + def allowed_domains_list(self) -> list[str]: + return [d.strip() for d in self.allowed_domains.split(",") if d.strip()] + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..da929cc --- /dev/null +++ b/app/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.config import get_settings + +settings = get_settings() +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3cb3278 --- /dev/null +++ b/app/main.py @@ -0,0 +1,55 @@ +"""BookingService — microservizio prenotazioni FastAPI. +Container Docker autonomo, DB dedicato, zero dipendenze dal resto. +Google OAuth2 per admin con vincolo dominio. +""" +import logging +from pathlib import Path +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from app.config import get_settings +from app.routers import public, admin, auth, customers +from app.routers import settings as settings_router + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +settings = get_settings() + +app = FastAPI( + title=settings.app_name, + version="1.3.0", + docs_url="/api/docs", + redoc_url=None, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router) +app.include_router(public.router) +app.include_router(admin.router) +app.include_router(customers.router) +app.include_router(settings_router.router) + + +@app.get("/api/health") +def health(): + return {"status": "ok", "service": settings.app_name, "version": "1.3.0"} + + +FRONTEND_DIR = Path("/app/frontend") +if FRONTEND_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") + + @app.get("/") + def serve_index(): + return FileResponse(str(FRONTEND_DIR / "index.html")) + + @app.get("/admin") + def serve_admin(): + return FileResponse(str(FRONTEND_DIR / "admin.html")) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..1a9c390 --- /dev/null +++ b/app/models.py @@ -0,0 +1,71 @@ +from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base + + +class Service(Base): + __tablename__ = "services" + + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False) + slug = Column(Text, nullable=False, unique=True) + duration_min = Column(Integer, nullable=False, default=15) + description = Column(Text) + category = Column(Text, default="generale") + active = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + provider_services = relationship("ProviderService", back_populates="service") + bookings = relationship("Booking", back_populates="service") + + +class Provider(Base): + __tablename__ = "providers" + + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False) + email = Column(Text) + phone = Column(Text) + google_calendar_id = Column(Text) + active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + provider_services = relationship("ProviderService", back_populates="provider") + bookings = relationship("Booking", back_populates="provider") + + +class ProviderService(Base): + __tablename__ = "provider_services" + __table_args__ = (UniqueConstraint("provider_id", "service_id"),) + + id = Column(Integer, primary_key=True) + provider_id = Column(Integer, ForeignKey("providers.id", ondelete="CASCADE")) + service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE")) + availability_rules = Column(JSONB, nullable=False, default=[]) + + provider = relationship("Provider", back_populates="provider_services") + service = relationship("Service", back_populates="provider_services") + + +class Booking(Base): + __tablename__ = "bookings" + + id = Column(Integer, primary_key=True) + service_id = Column(Integer, ForeignKey("services.id")) + provider_id = Column(Integer, ForeignKey("providers.id")) + customer_name = Column(Text, nullable=False) + customer_phone = Column(Text, nullable=False) + customer_email = Column(Text) + start_at = Column(DateTime(timezone=True), nullable=False) + end_at = Column(DateTime(timezone=True), nullable=False) + status = Column(Text, default="confirmed") + google_event_id = Column(Text) + notes = Column(Text) + reminder_sent = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + service = relationship("Service", back_populates="bookings") + provider = relationship("Provider", back_populates="bookings") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..2d6a15c --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,258 @@ +"""Endpoint admin — protetti con API key. Vista operatore.""" +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import text +from app.database import get_db +from app.models import Service, Provider, ProviderService, Booking +from app.schemas import ServiceCreate, ServiceOut, ProviderCreate, ProviderOut, BookingOut, BookingUpdate +from app.config import get_settings + +router = APIRouter(prefix="/api/admin", tags=["admin"]) +settings = get_settings() +TZ = ZoneInfo("Europe/Rome") +DAYS = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'] + + +def verify_api_key(x_api_key: str = Header(...)): + if x_api_key != settings.api_key: + raise HTTPException(401, "API key non valida") + return True + + +# === Bookings === + +@router.get("/bookings", dependencies=[Depends(verify_api_key)]) +def list_bookings( + date: date | None = None, + from_date: date | None = None, + to_date: date | None = None, + status: str | None = None, + provider_id: int | None = None, + page: int = 1, + per_page: int = 20, + db: Session = Depends(get_db), +): + q = db.query(Booking).options(joinedload(Booking.service), joinedload(Booking.provider)) + if date: + day_start = datetime.combine(date, datetime.min.time(), tzinfo=TZ) + q = q.filter(Booking.start_at >= day_start, Booking.start_at < day_start + timedelta(days=1)) + elif from_date and to_date: + q = q.filter( + Booking.start_at >= datetime.combine(from_date, datetime.min.time(), tzinfo=TZ), + Booking.start_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time(), tzinfo=TZ), + ) + if status: + q = q.filter(Booking.status == status) + if provider_id: + q = q.filter(Booking.provider_id == provider_id) + total = q.count() + items = q.order_by(Booking.start_at.desc()).offset((page-1)*per_page).limit(per_page).all() + return {"items": [BookingOut.model_validate(b) for b in items], "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page} + + +@router.put("/bookings/{booking_id}", response_model=BookingOut, dependencies=[Depends(verify_api_key)]) +def update_booking(booking_id: int, data: BookingUpdate, db: Session = Depends(get_db)): + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if not booking: + raise HTTPException(404) + if data.status: + booking.status = data.status + if data.notes is not None: + booking.notes = data.notes + db.commit() + db.refresh(booking) + return db.query(Booking).options( + joinedload(Booking.service), joinedload(Booking.provider) + ).filter(Booking.id == booking_id).first() + + +# === Services CRUD === + +@router.get("/services", response_model=list[ServiceOut], dependencies=[Depends(verify_api_key)]) +def admin_list_services(db: Session = Depends(get_db)): + return db.query(Service).order_by(Service.sort_order, Service.name).all() + +@router.post("/services", response_model=ServiceOut, dependencies=[Depends(verify_api_key)]) +def create_service(data: ServiceCreate, db: Session = Depends(get_db)): + s = Service(**data.model_dump()) + db.add(s) + db.commit() + db.refresh(s) + return s + +@router.put("/services/{service_id}", response_model=ServiceOut, dependencies=[Depends(verify_api_key)]) +def update_service(service_id: int, data: ServiceCreate, db: Session = Depends(get_db)): + s = db.query(Service).filter(Service.id == service_id).first() + if not s: + raise HTTPException(404) + for k, v in data.model_dump().items(): + setattr(s, k, v) + db.commit() + db.refresh(s) + return s + +@router.delete("/services/{service_id}", dependencies=[Depends(verify_api_key)]) +def delete_service(service_id: int, db: Session = Depends(get_db)): + s = db.query(Service).filter(Service.id == service_id).first() + if not s: + raise HTTPException(404) + db.delete(s) + db.commit() + return {"ok": True} + + +# === Providers CRUD === + +@router.get("/providers", response_model=list[ProviderOut], dependencies=[Depends(verify_api_key)]) +def admin_list_providers(db: Session = Depends(get_db)): + return db.query(Provider).filter(Provider.active == True).all() + +@router.post("/providers", dependencies=[Depends(verify_api_key)]) +def create_provider(data: ProviderCreate, db: Session = Depends(get_db)): + p = Provider(**data.model_dump()) + db.add(p) + db.commit() + db.refresh(p) + return {"id": p.id, "name": p.name} + + +# === Provider detail with service assignments === + +@router.get("/providers/detail", dependencies=[Depends(verify_api_key)]) +def providers_detail(db: Session = Depends(get_db)): + """Tutti i provider con i loro servizi assegnati e regole orarie.""" + providers = db.query(Provider).filter(Provider.active == True).order_by(Provider.id).all() + result = [] + for p in providers: + assignments = db.query(ProviderService).filter(ProviderService.provider_id == p.id).all() + services = [] + for a in assignments: + svc = db.query(Service).filter(Service.id == a.service_id).first() + if svc: + services.append({ + "assignment_id": a.id, + "service_id": svc.id, + "service_name": svc.name, + "service_slug": svc.slug, + "duration_min": svc.duration_min, + "category": svc.category, + "availability_rules": a.availability_rules or [], + }) + result.append({ + "id": p.id, + "name": p.name, + "email": p.email, + "phone": p.phone, + "services": services, + }) + return result + + +# === Provider-Service assignment CRUD === + +@router.post("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)]) +def assign_service(provider_id: int, service_id: int, rules: list[dict], db: Session = Depends(get_db)): + """Assegna servizio a operatore con regole orarie. + Body: [{"weekday":0,"start":"09:00","end":"13:00"}, ...] + """ + existing = db.query(ProviderService).filter( + ProviderService.provider_id == provider_id, + ProviderService.service_id == service_id + ).first() + if existing: + existing.availability_rules = rules + else: + ps = ProviderService(provider_id=provider_id, service_id=service_id, availability_rules=rules) + db.add(ps) + db.commit() + return {"ok": True} + + +@router.put("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)]) +def update_assignment(provider_id: int, service_id: int, rules: list[dict], db: Session = Depends(get_db)): + """Aggiorna le regole orarie di un'assegnazione.""" + ps = db.query(ProviderService).filter( + ProviderService.provider_id == provider_id, + ProviderService.service_id == service_id + ).first() + if not ps: + raise HTTPException(404, "Assegnazione non trovata") + ps.availability_rules = rules + db.commit() + return {"ok": True} + + +@router.delete("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)]) +def remove_assignment(provider_id: int, service_id: int, db: Session = Depends(get_db)): + """Rimuovi assegnazione servizio da operatore.""" + ps = db.query(ProviderService).filter( + ProviderService.provider_id == provider_id, + ProviderService.service_id == service_id + ).first() + if not ps: + raise HTTPException(404) + db.delete(ps) + db.commit() + return {"ok": True} + + +# === Calendar view === + +@router.get("/calendar", dependencies=[Depends(verify_api_key)]) +def calendar_view( + from_date: date, + to_date: date, + provider_id: int | None = None, + db: Session = Depends(get_db), +): + """Prenotazioni per vista calendario, raggruppate per provider e giorno.""" + q = db.query(Booking).options(joinedload(Booking.service), joinedload(Booking.provider)).filter( + Booking.start_at >= datetime.combine(from_date, datetime.min.time(), tzinfo=TZ), + Booking.start_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time(), tzinfo=TZ), + Booking.status.in_(["confirmed", "completed"]), + ) + if provider_id: + q = q.filter(Booking.provider_id == provider_id) + + bookings = q.order_by(Booking.start_at).all() + + # Group by date then provider + cal = {} + for b in bookings: + day = b.start_at.astimezone(TZ).strftime("%Y-%m-%d") + if day not in cal: + cal[day] = [] + cal[day].append({ + "id": b.id, + "start": b.start_at.astimezone(TZ).strftime("%H:%M"), + "end": b.end_at.astimezone(TZ).strftime("%H:%M"), + "service": b.service.name if b.service else "—", + "service_slug": b.service.slug if b.service else "", + "category": b.service.category if b.service else "", + "provider_id": b.provider_id, + "provider": b.provider.name if b.provider else "—", + "customer": b.customer_name, + "phone": b.customer_phone, + "status": b.status, + "notes": b.notes, + }) + return cal + + +# === Stats === + +@router.get("/stats", dependencies=[Depends(verify_api_key)]) +def booking_stats(db: Session = Depends(get_db)): + today_start = datetime.combine(datetime.now(TZ).date(), datetime.min.time(), tzinfo=TZ) + today_end = today_start + timedelta(days=1) + week_start = today_start - timedelta(days=today_start.weekday()) + week_end = week_start + timedelta(days=7) + + return { + "today": db.query(Booking).filter(Booking.start_at >= today_start, Booking.start_at < today_end, Booking.status == "confirmed").count(), + "this_week": db.query(Booking).filter(Booking.start_at >= week_start, Booking.start_at < week_end, Booking.status == "confirmed").count(), + "total_confirmed": db.query(Booking).filter(Booking.status == "confirmed").count(), + "total_no_shows": db.query(Booking).filter(Booking.status == "no_show").count(), + } diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..3259f41 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,135 @@ +"""Auth — dual mode: cookie (legacy) + localStorage token via URL param. +Flow: /auth/login → Google → /auth/callback → /admin?token=JWT +Il JS salva in localStorage, le API leggono da Authorization header O cookie. +""" +import logging +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, Request, HTTPException, Header +from fastapi.responses import RedirectResponse, HTMLResponse +from jose import jwt +import httpx +from typing import Optional + +from app.config import get_settings + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["auth"]) + +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" +COOKIE_NAME = "booking_token" + + +def create_jwt(email: str, name: str, picture: str = "") -> str: + settings = get_settings() + return jwt.encode({ + "sub": email, "name": name, "picture": picture, + "exp": datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours), + "iat": datetime.now(timezone.utc), + }, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def verify_jwt(token: str) -> dict | None: + settings = get_settings() + try: + return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + except Exception: + return None + + +def get_current_user(request: Request) -> dict | None: + # 1. Check Authorization header (localStorage flow) + auth = request.headers.get("authorization", "") + if auth.startswith("Bearer "): + payload = verify_jwt(auth[7:]) + if payload: + return payload + # 2. Fallback: check cookie + token = request.cookies.get(COOKIE_NAME) + if token: + return verify_jwt(token) + return None + + +@router.get("/login") +def login(): + settings = get_settings() + params = { + "client_id": settings.google_client_id, + "redirect_uri": settings.google_redirect_uri, + "response_type": "code", + "scope": "openid email profile", + "access_type": "offline", + "prompt": "select_account", + } + url = GOOGLE_AUTH_URL + "?" + "&".join(f"{k}={v}" for k, v in params.items()) + return RedirectResponse(url) + + +@router.get("/dev") +def dev_login(key: str = ""): + """Dev bypass.""" + settings = get_settings() + if key != settings.api_key: + raise HTTPException(403, "Chiave non valida") + token = create_jwt("mancosu@kitzanos.com", "Carlo Mancosu") + logger.info("Dev login: mancosu@kitzanos.com") + return RedirectResponse(url=f"/admin?token={token}", status_code=302) + + +@router.get("/callback") +async def callback(code: str): + """Callback da Google → redirect a /admin?token=JWT""" + settings = get_settings() + + async with httpx.AsyncClient() as client: + token_resp = await client.post(GOOGLE_TOKEN_URL, data={ + "code": code, + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "redirect_uri": settings.google_redirect_uri, + "grant_type": "authorization_code", + }) + + if token_resp.status_code != 200: + logger.error(f"Google token error: {token_resp.text}") + raise HTTPException(401, "Autenticazione Google fallita") + + access_token = token_resp.json().get("access_token") + + async with httpx.AsyncClient() as client: + user_resp = await client.get(GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {access_token}"}) + + if user_resp.status_code != 200: + raise HTTPException(401, "Impossibile ottenere info utente") + + user_info = user_resp.json() + email = user_info.get("email", "") + name = user_info.get("name", "") + picture = user_info.get("picture", "") + + domain = email.split("@")[-1].lower() if "@" in email else "" + if domain not in settings.allowed_domains_list: + logger.warning(f"Accesso negato: {email}") + return RedirectResponse(url="/admin?error=domain", status_code=302) + + token = create_jwt(email, name, picture) + logger.info(f"Login riuscito: {email}") + return RedirectResponse(url=f"/admin?token={token}", status_code=302) + + +@router.get("/me") +def me(request: Request): + user = get_current_user(request) + if not user: + raise HTTPException(401, "Non autenticato") + return {"email": user["sub"], "name": user.get("name", ""), "picture": user.get("picture", "")} + + +@router.get("/logout") +def logout(): + resp = RedirectResponse(url="/admin?logout=1", status_code=302) + resp.delete_cookie(COOKIE_NAME, path="/") + return resp diff --git a/app/routers/customers.py b/app/routers/customers.py new file mode 100644 index 0000000..3667f1b --- /dev/null +++ b/app/routers/customers.py @@ -0,0 +1,161 @@ +"""Endpoint clienti e dashboard avanzata.""" +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.orm import Session +from sqlalchemy import text, func +from app.database import get_db +from app.models import Booking, Service, Provider +from app.routers.admin import verify_api_key + +router = APIRouter(prefix="/api/admin", tags=["customers"]) +TZ = ZoneInfo("Europe/Rome") + + +# === Customers === + +@router.get("/customers", dependencies=[Depends(verify_api_key)]) +def list_customers(q: str = "", page: int = 1, per_page: int = 20, db: Session = Depends(get_db)): + """Lista clienti con ricerca e paginazione.""" + where = "" + params = {} + if q: + where = " WHERE name ILIKE :q OR phone ILIKE :q OR email ILIKE :q" + params["q"] = f"%{q}%" + total = db.execute(text("SELECT COUNT(*) FROM customers" + where), params).scalar() + sql = "SELECT id, name, phone, email, notes, total_visits, last_visit_at, first_visit_at, created_at FROM customers" + where + sql += " ORDER BY total_visits DESC, last_visit_at DESC NULLS LAST" + sql += f" LIMIT {per_page} OFFSET {(page-1)*per_page}" + rows = db.execute(text(sql), params).fetchall() + return {"items": [dict(r._mapping) for r in rows], "total": total, "page": page, "per_page": per_page, "pages": max(1,(total + per_page - 1) // per_page)} + + +@router.get("/customers/{customer_id}", dependencies=[Depends(verify_api_key)]) +def customer_detail(customer_id: int, db: Session = Depends(get_db)): + """Dettaglio cliente con storico prenotazioni.""" + c = db.execute(text("SELECT * FROM customers WHERE id = :id"), {"id": customer_id}).fetchone() + if not c: + raise HTTPException(404) + customer = dict(c._mapping) + + # Storico prenotazioni + bookings = db.execute(text(""" + SELECT b.id, b.start_at, b.end_at, b.status, b.notes, + s.name as service, p.name as provider + FROM bookings b + LEFT JOIN services s ON s.id = b.service_id + LEFT JOIN providers p ON p.id = b.provider_id + WHERE b.customer_id = :cid + ORDER BY b.start_at DESC + """), {"cid": customer_id}).fetchall() + customer["bookings"] = [dict(r._mapping) for r in bookings] + + # Servizi più usati + top_svcs = db.execute(text(""" + SELECT s.name, COUNT(*) as cnt + FROM bookings b JOIN services s ON s.id = b.service_id + WHERE b.customer_id = :cid + GROUP BY s.name ORDER BY cnt DESC LIMIT 5 + """), {"cid": customer_id}).fetchall() + customer["top_services"] = [{"name": r[0], "count": r[1]} for r in top_svcs] + + return customer + + +@router.put("/customers/{customer_id}", dependencies=[Depends(verify_api_key)]) +def update_customer(customer_id: int, data: dict, db: Session = Depends(get_db)): + """Aggiorna note cliente.""" + db.execute(text("UPDATE customers SET notes = :notes WHERE id = :id"), + {"notes": data.get("notes", ""), "id": customer_id}) + db.commit() + return {"ok": True} + + +# === Dashboard avanzata === + +@router.get("/dashboard", dependencies=[Depends(verify_api_key)]) +def dashboard_data(db: Session = Depends(get_db)): + """Dati completi per la dashboard.""" + now = datetime.now(TZ) + today = now.date() + today_start = datetime.combine(today, datetime.min.time(), tzinfo=TZ) + next7_end = today_start + timedelta(days=7) + + # KPI base + today_count = db.execute(text( + "SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status = 'confirmed'" + ), {"s": today_start, "e": today_start + timedelta(days=1)}).scalar() + + week_count = db.execute(text( + "SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status IN ('confirmed','completed')" + ), {"s": today_start, "e": next7_end}).scalar() + + # Domani + tomorrow_start = today_start + timedelta(days=1) + tomorrow_count = db.execute(text( + "SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status = 'confirmed'" + ), {"s": tomorrow_start, "e": tomorrow_start + timedelta(days=1)}).scalar() + + total = db.execute(text("SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','completed')")).scalar() + no_shows = db.execute(text("SELECT COUNT(*) FROM bookings WHERE status = 'no_show'")).scalar() + total_customers = db.execute(text("SELECT COUNT(*) FROM customers")).scalar() + + # Prenotazioni per giorno questa settimana (per chart) + week_daily = [] + for d in range(7): + day = today_start + timedelta(days=d) + day_end = day + timedelta(days=1) + cnt = db.execute(text( + "SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status IN ('confirmed','completed')" + ), {"s": day, "e": day_end}).scalar() + weekday_idx = (today.weekday() + d) % 7 + week_daily.append({"day": ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][weekday_idx], "date": day.strftime("%Y-%m-%d"), "count": cnt, "is_today": d == 0}) + + # Top servizi (ultimi 30gg) + top_services = db.execute(text(""" + SELECT s.name, COUNT(*) as cnt FROM bookings b + JOIN services s ON s.id = b.service_id + WHERE b.start_at >= :s AND b.status IN ('confirmed','completed') + GROUP BY s.name ORDER BY cnt DESC LIMIT 5 + """), {"s": today_start - timedelta(days=30)}).fetchall() + + # Top operatori (ultimi 30gg) + top_providers = db.execute(text(""" + SELECT p.name, COUNT(*) as cnt FROM bookings b + JOIN providers p ON p.id = b.provider_id + WHERE b.start_at >= :s AND b.status IN ('confirmed','completed') + GROUP BY p.name ORDER BY cnt DESC LIMIT 5 + """), {"s": today_start - timedelta(days=30)}).fetchall() + + # Prossime prenotazioni (prossimi 7 giorni) + upcoming = db.execute(text(""" + SELECT b.id, b.start_at, b.customer_name, s.name as service, p.name as provider, b.status + FROM bookings b + LEFT JOIN services s ON s.id = b.service_id + LEFT JOIN providers p ON p.id = b.provider_id + WHERE b.start_at >= :now AND b.start_at < :week_end AND b.status = 'confirmed' + ORDER BY b.start_at LIMIT 8 + """), {"now": now, "week_end": today_start + timedelta(days=7)}).fetchall() + + # Attività recenti (ultime 10 prenotazioni create) + recent = db.execute(text(""" + SELECT b.id, b.created_at, b.customer_name, s.name as service, b.status + FROM bookings b LEFT JOIN services s ON s.id = b.service_id + ORDER BY b.created_at DESC LIMIT 8 + """)).fetchall() + + return { + "kpi": { + "today": today_count, + "week": week_count, + "tomorrow": tomorrow_count, + "total": total, + "no_shows": no_shows, + "customers": total_customers, + }, + "week_chart": week_daily, + "top_services": [{"name": r[0], "count": r[1]} for r in top_services], + "top_providers": [{"name": r[0], "count": r[1]} for r in top_providers], + "upcoming": [dict(r._mapping) for r in upcoming], + "recent": [dict(r._mapping) for r in recent], + } diff --git a/app/routers/public.py b/app/routers/public.py new file mode 100644 index 0000000..794edd9 --- /dev/null +++ b/app/routers/public.py @@ -0,0 +1,113 @@ +"""Endpoint pubblici — no auth. Usati dal widget di prenotazione.""" +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload +from app.database import get_db +from app.models import Service, Booking, ProviderService +from app.schemas import ServiceOut, BookingCreate, BookingOut, TimeSlot +from app.services.availability import get_available_slots +from app.services.notifications import notify_booking_confirmed, notify_operator + +router = APIRouter(prefix="/api", tags=["public"]) +TZ = ZoneInfo("Europe/Rome") + + +@router.get("/services", response_model=list[ServiceOut]) +def list_services(db: Session = Depends(get_db)): + """Lista servizi attivi.""" + return ( + db.query(Service) + .filter(Service.active == True) + .order_by(Service.sort_order, Service.name) + .all() + ) + + +@router.get("/services/{service_id}/slots") +def get_slots(service_id: int, date: date, db: Session = Depends(get_db)): + """Slot disponibili per un servizio in una data.""" + service = db.query(Service).filter(Service.id == service_id).first() + if not service: + raise HTTPException(404, "Servizio non trovato") + + slots = get_available_slots(db, service_id, date, service.duration_min) + return {"service": service.name, "date": str(date), "duration_min": service.duration_min, "slots": slots} + + +@router.post("/bookings", response_model=BookingOut) +async def create_booking(data: BookingCreate, db: Session = Depends(get_db)): + """Crea una prenotazione. No login richiesto.""" + # Verifica servizio + service = db.query(Service).filter(Service.id == data.service_id).first() + if not service: + raise HTTPException(404, "Servizio non trovato") + + # Verifica che lo slot sia ancora libero + slots = get_available_slots(db, data.service_id, data.start_at.date(), service.duration_min) + slot_time = data.start_at.astimezone(TZ).strftime("%H:%M") + available = [s for s in slots if s.start == slot_time and s.provider_id == data.provider_id] + if not available: + raise HTTPException(409, "Lo slot non è più disponibile") + + # Crea prenotazione + end_at = data.start_at + timedelta(minutes=service.duration_min) + booking = Booking( + service_id=data.service_id, + provider_id=data.provider_id, + customer_name=data.customer_name, + customer_phone=data.customer_phone, + customer_email=data.customer_email, + start_at=data.start_at, + end_at=end_at, + notes=data.notes, + ) + db.add(booking) + db.commit() + db.refresh(booking) + + # Carica relazioni per la risposta + booking = db.query(Booking).options( + joinedload(Booking.service), joinedload(Booking.provider) + ).filter(Booking.id == booking.id).first() + + # Notifiche async + await notify_booking_confirmed(booking, service.name, booking.provider.name) + if booking.provider.email: + await notify_operator(booking, service.name, booking.provider.email) + + return booking + + +@router.get("/bookings/my") +def my_bookings(phone: str, db: Session = Depends(get_db)): + """Le prenotazioni di un numero di telefono (per check 'ho già prenotato?').""" + clean = phone.replace("+", "").replace(" ", "").replace("-", "") + bookings = ( + db.query(Booking) + .options(joinedload(Booking.service), joinedload(Booking.provider)) + .filter( + Booking.customer_phone.contains(clean), + Booking.status == "confirmed", + Booking.start_at >= datetime.now(TZ), + ) + .order_by(Booking.start_at) + .all() + ) + return bookings + + +@router.delete("/bookings/{booking_id}") +def cancel_booking(booking_id: int, phone: str, db: Session = Depends(get_db)): + """Cancella una prenotazione (verifica phone per sicurezza).""" + booking = db.query(Booking).filter(Booking.id == booking_id).first() + if not booking: + raise HTTPException(404, "Prenotazione non trovata") + + clean = phone.replace("+", "").replace(" ", "").replace("-", "") + if clean not in booking.customer_phone: + raise HTTPException(403, "Numero non corrispondente") + + booking.status = "cancelled" + db.commit() + return {"ok": True, "message": "Prenotazione cancellata"} diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 0000000..5d4333f --- /dev/null +++ b/app/routers/settings.py @@ -0,0 +1,85 @@ +"""Endpoint impostazioni — protetti con API key.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import text +from app.database import get_db +from app.routers.admin import verify_api_key + +router = APIRouter(prefix="/api/admin/settings", tags=["settings"], dependencies=[Depends(verify_api_key)]) + + +@router.get("") +def get_settings(db: Session = Depends(get_db)): + """Tutte le impostazioni come dict.""" + rows = db.execute(text("SELECT key, value FROM settings ORDER BY key")).fetchall() + return {r[0]: r[1] for r in rows} + + +@router.put("") +def update_settings(data: dict, db: Session = Depends(get_db)): + """Aggiorna una o più impostazioni. Body: {"key": "value", ...}""" + for k, v in data.items(): + db.execute( + text("INSERT INTO settings (key, value, updated_at) VALUES (:k, :v, NOW()) " + "ON CONFLICT (key) DO UPDATE SET value = :v, updated_at = NOW()"), + {"k": k, "v": str(v)} + ) + db.commit() + return get_settings(db) + + +@router.post("/test-email") +async def test_email(db: Session = Depends(get_db)): + """Invia email di test con le impostazioni correnti.""" + rows = db.execute(text("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'")).fetchall() + cfg = {r[0]: r[1] for r in rows} + + if not cfg.get("smtp_user"): + raise HTTPException(400, "Credenziali SMTP non configurate") + + import smtplib + from email.mime.text import MIMEText + + try: + msg = MIMEText("

Test notifiche Farmacia Ianni

Le impostazioni email funzionano correttamente.

", "html") + msg["Subject"] = "Test — Booking Farmacia Ianni" + msg["From"] = cfg.get("smtp_from", cfg["smtp_user"]) + msg["To"] = cfg["smtp_user"] + + with smtplib.SMTP(cfg.get("smtp_host", "smtp.gmail.com"), int(cfg.get("smtp_port", 587))) as server: + server.starttls() + server.login(cfg["smtp_user"], cfg.get("smtp_pass", "")) + server.sendmail(msg["From"], cfg["smtp_user"], msg.as_string()) + return {"ok": True, "message": f"Email di test inviata a {cfg['smtp_user']}"} + except Exception as e: + raise HTTPException(500, f"Errore invio: {str(e)}") + + +@router.get("/wa-status") +async def wa_status(db: Session = Depends(get_db)): + """Stato connessione WhatsApp Baileys.""" + import httpx + rows = db.execute(text("SELECT value FROM settings WHERE key = 'wa_service_url'")).fetchone() + url = rows[0] if rows else "http://booking-wa:3100" + + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{url}/status") + return r.json() + except Exception as e: + return {"connected": False, "error": str(e), "message": "Servizio WhatsApp non raggiungibile"} + + +@router.get("/wa-qr") +async def wa_qr(db: Session = Depends(get_db)): + """QR code per collegare WhatsApp.""" + import httpx + rows = db.execute(text("SELECT value FROM settings WHERE key = 'wa_service_url'")).fetchone() + url = rows[0] if rows else "http://booking-wa:3100" + + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(f"{url}/qr") + return r.json() + except Exception as e: + return {"qr": None, "error": str(e)} diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..7738120 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,90 @@ +from pydantic import BaseModel, Field +from datetime import datetime, date +from typing import Optional + + +# === Services === +class ServiceOut(BaseModel): + id: int + name: str + slug: str + duration_min: int + description: Optional[str] = None + category: str + + class Config: + from_attributes = True + + +class ServiceCreate(BaseModel): + name: str + slug: str + duration_min: int = 15 + description: Optional[str] = None + category: str = "generale" + + +# === Providers === +class ProviderOut(BaseModel): + id: int + name: str + + class Config: + from_attributes = True + + +class ProviderCreate(BaseModel): + name: str + email: Optional[str] = None + phone: Optional[str] = None + google_calendar_id: Optional[str] = None + + +# === Availability === +class TimeSlot(BaseModel): + start: str # "09:00" + end: str # "09:30" + provider_id: int + provider_name: str + + +class AvailabilityRule(BaseModel): + weekday: int # 0=lun, 6=dom + start: str # "09:00" + end: str # "13:00" + + +# === Bookings === +class BookingCreate(BaseModel): + service_id: int + provider_id: int + start_at: datetime + customer_name: str + customer_phone: str + customer_email: Optional[str] = None + notes: Optional[str] = None + + +class BookingOut(BaseModel): + id: int + service_id: int + provider_id: int + customer_name: str + customer_phone: str + customer_email: Optional[str] = None + start_at: datetime + end_at: datetime + status: str + notes: Optional[str] = None + created_at: datetime + + service: Optional[ServiceOut] = None + provider: Optional[ProviderOut] = None + + class Config: + from_attributes = True + + +class BookingUpdate(BaseModel): + status: Optional[str] = None + notes: Optional[str] = None diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/availability.py b/app/services/availability.py new file mode 100644 index 0000000..8a535b7 --- /dev/null +++ b/app/services/availability.py @@ -0,0 +1,100 @@ +"""Calcolo slot disponibili per un servizio in una data. + +Logica: regole orario provider - prenotazioni esistenti - busy Google Calendar. +Google Calendar è opzionale (fase 2). +""" +from datetime import datetime, date, timedelta, time +from zoneinfo import ZoneInfo +from sqlalchemy.orm import Session +from app.models import ProviderService, Booking +from app.schemas import TimeSlot + +TZ = ZoneInfo("Europe/Rome") + + +def get_available_slots( + db: Session, + service_id: int, + target_date: date, + duration_min: int, +) -> list[TimeSlot]: + """Restituisce tutti gli slot liberi per un servizio in una data.""" + + # 1. Trova tutti i provider che erogano questo servizio + ps_list = ( + db.query(ProviderService) + .filter(ProviderService.service_id == service_id) + .all() + ) + if not ps_list: + return [] + + weekday = target_date.weekday() # 0=lunedì + all_slots = [] + + for ps in ps_list: + if not ps.provider.active: + continue + + # 2. Filtra le regole di disponibilità per il giorno della settimana + rules = ps.availability_rules or [] + day_rules = [r for r in rules if r.get("weekday") == weekday] + + if not day_rules: + continue + + # 3. Genera tutti gli slot possibili dalle regole + raw_slots = [] + for rule in day_rules: + start_h, start_m = map(int, rule["start"].split(":")) + end_h, end_m = map(int, rule["end"].split(":")) + + slot_start = datetime.combine(target_date, time(start_h, start_m), tzinfo=TZ) + slot_end_limit = datetime.combine(target_date, time(end_h, end_m), tzinfo=TZ) + + while slot_start + timedelta(minutes=duration_min) <= slot_end_limit: + raw_slots.append(slot_start) + slot_start += timedelta(minutes=duration_min) + + # 4. Filtra via le prenotazioni esistenti (confirmed) + day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ) + day_end = day_start + timedelta(days=1) + + existing = ( + db.query(Booking) + .filter( + Booking.provider_id == ps.provider_id, + Booking.start_at >= day_start, + Booking.start_at < day_end, + Booking.status == "confirmed", + ) + .all() + ) + + busy_ranges = [(b.start_at, b.end_at) for b in existing] + + for slot_start in raw_slots: + slot_end = slot_start + timedelta(minutes=duration_min) + conflict = any( + slot_start < busy_end and slot_end > busy_start + for busy_start, busy_end in busy_ranges + ) + if not conflict: + all_slots.append( + TimeSlot( + start=slot_start.strftime("%H:%M"), + end=slot_end.strftime("%H:%M"), + provider_id=ps.provider_id, + provider_name=ps.provider.name, + ) + ) + + # 5. Ordina per orario + all_slots.sort(key=lambda s: s.start) + + # 6. Filtra slot nel passato se target_date è oggi + now = datetime.now(TZ) + if target_date == now.date(): + all_slots = [s for s in all_slots if s.start > now.strftime("%H:%M")] + + return all_slots diff --git a/app/services/notifications.py b/app/services/notifications.py new file mode 100644 index 0000000..636a57c --- /dev/null +++ b/app/services/notifications.py @@ -0,0 +1,114 @@ +"""Notifiche: email SMTP + WhatsApp via gateway XAB.""" +import smtplib +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime +import httpx +from app.config import get_settings + +log = logging.getLogger(__name__) +settings = get_settings() + + +async def send_wa_message(phone: str, text: str) -> bool: + """Invia messaggio WhatsApp via gateway XAB su APP:18800.""" + if not settings.wa_enabled: + log.info(f"WA disabled, skip: {phone}") + return False + try: + # Normalizza numero: +39 3xx -> 39 3xx + clean = phone.replace("+", "").replace(" ", "").replace("-", "") + if not clean.startswith("39"): + clean = "39" + clean + + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post( + settings.wa_gateway_url, + json={"to": clean, "text": text}, + ) + if r.status_code == 200: + log.info(f"WA sent to {clean}") + return True + else: + log.error(f"WA error {r.status_code}: {r.text}") + return False + except Exception as e: + log.error(f"WA exception: {e}") + return False + + +def send_email(to: str, subject: str, html_body: str) -> bool: + """Invia email via SMTP.""" + if not settings.smtp_user: + log.info(f"SMTP not configured, skip: {to}") + return False + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings.smtp_from + msg["To"] = to + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: + server.starttls() + server.login(settings.smtp_user, settings.smtp_pass) + server.sendmail(settings.smtp_from, to, msg.as_string()) + log.info(f"Email sent to {to}") + return True + except Exception as e: + log.error(f"Email exception: {e}") + return False + + +async def notify_booking_confirmed(booking, service_name: str, provider_name: str): + """Notifica al cliente: conferma prenotazione.""" + dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M") + # WhatsApp al cliente + wa_text = ( + f"✅ Prenotazione confermata!\n\n" + f"Servizio: {service_name}\n" + f"Data: {dt}\n" + f"Operatore: {provider_name}\n\n" + f"Farmacia Ianni - Via Cassia 940, Roma\n" + f"Per cancellare, rispondi CANCELLA." + ) + await send_wa_message(booking.customer_phone, wa_text) + + # Email se fornita + if booking.customer_email: + html = f""" +

Prenotazione confermata

+

Servizio: {service_name}

+

Data: {dt}

+

Operatore: {provider_name}

+
+

Farmacia Ianni - Via Cassia 940, Roma

+ """ + send_email(booking.customer_email, f"Prenotazione {service_name} - {dt}", html) + + +async def notify_operator(booking, service_name: str, provider_email: str): + """Notifica all'operatore: nuova prenotazione.""" + dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M") + html = f""" +

Nuova prenotazione

+

Cliente: {booking.customer_name}

+

Telefono: {booking.customer_phone}

+

Servizio: {service_name}

+

Data: {dt}

+ """ + if provider_email: + send_email(provider_email, f"Nuova prenotazione: {booking.customer_name} - {dt}", html) + + +async def send_reminder(booking, service_name: str): + """Reminder 24h prima via WhatsApp.""" + dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M") + text = ( + f"📅 Promemoria: domani hai un appuntamento\n\n" + f"Servizio: {service_name}\n" + f"Data: {dt}\n\n" + f"Farmacia Ianni - Via Cassia 940, Roma" + ) + await send_wa_message(booking.customer_phone, text) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0ff23d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + db: + image: postgres:16-alpine + container_name: booking-db + environment: + POSTGRES_DB: booking + POSTGRES_USER: booking + POSTGRES_PASSWORD: booking2026 + volumes: + - booking_pgdata:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5440:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U booking"] + interval: 5s + timeout: 3s + retries: 5 + + app: + build: . + container_name: booking-app + env_file: + - .env + environment: + BOOKING_DATABASE_URL: postgresql://booking:booking2026@db:5432/booking + BOOKING_API_KEY: ianni-booking-2026 + BOOKING_APP_URL: https://booking.scan360.app + BOOKING_WA_ENABLED: "true" + BOOKING_WA_GATEWAY_URL: http://wa:3100/send + BOOKING_TIMEZONE: Europe/Rome + ports: + - "8020:8020" + depends_on: + db: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + wa: + build: ./wa-service + container_name: booking-wa + volumes: + - wa_auth:/data/auth + environment: + PORT: "3100" + restart: unless-stopped + +volumes: + booking_pgdata: + wa_auth: diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..b072d60 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,773 @@ + + + + + +Gestionale — Farmacia Ianni + + + + + + + +
+ +
+ + +
+ +
+

Dashboard

+
+ + +
+ +
+
Oggi
confermate
+
Domani
confermate
+
Prossimi 7gg
conf. + completate
+
Clienti
nel sistema
+
No-show
non presentati
+
+ +
+
Prossimi 7 giorni
+
+
+
Top servizi (30gg)
+
+
+
+ +
+
Prossimi appuntamenti
+
Attività recenti
+
+
+ + +
+
+
+ Tutte le prenotazioni +
+ + + +
+
+
+
+
+ + +
+
Servizi
+
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +

+ + +
+
+
+
+
+ + +
+ + +
+
Dati farmacia
+
+
+
+
+
+
+
+ +
+
+ + +
+
WhatsApp (Baileys)
+
+
+

Caricamento stato...

+
+ +
+ + + +
+
+
+ + +
+
Email (SMTP)
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+

Nuovo servizio

+
+
+
+
+
+
+
+ + +
+

Nuovo operatore

+
+
+
+
+
+ + + +
+ + +
+

Assegna servizio

+
+
+
+
+
+
+ +
+ + + + \ No newline at end of file diff --git a/frontend/favicon.svg b/frontend/favicon.svg new file mode 100644 index 0000000..dbdaaab --- /dev/null +++ b/frontend/favicon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..77dd3ad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,339 @@ + + + + + + +Prenota — Farmacia Ianni + + + + + + + + +
+
+
+ + +
+ Farmacia Ianni +
+ + +
+ + +
+
+ + +
+
+ + +
+ + Serve aiuto? +
+
+ + +
+ +
+
+ +
+
+
+ + + + diff --git a/frontend/logo-white.svg b/frontend/logo-white.svg new file mode 100644 index 0000000..420aed4 --- /dev/null +++ b/frontend/logo-white.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/logo.jpg b/frontend/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9c0aca8144465697f1e5ff9add47ddda397f3201 GIT binary patch literal 10796 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 literal 0 HcmV?d00001 diff --git a/frontend/logo.png b/frontend/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7fa1b2b6aa05fa40aa3ade105475f62b611980d8 GIT binary patch literal 6196 zcmeI0_ct4k_y1F+i%zwb7Bya0t=e><5yYyf6*X%&_KLK%_e-prX^A~+G%-^YwTluI zL1;ycScwsQ`a17(zJJB{{&3Iz>7IKZ_nzlD_r@9;=v=+ReFXpjTm?RVW()wF>-`&h zUApksXMJOi1OTd1fX^PA2Ij8k1>`zvW#LfeDx}{bszbhD+e=TiAKiEmf2Fw^hHx?+ ztacmboNN#tbg}lszrATBW6QwCBovDre=zu<$%i&XLzf?&z&06e1w=WIvPKSl!2tvx z@=s;Vd^u*Gc6`N6l3AkmE5osF|G{PJwO(Og+|;K&clnOuA-5^nu#0lL#0aDK=_X(r z65FnGgOM@p9DxRFRm=BPqEhU+%93OXG73vSmTIVvoYQQ@)p)#P`t}}XW&4h4^RkdlDXAIy2A(_FEhD)kZql;|0l&?B(_HK&E zEF|Qwf8L|fXnxX^k>3B+Onp9BtW%YRT?xRco#`~F?p97AxD2k{Qhdh5K)PaVUBvS+ zW=Xj8cCUi3isaY#4VOOMc*0;C`NZO4=G#)9lxJdBVk^`V%=Wm&@|-%mP%4qcWH_Tj z<6bP^MLPX-Ba-Pf%ltvof54?;r|6}UL#l|7#Vy3}%+(6z*DJE@iC1rksORQ>%%r}i zam&y6AseE}@5o%038TWoz~H+QJrc489 zXYPT$V`9g$x;fP}K%fXQOl5mEBBC#@8eK z+_CoIJ(-)pTZ_TS4e{QFEsMt2jFXo_HSZeOFO`NrlzAeIHNJZ!K2Yi=dO^W^^M=ug zqo?c>5sm9A)J3(;22tD=$BE#P?Cn}6Xroiycr@03dd*!tc>4Wkj z4+MlB-izJ5NlsH5Vkwm2cC%vur+kR;Zvi5sM0=6h5jHi6{8)z`qx0*wy!9|0o(G+G zE6=f9Ixl=|=~554<0fRL`r&7{@HK7m%8VFve-8L~9mi(AU3gs9!hUe^f6z6RWr){P zIIE#PLq!DjKRLXJrSnQltKnH&daOkrEMlKY$khlX{aC+S)Fh z$m^UjvfuNun87O7&t_h7N!oAOrjL1EO{rW1=5QIlIww?Z3NY8t;4iys<{Xmw50@SA z@SYkejpGzzkX}<(>)Y{Mio)@9o^aLtKM~M$>dGNp-_UX^slo02`GY{Ebl*=>^1h!h z9b0gzl99=R*~0wa83vXo+=h1}-hlMkTyOtSx@48M|0YlEk%?fQgrR7L`0elEjAR-M zNA>mlkqydK)N%admvJ3-PFKXwqu*10aeJY}s?OLnRnJH#mj^$`8zkd!hdyt^csY`Y z0OqrshRkREf{r7aTO6}ms>}w67312``q$g4A!<=|EMHanT4mWyKPG88m&P^cKE8~s z+>3e&5{}iLV9({MOyxpy_6dz@+dr+rf0`zZd;0plj(_!`KpViaXq(jGX=7{0YADDX z!WaN_q=y z;Pz~K?~@1fyYG%GlsJxN_-L)8>2zp?dJ|u~HUtprWzIpvaBi@sHli zODq%^G`QYoLw|X0l=f3V!H|cS5gny`%}t)%IOCoo{E?7_p79vlz{C)5#c6@Umz$45 zIjU+mZcZJhKBvdj_E;QW`0;(@<)VPrRRNBUOXfJWLF(ztNK|uq$2im7maoP~dDH?;o4h0QSL!W8qywz9@i zT-F21!LUZmfPhRYycvzb(70ZW{YkGoa%-_Vs+2*FSW|r-kqRSwoPVq&bM`JyFcqU_ zpV-+B(`oT-zF&$@Zozv+K>6%$cz6SBU&&eOM1kP($d_vLZK3BuGuy_YUv=@IRWwJu zzaZ|a=s-!C?;()e@yDP06B#2;f%{lXMTyM0vQDFS9;`v0?L|^^CU}pVO(_xH8;KUH zCax+W8|~jP(Uk2vSY`TzSfjcy;rn>aWd|R2`=@IVu7ThKEm=8Y|Msggb;OF2x4p8@ zTsw;LguX@2eG*c1Cq4Ze!7 zY5STrjAXrH(}8`LTA03h*5SkDIk-<`UyFQh!Ox5_o6{73zGU-D?2X7nrS!^dQ*vT*VM(<*2-9V`tD4Lo7bDS!>|U)_V*Xj0Aq{GFD@ z9keoiQrSr9_Ll4C3dK(Puv!C=g&B8Q6ocz1I=VKHz0$-&I5?AfkxI$evEw>BS+~_Fb} zM@49d-)r6FN0TBtpd^AGhTzzGUjh1EyT!JIgJb%n#}mO_E?Zv1*T`3TC>=k2RINma z#+cxCy}VI3g}1zx<1YfC{O`i!DhG^maaCdgTdOI4fwPc%R&Cz|8hLBkMBjtUfKoSS}tJ|6WLwg(_OKJVX4~3X>Mhbz8fb_&b?!Q@)ae?5PfBp z$_M@SRhu3|=S!nRxbl+b6uNAz4J};$oihJyH3;+6g&C(oM}Vn)V=Ixpc<;jG@?Pc4 z;>y?Oi~5NNvq3%oFDI16Ku#r!RL#A!_I_M%d_j&7?c*Bi&pltAdj{aSQj%vXjc@TH zdys-yN3KWjmz)7O&HzY3R(6@+_VL~(aXImaREOOqisst(iNrX13Byd6Jvy#iJ9Owr zu;u3ufteA47&>?=ChLXAY+L%+th2adF)A})HjOgW*mY|{^&ZEThAh5=n{%3txSQJS zwHDG>j4vd3%TeZ~#9XHWOQi3o2!>s|rVa}_d}w}p#~6WBR4Q8n@j;)4C&DsXY826S z%eAjZX#OYn9%}_v$&UtbIbFToA14)Wm>HxCB5Rt|vxM@tp;4N_#1UzJju5Yb8NmbT zKe?!8=$bY^>o8d3f!A6OGiWah8_nHRi3(D>KQ5_JRMI5u78v&ib#LObHt16TIIM&{ zz*+$Xabkt*;k5CQYCDL5WBqGBhm&W3wlK8kflWU-=#Ec9z<5<#aJJgdLMa21wl1)- z7}dwNj9ZaGL7QiN=#vl<73_WWbQ`#ZYffGlXqBNaN>d2rXG!o0jcbNx9RqW{bx6Y3 z1j_6p%b{|=jLink3wqmY!1)`jb}4gl*ZEIH-Mn4)numcQQ2j8#se6jk_XU^p%1iqL zKj7)}uJ6ke5cOn)ahf1wPW(Kk@mPNzbS+7nZL03JX+W72so3IG5(L@T(%wJ2DVJnf zR=u99M_JLEyloNTIM?`l$be0nphw#_aXSOZJyhyjAmY`6z2a9x($IMSGXPy+wcp%o zZY;;3LWnv}aH3{~)_)jQ&1jRxIKnztf`nB!w&tS0Nw%APqa)*>AG@^;Ld6V={mb!4 z&}a3m@YwJDisB(;YeTnYiQ)cUVr^LBv();MVlnl`T_>(Rs&nT30i*Ui&#?BrGIRD6 z%nk_g?IbEPkmj)a@Vh@U6aC9e&NcPA%ENuslK|##}68L@eFM8stZq~MO`b|>B+(41r;_T05>}+U4 zbh;C`B_32UVBPN2t!M{M_8WD-9n33GB!v1j9zk(Vs{?NAR=ckcu(*FAAkw^@N5i_> z3Kn+`u=Isnl~%Xzj1vF3+3hFfXshN;VC(3AI_e z(`?SughOnWMWeJmMc6vKtI_px5DU7v@#xNpvsN@DI$Gm&V*O7-e{g%kQXt;H@eZ=d zN{9$HiJweXpk%&m_(eyLzbG{vY&WFXG~0XrJH)J9^^ zL01k`+oU4C+f(UBrn4~p&;7L%4d!iid=G!q$P7MF`p5~~aQ*sjRX{Zc-uOgwF1m%J zlMrG;mE5>KU419$RBZ7Rgx{mQ)X6ARvTUe$Y$ik>O&{A)Ts;Hm_7;x}4&bHN$FC`? zTRBG~^6s7Bj}lS0w0%Z;BNQizrDVKQP4o(q*B4xHRuatE9;Q0Io=YV%rl-)8LyqjJi)=veEQ5JrAF6G!`q zk308}A;#7NN+TZ^n~3;OM~z``51~^XC#6AUCp3kAGW*hzNNmA$?@(XkPyRv7BS=!) zmhixBf(P)l;3tuvRh)NmRCp%S7(eZ(26m9BON#BW00)QcDf{3Syumbho;i3MyLBA4 zK7acR5S>EnrG9WbP1D@M?PVU=^^>blTLkZ5$zC?a|NObWD2}oURv}Chyz2Do`<715 z02}O3)lBLcpcu-AnMlI?GDeiPbfWA9w~{wx0`vRV^X{-FG!(U|A94JrFiASrDM_<_ zm>Oe86c28=J9A)_pGU!7+XWYRY$C2P(j9sgu907*9^~HGGrTrfwEj^l(I-{+ zL8^y`P-|zkC;ZAS^679(N_k%Q$01&~IiJrEsSol+zn1LZWLv&L+F1U;POs|~(JYut zE>G?&=}I0|aplYBD?FSmQhqssEoyUvZtC^S6|4wnulUoynBB--8NZ|p9+iy2E30eY z@(Lb<-cm5ZHgNcYYYGdjZLC!`A#>AP>V#*_XMp-@qUo$fj*Wgn05f!<{-Vp_;RJTFboH!4%r6|j0@>2S2j(6^? zir;GHajfLJrHKFSnGmqvrkeT&VkSCzHIf&S)HLJr{^vjWrd1J;f&^*R zbuzA}MF_*aJc*eOy-)q-!)iPe z`5&72bB$SX<+xeXs$3$qy~ddqniz<8KodEEeL?7!)C>-u@&unO{qB!V5c>r5C*i`s zprz`nbZ>_Hc*g}Bg?TOLM3ul2IF$5(-JQCi7X1L$h=sA-`*&!T7XJ;zTIJ7GzfL1U z#^e_Z9fvJjz-4n?jWzz42!ZU~gN^J~|4JIR4yn6(Vn$EP^gD(uxt@0$Xpw4>&9%tz z5Eob9lXdIKD9(Mwn4k0a_7Br*sqS`svr3yhPKs}KF1z18cLK4y%f7po|4dg`E>XF` zB(xHiCe)6ByR5fa)4gLF!vqB`BN7hqO~R|JxJEG*Y%Bo zrHjkP$-EZjB`fjsr$lw2t^p7P9&$O(6>rw{s#yG`MOB8PGDRGIlRchbM@q`P_Cue4)XCcer|x_1Y^cQ%Jemt9 z%Dmd!!h}ywk9hOGywqGOmjsdh@!*U$|HoOfp6RrH61qrQJ&08q{pG~*bhT9#370tS z$IBg6H_)^pggZKv>wZ-mvR{ z&nvzP30hm4JIPa>jv`PbtNnk$(+Buq32B1%iSgwsJ0T|yk0iG_{_5>b%1#)9wwhKg zWj^^r4EW-rA~R843{EDtaWr{Ku>r+b;w{4GQA5)CF>lR$$WyI6G(96ltItGtJTB6I z%gaO3AG|BGILBr5Y{z+GJoS%An-prIPJ(rtJtQm`o-7c022c+p@seKhx + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..0439c43 --- /dev/null +++ b/init.sql @@ -0,0 +1,69 @@ +-- Init script per il container PostgreSQL del booking-service +-- Gira automaticamente al primo avvio + +CREATE TABLE IF NOT EXISTS services ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + duration_min INT NOT NULL DEFAULT 15, + description TEXT, + category TEXT DEFAULT 'generale', + active BOOLEAN DEFAULT true, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS providers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + phone TEXT, + google_calendar_id TEXT, + active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS provider_services ( + id SERIAL PRIMARY KEY, + provider_id INT REFERENCES providers(id) ON DELETE CASCADE, + service_id INT REFERENCES services(id) ON DELETE CASCADE, + availability_rules JSONB NOT NULL DEFAULT '[]', + UNIQUE(provider_id, service_id) +); + +CREATE TABLE IF NOT EXISTS bookings ( + id SERIAL PRIMARY KEY, + service_id INT REFERENCES services(id), + provider_id INT REFERENCES providers(id), + customer_name TEXT NOT NULL, + customer_phone TEXT NOT NULL, + customer_email TEXT, + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + status TEXT DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled','completed','no_show')), + google_event_id TEXT, + notes TEXT, + reminder_sent BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bookings_date ON bookings(start_at); +CREATE INDEX IF NOT EXISTS idx_bookings_phone ON bookings(customer_phone); +CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status); +CREATE INDEX IF NOT EXISTS idx_bookings_provider ON bookings(provider_id, start_at); + +-- Dati di esempio (da sostituire con i servizi reali dopo la riunione) +INSERT INTO providers (name, email, phone) VALUES + ('Farmacia Ianni', 'e-shop@farmaciaianni.it', '+39 393 0579002'); + +INSERT INTO services (name, slug, duration_min, description, category, sort_order) VALUES + ('Misurazione pressione', 'pressione', 10, 'Misurazione della pressione arteriosa', 'diagnostica', 1), + ('Test glicemia', 'glicemia', 10, 'Test rapido della glicemia', 'diagnostica', 2), + ('Holter pressorio 24h', 'holter', 30, 'Installazione e ritiro holter pressorio', 'diagnostica', 3), + ('Consulenza dermocosmesi', 'dermocosmesi', 20, 'Consulenza personalizzata prodotti dermocosmesi', 'consulenza', 4), + ('Preparazione galenica', 'galenica', 15, 'Preparazione magistrale su richiesta medica', 'galenica', 5); + +-- Assegna tutti i servizi al provider di default con orari standard farmacia +INSERT INTO provider_services (provider_id, service_id, availability_rules) +SELECT 1, id, '[{"weekday":0,"start":"09:00","end":"13:00"},{"weekday":0,"start":"15:30","end":"19:30"},{"weekday":1,"start":"09:00","end":"13:00"},{"weekday":1,"start":"15:30","end":"19:30"},{"weekday":2,"start":"09:00","end":"13:00"},{"weekday":2,"start":"15:30","end":"19:30"},{"weekday":3,"start":"09:00","end":"13:00"},{"weekday":3,"start":"15:30","end":"19:30"},{"weekday":4,"start":"09:00","end":"13:00"},{"weekday":4,"start":"15:30","end":"19:30"},{"weekday":5,"start":"09:00","end":"13:00"}]'::jsonb +FROM services; diff --git a/nginx-booking.conf b/nginx-booking.conf new file mode 100644 index 0000000..e91513b --- /dev/null +++ b/nginx-booking.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name booking.scan360.app; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} +server { + listen 443 ssl; + server_name booking.scan360.app; + + ssl_certificate /etc/letsencrypt/live/booking.scan360.app/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/booking.scan360.app/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://127.0.0.1:8020; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..37004e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi>=0.115.0 +uvicorn>=0.31.1 +sqlalchemy>=2.0.35 +psycopg2-binary>=2.9.9 +pydantic>=2.11.0 +pydantic-settings>=2.10.1 +google-api-python-client>=2.149.0 +google-auth-oauthlib>=1.2.1 +httpx>=0.28.1 +jinja2>=3.1.4 +python-multipart>=0.0.12 +apscheduler>=3.10.4 +authlib>=1.3.0 +python-jose[cryptography]>=3.3.0 +itsdangerous>=2.1.0 diff --git a/wa-service/Dockerfile b/wa-service/Dockerfile new file mode 100644 index 0000000..5197ce7 --- /dev/null +++ b/wa-service/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-slim + +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json . +RUN npm install --omit=dev +COPY server.js . + +RUN mkdir -p /data/auth + +EXPOSE 3100 +CMD ["node", "server.js"] diff --git a/wa-service/package.json b/wa-service/package.json new file mode 100644 index 0000000..76bd256 --- /dev/null +++ b/wa-service/package.json @@ -0,0 +1,12 @@ +{ + "name": "booking-wa", + "version": "1.0.0", + "private": true, + "scripts": { "start": "node server.js" }, + "dependencies": { + "@whiskeysockets/baileys": "^6.7.16", + "express": "^4.21.0", + "pino": "^9.6.0", + "qrcode": "^1.5.4" + } +} diff --git a/wa-service/server.js b/wa-service/server.js new file mode 100644 index 0000000..3be5585 --- /dev/null +++ b/wa-service/server.js @@ -0,0 +1,194 @@ +/** + * BookingWA — Servizio WhatsApp Baileys per Farmacia Ianni + * Espone REST: GET /status, GET /qr, POST /send, POST /disconnect + * Sessione persistente in /data/auth per sopravvivere ai restart. + */ +const express = require('express'); +const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys'); +const qrcode = require('qrcode'); +const pino = require('pino'); + +const app = express(); +app.use(express.json()); + +const PORT = process.env.PORT || 3100; +const AUTH_DIR = '/data/auth'; +const logger = pino({ level: 'warn' }); + +let sock = null; +let currentQR = null; +let qrGeneratedAt = null; +let qrAttempt = 0; +let connectionState = 'disconnected'; +let phoneNumber = null; +let retryCount = 0; +const MAX_RETRIES = 5; +const QR_TTL_SEC = 20; + +async function startSocket() { + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); + const { version } = await fetchLatestBaileysVersion(); + + sock = makeWASocket({ + version, + auth: state, + logger, + printQRInTerminal: false, + browser: ['Farmacia Ianni', 'Chrome', '120.0'], + connectTimeoutMs: 30000, + defaultQueryTimeoutMs: 30000, + generateHighQualityLinkPreview: false, + syncFullHistory: false, + qrTimeout: 60000, + }); + + sock.ev.on('creds.update', saveCreds); + + sock.ev.on('connection.update', async (update) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + currentQR = await qrcode.toDataURL(qr, { width: 280, margin: 2 }); + qrGeneratedAt = Date.now(); + qrAttempt++; + connectionState = 'connecting'; + console.log(`[WA] QR #${qrAttempt} generato — scade tra ${QR_TTL_SEC}s`); + } + + if (connection === 'close') { + currentQR = null; + qrGeneratedAt = null; + const statusCode = lastDisconnect?.error?.output?.statusCode; + const shouldReconnect = statusCode !== DisconnectReason.loggedOut; + + if (statusCode === DisconnectReason.loggedOut) { + connectionState = 'disconnected'; + phoneNumber = null; + retryCount = 0; + qrAttempt = 0; + console.log('[WA] Disconnesso (logout). Serve nuovo QR.'); + const fs = require('fs'); + fs.rmSync(AUTH_DIR, { recursive: true, force: true }); + fs.mkdirSync(AUTH_DIR, { recursive: true }); + } else if (shouldReconnect && retryCount < MAX_RETRIES) { + retryCount++; + const delay = Math.min(retryCount * 2000, 15000); + console.log(`[WA] Riconnessione ${retryCount}/${MAX_RETRIES} in ${delay}ms...`); + connectionState = 'connecting'; + setTimeout(startSocket, delay); + } else { + connectionState = 'disconnected'; + console.log('[WA] Connessione persa, max retry raggiunto.'); + } + } + + if (connection === 'open') { + connectionState = 'connected'; + currentQR = null; + qrGeneratedAt = null; + retryCount = 0; + qrAttempt = 0; + phoneNumber = sock.user?.id?.split(':')[0] || sock.user?.id; + console.log(`[WA] Connesso come ${phoneNumber}`); + } + }); +} + +// ── API ── + +app.get('/status', (req, res) => { + res.json({ + connected: connectionState === 'connected', + state: connectionState, + phone: phoneNumber, + message: connectionState === 'connected' + ? `Connesso come ${phoneNumber}` + : connectionState === 'connecting' + ? 'In attesa di scansione QR...' + : 'Disconnesso' + }); +}); + +app.get('/qr', (req, res) => { + if (connectionState === 'connected') { + return res.json({ qr: null, expired: false, message: 'Già connesso' }); + } + + if (currentQR && qrGeneratedAt) { + const elapsed = (Date.now() - qrGeneratedAt) / 1000; + const remaining = Math.max(0, Math.round(QR_TTL_SEC - elapsed)); + + if (remaining <= 0) { + return res.json({ + qr: null, + expired: true, + attempt: qrAttempt, + message: 'QR scaduto — in attesa del prossimo...' + }); + } + + return res.json({ + qr: currentQR, + expired: false, + expires_in: remaining, + ttl: QR_TTL_SEC, + attempt: qrAttempt, + }); + } + + // Se non c'è QR e non siamo connessi, avvia socket + if (connectionState === 'disconnected') { + startSocket().catch(console.error); + return res.json({ qr: null, expired: false, message: 'Avvio connessione... riprova tra 5 secondi' }); + } + + res.json({ qr: null, expired: false, message: 'QR in generazione...' }); +}); + +app.post('/send', async (req, res) => { + const { to, text } = req.body; + if (!to || !text) return res.status(400).json({ error: 'Servono "to" e "text"' }); + if (connectionState !== 'connected' || !sock) return res.status(503).json({ error: 'WhatsApp non connesso' }); + + try { + let jid = to.replace(/[^0-9]/g, ''); + if (!jid.endsWith('@s.whatsapp.net')) jid = jid + '@s.whatsapp.net'; + await sock.sendMessage(jid, { text }); + console.log(`[WA] Messaggio inviato a ${to}`); + res.json({ ok: true, to, message: 'Inviato' }); + } catch (e) { + console.error('[WA] Errore invio:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +app.post('/disconnect', async (req, res) => { + try { + if (sock) { await sock.logout(); sock = null; } + connectionState = 'disconnected'; + phoneNumber = null; + currentQR = null; + qrGeneratedAt = null; + qrAttempt = 0; + res.json({ ok: true, message: 'Disconnesso' }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'booking-wa', state: connectionState }); +}); + +// ── START ── +app.listen(PORT, '0.0.0.0', () => { + console.log(`[WA] Servizio avviato su :${PORT}`); + const fs = require('fs'); + if (fs.existsSync(AUTH_DIR) && fs.readdirSync(AUTH_DIR).length > 0) { + console.log('[WA] Sessione trovata, riconnessione...'); + startSocket().catch(console.error); + } else { + console.log('[WA] Nessuna sessione. Chiama GET /qr per iniziare.'); + fs.mkdirSync(AUTH_DIR, { recursive: true }); + } +});