from fastapi import FastAPI, Depends, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, HTMLResponse from sqlalchemy.orm import Session import uvicorn from typing import List, Optional from uuid import UUID import os from dotenv import load_dotenv import httpx from urllib.parse import urlencode, quote import models import schemas import crud import authz import google_contacts from database import engine, get_db from whatsapp import get_whatsapp_service, WhatsAppError # 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", ]) # Configure CORS app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ============================================ # Helper: Get current user (placeholder - implement with your auth) # ============================================ def get_current_user_id() -> UUID: """ Extract current user from request TODO: Implement with JWT, session, or your auth system For now returns a test user - replace this with real auth """ # This is a placeholder - you need to implement authentication # Options: # 1. JWT tokens from Authorization header # 2. Session cookies # 3. API keys # 4. OAuth2 # For development, use a test user test_user_email = os.getenv("TEST_USER_EMAIL", "test@example.com") db = SessionLocal() user = crud.get_or_create_user(db, test_user_email) db.close() return user.id from database import SessionLocal # ============================================ # 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)""" 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""" 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), authz_info: dict = Depends(lambda: None) ): """Get event details (only for members)""" # First verify access try: current_user_id = UUID("00000000-0000-0000-0000-000000000000") # Placeholder authz_info = await authz.verify_event_access(event_id, db, current_user_id) except: raise HTTPException(status_code=403, detail="Not authorized") 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""" 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""" 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 @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 } # ============================================ # 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 # This is needed because added_by_user_id is required user = db.query(models.User).filter(models.User.email == user_email).first() if not user: # Create a new user with this email 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 and redirects frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173") if event_id: # Build the target URL target_url = f"{frontend_url}/events/{event_id}/guests" else: target_url = frontend_url # Return HTML that sets sessionStorage and redirects html_content = f"""
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, } if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)