mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
- Add new routers: admin, appointments, campaigns - Add storage service and logging config - Add migrations directory and test suite with pytest config - Add supabase_migration_features.sql - Update models, dependencies, config, and existing routers - Remove whatsapp_service (deleted) - Update pyproject.toml and uv.lock dependencies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
12 KiB
Python
288 lines
12 KiB
Python
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])
|