1423 lines
51 KiB
Python
1423 lines
51 KiB
Python
from fastapi import FastAPI, Depends, HTTPException, Query, Request
|
||
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
|
||
import os
|
||
import secrets
|
||
from dotenv import load_dotenv
|
||
import httpx
|
||
from urllib.parse import urlencode, quote
|
||
from datetime import timezone, timedelta
|
||
|
||
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 (customize per your deployment)
|
||
guest_link = (
|
||
event.guest_link or
|
||
f"https://invy.dvirlabs.com/guest?event={event_id}" or
|
||
f"https://localhost:5173/guest?event={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.
|
||
_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
|
||
_sep = "&" if "?" in _base else "?"
|
||
per_guest_link = f"{_base}{_sep}event={event_id}&guest_id={guest.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:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון.",
|
||
)
|
||
|
||
return {
|
||
"guest_id": str(guest.id),
|
||
"first_name": guest.first_name,
|
||
"last_name": guest.last_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:
|
||
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
|
||
|
||
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),
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) |