feat: add appointments, campaigns, admin, storage, tests and various updates

- 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>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

287
app/routers/appointments.py Normal file
View File

@@ -0,0 +1,287 @@
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])