commit c33ec8450e5a1cb7f5eca783591c646eaaaba9eb Author: Carlo Mancosu Date: Sun Apr 12 17:46:08 2026 +0000 v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg 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 0000000..9c0aca8 Binary files /dev/null and b/frontend/logo.jpg differ diff --git a/frontend/logo.png b/frontend/logo.png new file mode 100644 index 0000000..7fa1b2b Binary files /dev/null and b/frontend/logo.png differ diff --git a/frontend/logo.svg b/frontend/logo.svg new file mode 100644 index 0000000..eb2f611 --- /dev/null +++ b/frontend/logo.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 }); + } +});