from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import or_ from pydantic import BaseModel, EmailStr, Field, validator from typing import List, Optional, Literal from datetime import datetime, timedelta, timezone from dotenv import load_dotenv from pathlib import Path from contextlib import asynccontextmanager import os import logging import uuid import secrets from database import engine, get_db, Base from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument from auth import ( get_password_hash, verify_password, create_access_token, get_current_user, get_current_admin_user, get_active_member, create_password_reset_token, verify_reset_token ) from email_service import ( send_verification_email, send_approval_notification, send_payment_prompt_email, send_password_reset_email, send_admin_password_reset_email ) from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date from r2_storage import get_r2_storage from calendar_service import CalendarService # Load environment variables ROOT_DIR = Path(__file__).parent load_dotenv(ROOT_DIR / '.env') # Create database tables Base.metadata.create_all(bind=engine) # Lifespan event handler (replaces deprecated on_event) @asynccontextmanager async def lifespan(app: FastAPI): # Startup logger.info("Application started") yield # Shutdown logger.info("Application shutdown") # Create the main app app = FastAPI( lifespan=lifespan, root_path="/membership" # Configure for serving under /membership path ) # Create a router with the /api prefix api_router = APIRouter(prefix="/api") # Initialize calendar service calendar_service = CalendarService() # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Pydantic Models class RegisterRequest(BaseModel): # Step 1: Personal & Partner Information first_name: str last_name: str phone: str address: str city: str state: str zipcode: str date_of_birth: datetime lead_sources: List[str] partner_first_name: Optional[str] = None partner_last_name: Optional[str] = None partner_is_member: Optional[bool] = False partner_plan_to_become_member: Optional[bool] = False # Step 2: Newsletter, Volunteer & Scholarship referred_by_member_name: Optional[str] = None newsletter_publish_name: bool newsletter_publish_photo: bool newsletter_publish_birthday: bool newsletter_publish_none: bool volunteer_interests: List[str] = [] scholarship_requested: bool = False scholarship_reason: Optional[str] = None # Step 3: Directory Settings show_in_directory: bool = False directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None directory_phone: Optional[str] = None directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None # Step 4: Account Credentials email: EmailStr password: str = Field(min_length=6) @validator('newsletter_publish_none') def validate_newsletter_preferences(cls, v, values): """At least one newsletter preference must be selected""" name = values.get('newsletter_publish_name', False) photo = values.get('newsletter_publish_photo', False) birthday = values.get('newsletter_publish_birthday', False) if not (name or photo or birthday or v): raise ValueError('At least one newsletter publication preference must be selected') return v @validator('scholarship_reason') def validate_scholarship_reason(cls, v, values): """If scholarship requested, reason must be provided""" requested = values.get('scholarship_requested', False) if requested and not v: raise ValueError('Scholarship reason is required when requesting scholarship') return v class LoginRequest(BaseModel): email: EmailStr password: str class LoginResponse(BaseModel): access_token: str token_type: str user: dict class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): token: str new_password: str = Field(min_length=6) class ChangePasswordRequest(BaseModel): current_password: str new_password: str = Field(min_length=6) class AdminPasswordUpdateRequest(BaseModel): force_change: bool = True class UserResponse(BaseModel): id: str email: str first_name: str last_name: str phone: str address: str city: str state: str zipcode: str date_of_birth: datetime status: str role: str email_verified: bool created_at: datetime # Subscription info (optional) subscription_start_date: Optional[datetime] = None subscription_end_date: Optional[datetime] = None subscription_status: Optional[str] = None model_config = {"from_attributes": True} class UpdateProfileRequest(BaseModel): first_name: Optional[str] = None last_name: Optional[str] = None phone: Optional[str] = None address: Optional[str] = None city: Optional[str] = None state: Optional[str] = None zipcode: Optional[str] = None class EnhancedProfileUpdateRequest(BaseModel): """Members Only - Enhanced profile update with social media and directory settings""" social_media_facebook: Optional[str] = None social_media_instagram: Optional[str] = None social_media_twitter: Optional[str] = None social_media_linkedin: Optional[str] = None show_in_directory: Optional[bool] = None directory_email: Optional[str] = None directory_bio: Optional[str] = None directory_address: Optional[str] = None directory_phone: Optional[str] = None directory_dob: Optional[datetime] = None directory_partner_name: Optional[str] = None class CalendarEventResponse(BaseModel): """Calendar view response with user RSVP status""" id: str title: str description: Optional[str] start_at: datetime end_at: datetime location: str capacity: Optional[int] user_rsvp_status: Optional[str] = None microsoft_calendar_synced: bool class SyncEventRequest(BaseModel): """Request to sync event to Microsoft Calendar""" event_id: str class EventCreate(BaseModel): title: str description: Optional[str] = None start_at: datetime end_at: datetime location: str capacity: Optional[int] = None published: bool = False class EventUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None start_at: Optional[datetime] = None end_at: Optional[datetime] = None location: Optional[str] = None capacity: Optional[int] = None published: Optional[bool] = None class EventResponse(BaseModel): id: str title: str description: Optional[str] start_at: datetime end_at: datetime location: str capacity: Optional[int] published: bool created_by: str created_at: datetime rsvp_count: Optional[int] = 0 user_rsvp_status: Optional[str] = None model_config = {"from_attributes": True} class RSVPRequest(BaseModel): rsvp_status: str class AttendanceUpdate(BaseModel): user_id: str attended: bool class UpdateUserStatusRequest(BaseModel): status: str class ManualPaymentRequest(BaseModel): plan_id: str = Field(..., description="Subscription plan ID") amount_cents: int = Field(..., description="Payment amount in cents") payment_date: datetime = Field(..., description="Date payment was received") payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other") use_custom_period: bool = Field(False, description="Whether to use custom dates instead of plan's billing cycle") custom_period_start: Optional[datetime] = Field(None, description="Custom subscription start date") custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date") notes: Optional[str] = Field(None, description="Admin notes about payment") # Auth Routes @api_router.post("/auth/register") async def register(request: RegisterRequest, db: Session = Depends(get_db)): # Check if email already exists existing_user = db.query(User).filter(User.email == request.email).first() if existing_user: raise HTTPException(status_code=400, detail="Email already registered") # Generate verification token verification_token = secrets.token_urlsafe(32) # Create user user = User( # Account credentials (Step 4) email=request.email, password_hash=get_password_hash(request.password), # Personal information (Step 1) first_name=request.first_name, last_name=request.last_name, phone=request.phone, address=request.address, city=request.city, state=request.state, zipcode=request.zipcode, date_of_birth=request.date_of_birth, lead_sources=request.lead_sources, # Partner information (Step 1) partner_first_name=request.partner_first_name, partner_last_name=request.partner_last_name, partner_is_member=request.partner_is_member, partner_plan_to_become_member=request.partner_plan_to_become_member, # Referral (Step 2) referred_by_member_name=request.referred_by_member_name, # Newsletter publication preferences (Step 2) newsletter_publish_name=request.newsletter_publish_name, newsletter_publish_photo=request.newsletter_publish_photo, newsletter_publish_birthday=request.newsletter_publish_birthday, newsletter_publish_none=request.newsletter_publish_none, # Volunteer interests (Step 2) volunteer_interests=request.volunteer_interests, # Scholarship (Step 2) scholarship_requested=request.scholarship_requested, scholarship_reason=request.scholarship_reason, # Directory settings (Step 3) show_in_directory=request.show_in_directory, directory_email=request.directory_email, directory_bio=request.directory_bio, directory_address=request.directory_address, directory_phone=request.directory_phone, directory_dob=request.directory_dob, directory_partner_name=request.directory_partner_name, # Status fields status=UserStatus.pending_email, role=UserRole.guest, email_verified=False, email_verification_token=verification_token ) db.add(user) db.commit() db.refresh(user) # Send verification email await send_verification_email(user.email, verification_token) logger.info(f"User registered: {user.email}") return {"message": "Registration successful. Please check your email to verify your account."} @api_router.get("/auth/verify-email") async def verify_email(token: str, db: Session = Depends(get_db)): """Verify user email with token (idempotent - safe to call multiple times)""" user = db.query(User).filter(User.email_verification_token == token).first() if not user: raise HTTPException(status_code=400, detail="Invalid verification token") # If user is already verified, return success (idempotent behavior) # This handles React Strict Mode's double-execution in development if user.email_verified: logger.info(f"Email already verified for user: {user.email}") return { "message": "Email is already verified", "status": user.status.value } # Proceed with first-time verification # Check if referred by current member - skip validation requirement if user.referred_by_member_name: referrer = db.query(User).filter( or_( User.first_name + ' ' + User.last_name == user.referred_by_member_name, User.email == user.referred_by_member_name ), User.status == UserStatus.active ).first() if referrer: user.status = UserStatus.pre_approved else: user.status = UserStatus.pending_approval else: user.status = UserStatus.pending_approval user.email_verified = True user.email_verification_token = None db.commit() db.refresh(user) logger.info(f"Email verified for user: {user.email}") return {"message": "Email verified successfully", "status": user.status.value} @api_router.post("/auth/resend-verification-email") async def resend_verification_email( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """User requests to resend their verification email""" # Check if email already verified if current_user.email_verified: raise HTTPException(status_code=400, detail="Email is already verified") # Generate new token verification_token = secrets.token_urlsafe(32) current_user.email_verification_token = verification_token db.commit() # Send verification email await send_verification_email(current_user.email, verification_token) logger.info(f"Verification email resent to: {current_user.email}") return {"message": "Verification email has been resent. Please check your inbox."} @api_router.post("/auth/login", response_model=LoginResponse) async def login(request: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == request.email).first() if not user or not verify_password(request.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password" ) access_token = create_access_token(data={"sub": str(user.id)}) return { "access_token": access_token, "token_type": "bearer", "user": { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name, "status": user.status.value, "role": user.role.value, "force_password_change": user.force_password_change } } @api_router.post("/auth/forgot-password") async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): """Request password reset - sends email with reset link""" user = db.query(User).filter(User.email == request.email).first() # Always return success (security: don't reveal if email exists) if user: token = create_password_reset_token(user, db) reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}" await send_password_reset_email(user.email, user.first_name, reset_url) return {"message": "If email exists, reset link has been sent"} @api_router.post("/auth/reset-password") async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)): """Complete password reset using token""" user = verify_reset_token(request.token, db) if not user: raise HTTPException(status_code=400, detail="Invalid or expired reset token") # Update password user.password_hash = get_password_hash(request.new_password) user.password_reset_token = None user.password_reset_expires = None user.force_password_change = False # Reset flag if it was set db.commit() return {"message": "Password reset successful"} @api_router.put("/users/change-password") async def change_password( request: ChangePasswordRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """User changes their own password""" # Verify current password if not verify_password(request.current_password, current_user.password_hash): raise HTTPException(status_code=400, detail="Current password is incorrect") # Update password current_user.password_hash = get_password_hash(request.new_password) current_user.force_password_change = False # Clear flag if set db.commit() return {"message": "Password changed successfully"} @api_router.get("/auth/me", response_model=UserResponse) async def get_me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): # Get user's active subscription if exists active_subscription = db.query(Subscription).filter( Subscription.user_id == current_user.id, Subscription.status == SubscriptionStatus.active ).first() return UserResponse( id=str(current_user.id), email=current_user.email, first_name=current_user.first_name, last_name=current_user.last_name, phone=current_user.phone, address=current_user.address, city=current_user.city, state=current_user.state, zipcode=current_user.zipcode, date_of_birth=current_user.date_of_birth, status=current_user.status.value, role=current_user.role.value, email_verified=current_user.email_verified, created_at=current_user.created_at, subscription_start_date=active_subscription.start_date if active_subscription else None, subscription_end_date=active_subscription.end_date if active_subscription else None, subscription_status=active_subscription.status.value if active_subscription else None ) # User Profile Routes @api_router.get("/users/profile", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): return UserResponse( id=str(current_user.id), email=current_user.email, first_name=current_user.first_name, last_name=current_user.last_name, phone=current_user.phone, address=current_user.address, city=current_user.city, state=current_user.state, zipcode=current_user.zipcode, date_of_birth=current_user.date_of_birth, status=current_user.status.value, role=current_user.role.value, email_verified=current_user.email_verified, created_at=current_user.created_at ) @api_router.put("/users/profile") async def update_profile( request: UpdateProfileRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): if request.first_name: current_user.first_name = request.first_name if request.last_name: current_user.last_name = request.last_name if request.phone: current_user.phone = request.phone if request.address: current_user.address = request.address if request.city: current_user.city = request.city if request.state: current_user.state = request.state if request.zipcode: current_user.zipcode = request.zipcode current_user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(current_user) return {"message": "Profile updated successfully"} # ==================== MEMBERS ONLY ROUTES ==================== # Enhanced Profile Routes (Active Members Only) @api_router.get("/members/profile") async def get_enhanced_profile( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get enhanced profile with all member-only fields""" return { "id": str(current_user.id), "email": current_user.email, "first_name": current_user.first_name, "last_name": current_user.last_name, "phone": current_user.phone, "address": current_user.address, "city": current_user.city, "state": current_user.state, "zipcode": current_user.zipcode, "date_of_birth": current_user.date_of_birth, "profile_photo_url": current_user.profile_photo_url, "social_media_facebook": current_user.social_media_facebook, "social_media_instagram": current_user.social_media_instagram, "social_media_twitter": current_user.social_media_twitter, "social_media_linkedin": current_user.social_media_linkedin, "show_in_directory": current_user.show_in_directory, "directory_email": current_user.directory_email, "directory_bio": current_user.directory_bio, "directory_address": current_user.directory_address, "directory_phone": current_user.directory_phone, "directory_dob": current_user.directory_dob, "directory_partner_name": current_user.directory_partner_name, "status": current_user.status.value, "role": current_user.role.value } @api_router.put("/members/profile") async def update_enhanced_profile( request: EnhancedProfileUpdateRequest, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Update enhanced profile with social media and directory settings""" if request.social_media_facebook is not None: current_user.social_media_facebook = request.social_media_facebook if request.social_media_instagram is not None: current_user.social_media_instagram = request.social_media_instagram if request.social_media_twitter is not None: current_user.social_media_twitter = request.social_media_twitter if request.social_media_linkedin is not None: current_user.social_media_linkedin = request.social_media_linkedin if request.show_in_directory is not None: current_user.show_in_directory = request.show_in_directory if request.directory_email is not None: current_user.directory_email = request.directory_email if request.directory_bio is not None: current_user.directory_bio = request.directory_bio if request.directory_address is not None: current_user.directory_address = request.directory_address if request.directory_phone is not None: current_user.directory_phone = request.directory_phone if request.directory_dob is not None: current_user.directory_dob = request.directory_dob if request.directory_partner_name is not None: current_user.directory_partner_name = request.directory_partner_name current_user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(current_user) return {"message": "Enhanced profile updated successfully"} @api_router.post("/members/profile/upload-photo") async def upload_profile_photo( file: UploadFile = File(...), current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Upload profile photo to Cloudflare R2""" r2 = get_r2_storage() # Get storage quota storage = db.query(StorageUsage).first() if not storage: # Initialize storage tracking storage = StorageUsage( total_bytes_used=0, max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) ) db.add(storage) db.commit() db.refresh(storage) # Get max file size from env max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) # Delete old profile photo if exists if current_user.profile_photo_url: # Extract object key from URL old_key = current_user.profile_photo_url.split('/')[-1] old_key = f"profiles/{old_key}" try: old_size = await r2.get_file_size(old_key) await r2.delete_file(old_key) # Update storage usage storage.total_bytes_used -= old_size except: pass # File might not exist # Upload new photo try: public_url, object_key, file_size = await r2.upload_file( file=file, folder="profiles", allowed_types=r2.ALLOWED_IMAGE_TYPES, max_size_bytes=max_file_size ) # Check storage quota if storage.total_bytes_used + file_size > storage.max_bytes_allowed: # Rollback upload await r2.delete_file(object_key) raise HTTPException( status_code=507, detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" ) # Update user profile current_user.profile_photo_url = public_url current_user.updated_at = datetime.now(timezone.utc) # Update storage usage storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() db.refresh(current_user) logger.info(f"Profile photo uploaded for user {current_user.email}: {file_size} bytes") return { "message": "Profile photo uploaded successfully", "profile_photo_url": public_url } except HTTPException: raise except Exception as e: logger.error(f"Error uploading profile photo: {str(e)}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") @api_router.delete("/members/profile/delete-photo") async def delete_profile_photo( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Delete profile photo from R2 and profile""" if not current_user.profile_photo_url: raise HTTPException(status_code=404, detail="No profile photo to delete") r2 = get_r2_storage() storage = db.query(StorageUsage).first() # Extract object key from URL object_key = current_user.profile_photo_url.split('/')[-1] object_key = f"profiles/{object_key}" try: file_size = await r2.get_file_size(object_key) await r2.delete_file(object_key) # Update storage usage if storage: storage.total_bytes_used -= file_size storage.last_updated = datetime.now(timezone.utc) # Update user profile current_user.profile_photo_url = None current_user.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Profile photo deleted for user {current_user.email}") return {"message": "Profile photo deleted successfully"} except Exception as e: logger.error(f"Error deleting profile photo: {str(e)}") raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") # Calendar Routes (Active Members Only) @api_router.get("/members/calendar/events", response_model=List[CalendarEventResponse]) async def get_calendar_events( start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get calendar events with user RSVP status""" query = db.query(Event).filter(Event.published == True) if start_date: query = query.filter(Event.start_at >= start_date) if end_date: query = query.filter(Event.end_at <= end_date) events = query.order_by(Event.start_at).all() result = [] for event in events: # Get user's RSVP status for this event rsvp = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.user_id == current_user.id ).first() user_rsvp_status = rsvp.rsvp_status.value if rsvp else None result.append(CalendarEventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, user_rsvp_status=user_rsvp_status, microsoft_calendar_synced=event.microsoft_calendar_sync_enabled )) return result # Members Directory Route @api_router.get("/members/directory") async def get_members_directory( search: Optional[str] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get members directory - only shows active members who opted in""" query = db.query(User).filter( User.show_in_directory == True, User.status == UserStatus.active ) if search: search_term = f"%{search}%" query = query.filter( or_( User.first_name.ilike(search_term), User.last_name.ilike(search_term), User.directory_bio.ilike(search_term) ) ) members = query.order_by(User.first_name, User.last_name).all() return [ { "id": str(member.id), "first_name": member.first_name, "last_name": member.last_name, "profile_photo_url": member.profile_photo_url, "directory_email": member.directory_email, "directory_bio": member.directory_bio, "directory_address": member.directory_address, "directory_phone": member.directory_phone, "directory_partner_name": member.directory_partner_name, "social_media_facebook": member.social_media_facebook, "social_media_instagram": member.social_media_instagram, "social_media_twitter": member.social_media_twitter, "social_media_linkedin": member.social_media_linkedin } for member in members ] # Admin Calendar Sync Routes @api_router.post("/admin/calendar/sync/{event_id}") async def sync_event_to_microsoft( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Sync event to Microsoft Calendar""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") ms_calendar = get_ms_calendar_service() try: # Sync event ms_event_id = await ms_calendar.sync_event( loaf_event=event, existing_ms_event_id=event.microsoft_calendar_id ) # Update event with MS Calendar ID event.microsoft_calendar_id = ms_event_id event.microsoft_calendar_sync_enabled = True event.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Event {event.title} synced to Microsoft Calendar by {current_user.email}") return { "message": "Event synced to Microsoft Calendar successfully", "microsoft_calendar_id": ms_event_id } except Exception as e: logger.error(f"Error syncing event to Microsoft Calendar: {str(e)}") raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}") @api_router.delete("/admin/calendar/unsync/{event_id}") async def unsync_event_from_microsoft( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Remove event from Microsoft Calendar""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") if not event.microsoft_calendar_id: raise HTTPException(status_code=400, detail="Event is not synced to Microsoft Calendar") ms_calendar = get_ms_calendar_service() try: # Delete from Microsoft Calendar await ms_calendar.delete_event(event.microsoft_calendar_id) # Update event event.microsoft_calendar_id = None event.microsoft_calendar_sync_enabled = False event.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Event {event.title} unsynced from Microsoft Calendar by {current_user.email}") return {"message": "Event removed from Microsoft Calendar successfully"} except Exception as e: logger.error(f"Error removing event from Microsoft Calendar: {str(e)}") raise HTTPException(status_code=500, detail=f"Unsync failed: {str(e)}") # Event Gallery Routes (Members Only) @api_router.get("/members/gallery") async def get_events_with_galleries( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get all events that have gallery images""" # Get events that have at least one gallery image events_with_galleries = db.query(Event).join(EventGallery).filter( Event.published == True ).distinct().order_by(Event.start_at.desc()).all() result = [] for event in events_with_galleries: gallery_count = db.query(EventGallery).filter( EventGallery.event_id == event.id ).count() # Get first image as thumbnail first_image = db.query(EventGallery).filter( EventGallery.event_id == event.id ).order_by(EventGallery.created_at).first() result.append({ "id": str(event.id), "title": event.title, "description": event.description, "start_at": event.start_at, "location": event.location, "gallery_count": gallery_count, "thumbnail_url": first_image.image_url if first_image else None }) return result @api_router.get("/events/{event_id}/gallery") async def get_event_gallery( event_id: str, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """Get all gallery images for a specific event""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") gallery_images = db.query(EventGallery).filter( EventGallery.event_id == event_id ).order_by(EventGallery.created_at.desc()).all() return [ { "id": str(img.id), "image_url": img.image_url, "image_key": img.image_key, "caption": img.caption, "uploaded_by": str(img.uploaded_by), "file_size_bytes": img.file_size_bytes, "created_at": img.created_at } for img in gallery_images ] # Admin Event Gallery Routes @api_router.post("/admin/events/{event_id}/gallery") async def upload_event_gallery_image( event_id: str, file: UploadFile = File(...), caption: Optional[str] = None, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Upload image to event gallery (Admin only)""" # Validate event exists event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") r2 = get_r2_storage() # Get storage quota storage = db.query(StorageUsage).first() if not storage: storage = StorageUsage( total_bytes_used=0, max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) ) db.add(storage) db.commit() db.refresh(storage) # Get max file size from env max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) try: # Upload to R2 public_url, object_key, file_size = await r2.upload_file( file=file, folder=f"gallery/{event_id}", allowed_types=r2.ALLOWED_IMAGE_TYPES, max_size_bytes=max_file_size ) # Check storage quota if storage.total_bytes_used + file_size > storage.max_bytes_allowed: # Rollback upload await r2.delete_file(object_key) raise HTTPException( status_code=507, detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" ) # Create gallery record gallery_image = EventGallery( event_id=event.id, image_url=public_url, image_key=object_key, caption=caption, uploaded_by=current_user.id, file_size_bytes=file_size ) db.add(gallery_image) # Update storage usage storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() db.refresh(gallery_image) logger.info(f"Gallery image uploaded for event {event.title} by {current_user.email}: {file_size} bytes") return { "message": "Image uploaded successfully", "image": { "id": str(gallery_image.id), "image_url": gallery_image.image_url, "caption": gallery_image.caption, "file_size_bytes": gallery_image.file_size_bytes } } except HTTPException: raise except Exception as e: logger.error(f"Error uploading gallery image: {str(e)}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") @api_router.delete("/admin/event-gallery/{image_id}") async def delete_gallery_image( image_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Delete image from event gallery (Admin only)""" gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() if not gallery_image: raise HTTPException(status_code=404, detail="Gallery image not found") r2 = get_r2_storage() storage = db.query(StorageUsage).first() try: # Delete from R2 await r2.delete_file(gallery_image.image_key) # Update storage usage if storage: storage.total_bytes_used -= gallery_image.file_size_bytes storage.last_updated = datetime.now(timezone.utc) # Delete from database db.delete(gallery_image) db.commit() logger.info(f"Gallery image deleted by {current_user.email}: {gallery_image.image_key}") return {"message": "Image deleted successfully"} except Exception as e: logger.error(f"Error deleting gallery image: {str(e)}") raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") @api_router.put("/admin/event-gallery/{image_id}") async def update_gallery_image_caption( image_id: str, caption: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Update gallery image caption (Admin only)""" gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() if not gallery_image: raise HTTPException(status_code=404, detail="Gallery image not found") gallery_image.caption = caption db.commit() db.refresh(gallery_image) return { "message": "Caption updated successfully", "image": { "id": str(gallery_image.id), "caption": gallery_image.caption } } # Event Routes @api_router.get("/events", response_model=List[EventResponse]) async def get_events( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): # Get published events for all users events = db.query(Event).filter(Event.published == True).order_by(Event.start_at).all() result = [] for event in events: rsvp_count = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.rsvp_status == RSVPStatus.yes ).count() # No user_rsvp_status in public endpoint result.append(EventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, published=event.published, created_by=str(event.created_by), created_at=event.created_at, rsvp_count=rsvp_count, user_rsvp_status=None )) return result @api_router.get("/events/{event_id}", response_model=EventResponse) async def get_event( event_id: str, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") rsvp_count = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.rsvp_status == RSVPStatus.yes ).count() # No user_rsvp_status in public endpoint user_rsvp = None return EventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, published=event.published, created_by=str(event.created_by), created_at=event.created_at, rsvp_count=rsvp_count, user_rsvp_status=user_rsvp ) @api_router.post("/events/{event_id}/rsvp") async def rsvp_to_event( event_id: str, request: RSVPRequest, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") # Check if RSVP already exists existing_rsvp = db.query(EventRSVP).filter( EventRSVP.event_id == event_id, EventRSVP.user_id == current_user.id ).first() if existing_rsvp: existing_rsvp.rsvp_status = RSVPStatus(request.rsvp_status) existing_rsvp.updated_at = datetime.now(timezone.utc) else: rsvp = EventRSVP( event_id=event.id, user_id=current_user.id, rsvp_status=RSVPStatus(request.rsvp_status) ) db.add(rsvp) db.commit() return {"message": "RSVP updated successfully"} # ============================================================================ # Calendar Export Endpoints (Universal iCalendar .ics format) # ============================================================================ @api_router.get("/events/{event_id}/download.ics") async def download_event_ics( event_id: str, db: Session = Depends(get_db) ): """ Download single event as .ics file (RFC 5545 iCalendar format) No authentication required for published events Works with Google Calendar, Apple Calendar, Microsoft Outlook, etc. """ event = db.query(Event).filter( Event.id == event_id, Event.published == True ).first() if not event: raise HTTPException(status_code=404, detail="Event not found") # Generate UID if not exists if not event.calendar_uid: event.calendar_uid = calendar_service.generate_event_uid() db.commit() ics_content = calendar_service.create_single_event_calendar(event) # Sanitize filename safe_filename = "".join(c for c in event.title if c.isalnum() or c in (' ', '-', '_')).rstrip() safe_filename = safe_filename.replace(' ', '_') or 'event' return StreamingResponse( iter([ics_content]), media_type="text/calendar", headers={ "Content-Disposition": f"attachment; filename={safe_filename}.ics", "Cache-Control": "public, max-age=300" # Cache for 5 minutes } ) @api_router.get("/calendars/subscribe.ics") async def subscribe_calendar( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Subscribe to user's RSVP'd events (live calendar feed) Auto-syncs events marked as "Yes" RSVP Use webcal:// protocol for auto-sync in calendar apps """ # Get all upcoming events user RSVP'd "yes" to rsvps = db.query(EventRSVP).filter( EventRSVP.user_id == current_user.id, EventRSVP.rsvp_status == RSVPStatus.yes ).join(Event).filter( Event.start_at > datetime.now(timezone.utc), Event.published == True ).all() events = [rsvp.event for rsvp in rsvps] # Generate UIDs for events that don't have them for event in events: if not event.calendar_uid: event.calendar_uid = calendar_service.generate_event_uid() db.commit() feed_name = f"{current_user.first_name}'s LOAF Events" ics_content = calendar_service.create_subscription_feed(events, feed_name) return StreamingResponse( iter([ics_content]), media_type="text/calendar", headers={ "Content-Disposition": "inline; filename=loaf-events.ics", "Cache-Control": "public, max-age=3600", # Cache for 1 hour "ETag": f'"{hash(ics_content)}"' } ) @api_router.get("/calendars/all-events.ics") async def download_all_events( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Download all upcoming published events as .ics file (one-time download) Useful for importing all events at once """ events = db.query(Event).filter( Event.published == True, Event.start_at > datetime.now(timezone.utc) ).order_by(Event.start_at).all() # Generate UIDs for event in events: if not event.calendar_uid: event.calendar_uid = calendar_service.generate_event_uid() db.commit() ics_content = calendar_service.create_subscription_feed(events, "All LOAF Events") return StreamingResponse( iter([ics_content]), media_type="text/calendar", headers={ "Content-Disposition": "attachment; filename=loaf-all-events.ics", "Cache-Control": "public, max-age=600" # Cache for 10 minutes } ) # ============================================================================ # Newsletter Archive Routes (Members Only) # ============================================================================ @api_router.get("/newsletters") async def get_newsletters( year: Optional[int] = None, current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get all newsletters, optionally filtered by year Members only """ from models import NewsletterArchive query = db.query(NewsletterArchive) if year: query = query.filter( db.func.extract('year', NewsletterArchive.published_date) == year ) newsletters = query.order_by(NewsletterArchive.published_date.desc()).all() return [{ "id": str(n.id), "title": n.title, "description": n.description, "published_date": n.published_date.isoformat(), "document_url": n.document_url, "document_type": n.document_type, "file_size_bytes": n.file_size_bytes, "created_at": n.created_at.isoformat() } for n in newsletters] @api_router.get("/newsletters/years") async def get_newsletter_years( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get list of years that have newsletters Members only """ from models import NewsletterArchive years = db.query( db.func.extract('year', NewsletterArchive.published_date).label('year') ).distinct().order_by(db.text('year DESC')).all() return [int(y.year) for y in years] # ============================================================================ # Financial Reports Routes (Members Only) # ============================================================================ @api_router.get("/financials") async def get_financial_reports( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get all financial reports sorted by year (newest first) Members only """ from models import FinancialReport reports = db.query(FinancialReport).order_by( FinancialReport.year.desc() ).all() return [{ "id": str(r.id), "year": r.year, "title": r.title, "document_url": r.document_url, "document_type": r.document_type, "file_size_bytes": r.file_size_bytes, "created_at": r.created_at.isoformat() } for r in reports] # ============================================================================ # Bylaws Routes (Members Only) # ============================================================================ @api_router.get("/bylaws/current") async def get_current_bylaws( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get current bylaws document Members only """ from models import BylawsDocument bylaws = db.query(BylawsDocument).filter( BylawsDocument.is_current == True ).first() if not bylaws: raise HTTPException(status_code=404, detail="No current bylaws found") return { "id": str(bylaws.id), "title": bylaws.title, "version": bylaws.version, "effective_date": bylaws.effective_date.isoformat(), "document_url": bylaws.document_url, "document_type": bylaws.document_type, "file_size_bytes": bylaws.file_size_bytes, "is_current": bylaws.is_current, "created_at": bylaws.created_at.isoformat() } @api_router.get("/bylaws/history") async def get_bylaws_history( current_user: User = Depends(get_active_member), db: Session = Depends(get_db) ): """ Get all bylaws versions (historical) Members only """ from models import BylawsDocument history = db.query(BylawsDocument).order_by( BylawsDocument.effective_date.desc() ).all() return [{ "id": str(b.id), "title": b.title, "version": b.version, "effective_date": b.effective_date.isoformat(), "document_url": b.document_url, "document_type": b.document_type, "file_size_bytes": b.file_size_bytes, "is_current": b.is_current, "created_at": b.created_at.isoformat() } for b in history] # ============================================================================ # Configuration Endpoints # ============================================================================ @api_router.get("/config/limits") async def get_config_limits(): """Get configuration limits for file uploads""" return { "max_file_size_bytes": int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)), "max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) } # ============================================================================ # Admin Routes # ============================================================================ @api_router.get("/admin/storage/usage") async def get_storage_usage( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get current storage usage statistics""" from models import StorageUsage storage = db.query(StorageUsage).first() if not storage: # Initialize if doesn't exist storage = StorageUsage( total_bytes_used=0, max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) ) db.add(storage) db.commit() db.refresh(storage) percentage = (storage.total_bytes_used / storage.max_bytes_allowed) * 100 if storage.max_bytes_allowed > 0 else 0 return { "total_bytes_used": storage.total_bytes_used, "max_bytes_allowed": storage.max_bytes_allowed, "percentage": round(percentage, 2), "available_bytes": storage.max_bytes_allowed - storage.total_bytes_used } @api_router.get("/admin/storage/breakdown") async def get_storage_breakdown( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get storage usage breakdown by category""" from sqlalchemy import func from models import User, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument # Count storage by category profile_photos = db.query(func.coalesce(func.sum(User.profile_photo_size), 0)).scalar() or 0 gallery_images = db.query(func.coalesce(func.sum(EventGallery.file_size_bytes), 0)).scalar() or 0 newsletters = db.query(func.coalesce(func.sum(NewsletterArchive.file_size_bytes), 0)).filter( NewsletterArchive.document_type == 'upload' ).scalar() or 0 financials = db.query(func.coalesce(func.sum(FinancialReport.file_size_bytes), 0)).filter( FinancialReport.document_type == 'upload' ).scalar() or 0 bylaws = db.query(func.coalesce(func.sum(BylawsDocument.file_size_bytes), 0)).filter( BylawsDocument.document_type == 'upload' ).scalar() or 0 return { "breakdown": { "profile_photos": profile_photos, "gallery_images": gallery_images, "newsletters": newsletters, "financials": financials, "bylaws": bylaws }, "total": profile_photos + gallery_images + newsletters + financials + bylaws } @api_router.get("/admin/users") async def get_all_users( status: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): query = db.query(User) if status: try: status_enum = UserStatus(status) query = query.filter(User.status == status_enum) except ValueError: raise HTTPException(status_code=400, detail="Invalid status") users = query.order_by(User.created_at.desc()).all() return [ { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, "status": user.status.value, "role": user.role.value, "email_verified": user.email_verified, "created_at": user.created_at.isoformat(), "lead_sources": user.lead_sources, "referred_by_member_name": user.referred_by_member_name } for user in users ] @api_router.get("/admin/users/{user_id}") async def get_user_by_id( user_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): """Get specific user by ID (admin only)""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") return { "id": str(user.id), "email": user.email, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, "address": user.address, "city": user.city, "state": user.state, "zipcode": user.zipcode, "date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None, "partner_first_name": user.partner_first_name, "partner_last_name": user.partner_last_name, "partner_is_member": user.partner_is_member, "partner_plan_to_become_member": user.partner_plan_to_become_member, "referred_by_member_name": user.referred_by_member_name, "status": user.status.value, "role": user.role.value, "email_verified": user.email_verified, "newsletter_subscribed": user.newsletter_subscribed, "lead_sources": user.lead_sources, "created_at": user.created_at.isoformat() if user.created_at else None, "updated_at": user.updated_at.isoformat() if user.updated_at else None } @api_router.put("/admin/users/{user_id}/approve") async def approve_user( user_id: str, bypass_email_verification: bool = False, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Handle bypass email verification for pending_email users if bypass_email_verification and user.status == UserStatus.pending_email: # Verify email manually user.email_verified = True user.email_verification_token = None # Determine status based on referral if user.referred_by_member_name: referrer = db.query(User).filter( or_( User.first_name + ' ' + User.last_name == user.referred_by_member_name, User.email == user.referred_by_member_name ), User.status == UserStatus.active ).first() user.status = UserStatus.pre_approved if referrer else UserStatus.pending_approval else: user.status = UserStatus.pending_approval logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}") # Validate user status - must be pending_approval or pre_approved if user.status not in [UserStatus.pending_approval, UserStatus.pre_approved]: raise HTTPException( status_code=400, detail=f"User must have verified email first. Current: {user.status.value}" ) # Set to payment_pending - user becomes active after payment via webhook user.status = UserStatus.payment_pending user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(user) # Send payment prompt email await send_payment_prompt_email(user.email, user.first_name) logger.info(f"User validated and approved (payment pending): {user.email} by admin: {current_user.email}") return {"message": "User approved - payment email sent"} @api_router.put("/admin/users/{user_id}/status") async def update_user_status( user_id: str, request: UpdateUserStatusRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") try: new_status = UserStatus(request.status) user.status = new_status user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(user) return {"message": "User status updated successfully"} except ValueError: raise HTTPException(status_code=400, detail="Invalid status") @api_router.post("/admin/users/{user_id}/activate-payment") async def activate_payment_manually( user_id: str, request: ManualPaymentRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user) ): """Manually activate user who paid offline (cash, bank transfer, etc.)""" # 1. Find user user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # 2. Validate status if user.status != UserStatus.payment_pending: raise HTTPException( status_code=400, detail=f"User must be in payment_pending status. Current: {user.status.value}" ) # 3. Get subscription plan plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == request.plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Subscription plan not found") # 4. Calculate subscription period if request.use_custom_period: # Use admin-specified custom dates if not request.custom_period_start or not request.custom_period_end: raise HTTPException( status_code=400, detail="Custom period start and end dates are required when use_custom_period is true" ) period_start = request.custom_period_start period_end = request.custom_period_end else: # Use plan's billing cycle period_start = datetime.now(timezone.utc) if plan.billing_cycle == 'monthly': period_end = period_start + timedelta(days=30) elif plan.billing_cycle == 'quarterly': period_end = period_start + timedelta(days=90) elif plan.billing_cycle == 'yearly': period_end = period_start + timedelta(days=365) elif plan.billing_cycle == 'lifetime': period_end = period_start + timedelta(days=36500) # 100 years else: period_end = period_start + timedelta(days=365) # Default 1 year # 5. Create subscription record (manual payment) subscription = Subscription( user_id=user.id, plan_id=plan.id, stripe_subscription_id=None, # No Stripe involvement stripe_customer_id=None, status=SubscriptionStatus.active, start_date=period_start, end_date=period_end, amount_paid_cents=request.amount_cents, payment_method=request.payment_method, manual_payment=True, manual_payment_notes=request.notes, manual_payment_admin_id=current_user.id, manual_payment_date=request.payment_date ) db.add(subscription) # 6. Activate user user.status = UserStatus.active user.role = UserRole.member user.updated_at = datetime.now(timezone.utc) # 7. Commit db.commit() db.refresh(subscription) # 8. Log admin action logger.info( f"Admin {current_user.email} manually activated payment for user {user.email} " f"via {request.payment_method} for ${request.amount_cents/100:.2f} " f"with plan {plan.name} ({period_start.date()} to {period_end.date()})" ) return { "message": "User payment activated successfully", "user_id": str(user.id), "subscription_id": str(subscription.id) } @api_router.put("/admin/users/{user_id}/reset-password") async def admin_reset_user_password( user_id: str, request: AdminPasswordUpdateRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Admin resets user password - generates temp password and emails it""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Generate random temporary password temp_password = secrets.token_urlsafe(12) # Update user user.password_hash = get_password_hash(temp_password) user.force_password_change = request.force_change db.commit() # Email user the temporary password await send_admin_password_reset_email( user.email, user.first_name, temp_password, request.force_change ) # Log admin action logger.info( f"Admin {current_user.email} reset password for user {user.email} " f"(force_change={request.force_change})" ) return {"message": f"Password reset for {user.email}. Temporary password emailed."} @api_router.post("/admin/users/{user_id}/resend-verification") async def admin_resend_verification( user_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Admin resends verification email for any user""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Check if email already verified if user.email_verified: raise HTTPException(status_code=400, detail="User's email is already verified") # Generate new token verification_token = secrets.token_urlsafe(32) user.email_verification_token = verification_token db.commit() # Send verification email await send_verification_email(user.email, verification_token) # Log admin action logger.info( f"Admin {current_user.email} resent verification email to user {user.email}" ) return {"message": f"Verification email resent to {user.email}"} @api_router.post("/admin/events", response_model=EventResponse) async def create_event( request: EventCreate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = Event( title=request.title, description=request.description, start_at=request.start_at, end_at=request.end_at, location=request.location, capacity=request.capacity, published=request.published, created_by=current_user.id ) db.add(event) db.commit() db.refresh(event) logger.info(f"Event created: {event.title} by {current_user.email}") return EventResponse( id=str(event.id), title=event.title, description=event.description, start_at=event.start_at, end_at=event.end_at, location=event.location, capacity=event.capacity, published=event.published, created_by=str(event.created_by), created_at=event.created_at, rsvp_count=0 ) @api_router.put("/admin/events/{event_id}") async def update_event( event_id: str, request: EventUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") if request.title: event.title = request.title if request.description is not None: event.description = request.description if request.start_at: event.start_at = request.start_at if request.end_at: event.end_at = request.end_at if request.location: event.location = request.location if request.capacity is not None: event.capacity = request.capacity if request.published is not None: event.published = request.published event.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(event) return {"message": "Event updated successfully"} @api_router.get("/admin/events/{event_id}/rsvps") async def get_event_rsvps( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all() result = [] for rsvp in rsvps: user = db.query(User).filter(User.id == rsvp.user_id).first() result.append({ "id": str(rsvp.id), "user_id": str(rsvp.user_id), "user_name": f"{user.first_name} {user.last_name}", "user_email": user.email, "rsvp_status": rsvp.rsvp_status.value, "attended": rsvp.attended, "attended_at": rsvp.attended_at.isoformat() if rsvp.attended_at else None }) return result @api_router.put("/admin/events/{event_id}/attendance") async def mark_attendance( event_id: str, request: AttendanceUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") rsvp = db.query(EventRSVP).filter( EventRSVP.event_id == event_id, EventRSVP.user_id == request.user_id ).first() if not rsvp: raise HTTPException(status_code=404, detail="RSVP not found") rsvp.attended = request.attended rsvp.attended_at = datetime.now(timezone.utc) if request.attended else None rsvp.updated_at = datetime.now(timezone.utc) # If user attended and they were pending approval, update their status if request.attended: user = db.query(User).filter(User.id == request.user_id).first() if user and user.status == UserStatus.pending_approval: user.status = UserStatus.pre_approved user.updated_at = datetime.now(timezone.utc) db.commit() return {"message": "Attendance marked successfully"} @api_router.get("/admin/events") async def get_admin_events( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get all events for admin (including unpublished)""" events = db.query(Event).order_by(Event.start_at.desc()).all() result = [] for event in events: rsvp_count = db.query(EventRSVP).filter( EventRSVP.event_id == event.id, EventRSVP.rsvp_status == RSVPStatus.yes ).count() result.append({ "id": str(event.id), "title": event.title, "description": event.description, "start_at": event.start_at, "end_at": event.end_at, "location": event.location, "capacity": event.capacity, "published": event.published, "created_by": str(event.created_by), "created_at": event.created_at, "rsvp_count": rsvp_count }) return result @api_router.delete("/admin/events/{event_id}") async def delete_event( event_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Delete an event (cascade deletes RSVPs)""" event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(status_code=404, detail="Event not found") db.delete(event) db.commit() return {"message": "Event deleted successfully"} # ==================== PAYMENT & SUBSCRIPTION ENDPOINTS ==================== # Pydantic model for checkout request class CheckoutRequest(BaseModel): plan_id: str # Pydantic model for plan CRUD class PlanCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=100) description: Optional[str] = Field(None, max_length=500) price_cents: int = Field(ge=0, le=100000000) billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime"] stripe_price_id: Optional[str] = None active: bool = True @validator('name') def validate_name(cls, v): if not v.strip(): raise ValueError('Name cannot be empty or whitespace') return v.strip() @api_router.get("/subscriptions/plans") async def get_subscription_plans(db: Session = Depends(get_db)): """Get all active subscription plans.""" plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.active == True).all() return plans # ==================== ADMIN PLAN CRUD ENDPOINTS ==================== @api_router.get("/admin/subscriptions/plans") async def get_all_plans_admin( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get all subscription plans for admin (including inactive) with subscriber counts.""" plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.created_at.desc()).all() result = [] for plan in plans: subscriber_count = db.query(Subscription).filter( Subscription.plan_id == plan.id, Subscription.status == SubscriptionStatus.active ).count() result.append({ "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": subscriber_count, "created_at": plan.created_at, "updated_at": plan.updated_at }) return result @api_router.get("/admin/subscriptions/plans/{plan_id}") async def get_plan_admin( plan_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Get single plan details with subscriber count.""" plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") subscriber_count = db.query(Subscription).filter( Subscription.plan_id == plan.id, Subscription.status == SubscriptionStatus.active ).count() return { "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": subscriber_count, "created_at": plan.created_at, "updated_at": plan.updated_at } @api_router.post("/admin/subscriptions/plans") async def create_plan( request: PlanCreateRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Create new subscription plan.""" # Check for duplicate name existing = db.query(SubscriptionPlan).filter( SubscriptionPlan.name == request.name ).first() if existing: raise HTTPException( status_code=400, detail="A plan with this name already exists" ) plan = SubscriptionPlan( name=request.name, description=request.description, price_cents=request.price_cents, billing_cycle=request.billing_cycle, stripe_price_id=request.stripe_price_id, active=request.active ) db.add(plan) db.commit() db.refresh(plan) logger.info(f"Admin {current_user.email} created plan: {plan.name}") return { "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": 0, "created_at": plan.created_at, "updated_at": plan.updated_at } @api_router.put("/admin/subscriptions/plans/{plan_id}") async def update_plan( plan_id: str, request: PlanCreateRequest, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Update subscription plan.""" plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") # Check for duplicate name (excluding current plan) existing = db.query(SubscriptionPlan).filter( SubscriptionPlan.name == request.name, SubscriptionPlan.id != plan_id ).first() if existing: raise HTTPException( status_code=400, detail="A plan with this name already exists" ) # Update fields plan.name = request.name plan.description = request.description plan.price_cents = request.price_cents plan.billing_cycle = request.billing_cycle plan.stripe_price_id = request.stripe_price_id plan.active = request.active plan.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(plan) logger.info(f"Admin {current_user.email} updated plan: {plan.name}") subscriber_count = db.query(Subscription).filter( Subscription.plan_id == plan.id, Subscription.status == SubscriptionStatus.active ).count() return { "id": str(plan.id), "name": plan.name, "description": plan.description, "price_cents": plan.price_cents, "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "active": plan.active, "subscriber_count": subscriber_count, "created_at": plan.created_at, "updated_at": plan.updated_at } @api_router.delete("/admin/subscriptions/plans/{plan_id}") async def delete_plan( plan_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Soft delete plan (set active = False).""" plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") # Check if plan has active subscriptions active_subs = db.query(Subscription).filter( Subscription.plan_id == plan_id, Subscription.status == SubscriptionStatus.active ).count() if active_subs > 0: raise HTTPException( status_code=400, detail=f"Cannot delete plan with {active_subs} active subscriptions" ) plan.active = False plan.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Admin {current_user.email} deactivated plan: {plan.name}") return {"message": "Plan deactivated successfully"} # ============================================================================ # Admin Document Management Routes # ============================================================================ # Newsletter Archive Admin Routes @api_router.post("/admin/newsletters") async def create_newsletter( title: str = Form(...), description: str = Form(None), published_date: str = Form(...), document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Create newsletter record Admin only - supports both URL links and file uploads """ from models import NewsletterArchive, StorageUsage from r2_storage import get_r2_storage final_url = document_url file_size = None # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="newsletters", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url file_size = file_size_bytes # Update storage usage storage = db.query(StorageUsage).first() if storage: storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() newsletter = NewsletterArchive( title=title, description=description, published_date=datetime.fromisoformat(published_date.replace('Z', '+00:00')), document_url=final_url, document_type=document_type, file_size_bytes=file_size, created_by=current_user.id ) db.add(newsletter) db.commit() db.refresh(newsletter) return { "id": str(newsletter.id), "message": "Newsletter created successfully" } @api_router.put("/admin/newsletters/{newsletter_id}") async def update_newsletter( newsletter_id: str, title: str = Form(...), description: str = Form(None), published_date: str = Form(...), document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Update newsletter record Admin only - supports both URL links and file uploads """ from models import NewsletterArchive, StorageUsage from r2_storage import get_r2_storage newsletter = db.query(NewsletterArchive).filter( NewsletterArchive.id == newsletter_id ).first() if not newsletter: raise HTTPException(status_code=404, detail="Newsletter not found") final_url = document_url file_size = newsletter.file_size_bytes # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="newsletters", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url # Update storage usage (subtract old, add new) storage = db.query(StorageUsage).first() if storage and newsletter.file_size_bytes: storage.total_bytes_used -= newsletter.file_size_bytes if storage: storage.total_bytes_used += file_size_bytes storage.last_updated = datetime.now(timezone.utc) db.commit() file_size = file_size_bytes newsletter.title = title newsletter.description = description newsletter.published_date = datetime.fromisoformat(published_date.replace('Z', '+00:00')) newsletter.document_url = final_url newsletter.document_type = document_type newsletter.file_size_bytes = file_size newsletter.updated_at = datetime.now(timezone.utc) db.commit() return {"message": "Newsletter updated successfully"} @api_router.delete("/admin/newsletters/{newsletter_id}") async def delete_newsletter( newsletter_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Delete newsletter record Admin only """ from models import NewsletterArchive newsletter = db.query(NewsletterArchive).filter( NewsletterArchive.id == newsletter_id ).first() if not newsletter: raise HTTPException(status_code=404, detail="Newsletter not found") db.delete(newsletter) db.commit() return {"message": "Newsletter deleted successfully"} # Financial Reports Admin Routes @api_router.post("/admin/financials") async def create_financial_report( year: int = Form(...), title: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Create financial report record Admin only - supports both URL links and file uploads """ from models import FinancialReport, StorageUsage from r2_storage import get_r2_storage final_url = document_url file_size = None # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="financials", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url file_size = file_size_bytes # Update storage usage storage = db.query(StorageUsage).first() if storage: storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() report = FinancialReport( year=year, title=title, document_url=final_url, document_type=document_type, file_size_bytes=file_size, created_by=current_user.id ) db.add(report) db.commit() db.refresh(report) return { "id": str(report.id), "message": "Financial report created successfully" } @api_router.put("/admin/financials/{report_id}") async def update_financial_report( report_id: str, year: int = Form(...), title: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Update financial report record Admin only - supports both URL links and file uploads """ from models import FinancialReport, StorageUsage from r2_storage import get_r2_storage report = db.query(FinancialReport).filter( FinancialReport.id == report_id ).first() if not report: raise HTTPException(status_code=404, detail="Financial report not found") final_url = document_url file_size = report.file_size_bytes # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="financials", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url # Update storage usage (subtract old, add new) storage = db.query(StorageUsage).first() if storage and report.file_size_bytes: storage.total_bytes_used -= report.file_size_bytes if storage: storage.total_bytes_used += file_size_bytes storage.last_updated = datetime.now(timezone.utc) db.commit() file_size = file_size_bytes report.year = year report.title = title report.document_url = final_url report.document_type = document_type report.file_size_bytes = file_size report.updated_at = datetime.now(timezone.utc) db.commit() return {"message": "Financial report updated successfully"} @api_router.delete("/admin/financials/{report_id}") async def delete_financial_report( report_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Delete financial report record Admin only """ from models import FinancialReport report = db.query(FinancialReport).filter( FinancialReport.id == report_id ).first() if not report: raise HTTPException(status_code=404, detail="Financial report not found") db.delete(report) db.commit() return {"message": "Financial report deleted successfully"} # Bylaws Admin Routes @api_router.post("/admin/bylaws") async def create_bylaws( title: str = Form(...), version: str = Form(...), effective_date: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), is_current: bool = Form(True), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Create bylaws document If is_current=True, sets all others to is_current=False Admin only - supports both URL links and file uploads """ from models import BylawsDocument, StorageUsage from r2_storage import get_r2_storage final_url = document_url file_size = None # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="bylaws", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url file_size = file_size_bytes # Update storage usage storage = db.query(StorageUsage).first() if storage: storage.total_bytes_used += file_size storage.last_updated = datetime.now(timezone.utc) db.commit() if is_current: # Set all other bylaws to not current db.query(BylawsDocument).update({"is_current": False}) bylaws = BylawsDocument( title=title, version=version, effective_date=datetime.fromisoformat(effective_date.replace('Z', '+00:00')), document_url=final_url, document_type=document_type, is_current=is_current, file_size_bytes=file_size, created_by=current_user.id ) db.add(bylaws) db.commit() db.refresh(bylaws) return { "id": str(bylaws.id), "message": "Bylaws created successfully" } @api_router.put("/admin/bylaws/{bylaws_id}") async def update_bylaws( bylaws_id: str, title: str = Form(...), version: str = Form(...), effective_date: str = Form(...), document_type: str = Form("google_drive"), document_url: str = Form(None), is_current: bool = Form(False), file: Optional[UploadFile] = File(None), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Update bylaws document If is_current=True, sets all others to is_current=False Admin only - supports both URL links and file uploads """ from models import BylawsDocument, StorageUsage from r2_storage import get_r2_storage bylaws = db.query(BylawsDocument).filter( BylawsDocument.id == bylaws_id ).first() if not bylaws: raise HTTPException(status_code=404, detail="Bylaws not found") final_url = document_url file_size = bylaws.file_size_bytes # If file uploaded, upload to R2 if file and document_type == 'upload': r2 = get_r2_storage() public_url, object_key, file_size_bytes = await r2.upload_file( file=file, folder="bylaws", allowed_types=r2.ALLOWED_DOCUMENT_TYPES, max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) ) final_url = public_url # Update storage usage (subtract old, add new) storage = db.query(StorageUsage).first() if storage and bylaws.file_size_bytes: storage.total_bytes_used -= bylaws.file_size_bytes if storage: storage.total_bytes_used += file_size_bytes storage.last_updated = datetime.now(timezone.utc) db.commit() file_size = file_size_bytes if is_current: # Set all other bylaws to not current db.query(BylawsDocument).filter( BylawsDocument.id != bylaws_id ).update({"is_current": False}) bylaws.title = title bylaws.version = version bylaws.effective_date = datetime.fromisoformat(effective_date.replace('Z', '+00:00')) bylaws.document_url = final_url bylaws.document_type = document_type bylaws.is_current = is_current bylaws.file_size_bytes = file_size db.commit() return {"message": "Bylaws updated successfully"} @api_router.delete("/admin/bylaws/{bylaws_id}") async def delete_bylaws( bylaws_id: str, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """ Delete bylaws document Admin only """ from models import BylawsDocument bylaws = db.query(BylawsDocument).filter( BylawsDocument.id == bylaws_id ).first() if not bylaws: raise HTTPException(status_code=404, detail="Bylaws not found") db.delete(bylaws) db.commit() return {"message": "Bylaws deleted successfully"} @api_router.post("/subscriptions/checkout") async def create_checkout( request: CheckoutRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Create Stripe Checkout session for subscription payment.""" # Get plan plan = db.query(SubscriptionPlan).filter( SubscriptionPlan.id == request.plan_id ).first() if not plan: raise HTTPException(status_code=404, detail="Plan not found") if not plan.active: raise HTTPException(status_code=400, detail="This plan is no longer available for subscription") if not plan.stripe_price_id: raise HTTPException(status_code=400, detail="Plan is not configured for payment") # Get frontend URL from env frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") try: # Create checkout session session = create_checkout_session( user_id=current_user.id, user_email=current_user.email, plan_id=plan.id, stripe_price_id=plan.stripe_price_id, success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}", cancel_url=f"{frontend_url}/payment-cancel" ) return {"checkout_url": session["url"]} except Exception as e: logger.error(f"Error creating checkout session: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create checkout session") @app.post("/api/webhooks/stripe") async def stripe_webhook(request: Request, db: Session = Depends(get_db)): """Handle Stripe webhook events. Note: This endpoint is NOT on the api_router to avoid /api/api prefix.""" # Get raw payload and signature payload = await request.body() sig_header = request.headers.get("stripe-signature") if not sig_header: raise HTTPException(status_code=400, detail="Missing stripe-signature header") try: # Verify webhook signature event = verify_webhook_signature(payload, sig_header) except ValueError as e: logger.error(f"Webhook signature verification failed: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) # Handle checkout.session.completed event if event["type"] == "checkout.session.completed": session = event["data"]["object"] # Get metadata user_id = session["metadata"].get("user_id") plan_id = session["metadata"].get("plan_id") if not user_id or not plan_id: logger.error("Missing user_id or plan_id in webhook metadata") return {"status": "error", "message": "Missing metadata"} # Get user and plan user = db.query(User).filter(User.id == user_id).first() plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() if user and plan: # Check if subscription already exists (idempotency) existing_subscription = db.query(Subscription).filter( Subscription.stripe_subscription_id == session.get("subscription") ).first() if not existing_subscription: # Create subscription record subscription = Subscription( user_id=user.id, plan_id=plan.id, stripe_subscription_id=session.get("subscription"), stripe_customer_id=session.get("customer"), status=SubscriptionStatus.active, start_date=datetime.now(timezone.utc), end_date=get_subscription_end_date(plan.billing_cycle), amount_paid_cents=session.get("amount_total", plan.price_cents) ) db.add(subscription) # Update user status and role user.status = UserStatus.active user.role = UserRole.member user.updated_at = datetime.now(timezone.utc) db.commit() logger.info(f"Subscription created for user {user.email}") else: logger.info(f"Subscription already exists for session {session.get('id')}") else: logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}") return {"status": "success"} # Include the router in the main app app.include_router(api_router) app.add_middleware( CORSMiddleware, allow_credentials=True, allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','), allow_methods=["*"], allow_headers=["*"], )