All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
by how many add refresh button choose column to display to the guest page
1885 lines
70 KiB
Python
1885 lines
70 KiB
Python
from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import or_
|
||
import uvicorn
|
||
from typing import List, Optional
|
||
from uuid import UUID, uuid4
|
||
import os
|
||
import io
|
||
import csv
|
||
import json
|
||
import secrets
|
||
import logging
|
||
import shutil
|
||
from pathlib import Path
|
||
from dotenv import load_dotenv
|
||
import httpx
|
||
from urllib.parse import urlencode, quote
|
||
from datetime import timezone, timedelta
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
import models
|
||
import schemas
|
||
import crud
|
||
import authz
|
||
import google_contacts
|
||
from database import engine, get_db
|
||
from whatsapp import get_whatsapp_service, WhatsAppError
|
||
from whatsapp_templates import list_templates_for_frontend, add_custom_template, delete_custom_template
|
||
|
||
# Load environment variables
|
||
load_dotenv()
|
||
|
||
# Create database tables
|
||
models.Base.metadata.create_all(bind=engine)
|
||
|
||
app = FastAPI(title="Multi-Event Invitation Management API")
|
||
|
||
# Ensure uploads directory exists and serve it as static files
|
||
UPLOADS_DIR = Path(__file__).parent / "uploads"
|
||
UPLOADS_DIR.mkdir(exist_ok=True)
|
||
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
|
||
|
||
# Get allowed origins from environment
|
||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
allowed_origins = [FRONTEND_URL]
|
||
# Allow common localhost development ports
|
||
allowed_origins.extend([
|
||
"http://localhost:5173",
|
||
"http://localhost:5174",
|
||
"http://127.0.0.1:5173",
|
||
"http://127.0.0.1:5174",
|
||
])
|
||
|
||
# ─── RSVP URL builder ────────────────────────────────────────────────────────
|
||
def build_rsvp_url(event_id) -> str:
|
||
"""
|
||
Build the public RSVP URL for an event.
|
||
In DEV → http://localhost:5173/guest/<event_id>
|
||
In PROD → https://invy.dvirlabs.com/guest/<event_id>
|
||
Controlled by FRONTEND_URL env var.
|
||
"""
|
||
base = os.getenv("FRONTEND_URL", "http://localhost:5173").rstrip("/")
|
||
return f"{base}/guest/{event_id}"
|
||
|
||
|
||
# Configure CORS
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=allowed_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# Helper: Get current user from headers/cookies
|
||
# ============================================
|
||
def get_current_user_id(request: Request, db: Session = Depends(get_db)):
|
||
"""
|
||
Extract current user from:
|
||
1. X-User-ID header (set by frontend)
|
||
2. _user_session cookie (from OAuth callback)
|
||
|
||
Returns:
|
||
User ID (UUID or string like 'admin-user') if authenticated, None if not authenticated
|
||
"""
|
||
# Check for X-User-ID header (from admin login or OAuth)
|
||
user_id_header = request.headers.get("X-User-ID")
|
||
if user_id_header and user_id_header.strip():
|
||
# Accept any non-empty user ID (admin-user, UUID, etc)
|
||
return user_id_header
|
||
|
||
# Check for session cookie set by OAuth callback
|
||
user_id_cookie = request.cookies.get("_user_session")
|
||
if user_id_cookie and user_id_cookie.strip():
|
||
# Try to convert to UUID if it's a valid one, otherwise return as string
|
||
try:
|
||
return UUID(user_id_cookie)
|
||
except ValueError:
|
||
return user_id_cookie
|
||
|
||
# Not authenticated - return None instead of raising error
|
||
# Let endpoints decide whether to require authentication
|
||
return None
|
||
|
||
|
||
# ============================================
|
||
# Root Endpoint
|
||
# ============================================
|
||
@app.get("/")
|
||
def read_root():
|
||
return {"message": "Multi-Event Invitation Management API"}
|
||
|
||
|
||
# ============================================
|
||
# Image Upload Endpoint
|
||
# ============================================
|
||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||
|
||
@app.post("/upload-image")
|
||
async def upload_image(
|
||
file: UploadFile = File(...),
|
||
current_user_id = Depends(get_current_user_id)
|
||
):
|
||
"""Upload an invitation background image. Returns the public URL."""
|
||
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
||
raise HTTPException(status_code=400, detail="Only JPEG, PNG, GIF and WebP images are allowed")
|
||
|
||
contents = await file.read()
|
||
if len(contents) > MAX_IMAGE_SIZE:
|
||
raise HTTPException(status_code=400, detail="Image must be smaller than 10 MB")
|
||
|
||
ext = Path(file.filename).suffix.lower() if file.filename else ".jpg"
|
||
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||
ext = ".jpg"
|
||
|
||
filename = f"{uuid4().hex}{ext}"
|
||
dest = UPLOADS_DIR / filename
|
||
dest.write_bytes(contents)
|
||
|
||
base_url = os.getenv("BACKEND_URL", "http://localhost:8000")
|
||
return {"url": f"{base_url}/uploads/{filename}"}
|
||
|
||
|
||
# ============================================
|
||
# Event Endpoints
|
||
# ============================================
|
||
@app.post("/events", response_model=schemas.Event)
|
||
def create_event(
|
||
event: schemas.EventCreate,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Create a new event (creator becomes admin). Requires authentication."""
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=403, detail="Not authenticated. Please login with Google first.")
|
||
|
||
return crud.create_event(db, event, current_user_id)
|
||
|
||
|
||
@app.get("/events", response_model=List[schemas.Event])
|
||
def list_events(
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""List all events user is a member of. Returns empty list if not authenticated."""
|
||
if not current_user_id:
|
||
# Return empty list for unauthenticated users
|
||
return []
|
||
|
||
return crud.get_events_for_user(db, current_user_id)
|
||
|
||
|
||
@app.get("/events/{event_id}", response_model=schemas.EventWithMembers)
|
||
async def get_event(
|
||
event_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id = Depends(get_current_user_id)
|
||
):
|
||
"""Get event details (only for members)"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
event = crud.get_event(db, event_id)
|
||
members = crud.get_event_members(db, event_id)
|
||
event.members = members
|
||
return event
|
||
|
||
|
||
@app.patch("/events/{event_id}", response_model=schemas.Event)
|
||
async def update_event(
|
||
event_id: UUID,
|
||
event_update: schemas.EventUpdate,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Update event (admin only)"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
return crud.update_event(db, event_id, event_update)
|
||
|
||
|
||
@app.delete("/events/{event_id}")
|
||
async def delete_event(
|
||
event_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Delete event (admin only)"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
success = crud.delete_event(db, event_id)
|
||
if not success:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
return {"message": "Event deleted successfully"}
|
||
|
||
|
||
# ============================================
|
||
# Event Member Endpoints
|
||
# ============================================
|
||
@app.post("/events/{event_id}/invite-member", response_model=schemas.EventMember)
|
||
async def invite_event_member(
|
||
event_id: UUID,
|
||
invite: schemas.EventMemberCreate,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Invite user to event by email (admin only)"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
|
||
# Find or create user
|
||
user = crud.get_or_create_user(db, invite.user_email)
|
||
|
||
# Add to event
|
||
member = crud.create_event_member(
|
||
db, event_id, user.id, invite.role, invite.display_name
|
||
)
|
||
return member
|
||
|
||
|
||
@app.get("/events/{event_id}/members", response_model=List[schemas.EventMember])
|
||
async def list_event_members(
|
||
event_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""List all members of an event"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
return crud.get_event_members(db, event_id)
|
||
|
||
|
||
@app.patch("/events/{event_id}/members/{user_id}")
|
||
async def update_member_role(
|
||
event_id: UUID,
|
||
user_id: UUID,
|
||
role_update: dict,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Update member role (admin only)"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
|
||
member = crud.update_event_member_role(
|
||
db, event_id, user_id, role_update.get("role", "viewer")
|
||
)
|
||
if not member:
|
||
raise HTTPException(status_code=404, detail="Member not found")
|
||
return member
|
||
|
||
|
||
@app.delete("/events/{event_id}/members/{user_id}")
|
||
async def remove_member(
|
||
event_id: UUID,
|
||
user_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Remove member from event (admin only)"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
|
||
success = crud.remove_event_member(db, event_id, user_id)
|
||
if not success:
|
||
raise HTTPException(status_code=404, detail="Member not found")
|
||
return {"message": "Member removed successfully"}
|
||
|
||
|
||
# ============================================
|
||
# Guest Endpoints (Event-Scoped)
|
||
# ============================================
|
||
@app.post("/events/{event_id}/guests", response_model=schemas.Guest)
|
||
async def create_guest(
|
||
event_id: UUID,
|
||
guest: schemas.GuestCreate,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Add a single guest to event (editor+ only)"""
|
||
authz_info = await authz.verify_event_editor(event_id, db, current_user_id)
|
||
return crud.create_guest(db, event_id, guest, current_user_id)
|
||
|
||
|
||
@app.get("/events/{event_id}/guests", response_model=List[schemas.Guest])
|
||
async def list_guests(
|
||
event_id: UUID,
|
||
search: Optional[str] = Query(None),
|
||
status: Optional[str] = Query(None),
|
||
rsvp_status: Optional[str] = Query(None),
|
||
side: Optional[str] = Query(None),
|
||
owner: Optional[str] = Query(None), # Filter by owner email or 'self-service'
|
||
added_by_me: bool = Query(False),
|
||
skip: int = Query(0),
|
||
limit: int = Query(1000),
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""List guests for event with optional filters. Requires authentication."""
|
||
# Require authentication for this endpoint
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=403, detail="Not authenticated. Please login with Google to continue.")
|
||
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
# Support both old (status) and new (rsvp_status) parameter names
|
||
filter_status = rsvp_status or status
|
||
|
||
added_by_user_id = current_user_id if added_by_me else None
|
||
guests = crud.search_guests(
|
||
db, event_id, search, filter_status, side, added_by_user_id, owner_email=owner
|
||
)
|
||
return guests[skip:skip+limit]
|
||
|
||
|
||
@app.get("/events/{event_id}/guest-owners")
|
||
async def get_guest_owners(
|
||
event_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Get list of unique owners/sources for guests in an event. Requires authentication."""
|
||
# Require authentication for this endpoint
|
||
if not current_user_id:
|
||
# Return empty result instead of error - allows UI to render without data
|
||
return {
|
||
"owners": [],
|
||
"has_self_service": False,
|
||
"total_guests": 0,
|
||
"requires_login": True,
|
||
"message": "Please login with Google to see event details"
|
||
}
|
||
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
# Query distinct owner_email values
|
||
from sqlalchemy import distinct
|
||
owners = db.query(distinct(models.Guest.owner_email)).filter(
|
||
models.Guest.event_id == event_id
|
||
).all()
|
||
|
||
# Extract values and filter out None
|
||
owner_list = [owner[0] for owner in owners if owner[0]]
|
||
owner_list.sort()
|
||
|
||
# Check for self-service guests
|
||
self_service_count = db.query(models.Guest).filter(
|
||
models.Guest.event_id == event_id,
|
||
models.Guest.source == "self-service"
|
||
).count()
|
||
|
||
result = {
|
||
"owners": owner_list,
|
||
"has_self_service": self_service_count > 0,
|
||
"total_guests": db.query(models.Guest).filter(
|
||
models.Guest.event_id == event_id
|
||
).count()
|
||
}
|
||
|
||
return result
|
||
|
||
|
||
# ============================================
|
||
# Duplicate Detection & Merging
|
||
# ============================================
|
||
@app.get("/events/{event_id}/guests/duplicates")
|
||
async def get_duplicate_guests(
|
||
event_id: UUID,
|
||
by: str = Query("phone", description="'phone', 'email', or 'name'"),
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Find duplicate guests by phone, email, or name (members only)"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
if by not in ["phone", "email", "name"]:
|
||
raise HTTPException(status_code=400, detail="Invalid 'by' parameter. Must be 'phone', 'email', or 'name'")
|
||
|
||
try:
|
||
result = crud.find_duplicate_guests(db, event_id, by)
|
||
return result
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"Error finding duplicates: {str(e)}")
|
||
|
||
|
||
@app.post("/events/{event_id}/guests/merge")
|
||
async def merge_duplicate_guests(
|
||
event_id: UUID,
|
||
merge_request: dict,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""
|
||
Merge duplicate guests (admin only)
|
||
|
||
Request body:
|
||
{
|
||
"keep_id": "uuid-to-keep",
|
||
"merge_ids": ["uuid1", "uuid2", ...]
|
||
}
|
||
"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
|
||
keep_id = merge_request.get("keep_id")
|
||
merge_ids = merge_request.get("merge_ids", [])
|
||
|
||
if not keep_id:
|
||
raise HTTPException(status_code=400, detail="keep_id is required")
|
||
|
||
if not merge_ids or len(merge_ids) == 0:
|
||
raise HTTPException(status_code=400, detail="merge_ids must be a non-empty list")
|
||
|
||
try:
|
||
# Convert string UUIDs to UUID objects
|
||
keep_id = UUID(keep_id)
|
||
merge_ids = [UUID(mid) for mid in merge_ids]
|
||
|
||
result = crud.merge_guests(db, event_id, keep_id, merge_ids)
|
||
return result
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"Error merging guests: {str(e)}")
|
||
|
||
|
||
@app.get("/events/{event_id}/guests/{guest_id}", response_model=schemas.Guest)
|
||
async def get_guest(
|
||
event_id: UUID,
|
||
guest_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Get guest details"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
guest = crud.get_guest(db, guest_id, event_id)
|
||
if not guest:
|
||
raise HTTPException(status_code=404, detail="Guest not found")
|
||
return guest
|
||
|
||
|
||
@app.patch("/events/{event_id}/guests/{guest_id}", response_model=schemas.Guest)
|
||
async def update_guest(
|
||
event_id: UUID,
|
||
guest_id: UUID,
|
||
guest_update: schemas.GuestUpdate,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Update guest details (editor+ only)"""
|
||
authz_info = await authz.verify_event_editor(event_id, db, current_user_id)
|
||
guest = crud.update_guest(db, guest_id, event_id, guest_update)
|
||
if not guest:
|
||
raise HTTPException(status_code=404, detail="Guest not found")
|
||
return guest
|
||
|
||
|
||
@app.delete("/events/{event_id}/guests/{guest_id}")
|
||
async def delete_guest(
|
||
event_id: UUID,
|
||
guest_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Delete guest (admin only)"""
|
||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||
success = crud.delete_guest(db, guest_id, event_id)
|
||
if not success:
|
||
raise HTTPException(status_code=404, detail="Guest not found")
|
||
return {"message": "Guest deleted successfully"}
|
||
|
||
|
||
# ============================================
|
||
# Bulk Guest Delete
|
||
# ============================================
|
||
@app.post("/events/{event_id}/guests/bulk-delete")
|
||
async def bulk_delete_guests(
|
||
event_id: UUID,
|
||
delete_data: schemas.GuestBulkDelete,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Bulk delete guests (admin only)"""
|
||
await authz.verify_event_admin(event_id, db, current_user_id)
|
||
deleted_count = crud.delete_guests_bulk(db, event_id, delete_data.guest_ids)
|
||
return {"message": f"{deleted_count} guests deleted successfully", "deleted_count": deleted_count}
|
||
@app.post("/events/{event_id}/guests/import", response_model=dict)
|
||
async def bulk_import_guests(
|
||
event_id: UUID,
|
||
import_data: schemas.GuestBulkImport,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Bulk import guests (editor+ only)"""
|
||
authz_info = await authz.verify_event_editor(event_id, db, current_user_id)
|
||
|
||
guests = crud.bulk_import_guests(db, event_id, import_data.guests, current_user_id)
|
||
return {
|
||
"imported_count": len(guests),
|
||
"guests": guests
|
||
}
|
||
|
||
|
||
# ============================================
|
||
# Event Statistics
|
||
# ============================================
|
||
@app.get("/events/{event_id}/stats")
|
||
async def get_event_stats(
|
||
event_id: UUID,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Get event statistics (members only)"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
stats = crud.get_event_stats(db, event_id)
|
||
sides = crud.get_sides_summary(db, event_id)
|
||
|
||
return {
|
||
"stats": stats,
|
||
"sides": sides
|
||
}
|
||
|
||
|
||
# ============================================
|
||
# WhatsApp Messaging
|
||
# ============================================
|
||
@app.post("/events/{event_id}/guests/{guest_id}/whatsapp")
|
||
async def send_guest_message(
|
||
event_id: UUID,
|
||
guest_id: UUID,
|
||
message_req: schemas.WhatsAppMessage,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Send WhatsApp message to guest (members only)"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
# Get guest
|
||
guest = crud.get_guest(db, guest_id, event_id)
|
||
if not guest:
|
||
raise HTTPException(status_code=404, detail="Guest not found")
|
||
|
||
# Use override phone or guest's phone
|
||
phone = message_req.phone or guest.phone
|
||
|
||
try:
|
||
service = get_whatsapp_service()
|
||
result = await service.send_text_message(phone, message_req.message)
|
||
return result
|
||
except WhatsAppError as e:
|
||
raise HTTPException(status_code=400, detail=f"WhatsApp error: {str(e)}")
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"Failed to send message: {str(e)}")
|
||
|
||
|
||
@app.post("/events/{event_id}/whatsapp/broadcast")
|
||
async def broadcast_whatsapp_message(
|
||
event_id: UUID,
|
||
broadcast_req: dict,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""
|
||
Broadcast WhatsApp message to multiple guests
|
||
|
||
Request body:
|
||
{
|
||
"message": "Your message here",
|
||
"guest_ids": ["uuid1", "uuid2"], // optional: if not provided, send to all
|
||
"filter_status": "confirmed" // optional: filter by status
|
||
}
|
||
"""
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
message = broadcast_req.get("message", "")
|
||
if not message:
|
||
raise HTTPException(status_code=400, detail="Message is required")
|
||
|
||
# Get guests to send to
|
||
if broadcast_req.get("guest_ids"):
|
||
guest_ids = [UUID(gid) for gid in broadcast_req["guest_ids"]]
|
||
guests = []
|
||
for gid in guest_ids:
|
||
g = crud.get_guest(db, gid, event_id)
|
||
if g:
|
||
guests.append(g)
|
||
elif broadcast_req.get("filter_status"):
|
||
guests = crud.get_guests_by_status(db, event_id, broadcast_req["filter_status"])
|
||
else:
|
||
guests = crud.get_guests(db, event_id)
|
||
|
||
# Send to all guests
|
||
results = []
|
||
failed = []
|
||
|
||
try:
|
||
service = get_whatsapp_service()
|
||
for guest in guests:
|
||
try:
|
||
result = await service.send_text_message(guest.phone, message)
|
||
results.append({
|
||
"guest_id": str(guest.id),
|
||
"phone": guest.phone,
|
||
"status": "sent",
|
||
"message_id": result.get("message_id")
|
||
})
|
||
except Exception as e:
|
||
failed.append({
|
||
"guest_id": str(guest.id),
|
||
"phone": guest.phone,
|
||
"error": str(e)
|
||
})
|
||
except WhatsAppError as e:
|
||
raise HTTPException(status_code=400, detail=f"WhatsApp error: {str(e)}")
|
||
|
||
return {
|
||
"total": len(guests),
|
||
"sent": len(results),
|
||
"failed": len(failed),
|
||
"results": results,
|
||
"failures": failed
|
||
}
|
||
|
||
|
||
# ============================================
|
||
# WhatsApp Template Registry Endpoints
|
||
# ============================================
|
||
@app.get("/whatsapp/templates")
|
||
async def get_whatsapp_templates():
|
||
"""
|
||
Return all registered WhatsApp templates (built-in + custom) for the frontend dropdown.
|
||
"""
|
||
return {"templates": list_templates_for_frontend()}
|
||
|
||
|
||
@app.post("/whatsapp/templates")
|
||
async def create_whatsapp_template(
|
||
body: dict,
|
||
current_user_id = Depends(get_current_user_id)
|
||
):
|
||
"""
|
||
Create a new custom WhatsApp template.
|
||
|
||
Expected body:
|
||
{
|
||
"key": "my_template", # unique key (no spaces)
|
||
"friendly_name": "My Template",
|
||
"meta_name": "my_template", # exact name in Meta BM
|
||
"language_code": "he",
|
||
"description": "optional description",
|
||
"header_text": "היי {{1}}", # raw text (for preview)
|
||
"body_text": "{{1}} ו-{{2}} ...", # raw text (for preview)
|
||
"header_param_keys": ["contact_name"], # ordered param keys for header {{N}}
|
||
"body_param_keys": ["groom_name", "bride_name", ...],
|
||
"fallbacks": { "contact_name": "חבר", ... }
|
||
}
|
||
"""
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||
|
||
key = body.get("key", "").strip().replace(" ", "_").lower()
|
||
if not key:
|
||
raise HTTPException(status_code=400, detail="'key' is required")
|
||
if not body.get("meta_name", "").strip():
|
||
raise HTTPException(status_code=400, detail="'meta_name' is required")
|
||
if not body.get("friendly_name", "").strip():
|
||
raise HTTPException(status_code=400, detail="'friendly_name' is required")
|
||
|
||
template = {
|
||
"meta_name": body.get("meta_name", key),
|
||
"language_code": body.get("language_code", "he"),
|
||
"friendly_name": body["friendly_name"],
|
||
"description": body.get("description", ""),
|
||
"header_text": body.get("header_text", ""),
|
||
"body_text": body.get("body_text", ""),
|
||
"header_params": body.get("header_param_keys", []),
|
||
"body_params": body.get("body_param_keys", []),
|
||
"fallbacks": body.get("fallbacks", {}),
|
||
"guest_name_key": body.get("guest_name_key", ""),
|
||
}
|
||
|
||
try:
|
||
add_custom_template(key, template)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=409, detail=str(e))
|
||
|
||
return {"status": "created", "key": key, "template": template}
|
||
|
||
|
||
@app.delete("/whatsapp/templates/{key}")
|
||
async def delete_whatsapp_template(
|
||
key: str,
|
||
current_user_id = Depends(get_current_user_id)
|
||
):
|
||
"""Delete a custom template by key (built-in templates cannot be deleted)."""
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||
|
||
try:
|
||
delete_custom_template(key)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=403, detail=str(e))
|
||
except KeyError as e:
|
||
raise HTTPException(status_code=404, detail=str(e))
|
||
|
||
return {"status": "deleted", "key": key}
|
||
|
||
|
||
|
||
# ============================================
|
||
# WhatsApp Wedding Invitation Endpoints
|
||
# ============================================
|
||
@app.post("/events/{event_id}/guests/{guest_id}/whatsapp/invite", response_model=schemas.WhatsAppSendResult)
|
||
async def send_wedding_invitation_single(
|
||
event_id: UUID,
|
||
guest_id: UUID,
|
||
request_body: Optional[dict] = None,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Send wedding invitation template to a single guest"""
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
# Get guest
|
||
guest = crud.get_guest_for_whatsapp(db, event_id, guest_id)
|
||
if not guest:
|
||
raise HTTPException(status_code=404, detail="Guest not found")
|
||
|
||
# Get event for template data
|
||
event = crud.get_event_for_whatsapp(db, event_id)
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
|
||
# Prepare phone (use override if provided)
|
||
phone_override = request_body.get("phone_override") if request_body else None
|
||
to_phone = phone_override or guest.phone_number or guest.phone
|
||
|
||
if not to_phone:
|
||
return schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=f"{guest.first_name}",
|
||
phone="",
|
||
status="failed",
|
||
error="No phone number available for guest"
|
||
)
|
||
|
||
try:
|
||
# Format event details
|
||
guest_name = guest.first_name or (f"{guest.first_name} {guest.last_name}".strip() or "חבר")
|
||
event_date = event.date.strftime("%d/%m") if event.date else ""
|
||
event_time = event.event_time or ""
|
||
venue = event.venue or event.location or ""
|
||
partner1 = event.partner1_name or ""
|
||
partner2 = event.partner2_name or ""
|
||
|
||
# Build guest link as clean /guest/<event_id> path so the frontend
|
||
# regex can reliably extract the event_id from the URL.
|
||
_gl_base = (event.guest_link or "https://invy.dvirlabs.com/guest").split("?")[0].rstrip("/")
|
||
guest_link = f"{_gl_base}/{event_id}"
|
||
|
||
service = get_whatsapp_service()
|
||
result = await service.send_wedding_invitation(
|
||
to_phone=to_phone,
|
||
guest_name=guest_name,
|
||
partner1_name=partner1,
|
||
partner2_name=partner2,
|
||
venue=venue,
|
||
event_date=event_date,
|
||
event_time=event_time,
|
||
guest_link=guest_link,
|
||
template_key=request_body.get("template_key") if request_body else None,
|
||
)
|
||
|
||
return schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=guest_name,
|
||
phone=to_phone,
|
||
status="sent",
|
||
message_id=result.get("message_id")
|
||
)
|
||
|
||
except WhatsAppError as e:
|
||
return schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=f"{guest.first_name}",
|
||
phone=to_phone,
|
||
status="failed",
|
||
error=str(e)
|
||
)
|
||
except Exception as e:
|
||
return schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=f"{guest.first_name}",
|
||
phone=to_phone,
|
||
status="failed",
|
||
error=f"Unexpected error: {str(e)}"
|
||
)
|
||
|
||
|
||
@app.post("/events/{event_id}/whatsapp/invite", response_model=schemas.WhatsAppBulkResult)
|
||
async def send_wedding_invitation_bulk(
|
||
event_id: UUID,
|
||
request_body: schemas.WhatsAppWeddingInviteRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""Send wedding invitation template to multiple guests"""
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||
|
||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||
|
||
# Get event for template data
|
||
event = crud.get_event_for_whatsapp(db, event_id)
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found")
|
||
|
||
# Get guests
|
||
if request_body.guest_ids:
|
||
guest_ids = [UUID(gid) for gid in request_body.guest_ids]
|
||
guests = crud.get_guests_for_whatsapp(db, event_id, guest_ids)
|
||
else:
|
||
raise HTTPException(status_code=400, detail="guest_ids are required")
|
||
|
||
# Send to all guests and collect results
|
||
results = []
|
||
import asyncio
|
||
|
||
service = get_whatsapp_service()
|
||
|
||
for guest in guests:
|
||
try:
|
||
# Prepare phone
|
||
to_phone = request_body.phone_override or guest.phone_number or guest.phone
|
||
|
||
if not to_phone:
|
||
results.append(schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=f"{guest.first_name}",
|
||
phone="",
|
||
status="failed",
|
||
error="No phone number available"
|
||
))
|
||
continue
|
||
|
||
# Build params — contact_name always comes from the guest record
|
||
guest_name = f"{guest.first_name} {guest.last_name}".strip() or guest.first_name or "חבר"
|
||
|
||
# Standard named params (built-in template keys) with DB fallbacks
|
||
partner1 = (request_body.partner1_name or event.partner1_name or "").strip()
|
||
partner2 = (request_body.partner2_name or event.partner2_name or "").strip()
|
||
venue = (request_body.venue or event.venue or event.location or "").strip()
|
||
event_time = (request_body.event_time or event.event_time or "").strip()
|
||
|
||
# Convert event_date YYYY-MM-DD → DD/MM if still in ISO format (backend fallback)
|
||
if request_body.event_date:
|
||
try:
|
||
from datetime import datetime as _dt
|
||
_d = _dt.strptime(request_body.event_date[:10], "%Y-%m-%d")
|
||
event_date = _d.strftime("%d/%m")
|
||
except Exception:
|
||
event_date = request_body.event_date
|
||
else:
|
||
event_date = event.date.strftime("%d/%m") if event.date else ""
|
||
|
||
# Build per-guest link — always unique per event + guest so that
|
||
# a guest invited to multiple events gets a distinct URL each time.
|
||
# Build a clean /guest/<event_id> path URL so the frontend regex
|
||
# /^\/guest\/([a-f0-9-]{36})/ can reliably extract the event_id.
|
||
_frontend_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
|
||
# Strip any existing ?event= / ?guest_id= to avoid double params
|
||
_frontend_base = _frontend_base.split("?")[0].rstrip("/")
|
||
per_guest_link = f"{_frontend_base}/{event_id}"
|
||
|
||
params = {
|
||
"contact_name": guest_name, # always auto from guest
|
||
"groom_name": partner1,
|
||
"bride_name": partner2,
|
||
"venue": venue,
|
||
"event_date": event_date,
|
||
"event_time": event_time,
|
||
"guest_link": per_guest_link,
|
||
}
|
||
|
||
# Merge extra_params (user-supplied values for custom param keys)
|
||
if request_body.extra_params:
|
||
params.update(request_body.extra_params)
|
||
|
||
# Always re-apply auto-computed values last so they can't be overridden
|
||
params["guest_link"] = per_guest_link # final override — always per-guest
|
||
|
||
# Auto-inject guest_name_key + event_id for url_button templates
|
||
try:
|
||
from whatsapp_templates import get_template as _get_tpl
|
||
_tpl_def = _get_tpl(request_body.template_key or "wedding_invitation")
|
||
_gnk = _tpl_def.get("guest_name_key", "")
|
||
if _gnk:
|
||
params[_gnk] = guest.first_name or guest_name
|
||
|
||
# For URL-button templates: inject event_id as the button URL suffix
|
||
# The Meta template base URL is https://invy.dvirlabs.com/guest/
|
||
# The button variable {{1}} = event_id → final URL = /guest/{event_id}
|
||
_url_btn = _tpl_def.get("url_button", {})
|
||
if _url_btn and _url_btn.get("enabled"):
|
||
_param_key = _url_btn.get("param_key", "event_id")
|
||
params[_param_key] = str(event_id)
|
||
except Exception:
|
||
pass
|
||
|
||
result = await service.send_by_template_key(
|
||
template_key=request_body.template_key or "wedding_invitation",
|
||
to_phone=to_phone,
|
||
params=params,
|
||
)
|
||
|
||
# Commit any pending DB changes (e.g. RSVP token) on successful send
|
||
db.commit()
|
||
|
||
results.append(schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=guest_name,
|
||
phone=to_phone,
|
||
status="sent",
|
||
message_id=result.get("message_id")
|
||
))
|
||
|
||
# Small delay to avoid rate limiting
|
||
await asyncio.sleep(0.5)
|
||
|
||
except WhatsAppError as e:
|
||
db.rollback()
|
||
results.append(schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=f"{guest.first_name}",
|
||
phone=guest.phone_number or guest.phone or "unknown",
|
||
status="failed",
|
||
error=str(e)
|
||
))
|
||
except Exception as e:
|
||
db.rollback()
|
||
results.append(schemas.WhatsAppSendResult(
|
||
guest_id=str(guest.id),
|
||
guest_name=f"{guest.first_name}",
|
||
phone=guest.phone_number or guest.phone or "unknown",
|
||
status="failed",
|
||
error=f"Unexpected error: {str(e)}"
|
||
))
|
||
|
||
# Calculate results
|
||
succeeded = sum(1 for r in results if r.status == "sent")
|
||
failed = sum(1 for r in results if r.status == "failed")
|
||
|
||
return schemas.WhatsAppBulkResult(
|
||
total=len(guests),
|
||
succeeded=succeeded,
|
||
failed=failed,
|
||
results=results
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# Google OAuth Integration
|
||
# ============================================
|
||
@app.get("/auth/google")
|
||
async def get_google_auth_url(
|
||
event_id: Optional[str] = None
|
||
):
|
||
"""
|
||
Initiate Google OAuth flow - redirects to Google.
|
||
"""
|
||
client_id = os.getenv("GOOGLE_CLIENT_ID")
|
||
redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/auth/google/callback")
|
||
|
||
if not client_id:
|
||
raise HTTPException(status_code=500, detail="Google Client ID not configured")
|
||
|
||
# Google OAuth2 authorization endpoint
|
||
auth_url = "https://accounts.google.com/o/oauth2/v2/auth"
|
||
|
||
params = {
|
||
"client_id": client_id,
|
||
"redirect_uri": redirect_uri,
|
||
"response_type": "code",
|
||
"scope": "https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/userinfo.email",
|
||
"access_type": "offline",
|
||
"state": event_id or "default" # Pass event_id as state for later use
|
||
}
|
||
|
||
full_url = f"{auth_url}?{urlencode(params)}"
|
||
|
||
# Redirect to Google OAuth endpoint
|
||
return RedirectResponse(url=full_url)
|
||
|
||
|
||
@app.get("/auth/google/callback")
|
||
async def google_callback(
|
||
code: str = Query(None),
|
||
state: str = Query(None),
|
||
error: str = Query(None),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
Handle Google OAuth callback.
|
||
Exchanges authorization code for access token and imports contacts.
|
||
"""
|
||
logger.info(f"Google OAuth callback received - state: {state}, has_code: {bool(code)}, error: {error}")
|
||
|
||
if error:
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
error_url = f"{frontend_url}?error={quote(error)}"
|
||
return RedirectResponse(url=error_url)
|
||
|
||
if not code:
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
return RedirectResponse(url=f"{frontend_url}?error={quote('Missing authorization code')}")
|
||
|
||
client_id = os.getenv("GOOGLE_CLIENT_ID")
|
||
client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
|
||
redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/auth/google/callback")
|
||
|
||
if not client_id or not client_secret:
|
||
raise HTTPException(status_code=500, detail="Google OAuth credentials not configured")
|
||
|
||
try:
|
||
async with httpx.AsyncClient() as client_http:
|
||
# Exchange authorization code for access token
|
||
token_url = "https://oauth2.googleapis.com/token"
|
||
|
||
token_data = {
|
||
"client_id": client_id,
|
||
"client_secret": client_secret,
|
||
"code": code,
|
||
"grant_type": "authorization_code",
|
||
"redirect_uri": redirect_uri
|
||
}
|
||
|
||
response = await client_http.post(token_url, data=token_data)
|
||
|
||
if response.status_code != 200:
|
||
error_detail = response.json().get("error_description", response.text)
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
return RedirectResponse(url=f"{frontend_url}?error={quote(error_detail)}")
|
||
|
||
tokens = response.json()
|
||
access_token = tokens.get("access_token")
|
||
|
||
if not access_token:
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
return RedirectResponse(url=f"{frontend_url}?error={quote('No access token')}")
|
||
|
||
# Get user info to extract email
|
||
user_info_response = await client_http.get(
|
||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||
headers={"Authorization": f"Bearer {access_token}"}
|
||
)
|
||
|
||
if user_info_response.status_code != 200:
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
return RedirectResponse(url=f"{frontend_url}?error={quote('Failed to get user info')}")
|
||
|
||
user_info = user_info_response.json()
|
||
user_email = user_info.get("email", "unknown")
|
||
|
||
# Look up or create a User for this Google account imports
|
||
# Since Google login is only for imports, we create a minimal user entry
|
||
user = db.query(models.User).filter(models.User.email == user_email).first()
|
||
if not user:
|
||
user = models.User(email=user_email)
|
||
db.add(user)
|
||
db.commit()
|
||
db.refresh(user)
|
||
|
||
# Import contacts - get event_id from state parameter
|
||
event_id = state if state and state != "default" else None
|
||
|
||
try:
|
||
imported_count = await google_contacts.import_contacts_from_google(
|
||
access_token=access_token,
|
||
db=db,
|
||
owner_email=user_email,
|
||
added_by_user_id=str(user.id),
|
||
event_id=event_id
|
||
)
|
||
|
||
logger.info(f"Successfully imported {imported_count} contacts from Google for event {event_id}")
|
||
|
||
# Success - return HTML that sets sessionStorage with import details and redirects
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
|
||
if event_id:
|
||
# Build the target URL - redirect back to the event
|
||
target_url = f"{frontend_url}/events/{event_id}/guests"
|
||
else:
|
||
target_url = frontend_url
|
||
|
||
# Return HTML that sets sessionStorage and redirects
|
||
html_content = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>Import Complete</title>
|
||
</head>
|
||
<body>
|
||
<script>
|
||
// Store import completion info for the UI to display
|
||
sessionStorage.setItem('googleImportJustCompleted', 'true');
|
||
sessionStorage.setItem('googleImportCount', '{imported_count}');
|
||
sessionStorage.setItem('googleImportEmail', '{user_email}');
|
||
|
||
window.location.href = '{target_url}';
|
||
</script>
|
||
<p>Redirecting...</p>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
return HTMLResponse(content=html_content)
|
||
|
||
except Exception as import_error:
|
||
logger.error(f"Failed to import contacts from Google: {str(import_error)}", exc_info=True)
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
return RedirectResponse(url=f"{frontend_url}?error={quote(f'Import failed: {str(import_error)}')}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"OAuth callback error: {str(e)}", exc_info=True)
|
||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||
return RedirectResponse(url=f"{frontend_url}?error={quote('OAuth error')}")
|
||
|
||
|
||
@app.post("/events/{event_id}/import-google-contacts")
|
||
async def import_google_contacts(
|
||
event_id: UUID,
|
||
import_data: schemas.GoogleContactsImport,
|
||
db: Session = Depends(get_db),
|
||
current_user_id: UUID = Depends(get_current_user_id)
|
||
):
|
||
"""
|
||
Deprecated: Use /auth/google endpoint instead.
|
||
This endpoint is kept for backward compatibility.
|
||
"""
|
||
raise HTTPException(
|
||
status_code=410,
|
||
detail="Google import flow has been updated. Use the Google Import button instead."
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# Public Guest Self-Service Endpoints
|
||
# ============================================
|
||
@app.get("/public/guest/{phone_number}")
|
||
def get_guest_by_phone(phone_number: str, db: Session = Depends(get_db)):
|
||
"""
|
||
Public endpoint: Get guest info by phone number (no authentication required)
|
||
Used for guest self-service lookup via shared link
|
||
|
||
Returns:
|
||
- {found: true, guest_data} if guest found
|
||
- {found: false, phone_number} if not found
|
||
"""
|
||
guest = db.query(models.Guest).filter(
|
||
models.Guest.phone_number == phone_number
|
||
).first()
|
||
|
||
if not guest:
|
||
return {"found": False, "phone_number": phone_number}
|
||
|
||
# Return guest data (exclude sensitive fields if needed)
|
||
guest_dict = {
|
||
"found": True,
|
||
"first_name": guest.first_name,
|
||
"last_name": guest.last_name,
|
||
"phone_number": guest.phone_number,
|
||
"email": guest.email,
|
||
"rsvp_status": guest.rsvp_status,
|
||
"meal_preference": guest.meal_preference,
|
||
"has_plus_one": guest.has_plus_one,
|
||
"plus_one_name": guest.plus_one_name,
|
||
}
|
||
return guest_dict
|
||
|
||
|
||
@app.put("/public/guest/{phone_number}")
|
||
def update_guest_by_phone(
|
||
phone_number: str,
|
||
guest_update: schemas.GuestPublicUpdate,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
Public endpoint: Allow guests to update their own info using phone number
|
||
No authentication required - guests can use shared URLs with phone number
|
||
|
||
Features:
|
||
- Updates existing guest fields (first_name, last_name override imported values)
|
||
- Used for self-service RSVP and preference collection
|
||
- Guest must exist (typically from Google import) - returns 404 if not found
|
||
"""
|
||
guest = db.query(models.Guest).filter(
|
||
models.Guest.phone_number == phone_number
|
||
).first()
|
||
|
||
if not guest:
|
||
# Guest not found - return 404
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"Guest with phone number {phone_number} not found. Please check the number and try again."
|
||
)
|
||
|
||
# Update existing guest - override with provided values
|
||
# This allows guests to correct their names/preferences even if imported from contacts
|
||
if guest_update.first_name is not None:
|
||
guest.first_name = guest_update.first_name
|
||
if guest_update.last_name is not None:
|
||
guest.last_name = guest_update.last_name
|
||
if guest_update.rsvp_status is not None:
|
||
guest.rsvp_status = guest_update.rsvp_status
|
||
if guest_update.meal_preference is not None:
|
||
guest.meal_preference = guest_update.meal_preference
|
||
if guest_update.has_plus_one is not None:
|
||
guest.has_plus_one = guest_update.has_plus_one
|
||
if guest_update.plus_one_name is not None:
|
||
guest.plus_one_name = guest_update.plus_one_name
|
||
|
||
db.commit()
|
||
db.refresh(guest)
|
||
|
||
return {
|
||
"id": guest.id,
|
||
"first_name": guest.first_name,
|
||
"last_name": guest.last_name,
|
||
"phone_number": guest.phone_number,
|
||
"email": guest.email,
|
||
"rsvp_status": guest.rsvp_status,
|
||
"meal_preference": guest.meal_preference,
|
||
"has_plus_one": guest.has_plus_one,
|
||
"plus_one_name": guest.plus_one_name,
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
# ============================================
|
||
# Event-Scoped Public RSVP Endpoints
|
||
# Guest RSVP flow: /guest/:eventId → phone lookup → RSVP form → submit
|
||
# ============================================
|
||
|
||
@app.get("/public/events/{event_id}")
|
||
def get_public_event(event_id: UUID, db: Session = Depends(get_db)):
|
||
"""
|
||
Public: return event details for the RSVP landing page.
|
||
No authentication required — the event_id comes from the WhatsApp button URL.
|
||
"""
|
||
event = db.query(models.Event).filter(models.Event.id == event_id).first()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
|
||
event_date_str = event.date.strftime("%d/%m/%Y") if event.date else None
|
||
return {
|
||
"event_id": str(event.id),
|
||
"name": event.name,
|
||
"date": event_date_str,
|
||
"venue": event.venue or event.location,
|
||
"partner1_name": event.partner1_name,
|
||
"partner2_name": event.partner2_name,
|
||
"event_time": event.event_time,
|
||
"invitation_image_url": event.invitation_image_url,
|
||
"guest_form_fields": event.guest_form_fields,
|
||
}
|
||
|
||
|
||
@app.get("/public/events/{event_id}/guest")
|
||
def get_event_guest_by_phone(
|
||
event_id: UUID,
|
||
phone: str = Query(..., description="Guest phone number"),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Public: look up a guest in a specific event by phone number.
|
||
Returns only that event's guest record — fully independent between events.
|
||
"""
|
||
from whatsapp import WhatsAppService as _WAS
|
||
normalized = _WAS.normalize_phone_to_e164(phone)
|
||
|
||
guest = db.query(models.Guest).filter(
|
||
models.Guest.event_id == event_id,
|
||
or_(
|
||
models.Guest.phone_number == phone,
|
||
models.Guest.phone == phone,
|
||
models.Guest.phone_number == normalized,
|
||
models.Guest.phone == normalized,
|
||
),
|
||
).first()
|
||
|
||
if not guest:
|
||
# Guest not in list — allow self-service registration instead of blocking
|
||
return {
|
||
"found": False,
|
||
"phone_number": normalized or phone,
|
||
}
|
||
|
||
return {
|
||
"found": True,
|
||
"guest_id": str(guest.id),
|
||
# NOTE: first_name / last_name intentionally omitted so the guest
|
||
# never sees the host's contact nickname — they enter their own name.
|
||
"rsvp_status": guest.rsvp_status,
|
||
"meal_preference": guest.meal_preference,
|
||
"has_plus_one": guest.has_plus_one,
|
||
"plus_one_name": guest.plus_one_name,
|
||
}
|
||
|
||
|
||
@app.post("/public/events/{event_id}/rsvp")
|
||
def submit_event_rsvp(
|
||
event_id: UUID,
|
||
data: schemas.EventScopedRsvpUpdate,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Public: update RSVP for a guest in a specific event.
|
||
Guest is identified by phone; update is scoped to ONLY this event's record.
|
||
Same phone guest in a different event is NOT affected.
|
||
"""
|
||
from whatsapp import WhatsAppService as _WAS
|
||
normalized = _WAS.normalize_phone_to_e164(data.phone)
|
||
|
||
guest = db.query(models.Guest).filter(
|
||
models.Guest.event_id == event_id,
|
||
or_(
|
||
models.Guest.phone_number == data.phone,
|
||
models.Guest.phone == data.phone,
|
||
models.Guest.phone_number == normalized,
|
||
models.Guest.phone == normalized,
|
||
),
|
||
).first()
|
||
|
||
if not guest:
|
||
# Guest not pre-imported — create them as a self-service entry
|
||
event_obj = db.query(models.Event).filter(models.Event.id == event_id).first()
|
||
if not event_obj:
|
||
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
|
||
|
||
# Find the event admin to use as added_by_user_id
|
||
admin_member = (
|
||
db.query(models.EventMember)
|
||
.filter(
|
||
models.EventMember.event_id == event_id,
|
||
models.EventMember.role == models.RoleEnum.admin,
|
||
)
|
||
.first()
|
||
)
|
||
if not admin_member:
|
||
admin_member = (
|
||
db.query(models.EventMember)
|
||
.filter(models.EventMember.event_id == event_id)
|
||
.first()
|
||
)
|
||
if not admin_member:
|
||
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
|
||
|
||
guest = models.Guest(
|
||
event_id=event_id,
|
||
added_by_user_id=admin_member.user_id,
|
||
first_name=data.first_name or "",
|
||
last_name=data.last_name or "",
|
||
phone_number=normalized,
|
||
phone=normalized,
|
||
rsvp_status=data.rsvp_status or models.GuestStatus.invited,
|
||
meal_preference=data.meal_preference,
|
||
companion_count=data.companion_count or 1,
|
||
source="self-service",
|
||
)
|
||
db.add(guest)
|
||
db.commit()
|
||
db.refresh(guest)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "תודה! אישור ההגעה שלך נשמר.",
|
||
"guest_id": str(guest.id),
|
||
"rsvp_status": guest.rsvp_status,
|
||
}
|
||
|
||
if data.rsvp_status is not None:
|
||
guest.rsvp_status = data.rsvp_status
|
||
if data.meal_preference is not None:
|
||
guest.meal_preference = data.meal_preference
|
||
if data.companion_count is not None:
|
||
guest.companion_count = data.companion_count
|
||
if data.first_name is not None:
|
||
guest.first_name = data.first_name
|
||
if data.last_name is not None:
|
||
guest.last_name = data.last_name
|
||
|
||
db.commit()
|
||
db.refresh(guest)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "תודה! אישור ההגעה שלך נשמר.",
|
||
"guest_id": str(guest.id),
|
||
"rsvp_status": guest.rsvp_status,
|
||
}
|
||
|
||
|
||
# ============================================
|
||
# RSVP Token Endpoints
|
||
# ============================================
|
||
|
||
@app.get("/rsvp/resolve", response_model=schemas.RsvpResolveResponse)
|
||
def rsvp_resolve(
|
||
token: str = Query(..., description="Per-guest RSVP token from WhatsApp link"),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Public endpoint: resolve an RSVP token and return event + guest details.
|
||
Called automatically when a guest opens their personal WhatsApp RSVP link.
|
||
No authentication required.
|
||
"""
|
||
record = db.query(models.RsvpToken).filter(models.RsvpToken.token == token).first()
|
||
if not record:
|
||
return schemas.RsvpResolveResponse(valid=False, token=token, error="הקישור אינו תקין.")
|
||
|
||
# Check expiry
|
||
if record.expires_at:
|
||
from datetime import datetime as _dt
|
||
if _dt.now(timezone.utc) > record.expires_at:
|
||
return schemas.RsvpResolveResponse(valid=False, token=token, error="הקישור פג תוקף.")
|
||
|
||
event = db.query(models.Event).filter(models.Event.id == record.event_id).first()
|
||
guest = db.query(models.Guest).filter(models.Guest.id == record.guest_id).first() if record.guest_id else None
|
||
|
||
event_date_str = None
|
||
if event and event.date:
|
||
event_date_str = event.date.strftime("%d/%m/%Y")
|
||
|
||
return schemas.RsvpResolveResponse(
|
||
valid=True,
|
||
token=token,
|
||
event_id=str(record.event_id),
|
||
event_name=event.name if event else None,
|
||
event_date=event_date_str,
|
||
venue=event.venue or event.location if event else None,
|
||
partner1_name=event.partner1_name if event else None,
|
||
partner2_name=event.partner2_name if event else None,
|
||
guest_id=str(guest.id) if guest else None,
|
||
guest_first_name=guest.first_name if guest else None,
|
||
guest_last_name=guest.last_name if guest else None,
|
||
current_rsvp_status=guest.rsvp_status if guest else None,
|
||
current_meal_preference=guest.meal_preference if guest else None,
|
||
current_has_plus_one=guest.has_plus_one if guest else None,
|
||
current_plus_one_name=guest.plus_one_name if guest else None,
|
||
)
|
||
|
||
|
||
@app.post("/rsvp/submit", response_model=schemas.RsvpSubmitResponse)
|
||
def rsvp_submit(
|
||
data: schemas.RsvpSubmit,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
Public endpoint: guest submits their RSVP using token.
|
||
Updates guest record and marks token as used.
|
||
No authentication required.
|
||
"""
|
||
from datetime import datetime as _dt
|
||
|
||
record = db.query(models.RsvpToken).filter(models.RsvpToken.token == data.token).first()
|
||
if not record:
|
||
raise HTTPException(status_code=404, detail="הקישור אינו תקין.")
|
||
|
||
if record.expires_at and _dt.now(timezone.utc) > record.expires_at:
|
||
raise HTTPException(status_code=410, detail="הקישור פג תוקף.")
|
||
|
||
# Update guest record
|
||
guest = db.query(models.Guest).filter(models.Guest.id == record.guest_id).first() if record.guest_id else None
|
||
if not guest:
|
||
raise HTTPException(status_code=404, detail="לא נמצא אורח.")
|
||
|
||
if data.rsvp_status is not None:
|
||
guest.rsvp_status = data.rsvp_status
|
||
if data.meal_preference is not None:
|
||
guest.meal_preference = data.meal_preference
|
||
if data.has_plus_one is not None:
|
||
guest.has_plus_one = data.has_plus_one
|
||
if data.plus_one_name is not None:
|
||
guest.plus_one_name = data.plus_one_name
|
||
if data.first_name is not None:
|
||
guest.first_name = data.first_name
|
||
if data.last_name is not None:
|
||
guest.last_name = data.last_name
|
||
|
||
# Mark token as used (allow re-use — don't block if already used)
|
||
record.used_at = _dt.now(timezone.utc)
|
||
|
||
db.commit()
|
||
db.refresh(guest)
|
||
|
||
return schemas.RsvpSubmitResponse(
|
||
success=True,
|
||
message="תודה! אישור ההגעה שלך נשמר בהצלחה.",
|
||
guest_id=str(guest.id),
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# Contact Import Endpoint
|
||
# POST /admin/import/contacts?event_id=<uuid>&dry_run=false
|
||
# Accepts: multipart/form-data with file field "file" (CSV or JSON)
|
||
# ============================================
|
||
|
||
def _normalize_phone(raw: str) -> str:
|
||
"""Normalize a phone number to E.164 (+972…) format, best-effort."""
|
||
if not raw:
|
||
return raw
|
||
try:
|
||
from whatsapp import WhatsAppService as _WAS
|
||
return _WAS.normalize_phone_to_e164(raw) or raw.strip()
|
||
except Exception:
|
||
return raw.strip()
|
||
|
||
|
||
def _parse_csv_rows(content: bytes) -> list[dict]:
|
||
"""Parse a CSV file and return a list of dicts (header row as keys)."""
|
||
text = content.decode("utf-8-sig", errors="replace") # handle BOM
|
||
reader = csv.DictReader(io.StringIO(text))
|
||
return [dict(row) for row in reader]
|
||
|
||
|
||
def _parse_xlsx_rows(content: bytes) -> list[dict]:
|
||
"""Parse an XLSX (Excel) file and return a list of dicts.
|
||
Uses the first sheet; first row is treated as the header.
|
||
"""
|
||
import openpyxl
|
||
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||
ws = wb.active
|
||
rows = list(ws.iter_rows(values_only=True))
|
||
wb.close()
|
||
if not rows:
|
||
return []
|
||
# First row = headers; normalise None headers to empty string
|
||
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
|
||
result = []
|
||
for row in rows[1:]:
|
||
# Skip completely empty rows
|
||
if all(v is None or str(v).strip() == "" for v in row):
|
||
continue
|
||
result.append({
|
||
headers[i]: (str(v).strip() if v is not None else "")
|
||
for i, v in enumerate(row)
|
||
if i < len(headers) and headers[i] # skip header-less columns
|
||
})
|
||
return result
|
||
|
||
|
||
def _parse_json_rows(content: bytes) -> list[dict]:
|
||
"""Parse a JSON file — supports array at root OR {data: [...]}."""
|
||
payload = json.loads(content.decode("utf-8-sig", errors="replace"))
|
||
if isinstance(payload, list):
|
||
return payload
|
||
if isinstance(payload, dict):
|
||
for key in ("data", "contacts", "guests", "rows"):
|
||
if key in payload and isinstance(payload[key], list):
|
||
return payload[key]
|
||
raise ValueError("JSON must be an array or an object with a list field.")
|
||
|
||
|
||
# Case-insensitive header normalization map
|
||
_FIELD_ALIASES: dict[str, str] = {
|
||
# first_name
|
||
"first name": "first_name", "firstname": "first_name", "שם פרטי": "first_name",
|
||
# last_name
|
||
"last name": "last_name", "lastname": "last_name", "שם משפחה": "last_name",
|
||
# full_name
|
||
"full name": "full_name", "fullname": "full_name", "name": "full_name", "שם מלא": "full_name",
|
||
# phone
|
||
"phone": "phone", "phone number": "phone", "mobile": "phone",
|
||
"טלפון": "phone", "נייד": "phone", "phone_number": "phone",
|
||
# email
|
||
"email": "email", "email address": "email", "אימייל": "email",
|
||
# rsvp status
|
||
"rsvp": "rsvp_status", "rsvp status": "rsvp_status", "status": "rsvp_status",
|
||
"סטטוס": "rsvp_status",
|
||
# meal
|
||
"meal": "meal_preference", "meal preference": "meal_preference",
|
||
"meal_preference": "meal_preference", "העדפת ארוחה": "meal_preference",
|
||
# notes
|
||
"notes": "notes", "הערות": "notes",
|
||
# side
|
||
"side": "side", "צד": "side",
|
||
# table
|
||
"table": "table_number", "table number": "table_number", "שולחן": "table_number",
|
||
# has_plus_one
|
||
"plus one": "has_plus_one", "has plus one": "has_plus_one",
|
||
"has_plus_one": "has_plus_one",
|
||
# plus_one_name
|
||
"plus one name": "plus_one_name", "plus_one_name": "plus_one_name",
|
||
}
|
||
|
||
|
||
def _normalize_row(raw: dict) -> dict:
|
||
"""Normalise column headers to canonical field names."""
|
||
out = {}
|
||
for k, v in raw.items():
|
||
canonical = _FIELD_ALIASES.get(k.strip().lower(), k.strip().lower())
|
||
out[canonical] = v.strip() if isinstance(v, str) else v
|
||
return out
|
||
|
||
|
||
def _split_full_name(full: str) -> tuple[str, str]:
|
||
parts = full.strip().split(None, 1)
|
||
if len(parts) == 2:
|
||
return parts[0], parts[1]
|
||
return parts[0], "" if parts else ("", "")
|
||
|
||
|
||
def _coerce_bool(val) -> bool:
|
||
if isinstance(val, bool):
|
||
return val
|
||
if isinstance(val, str):
|
||
return val.strip().lower() in ("1", "yes", "true", "כן")
|
||
return bool(val)
|
||
|
||
|
||
@app.post("/admin/import/contacts", response_model=schemas.ImportContactsResponse)
|
||
async def import_contacts(
|
||
event_id: UUID = Query(..., description="Target event UUID"),
|
||
dry_run: bool = Query(False, description="If true, validate and preview but do not write"),
|
||
file: UploadFile = File(..., description="CSV or JSON file"),
|
||
db: Session = Depends(get_db),
|
||
current_user_id=Depends(get_current_user_id),
|
||
):
|
||
"""
|
||
Import contacts from a CSV or JSON file into an event's guest list.
|
||
|
||
• Idempotent: if a guest with the same phone_number already exists in this
|
||
event, their *missing* fields are filled in — existing data is never
|
||
overwritten unless the existing value is blank.
|
||
• dry_run=true: returns the preview without touching the database.
|
||
• source is always set to 'google' for imported rows.
|
||
"""
|
||
# ── Auth / access check ──────────────────────────────────────────────────
|
||
if not current_user_id:
|
||
raise HTTPException(status_code=401, detail="Authentication required.")
|
||
|
||
event = db.query(models.Event).filter(models.Event.id == event_id).first()
|
||
if not event:
|
||
raise HTTPException(status_code=404, detail="Event not found.")
|
||
|
||
# ── Read and parse the uploaded file ────────────────────────────────────
|
||
content = await file.read()
|
||
filename = (file.filename or "").lower()
|
||
try:
|
||
if filename.endswith(".json"):
|
||
raw_rows = _parse_json_rows(content)
|
||
elif filename.endswith(".xlsx"):
|
||
raw_rows = _parse_xlsx_rows(content)
|
||
elif filename.endswith(".csv"):
|
||
raw_rows = _parse_csv_rows(content)
|
||
else:
|
||
# Sniff: try JSON → xlsx magic bytes → CSV
|
||
try:
|
||
raw_rows = _parse_json_rows(content)
|
||
except Exception:
|
||
# XLSX files start with PK (zip magic bytes 50 4B)
|
||
if content[:2] == b'PK':
|
||
raw_rows = _parse_xlsx_rows(content)
|
||
else:
|
||
raw_rows = _parse_csv_rows(content)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=422, detail=f"Cannot parse file: {exc}")
|
||
|
||
if not raw_rows:
|
||
raise HTTPException(status_code=422, detail="File is empty or could not be parsed.")
|
||
|
||
# ── Resolve or create the user record for added_by_user_id ──────────────
|
||
# current_user_id can be a UUID object, a UUID string, or a plain string (email/admin-user)
|
||
user = None
|
||
try:
|
||
_uid = UUID(str(current_user_id))
|
||
user = db.query(models.User).filter(models.User.id == _uid).first()
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
if user is None:
|
||
user = db.query(models.User).filter(
|
||
models.User.email == str(current_user_id)
|
||
).first()
|
||
if user is None:
|
||
# Create a stub user (can happen if logged in via Google but no DB row yet)
|
||
user = models.User(email=str(current_user_id))
|
||
if not dry_run:
|
||
db.add(user)
|
||
db.flush()
|
||
|
||
# ── Process rows ─────────────────────────────────────────────────────────
|
||
results: list[schemas.ImportRowResult] = []
|
||
counters = {"created": 0, "updated": 0, "skipped": 0, "errors": 0}
|
||
|
||
for idx, raw in enumerate(raw_rows, start=1):
|
||
row = _normalize_row(raw)
|
||
|
||
# Resolve name
|
||
first = row.get("first_name") or ""
|
||
last = row.get("last_name") or ""
|
||
if not first and row.get("full_name"):
|
||
first, last = _split_full_name(row["full_name"])
|
||
|
||
if not first:
|
||
counters["skipped"] += 1
|
||
results.append(schemas.ImportRowResult(
|
||
row=idx, action="skipped", reason="No name found"
|
||
))
|
||
continue
|
||
|
||
# Resolve phone
|
||
raw_phone = row.get("phone") or row.get("phone_number") or ""
|
||
phone = _normalize_phone(raw_phone) if raw_phone else None
|
||
|
||
email = row.get("email") or None
|
||
|
||
# Must have at least phone or email to identify the guest
|
||
if not phone and not email:
|
||
counters["skipped"] += 1
|
||
results.append(schemas.ImportRowResult(
|
||
row=idx, action="skipped", name=f"{first} {last}".strip(),
|
||
reason="No phone or email — cannot identify guest"
|
||
))
|
||
continue
|
||
|
||
# ── Idempotent lookup ──────────────────────────────────────────────
|
||
existing = None
|
||
if phone:
|
||
existing = db.query(models.Guest).filter(
|
||
models.Guest.event_id == event_id,
|
||
or_(
|
||
models.Guest.phone_number == phone,
|
||
models.Guest.phone == phone,
|
||
models.Guest.phone_number == raw_phone,
|
||
models.Guest.phone == raw_phone,
|
||
),
|
||
).first()
|
||
if existing is None and email:
|
||
existing = db.query(models.Guest).filter(
|
||
models.Guest.event_id == event_id,
|
||
models.Guest.email == email,
|
||
).first()
|
||
|
||
# ── Map optional fields ────────────────────────────────────────────
|
||
rsvp_raw = (row.get("rsvp_status") or "").lower()
|
||
rsvp_map = {"accepted": "confirmed", "pending": "invited", "yes": "confirmed",
|
||
"no": "declined", "כן": "confirmed", "לא": "declined"}
|
||
rsvp = rsvp_map.get(rsvp_raw, rsvp_raw) if rsvp_raw else None
|
||
if rsvp and rsvp not in ("invited", "confirmed", "declined"):
|
||
rsvp = "invited"
|
||
|
||
has_plus = _coerce_bool(row["has_plus_one"]) if "has_plus_one" in row else None
|
||
|
||
if existing:
|
||
# Update only blank fields — never overwrite existing data
|
||
changed = False
|
||
if not existing.first_name and first:
|
||
existing.first_name = first; changed = True
|
||
if not existing.last_name and last:
|
||
existing.last_name = last; changed = True
|
||
if not existing.email and email:
|
||
existing.email = email; changed = True
|
||
if not existing.phone_number and phone:
|
||
existing.phone_number = phone; existing.phone = phone; changed = True
|
||
if not existing.meal_preference and row.get("meal_preference"):
|
||
existing.meal_preference = row["meal_preference"]; changed = True
|
||
if not existing.notes and row.get("notes"):
|
||
existing.notes = row["notes"]; changed = True
|
||
if not existing.side and row.get("side"):
|
||
existing.side = row["side"]; changed = True
|
||
if not existing.table_number and row.get("table_number"):
|
||
existing.table_number = row["table_number"]; changed = True
|
||
if has_plus is not None and not existing.has_plus_one:
|
||
existing.has_plus_one = has_plus; changed = True
|
||
if not existing.plus_one_name and row.get("plus_one_name"):
|
||
existing.plus_one_name = row["plus_one_name"]; changed = True
|
||
if rsvp and existing.rsvp_status in (None, "invited", models.GuestStatus.invited):
|
||
existing.rsvp_status = rsvp; changed = True
|
||
|
||
if changed:
|
||
counters["updated"] += 1
|
||
action = "updated"
|
||
else:
|
||
counters["skipped"] += 1
|
||
action = "skipped"
|
||
|
||
results.append(schemas.ImportRowResult(
|
||
row=idx, action=action,
|
||
name=f"{existing.first_name} {existing.last_name}".strip(),
|
||
phone=existing.phone_number,
|
||
))
|
||
else:
|
||
# Create new guest
|
||
# On dry_run user.id may be None (not flushed); use a placeholder UUID
|
||
_added_by = user.id
|
||
if _added_by is None:
|
||
try:
|
||
_added_by = UUID(str(current_user_id))
|
||
except ValueError:
|
||
_added_by = uuid4() # unreachable in prod, safety net
|
||
new_guest = models.Guest(
|
||
event_id=event_id,
|
||
added_by_user_id=_added_by,
|
||
first_name=first,
|
||
last_name=last,
|
||
email=email,
|
||
phone_number=phone,
|
||
phone=phone,
|
||
rsvp_status=rsvp or models.GuestStatus.invited,
|
||
meal_preference=row.get("meal_preference") or None,
|
||
has_plus_one=has_plus or False,
|
||
plus_one_name=row.get("plus_one_name") or None,
|
||
table_number=row.get("table_number") or None,
|
||
side=row.get("side") or None,
|
||
notes=row.get("notes") or None,
|
||
owner_email=str(current_user_id),
|
||
source="google",
|
||
)
|
||
if not dry_run:
|
||
db.add(new_guest)
|
||
counters["created"] += 1
|
||
results.append(schemas.ImportRowResult(
|
||
row=idx, action="created" if not dry_run else "would_create",
|
||
name=f"{first} {last}".strip(),
|
||
phone=phone,
|
||
))
|
||
|
||
# ── Commit or rollback ────────────────────────────────────────────────
|
||
if dry_run:
|
||
db.rollback()
|
||
else:
|
||
try:
|
||
db.commit()
|
||
except Exception as exc:
|
||
db.rollback()
|
||
logger.error("Import commit failed: %s", exc)
|
||
raise HTTPException(status_code=500, detail=f"Database error: {exc}")
|
||
|
||
logger.info(
|
||
"Import %s event=%s total=%d created=%d updated=%d skipped=%d errors=%d",
|
||
"[dry-run]" if dry_run else "[committed]",
|
||
event_id, len(raw_rows),
|
||
counters["created"], counters["updated"],
|
||
counters["skipped"], counters["errors"],
|
||
)
|
||
|
||
return schemas.ImportContactsResponse(
|
||
dry_run=dry_run,
|
||
total=len(raw_rows),
|
||
created=counters["created"],
|
||
updated=counters["updated"],
|
||
skipped=counters["skipped"],
|
||
errors=counters["errors"],
|
||
rows=results,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) |