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/ In PROD → https://invy.dvirlabs.com/guest/ 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""" Import Complete

Redirecting...

""" 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), ) if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)