1830 lines
67 KiB
Python
1830 lines
67 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 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
|
||
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")
|
||
|
||
# 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"}
|
||
|
||
|
||
# ============================================
|
||
# 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 Import
|
||
# ============================================
|
||
@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.
|
||
"""
|
||
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
|
||
)
|
||
|
||
# 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:
|
||
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:
|
||
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,
|
||
}
|
||
|
||
|
||
@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,
|
||
has_plus_one=data.has_plus_one or False,
|
||
plus_one_name=data.plus_one_name,
|
||
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.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
|
||
|
||
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) |