from fastapi import APIRouter, HTTPException, Depends, Query from app.database import get_supabase from app.dependencies import get_current_user from app.config import PLAN_LIMITS from app.models import ( AppointmentCreate, AppointmentResponse, AppointmentStatusUpdate, BusinessHoursEntry, BusinessHoursSave, ) from typing import List, Optional from datetime import datetime, timedelta, date import uuid import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/appointments", tags=["Appointments"]) public_router = APIRouter(tags=["Appointments"]) # ── Helpers ─────────────────────────────────────────────────────────────────── def _check_booking_access(user_id: str, supabase): sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute() plan = sub.data[0]["plan"] if sub.data else "free" if plan not in ("starter", "business", "agency", "enterprise"): raise HTTPException(status_code=402, detail="Appointment booking requires Starter plan or higher") return plan def _get_user_company_id(user_id: str, supabase) -> str: result = supabase.table("companies").select("id").eq("owner_id", user_id).execute() if not result.data: raise HTTPException(status_code=404, detail="Company not found") return result.data[0]["id"] def _verify_chatbot_ownership(chatbot_id: str, company_id: str, supabase): chatbot = supabase.table("chatbots").select("id").eq("id", chatbot_id).eq("company_id", company_id).execute() if not chatbot.data: raise HTTPException(status_code=404, detail="Chatbot not found") def _parse_time(t: str) -> tuple[int, int]: """Parse HH:MM into (hour, minute).""" h, m = t.split(":") return int(h), int(m) def _get_available_slots(chatbot_id: str, target_date: date, supabase) -> List[dict]: """Return list of available {slot_start, slot_end} dicts for the given date.""" day_of_week = target_date.weekday() # 0=Mon hours = supabase.table("business_hours") \ .select("*").eq("chatbot_id", chatbot_id).eq("day_of_week", day_of_week).execute() if not hours.data or not hours.data[0].get("is_open"): return [] h = hours.data[0] open_h, open_m = _parse_time(h["open_time"]) close_h, close_m = _parse_time(h["close_time"]) duration = h.get("slot_duration_minutes", 60) slot_start = datetime(target_date.year, target_date.month, target_date.day, open_h, open_m) slot_end_limit = datetime(target_date.year, target_date.month, target_date.day, close_h, close_m) # Fetch already-booked slots for that day day_start = datetime(target_date.year, target_date.month, target_date.day, 0, 0) day_end = day_start + timedelta(days=1) booked = supabase.table("appointments") \ .select("slot_start, slot_end") \ .eq("chatbot_id", chatbot_id) \ .neq("status", "cancelled") \ .gte("slot_start", day_start.isoformat()) \ .lt("slot_start", day_end.isoformat()) \ .execute() booked_starts = {b["slot_start"] for b in (booked.data or [])} slots = [] now = datetime.utcnow() while slot_start + timedelta(minutes=duration) <= slot_end_limit: slot_end = slot_start + timedelta(minutes=duration) # Skip past slots if slot_start > now: iso_start = slot_start.isoformat() if iso_start not in booked_starts: slots.append({"slot_start": iso_start, "slot_end": slot_end.isoformat()}) slot_start = slot_end return slots # ── Protected endpoints (business owner) ───────────────────────────────────── @router.get("", response_model=List[AppointmentResponse]) async def list_appointments( chatbot_id: Optional[str] = Query(None), status: Optional[str] = Query(None), page: int = Query(1, ge=1), limit: int = Query(50, ge=1, le=200), user=Depends(get_current_user), ): supabase = get_supabase() _check_booking_access(user.id, supabase) company_id = _get_user_company_id(user.id, supabase) chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id) if chatbot_id: chatbots_q = chatbots_q.eq("id", chatbot_id) chatbots = chatbots_q.execute() chatbot_ids = [c["id"] for c in (chatbots.data or [])] if not chatbot_ids: return [] offset = (page - 1) * limit q = supabase.table("appointments").select("*").in_("chatbot_id", chatbot_ids) if status: q = q.eq("status", status) result = q.order("slot_start", desc=False).range(offset, offset + limit - 1).execute() return [AppointmentResponse(**a) for a in (result.data or [])] @router.patch("/{appointment_id}", response_model=AppointmentResponse) async def update_appointment_status( appointment_id: str, data: AppointmentStatusUpdate, user=Depends(get_current_user), ): valid = ("pending", "confirmed", "cancelled", "completed") if data.status not in valid: raise HTTPException(status_code=400, detail=f"Status must be one of {valid}") supabase = get_supabase() _check_booking_access(user.id, supabase) company_id = _get_user_company_id(user.id, supabase) appt = supabase.table("appointments").select("*, chatbots(company_id)") \ .eq("id", appointment_id).execute() if not appt.data: raise HTTPException(status_code=404, detail="Appointment not found") if appt.data[0].get("chatbots", {}).get("company_id") != company_id: raise HTTPException(status_code=403, detail="Not authorized") result = supabase.table("appointments").update({"status": data.status}) \ .eq("id", appointment_id).execute() return AppointmentResponse(**result.data[0]) @router.get("/chatbot/{chatbot_id}/hours") async def get_business_hours(chatbot_id: str, user=Depends(get_current_user)): supabase = get_supabase() _check_booking_access(user.id, supabase) company_id = _get_user_company_id(user.id, supabase) _verify_chatbot_ownership(chatbot_id, company_id, supabase) result = supabase.table("business_hours").select("*") \ .eq("chatbot_id", chatbot_id).order("day_of_week").execute() return result.data or [] @router.put("/chatbot/{chatbot_id}/hours") async def save_business_hours( chatbot_id: str, data: BusinessHoursSave, user=Depends(get_current_user), ): supabase = get_supabase() _check_booking_access(user.id, supabase) company_id = _get_user_company_id(user.id, supabase) _verify_chatbot_ownership(chatbot_id, company_id, supabase) # Upsert each day for entry in data.hours: existing = supabase.table("business_hours").select("id") \ .eq("chatbot_id", chatbot_id).eq("day_of_week", entry.day_of_week).execute() row = { "chatbot_id": chatbot_id, "day_of_week": entry.day_of_week, "is_open": entry.is_open, "open_time": entry.open_time, "close_time": entry.close_time, "slot_duration_minutes": entry.slot_duration_minutes, } if existing.data: supabase.table("business_hours").update(row).eq("id", existing.data[0]["id"]).execute() else: row["id"] = str(uuid.uuid4()) supabase.table("business_hours").insert(row).execute() return {"success": True} # ── Public endpoints (customers booking) ───────────────────────────────────── @public_router.get("/chatbots/{chatbot_id}/booking-info") async def get_booking_info(chatbot_id: str): """Return public booking info for the booking page (no auth required).""" supabase = get_supabase() result = supabase.table("chatbots") \ .select("id, name, booking_enabled, companies(name)") \ .eq("id", chatbot_id).execute() if not result.data: raise HTTPException(status_code=404, detail="Chatbot not found") chatbot = result.data[0] if not chatbot.get("booking_enabled"): raise HTTPException(status_code=400, detail="Booking is not enabled for this chatbot") return { "chatbot_id": chatbot["id"], "chatbot_name": chatbot.get("name", ""), "company_name": (chatbot.get("companies") or {}).get("name", ""), } @public_router.get("/chatbots/{chatbot_id}/available-slots") async def get_available_slots( chatbot_id: str, date: str = Query(..., description="YYYY-MM-DD"), ): """Return available time slots for a given date (public).""" supabase = get_supabase() chatbot = supabase.table("chatbots").select("id, booking_enabled, is_published") \ .eq("id", chatbot_id).execute() if not chatbot.data: raise HTTPException(status_code=404, detail="Chatbot not found") if not chatbot.data[0].get("booking_enabled"): raise HTTPException(status_code=400, detail="Booking not enabled for this chatbot") try: target = datetime.strptime(date, "%Y-%m-%d").date() except ValueError: raise HTTPException(status_code=400, detail="Invalid date format, use YYYY-MM-DD") slots = _get_available_slots(chatbot_id, target, supabase) return {"date": date, "slots": slots} @public_router.post("/chatbots/{chatbot_id}/appointments", response_model=AppointmentResponse, status_code=201) async def create_appointment(chatbot_id: str, data: AppointmentCreate): """Create an appointment (public endpoint, no auth required).""" supabase = get_supabase() chatbot = supabase.table("chatbots").select("id, booking_enabled") \ .eq("id", chatbot_id).execute() if not chatbot.data: raise HTTPException(status_code=404, detail="Chatbot not found") if not chatbot.data[0].get("booking_enabled"): raise HTTPException(status_code=400, detail="Booking not enabled for this chatbot") # Verify the slot is still available slot_start_dt = data.slot_start target_date = slot_start_dt.date() available = _get_available_slots(chatbot_id, target_date, supabase) available_starts = {s["slot_start"] for s in available} slot_iso = slot_start_dt.isoformat() # Try a few normalizations (with/without timezone suffix) if slot_iso not in available_starts and slot_iso + "Z" not in available_starts: # Check without microseconds slot_iso_no_ms = slot_start_dt.replace(microsecond=0).isoformat() if slot_iso_no_ms not in available_starts: raise HTTPException(status_code=409, detail="This slot is no longer available") # Calculate slot_end based on business hours duration hours = supabase.table("business_hours").select("slot_duration_minutes") \ .eq("chatbot_id", chatbot_id).eq("day_of_week", target_date.weekday()).execute() duration = hours.data[0]["slot_duration_minutes"] if hours.data else 60 slot_end_dt = slot_start_dt + timedelta(minutes=duration) appt_data = { "id": str(uuid.uuid4()), "chatbot_id": chatbot_id, "conversation_id": data.conversation_id, "customer_name": data.customer_name, "customer_contact": data.customer_contact, "service": data.service, "slot_start": data.slot_start.isoformat(), "slot_end": slot_end_dt.isoformat(), "status": "pending", "notes": data.notes, } result = supabase.table("appointments").insert(appt_data).execute() if not result.data: raise HTTPException(status_code=500, detail="Failed to create appointment") return AppointmentResponse(**result.data[0])