1191 lines
39 KiB
Python
1191 lines
39 KiB
Python
from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
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
|
|
from auth import (
|
|
get_password_hash,
|
|
verify_password,
|
|
create_access_token,
|
|
get_current_user,
|
|
get_current_admin_user
|
|
)
|
|
from email_service import send_verification_email, send_approval_notification, send_payment_prompt_email
|
|
from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date
|
|
|
|
# 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)
|
|
|
|
# Create a router with the /api prefix
|
|
api_router = APIRouter(prefix="/api")
|
|
|
|
# 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):
|
|
email: EmailStr
|
|
password: str = Field(min_length=6)
|
|
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
|
|
referred_by_member_name: Optional[str] = None
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
|
|
class LoginResponse(BaseModel):
|
|
access_token: str
|
|
token_type: str
|
|
user: dict
|
|
|
|
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
|
|
|
|
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 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(
|
|
email=request.email,
|
|
password_hash=get_password_hash(request.password),
|
|
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_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,
|
|
referred_by_member_name=request.referred_by_member_name,
|
|
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)):
|
|
user = db.query(User).filter(User.email_verification_token == token).first()
|
|
|
|
if not user:
|
|
raise HTTPException(status_code=400, detail="Invalid verification token")
|
|
|
|
# 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/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
|
|
}
|
|
}
|
|
|
|
@api_router.get("/auth/me", response_model=UserResponse)
|
|
async def get_me(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
|
|
)
|
|
|
|
# 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"}
|
|
|
|
# Event Routes
|
|
@api_router.get("/events", response_model=List[EventResponse])
|
|
async def get_events(
|
|
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,
|
|
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_current_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")
|
|
|
|
# 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"}
|
|
|
|
# Admin Routes
|
|
@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.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"}
|
|
|
|
@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=["*"],
|
|
) |