diff --git a/MIGRATIONS.md b/MIGRATIONS.md
new file mode 100644
index 0000000..63b4707
--- /dev/null
+++ b/MIGRATIONS.md
@@ -0,0 +1,328 @@
+# Database Migrations Guide
+
+This document explains how to set up the database for the LOAF membership platform on a fresh server.
+
+---
+
+## Quick Start (Fresh Database Setup)
+
+For a **brand new deployment** on a fresh PostgreSQL database:
+
+```bash
+# 1. Create PostgreSQL database
+psql -U postgres
+CREATE DATABASE membership_db;
+CREATE USER membership_user WITH PASSWORD 'your_password';
+GRANT ALL PRIVILEGES ON DATABASE membership_db TO membership_user;
+\q
+
+# 2. Run initial schema migration
+psql -U postgres -d membership_db -f migrations/000_initial_schema.sql
+
+# 3. Seed permissions and RBAC roles
+python seed_permissions_rbac.py
+
+# 4. Create superadmin user
+python create_admin.py
+```
+
+**That's it!** The database is now fully configured and ready for use.
+
+---
+
+## Migration Files Overview
+
+### Core Migration (Run This First)
+
+**`000_initial_schema.sql`** ✅ **START HERE**
+- Creates all 10 ENUM types (userstatus, userrole, rsvpstatus, etc.)
+- Creates all 17 base tables:
+ - Core: users, events, event_rsvps, event_galleries
+ - Financial: subscription_plans, subscriptions, donations
+ - Documents: newsletter_archives, financial_reports, bylaws_documents
+ - RBAC: permissions, roles, role_permissions
+ - System: storage_usage, user_invitations, import_jobs
+- Creates 30+ performance indexes
+- Initializes default storage_usage record
+- **Status:** Required for fresh deployment
+- **Run Order:** #1
+
+---
+
+### Incremental Migrations (Historical - Only for Existing Databases)
+
+These migrations were created to update existing databases incrementally. **If you're starting fresh with 000_initial_schema.sql, you DO NOT need to run these** as their changes are already included in the initial schema.
+
+| File | Purpose | Included in 000? | Run on Fresh DB? |
+|------|---------|------------------|------------------|
+| `001_add_member_since_field.sql` | Adds member_since field to users | ✅ Yes | ❌ No |
+| `002_rename_approval_to_validation.sql` | Renames approval-related fields | ✅ Yes | ❌ No |
+| `003_add_tos_acceptance.sql` | Adds TOS acceptance tracking | ✅ Yes | ❌ No |
+| `004_add_reminder_tracking_fields.sql` | Adds reminder sent flags | ✅ Yes | ❌ No |
+| `005_add_rbac_and_invitations.sql` | Adds RBAC permissions & invitations | ✅ Yes | ❌ No |
+| `006_add_dynamic_roles.sql` | Adds dynamic roles table | ✅ Yes | ❌ No |
+| `009_create_donations.sql` | Creates donations table | ✅ Yes | ❌ No |
+| `010_add_rejection_fields.sql` | Adds rejection tracking to users | ✅ Yes | ❌ No |
+
+**Note:** These files are kept for reference and for updating existing production databases that were created before 000_initial_schema.sql existed.
+
+---
+
+### Ad-Hoc Fix Migrations (Legacy - Do Not Run)
+
+These were one-time fixes for specific issues during development:
+
+- `add_calendar_uid.sql` - Added calendar UID field (now in 000)
+- `complete_fix.sql` - Added various profile fields (now in 000)
+- `fix_storage_usage.sql` - Fixed storage_usage initialization (now in 000)
+- `sprint_1_2_3_migration.sql` - Combined early sprint migrations (obsolete)
+- `verify_columns.sql` - Debugging script (not a migration)
+
+**Status:** Do NOT run these on any database. They are archived for historical reference only.
+
+---
+
+## Python Migration Scripts (Data Migrations)
+
+These scripts migrate **data**, not schema. Run these AFTER the SQL migrations if you have existing data to migrate:
+
+| Script | Purpose | When to Run |
+|--------|---------|-------------|
+| `migrate_add_manual_payment.py` | Migrates manual payment data | Only if you have existing subscriptions with manual payments |
+| `migrate_billing_enhancements.py` | Migrates billing cycle data | Only if you have existing subscription plans |
+| `migrate_multistep_registration.py` | Migrates old registration format | Only if upgrading from Phase 0 |
+| `migrate_password_reset.py` | Migrates password reset tokens | Only if you have password reset data |
+| `migrate_role_permissions_to_dynamic_roles.py` | Migrates RBAC permissions | Run after seeding permissions (if upgrading) |
+| `migrate_status.py` | Migrates user status enum values | Only if upgrading from old status values |
+| `migrate_users_to_dynamic_roles.py` | Assigns users to dynamic roles | Run after seeding roles (if upgrading) |
+
+**For Fresh Deployment:** You do NOT need to run any of these Python migration scripts. They are only for migrating data from older versions of the platform.
+
+---
+
+## Complete Deployment Workflow
+
+### Scenario 1: Fresh Server (Brand New Database)
+
+```bash
+# Step 1: Create database
+psql -U postgres << EOF
+CREATE DATABASE membership_db;
+CREATE USER membership_user WITH PASSWORD 'secure_password_here';
+GRANT ALL PRIVILEGES ON DATABASE membership_db TO membership_user;
+EOF
+
+# Step 2: Run initial schema
+psql postgresql://membership_user:secure_password_here@localhost/membership_db \
+ -f migrations/000_initial_schema.sql
+
+# Expected output:
+# Step 1/8 completed: ENUM types created
+# Step 2/8 completed: Core tables created
+# ...
+# ✅ Migration 000 completed successfully!
+
+# Step 3: Seed permissions (59 permissions across 10 modules)
+python seed_permissions_rbac.py
+
+# Expected output:
+# ✅ Seeded 59 permissions
+# ✅ Created 5 system roles
+# ✅ Assigned permissions to roles
+
+# Step 4: Create superadmin user (interactive)
+python create_admin.py
+
+# Follow prompts to create your first superadmin account
+
+# Step 5: Verify database
+psql postgresql://membership_user:secure_password_here@localhost/membership_db -c "
+SELECT
+ (SELECT COUNT(*) FROM users) as users,
+ (SELECT COUNT(*) FROM permissions) as permissions,
+ (SELECT COUNT(*) FROM roles) as roles,
+ (SELECT COUNT(*) FROM subscription_plans) as plans;
+"
+
+# Expected output (fresh database):
+# users | permissions | roles | plans
+# ------+-------------+-------+-------
+# 1 | 59 | 5 | 0
+```
+
+### Scenario 2: Upgrading Existing Database
+
+If you already have a database with data and need to upgrade:
+
+```bash
+# Check what migrations have been applied
+psql -d membership_db -c "SELECT * FROM users LIMIT 1;" # Check if tables exist
+
+# Run missing migrations in order
+# Example: If you're on migration 006, run 009 and 010
+psql -d membership_db -f migrations/009_create_donations.sql
+psql -d membership_db -f migrations/010_add_rejection_fields.sql
+
+# Run data migrations if needed
+python migrate_users_to_dynamic_roles.py # If upgrading RBAC
+python migrate_billing_enhancements.py # If upgrading subscriptions
+
+# Update permissions
+python seed_permissions_rbac.py
+```
+
+---
+
+## Verification & Troubleshooting
+
+### Verify Database Schema
+
+```bash
+# Check all tables exist (should show 17 tables)
+psql -d membership_db -c "\dt"
+
+# Expected tables:
+# users, events, event_rsvps, event_galleries
+# subscription_plans, subscriptions, donations
+# newsletter_archives, financial_reports, bylaws_documents
+# permissions, roles, role_permissions
+# storage_usage, user_invitations, import_jobs
+
+# Check ENUM types (should show 8 types)
+psql -d membership_db -c "SELECT typname FROM pg_type WHERE typcategory = 'E';"
+
+# Expected ENUMs:
+# userstatus, userrole, rsvpstatus, subscriptionstatus
+# donationtype, donationstatus, invitationstatus, importjobstatus
+
+# Check indexes (should show 30+ indexes)
+psql -d membership_db -c "SELECT indexname FROM pg_indexes WHERE schemaname = 'public';"
+```
+
+### Common Issues
+
+**Issue 1: "relation already exists"**
+- **Cause:** Migration already run
+- **Solution:** Safe to ignore. 000_initial_schema.sql uses `IF NOT EXISTS` checks.
+
+**Issue 2: "type already exists"**
+- **Cause:** ENUM type already created
+- **Solution:** Safe to ignore. The migration checks for existing types.
+
+**Issue 3: "permission denied"**
+- **Cause:** Database user lacks privileges
+- **Solution:**
+ ```bash
+ psql -U postgres -d membership_db
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO membership_user;
+ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO membership_user;
+ ```
+
+**Issue 4: "could not connect to database"**
+- **Cause:** DATABASE_URL incorrect in .env
+- **Solution:** Verify connection string format:
+ ```
+ DATABASE_URL=postgresql://username:password@localhost:5432/database_name
+ ```
+
+---
+
+## Migration History & Rationale
+
+### Why 000_initial_schema.sql?
+
+The `000_initial_schema.sql` file was created to consolidate all incremental migrations (001-010) into a single comprehensive schema for fresh deployments. This approach:
+
+✅ **Simplifies fresh deployments** - One file instead of 10
+✅ **Reduces errors** - No risk of running migrations out of order
+✅ **Faster setup** - Single transaction vs multiple files
+✅ **Easier to maintain** - One source of truth for base schema
+✅ **Preserves history** - Old migrations kept for existing databases
+
+### Schema Evolution Timeline
+
+```
+Phase 0 (Early Development)
+├── Basic users table
+├── Events and RSVPs
+└── Email verification
+
+Phase 1 (Current - MVP)
+├── 000_initial_schema.sql (COMPREHENSIVE)
+│ ├── All ENUM types
+│ ├── 17 tables
+│ ├── 30+ indexes
+│ └── Default data
+├── seed_permissions_rbac.py (59 permissions, 5 roles)
+└── create_admin.py (Interactive superadmin creation)
+
+Phase 2 (Future - Multi-tenant SaaS)
+├── Add tenant_id to all tables
+├── Tenant isolation middleware
+├── Per-tenant customization
+└── Tenant provisioning automation
+```
+
+---
+
+## Database Backup & Restore
+
+### Backup
+
+```bash
+# Full database backup
+pg_dump -U postgres membership_db > backup_$(date +%Y%m%d).sql
+
+# Compressed backup
+pg_dump -U postgres membership_db | gzip > backup_$(date +%Y%m%d).sql.gz
+
+# Schema only (no data)
+pg_dump -U postgres --schema-only membership_db > schema_backup.sql
+
+# Data only (no schema)
+pg_dump -U postgres --data-only membership_db > data_backup.sql
+```
+
+### Restore
+
+```bash
+# From uncompressed backup
+psql -U postgres -d membership_db < backup_20250118.sql
+
+# From compressed backup
+gunzip -c backup_20250118.sql.gz | psql -U postgres -d membership_db
+```
+
+---
+
+## Production Deployment Checklist
+
+Before deploying to production:
+
+- [ ] PostgreSQL 13+ installed
+- [ ] Database created with secure credentials
+- [ ] `000_initial_schema.sql` executed successfully
+- [ ] `seed_permissions_rbac.py` completed (59 permissions created)
+- [ ] Superadmin user created via `create_admin.py`
+- [ ] DATABASE_URL configured in backend `.env`
+- [ ] Backend server connects successfully (`uvicorn server:app`)
+- [ ] Test API endpoints: GET /api/auth/me (should work after login)
+- [ ] Database backup configured (daily cron job)
+- [ ] SSL/TLS enabled for PostgreSQL connections
+- [ ] Firewall rules restrict database access
+- [ ] Connection pooling configured (if high traffic)
+
+---
+
+## Additional Resources
+
+- **Backend README:** See `README.md` for complete backend setup guide
+- **API Documentation:** http://localhost:8000/docs (Swagger UI)
+- **PostgreSQL Docs:** https://www.postgresql.org/docs/13/
+- **SQLAlchemy Docs:** https://docs.sqlalchemy.org/en/20/
+
+---
+
+**Last Updated:** December 18, 2024
+**Version:** 1.0.0
+**Maintainer:** LOAF Development Team
diff --git a/README.md b/README.md
index e69de29..e5066ea 100644
Binary files a/README.md and b/README.md differ
diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc
index 4a6965b..a4fb7d5 100644
Binary files a/__pycache__/email_service.cpython-312.pyc and b/__pycache__/email_service.cpython-312.pyc differ
diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc
index 9abfe4f..f5045d6 100644
Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ
diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc
index 095a938..72d31b1 100644
Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ
diff --git a/create_admin.py b/create_admin.py
index 76463a3..9703e74 100644
--- a/create_admin.py
+++ b/create_admin.py
@@ -1,73 +1,203 @@
"""
-Create an admin user for testing.
-Run this script to add an admin account to your database.
+Create a superadmin user interactively.
+Run this script to add a superadmin account to your database.
"""
+import getpass
+import re
from database import SessionLocal
-from models import User, UserStatus, UserRole
+from models import User, UserStatus, UserRole, Role
from auth import get_password_hash
from datetime import datetime, timezone
+import sys
-def create_admin():
- """Create an admin user"""
+def validate_email(email):
+ """Validate email format"""
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+ return re.match(pattern, email) is not None
+
+def validate_phone(phone):
+ """Validate phone format (simple check)"""
+ # Remove common separators
+ cleaned = phone.replace('-', '').replace('(', '').replace(')', '').replace(' ', '').replace('.', '')
+ return len(cleaned) >= 10 and cleaned.isdigit()
+
+def validate_zipcode(zipcode):
+ """Validate US zipcode format"""
+ return len(zipcode) == 5 and zipcode.isdigit()
+
+def get_input(prompt, validator=None, required=True, default=None):
+ """Get user input with optional validation"""
+ while True:
+ if default:
+ user_input = input(f"{prompt} [{default}]: ").strip()
+ if not user_input:
+ return default
+ else:
+ user_input = input(f"{prompt}: ").strip()
+
+ if not user_input and not required:
+ return None
+
+ if not user_input and required:
+ print("❌ This field is required. Please try again.")
+ continue
+
+ if validator and not validator(user_input):
+ print("❌ Invalid format. Please try again.")
+ continue
+
+ return user_input
+
+def get_password():
+ """Get password with confirmation and validation"""
+ while True:
+ password = getpass.getpass("Password (min 8 characters): ")
+
+ if len(password) < 8:
+ print("❌ Password must be at least 8 characters long.")
+ continue
+
+ confirm = getpass.getpass("Confirm password: ")
+
+ if password != confirm:
+ print("❌ Passwords do not match. Please try again.")
+ continue
+
+ return password
+
+def create_superadmin():
+ """Create a superadmin user interactively"""
db = SessionLocal()
try:
- # Check if admin already exists
- existing_admin = db.query(User).filter(
- User.email == "admin@loaf.org"
- ).first()
+ print("\n" + "="*60)
+ print("🔧 LOAF Membership Platform - Superadmin Creation")
+ print("="*60 + "\n")
- if existing_admin:
- print(f"⚠️ Admin user already exists: {existing_admin.email}")
- print(f" Role: {existing_admin.role.value}")
- print(f" Status: {existing_admin.status.value}")
+ # Get user information interactively
+ print("📝 Please provide the superadmin account details:\n")
+
+ email = get_input(
+ "Email address",
+ validator=validate_email,
+ required=True
+ )
+
+ # Check if user already exists
+ existing_user = db.query(User).filter(User.email == email).first()
+
+ if existing_user:
+ print(f"\n⚠️ User with email '{email}' already exists!")
+ print(f" Current Role: {existing_user.role.value}")
+ print(f" Current Status: {existing_user.status.value}")
+
+ update = input("\n❓ Would you like to update this user to superadmin? (yes/no): ").strip().lower()
+
+ if update in ['yes', 'y']:
+ existing_user.role = UserRole.superadmin
+ existing_user.status = UserStatus.active
+ existing_user.email_verified = True
+
+ # Assign superadmin role in dynamic RBAC if roles table exists
+ try:
+ superadmin_role = db.query(Role).filter(Role.code == 'superadmin').first()
+ if superadmin_role and not existing_user.role_id:
+ existing_user.role_id = superadmin_role.id
+ except Exception:
+ pass # Roles table might not exist yet
- # Update to admin role if not already
- if existing_admin.role != UserRole.admin:
- existing_admin.role = UserRole.admin
- existing_admin.status = UserStatus.active
- existing_admin.email_verified = True
db.commit()
- print("✅ Updated existing user to admin role")
+ print("✅ User updated to superadmin successfully!")
+ print(f" Email: {existing_user.email}")
+ print(f" Role: {existing_user.role.value}")
+ print(f" User ID: {existing_user.id}")
+ else:
+ print("❌ Operation cancelled.")
return
- print("Creating admin user...")
+ password = get_password()
- # Create admin user
- admin_user = User(
- email="admin@loaf.org",
- password_hash=get_password_hash("admin123"), # Change this password!
- first_name="Admin",
- last_name="User",
- phone="555-0001",
- address="123 Admin Street",
- city="Admin City",
- state="CA",
- zipcode="90001",
- date_of_birth=datetime(1990, 1, 1),
+ print("\n👤 Personal Information:\n")
+
+ first_name = get_input("First name", required=True)
+ last_name = get_input("Last name", required=True)
+ phone = get_input("Phone number", validator=validate_phone, required=True)
+
+ print("\n📍 Address Information:\n")
+
+ address = get_input("Street address", required=True)
+ city = get_input("City", required=True)
+ state = get_input("State (2-letter code)", required=True, default="CA")
+ zipcode = get_input("ZIP code", validator=validate_zipcode, required=True)
+
+ print("\n📅 Date of Birth (YYYY-MM-DD format):\n")
+
+ while True:
+ dob_str = get_input("Date of birth (e.g., 1990-01-15)", required=True)
+ try:
+ date_of_birth = datetime.strptime(dob_str, "%Y-%m-%d")
+ break
+ except ValueError:
+ print("❌ Invalid date format. Please use YYYY-MM-DD format.")
+
+ # Create superadmin user
+ print("\n⏳ Creating superadmin user...")
+
+ superadmin_user = User(
+ email=email,
+ password_hash=get_password_hash(password),
+ first_name=first_name,
+ last_name=last_name,
+ phone=phone,
+ address=address,
+ city=city,
+ state=state.upper(),
+ zipcode=zipcode,
+ date_of_birth=date_of_birth,
status=UserStatus.active,
- role=UserRole.admin,
+ role=UserRole.superadmin,
email_verified=True,
newsletter_subscribed=False
)
- db.add(admin_user)
+ db.add(superadmin_user)
+ db.flush() # Flush to get the user ID before looking up roles
+
+ # Assign superadmin role in dynamic RBAC if roles table exists
+ try:
+ superadmin_role = db.query(Role).filter(Role.code == 'superadmin').first()
+ if superadmin_role:
+ superadmin_user.role_id = superadmin_role.id
+ print(" ✓ Assigned dynamic superadmin role")
+ except Exception as e:
+ print(f" ⚠️ Dynamic roles not yet set up (this is normal for fresh installs)")
+
db.commit()
- db.refresh(admin_user)
+ db.refresh(superadmin_user)
- print("✅ Admin user created successfully!")
- print(f" Email: admin@loaf.org")
- print(f" Password: admin123")
- print(f" Role: {admin_user.role.value}")
- print(f" User ID: {admin_user.id}")
- print("\n⚠️ IMPORTANT: Change the password after first login!")
+ print("\n" + "="*60)
+ print("✅ Superadmin user created successfully!")
+ print("="*60)
+ print(f"\n📧 Email: {superadmin_user.email}")
+ print(f"👤 Name: {superadmin_user.first_name} {superadmin_user.last_name}")
+ print(f"🔑 Role: {superadmin_user.role.value}")
+ print(f"🆔 User ID: {superadmin_user.id}")
+ print(f"\n✨ You can now log in to the admin panel at /admin/login")
+ print("\n" + "="*60 + "\n")
- except Exception as e:
- print(f"❌ Error creating admin user: {e}")
+ except KeyboardInterrupt:
+ print("\n\n❌ Operation cancelled by user.")
db.rollback()
+ sys.exit(1)
+ except Exception as e:
+ print(f"\n❌ Error creating superadmin user: {e}")
+ import traceback
+ traceback.print_exc()
+ db.rollback()
+ sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
- create_admin()
+ create_superadmin()
diff --git a/email_service.py b/email_service.py
index 588086a..a645ca1 100644
--- a/email_service.py
+++ b/email_service.py
@@ -450,3 +450,117 @@ async def send_invitation_email(
"""
return await send_email(to_email, subject, html_content)
+
+
+async def send_donation_thank_you_email(email: str, first_name: str, amount_cents: int):
+ """Send donation thank you email"""
+ subject = "Thank You for Your Generous Donation!"
+ amount = f"${amount_cents / 100:.2f}"
+
+ html_content = f"""
+
+
+
+
+
+
+
+
+
+
Dear {first_name},
+
+
Thank you for your generous donation to LOAF!
+
+
+
Donation Amount
+
{amount}
+
+
+
+
+ Your support helps us continue our mission to build and strengthen the LGBTQ+ community.
+
+
+
+
Your donation is tax-deductible to the extent allowed by law. Please keep this email for your records.
+
+
We are deeply grateful for your commitment to our community and your belief in our work.
+
+
+ With gratitude,
+ The LOAF Team
+
+
+
+ Questions about your donation? Contact us at support@loaf.org
+
+
+
+
+
+ """
+
+ return await send_email(email, subject, html_content)
+
+
+async def send_rejection_email(email: str, first_name: str, reason: str):
+ """Send rejection notification email"""
+ subject = "LOAF Membership Application Update"
+
+ html_content = f"""
+
+
+
+
+
+
+
+
+
+
Dear {first_name},
+
+
Thank you for your interest in joining LOAF. After careful review, we are unable to approve your membership application at this time.
+
+
+
+
If you have questions or would like to discuss this decision, please don't hesitate to contact us at support@loaf.org.
+
+
+ Warm regards,
+ The LOAF Team
+
+
+
+ Questions? Contact us at support@loaf.org
+
+
+
+
+
+ """
+
+ return await send_email(email, subject, html_content)
diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql
new file mode 100644
index 0000000..a795b24
--- /dev/null
+++ b/migrations/000_initial_schema.sql
@@ -0,0 +1,578 @@
+-- ============================================================================
+-- Migration 000: Initial Database Schema
+-- ============================================================================
+-- Description: Creates all base tables, enums, and indexes for the LOAF
+-- membership platform. This migration should be run first on
+-- a fresh database.
+-- Date: 2024-12-18
+-- Author: LOAF Development Team
+-- ============================================================================
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 1: Create ENUM Types
+-- ============================================================================
+
+-- User status enum
+CREATE TYPE userstatus AS ENUM (
+ 'pending_email',
+ 'pending_validation',
+ 'pre_validated',
+ 'payment_pending',
+ 'active',
+ 'inactive',
+ 'canceled',
+ 'expired',
+ 'abandoned',
+ 'rejected'
+);
+
+-- User role enum
+CREATE TYPE userrole AS ENUM (
+ 'guest',
+ 'member',
+ 'admin',
+ 'finance',
+ 'superadmin'
+);
+
+-- RSVP status enum
+CREATE TYPE rsvpstatus AS ENUM (
+ 'yes',
+ 'no',
+ 'maybe'
+);
+
+-- Subscription status enum
+CREATE TYPE subscriptionstatus AS ENUM (
+ 'active',
+ 'cancelled',
+ 'expired'
+);
+
+-- Donation type enum
+CREATE TYPE donationtype AS ENUM (
+ 'member',
+ 'public'
+);
+
+-- Donation status enum
+CREATE TYPE donationstatus AS ENUM (
+ 'pending',
+ 'completed',
+ 'failed'
+);
+
+-- Invitation status enum
+CREATE TYPE invitationstatus AS ENUM (
+ 'pending',
+ 'accepted',
+ 'expired',
+ 'revoked'
+);
+
+-- Import job status enum
+CREATE TYPE importjobstatus AS ENUM (
+ 'processing',
+ 'completed',
+ 'failed',
+ 'partial'
+);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 1/8 completed: ENUM types created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 2: Create Core Tables
+-- ============================================================================
+
+-- Users table
+CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Authentication
+ email VARCHAR NOT NULL UNIQUE,
+ password_hash VARCHAR NOT NULL,
+ email_verified BOOLEAN NOT NULL DEFAULT FALSE,
+ email_verification_token VARCHAR UNIQUE,
+
+ -- Personal Information
+ first_name VARCHAR NOT NULL,
+ last_name VARCHAR NOT NULL,
+ phone VARCHAR,
+ address VARCHAR,
+ city VARCHAR,
+ state VARCHAR(2),
+ zipcode VARCHAR(10),
+ date_of_birth DATE,
+ bio TEXT,
+
+ -- Profile
+ profile_photo_url VARCHAR,
+
+ -- Social Media
+ social_media_facebook VARCHAR,
+ social_media_instagram VARCHAR,
+ social_media_twitter VARCHAR,
+ social_media_linkedin VARCHAR,
+
+ -- Partner Information
+ partner_first_name VARCHAR,
+ partner_last_name VARCHAR,
+ partner_is_member BOOLEAN DEFAULT FALSE,
+ partner_plan_to_become_member BOOLEAN DEFAULT FALSE,
+
+ -- Referral
+ referred_by_member_name VARCHAR,
+ lead_sources JSONB DEFAULT '[]'::jsonb,
+
+ -- Status & Role
+ status userstatus NOT NULL DEFAULT 'pending_email',
+ role userrole NOT NULL DEFAULT 'guest',
+ role_id UUID, -- For dynamic RBAC (added in later migration)
+
+ -- Rejection Tracking
+ rejection_reason TEXT,
+ rejected_at TIMESTAMP WITH TIME ZONE,
+ rejected_by UUID REFERENCES users(id),
+
+ -- Membership
+ member_since DATE,
+ tos_accepted BOOLEAN DEFAULT FALSE,
+ tos_accepted_at TIMESTAMP WITH TIME ZONE,
+ newsletter_subscribed BOOLEAN DEFAULT TRUE,
+
+ -- Reminder Tracking
+ reminder_30_days_sent BOOLEAN DEFAULT FALSE,
+ reminder_60_days_sent BOOLEAN DEFAULT FALSE,
+ reminder_85_days_sent BOOLEAN DEFAULT FALSE,
+
+ -- Timestamps
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Events table
+CREATE TABLE IF NOT EXISTS events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Event Details
+ title VARCHAR NOT NULL,
+ description TEXT,
+ location VARCHAR,
+ cover_image_url VARCHAR,
+
+ -- Schedule
+ start_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_at TIMESTAMP WITH TIME ZONE,
+
+ -- Capacity
+ capacity INTEGER,
+ published BOOLEAN NOT NULL DEFAULT FALSE,
+
+ -- Calendar Integration
+ calendar_uid VARCHAR UNIQUE,
+ microsoft_calendar_id VARCHAR,
+ microsoft_calendar_sync_enabled BOOLEAN DEFAULT FALSE,
+
+ -- Metadata
+ created_by UUID REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Event RSVPs table
+CREATE TABLE IF NOT EXISTS event_rsvps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+ -- RSVP Details
+ rsvp_status rsvpstatus NOT NULL DEFAULT 'maybe',
+ attended BOOLEAN DEFAULT FALSE,
+ attended_at TIMESTAMP WITH TIME ZONE,
+
+ -- Timestamps
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
+ -- Unique constraint: one RSVP per user per event
+ UNIQUE(event_id, user_id)
+);
+
+-- Event Gallery table
+CREATE TABLE IF NOT EXISTS event_galleries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+
+ -- Image Details
+ image_url VARCHAR NOT NULL,
+ caption TEXT,
+ order_index INTEGER DEFAULT 0,
+
+ -- Metadata
+ uploaded_by UUID REFERENCES users(id),
+ uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 2/8 completed: Core tables (users, events, rsvps, gallery) created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 3: Create Subscription & Payment Tables
+-- ============================================================================
+
+-- Subscription Plans table
+CREATE TABLE IF NOT EXISTS subscription_plans (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Plan Details
+ name VARCHAR NOT NULL,
+ description TEXT,
+ price_cents INTEGER NOT NULL,
+ billing_cycle VARCHAR NOT NULL DEFAULT 'annual',
+
+ -- Configuration
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ features JSONB DEFAULT '[]'::jsonb,
+
+ -- Timestamps
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Subscriptions table
+CREATE TABLE IF NOT EXISTS subscriptions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ plan_id UUID NOT NULL REFERENCES subscription_plans(id),
+
+ -- Stripe Integration
+ stripe_subscription_id VARCHAR,
+ stripe_customer_id VARCHAR,
+
+ -- Status & Dates
+ status subscriptionstatus DEFAULT 'active',
+ start_date TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_date TIMESTAMP WITH TIME ZONE,
+ next_billing_date TIMESTAMP WITH TIME ZONE,
+
+ -- Payment Details
+ amount_paid_cents INTEGER,
+ base_subscription_cents INTEGER NOT NULL,
+ donation_cents INTEGER DEFAULT 0 NOT NULL,
+
+ -- Manual Payment Support
+ manual_payment BOOLEAN DEFAULT FALSE NOT NULL,
+ manual_payment_notes TEXT,
+ manual_payment_admin_id UUID REFERENCES users(id),
+ manual_payment_date TIMESTAMP WITH TIME ZONE,
+ payment_method VARCHAR,
+
+ -- Timestamps
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Donations table
+CREATE TABLE IF NOT EXISTS donations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Donation Details
+ amount_cents INTEGER NOT NULL,
+ donation_type donationtype NOT NULL DEFAULT 'public',
+ status donationstatus NOT NULL DEFAULT 'pending',
+
+ -- Donor Information
+ user_id UUID REFERENCES users(id), -- NULL for public donations
+ donor_email VARCHAR,
+ donor_name VARCHAR,
+
+ -- Payment Details
+ stripe_checkout_session_id VARCHAR,
+ stripe_payment_intent_id VARCHAR,
+ payment_method VARCHAR,
+
+ -- Metadata
+ notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE
+);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 3/8 completed: Subscription and donation tables created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 4: Create RBAC Tables
+-- ============================================================================
+
+-- Permissions table
+CREATE TABLE IF NOT EXISTS permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ code VARCHAR NOT NULL UNIQUE,
+ name VARCHAR NOT NULL,
+ description TEXT,
+ module VARCHAR NOT NULL,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Roles table (for dynamic RBAC)
+CREATE TABLE IF NOT EXISTS roles (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ code VARCHAR NOT NULL UNIQUE,
+ name VARCHAR NOT NULL,
+ description TEXT,
+ is_system_role BOOLEAN NOT NULL DEFAULT FALSE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ created_by UUID REFERENCES users(id) ON DELETE SET NULL
+);
+
+-- Role Permissions junction table
+CREATE TABLE IF NOT EXISTS role_permissions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ role userrole, -- Legacy enum-based role (for backward compatibility)
+ role_id UUID REFERENCES roles(id) ON DELETE CASCADE, -- Dynamic role
+ permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ created_by UUID REFERENCES users(id) ON DELETE SET NULL
+);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 4/8 completed: RBAC tables created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 5: Create Document Management Tables
+-- ============================================================================
+
+-- Newsletter Archive table
+CREATE TABLE IF NOT EXISTS newsletter_archives (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title VARCHAR NOT NULL,
+ file_url VARCHAR NOT NULL,
+ file_size_bytes INTEGER,
+ issue_date DATE NOT NULL,
+ description TEXT,
+
+ uploaded_by UUID REFERENCES users(id),
+ uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Financial Reports table
+CREATE TABLE IF NOT EXISTS financial_reports (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title VARCHAR NOT NULL,
+ file_url VARCHAR NOT NULL,
+ file_size_bytes INTEGER,
+ fiscal_period VARCHAR NOT NULL,
+ report_type VARCHAR,
+
+ uploaded_by UUID REFERENCES users(id),
+ uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Bylaws Documents table
+CREATE TABLE IF NOT EXISTS bylaws_documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title VARCHAR NOT NULL,
+ file_url VARCHAR NOT NULL,
+ file_size_bytes INTEGER,
+ version VARCHAR NOT NULL,
+ effective_date DATE NOT NULL,
+ description TEXT,
+ is_current BOOLEAN DEFAULT TRUE,
+
+ uploaded_by UUID REFERENCES users(id),
+ uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 5/8 completed: Document management tables created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 6: Create System Tables
+-- ============================================================================
+
+-- Storage Usage table
+CREATE TABLE IF NOT EXISTS storage_usage (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ total_bytes_used BIGINT NOT NULL DEFAULT 0,
+ max_bytes_allowed BIGINT NOT NULL DEFAULT 10737418240, -- 10GB
+ last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- User Invitations table
+CREATE TABLE IF NOT EXISTS user_invitations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ email VARCHAR NOT NULL,
+ token VARCHAR NOT NULL UNIQUE,
+ role userrole NOT NULL,
+ status invitationstatus NOT NULL DEFAULT 'pending',
+
+ invited_by UUID REFERENCES users(id) ON DELETE SET NULL,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ accepted_at TIMESTAMP WITH TIME ZONE,
+ revoked_at TIMESTAMP WITH TIME ZONE,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Import Jobs table
+CREATE TABLE IF NOT EXISTS import_jobs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ filename VARCHAR NOT NULL,
+ status importjobstatus NOT NULL DEFAULT 'processing',
+ total_rows INTEGER DEFAULT 0,
+ processed_rows INTEGER DEFAULT 0,
+ success_count INTEGER DEFAULT 0,
+ error_count INTEGER DEFAULT 0,
+ error_log JSONB DEFAULT '[]'::jsonb,
+
+ started_by UUID REFERENCES users(id),
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ completed_at TIMESTAMP WITH TIME ZONE
+);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 6/8 completed: System tables created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 7: Create Indexes
+-- ============================================================================
+
+-- Users table indexes
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
+CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id);
+CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);
+CREATE INDEX IF NOT EXISTS idx_users_rejected_at ON users(rejected_at) WHERE rejected_at IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
+
+-- Events table indexes
+CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);
+CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at);
+CREATE INDEX IF NOT EXISTS idx_events_published ON events(published);
+
+-- Event RSVPs indexes
+CREATE INDEX IF NOT EXISTS idx_event_rsvps_event_id ON event_rsvps(event_id);
+CREATE INDEX IF NOT EXISTS idx_event_rsvps_user_id ON event_rsvps(user_id);
+CREATE INDEX IF NOT EXISTS idx_event_rsvps_rsvp_status ON event_rsvps(rsvp_status);
+
+-- Event Gallery indexes
+CREATE INDEX IF NOT EXISTS idx_event_galleries_event_id ON event_galleries(event_id);
+
+-- Subscriptions indexes
+CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
+CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id);
+CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
+CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);
+
+-- Donations indexes
+CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id);
+CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type);
+CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status);
+CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at);
+
+-- Permissions indexes
+CREATE INDEX IF NOT EXISTS idx_permissions_code ON permissions(code);
+CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module);
+
+-- Roles indexes
+CREATE INDEX IF NOT EXISTS idx_roles_code ON roles(code);
+CREATE INDEX IF NOT EXISTS idx_roles_is_system_role ON roles(is_system_role);
+
+-- Role Permissions indexes
+CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role);
+CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_role_permission ON role_permissions(role, permission_id) WHERE role IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_dynamic_role_permission ON role_permissions(role_id, permission_id) WHERE role_id IS NOT NULL;
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 7/8 completed: Indexes created' AS progress;
+
+BEGIN;
+
+-- ============================================================================
+-- SECTION 8: Initialize Default Data
+-- ============================================================================
+
+-- Insert initial storage usage record
+INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated)
+SELECT
+ gen_random_uuid(),
+ 0,
+ 10737418240, -- 10GB
+ CURRENT_TIMESTAMP
+WHERE NOT EXISTS (SELECT 1 FROM storage_usage);
+
+COMMIT;
+
+-- Display progress
+SELECT 'Step 8/8 completed: Default data initialized' AS progress;
+
+-- ============================================================================
+-- Migration Complete
+-- ============================================================================
+
+SELECT '
+================================================================================
+✅ Migration 000 completed successfully!
+================================================================================
+
+Database schema initialized with:
+- 10 ENUM types
+- 17 tables (users, events, subscriptions, donations, RBAC, documents, system)
+- 30+ indexes for performance
+- 1 storage usage record
+
+Next steps:
+1. Run: python seed_permissions_rbac.py (to populate permissions and roles)
+2. Run: python create_admin.py (to create superadmin user)
+3. Run remaining migrations in sequence (001-010)
+
+Database is ready for LOAF membership platform! 🎉
+================================================================================
+' AS migration_complete;
diff --git a/migrations/009_create_donations.sql b/migrations/009_create_donations.sql
new file mode 100644
index 0000000..7003414
--- /dev/null
+++ b/migrations/009_create_donations.sql
@@ -0,0 +1,44 @@
+-- Migration: Create Donations Table
+-- Description: Adds donations table to track both member and public donations
+-- Date: 2025-12-17
+-- CRITICAL: Fixes data loss issue where standalone donations weren't being saved
+
+BEGIN;
+
+-- Create donation type enum
+CREATE TYPE donationtype AS ENUM ('member', 'public');
+
+-- Create donation status enum
+CREATE TYPE donationstatus AS ENUM ('pending', 'completed', 'failed');
+
+-- Create donations table
+CREATE TABLE donations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ amount_cents INTEGER NOT NULL,
+ donation_type donationtype NOT NULL DEFAULT 'public',
+ status donationstatus NOT NULL DEFAULT 'pending',
+ user_id UUID REFERENCES users(id),
+ donor_email VARCHAR,
+ donor_name VARCHAR,
+ stripe_checkout_session_id VARCHAR,
+ stripe_payment_intent_id VARCHAR,
+ payment_method VARCHAR,
+ notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE
+);
+
+-- Create indexes for performance
+CREATE INDEX idx_donation_user ON donations(user_id);
+CREATE INDEX idx_donation_type ON donations(donation_type);
+CREATE INDEX idx_donation_status ON donations(status);
+CREATE INDEX idx_donation_created ON donations(created_at);
+
+-- Add comment
+COMMENT ON TABLE donations IS 'Tracks both member and public one-time donations';
+
+COMMIT;
+
+-- Verify migration
+SELECT 'Donations table created successfully' as status;
+SELECT COUNT(*) as initial_count FROM donations;
diff --git a/migrations/010_add_rejection_fields.sql b/migrations/010_add_rejection_fields.sql
new file mode 100644
index 0000000..84816c9
--- /dev/null
+++ b/migrations/010_add_rejection_fields.sql
@@ -0,0 +1,40 @@
+-- Migration: Add Rejection Fields to Users Table
+-- Description: Adds rejection tracking fields and rejected status to UserStatus enum
+-- Date: 2025-12-18
+
+BEGIN;
+
+-- Add 'rejected' value to UserStatus enum if it doesn't exist
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_enum
+ WHERE enumlabel = 'rejected'
+ AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'userstatus')
+ ) THEN
+ ALTER TYPE userstatus ADD VALUE 'rejected';
+ RAISE NOTICE 'Added rejected to userstatus enum';
+ ELSE
+ RAISE NOTICE 'rejected already exists in userstatus enum';
+ END IF;
+END$$;
+
+-- Add rejection tracking fields to users table
+ALTER TABLE users
+ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
+ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP WITH TIME ZONE,
+ADD COLUMN IF NOT EXISTS rejected_by UUID REFERENCES users(id);
+
+-- Add comments for documentation
+COMMENT ON COLUMN users.rejection_reason IS 'Reason provided when application was rejected';
+COMMENT ON COLUMN users.rejected_at IS 'Timestamp when application was rejected';
+COMMENT ON COLUMN users.rejected_by IS 'Admin who rejected the application';
+
+-- Create index on rejected_at for filtering rejected users
+CREATE INDEX IF NOT EXISTS idx_users_rejected_at ON users(rejected_at) WHERE rejected_at IS NOT NULL;
+
+COMMIT;
+
+-- Verify migration
+SELECT 'Rejection fields added successfully' AS status;
+SELECT COUNT(*) AS rejected_users_count FROM users WHERE status = 'rejected';
diff --git a/models.py b/models.py
index ca4e842..ad4f06e 100644
--- a/models.py
+++ b/models.py
@@ -16,6 +16,7 @@ class UserStatus(enum.Enum):
canceled = "canceled" # User or admin canceled membership
expired = "expired" # Subscription ended without renewal
abandoned = "abandoned" # Incomplete registration (no verification/event/payment)
+ rejected = "rejected" # Application rejected by admin
class UserRole(enum.Enum):
guest = "guest"
@@ -34,6 +35,15 @@ class SubscriptionStatus(enum.Enum):
expired = "expired"
cancelled = "cancelled"
+class DonationType(enum.Enum):
+ member = "member"
+ public = "public"
+
+class DonationStatus(enum.Enum):
+ pending = "pending"
+ completed = "completed"
+ failed = "failed"
+
class User(Base):
__tablename__ = "users"
@@ -115,6 +125,11 @@ class User(Base):
renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent")
last_renewal_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last renewal reminder")
+ # Rejection Tracking
+ rejection_reason = Column(Text, nullable=True, comment="Reason provided when application was rejected")
+ rejected_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when application was rejected")
+ rejected_by = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=True, comment="Admin who rejected the application")
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -225,6 +240,41 @@ class Subscription(Base):
user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id])
plan = relationship("SubscriptionPlan", back_populates="subscriptions")
+class Donation(Base):
+ __tablename__ = "donations"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+
+ # Donation details
+ amount_cents = Column(Integer, nullable=False)
+ donation_type = Column(SQLEnum(DonationType), nullable=False, default=DonationType.public)
+ status = Column(SQLEnum(DonationStatus), nullable=False, default=DonationStatus.pending)
+
+ # Donor information
+ user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=True) # NULL for public donations
+ donor_email = Column(String, nullable=True) # For non-members
+ donor_name = Column(String, nullable=True) # For non-members
+
+ # Payment details
+ stripe_checkout_session_id = Column(String, nullable=True)
+ stripe_payment_intent_id = Column(String, nullable=True)
+ payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
+
+ # Metadata
+ notes = Column(Text, nullable=True)
+ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
+ updated_at = Column(DateTime(timezone=True), onupdate=lambda: datetime.now(timezone.utc))
+
+ # Relationship
+ user = relationship("User", backref="donations", foreign_keys=[user_id])
+
+ __table_args__ = (
+ Index('idx_donation_user', 'user_id'),
+ Index('idx_donation_type', 'donation_type'),
+ Index('idx_donation_status', 'status'),
+ Index('idx_donation_created', 'created_at'),
+ )
+
class EventGallery(Base):
__tablename__ = "event_galleries"
diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py
index 1ab34ff..6e0529b 100755
--- a/seed_permissions_rbac.py
+++ b/seed_permissions_rbac.py
@@ -2,7 +2,7 @@
"""
Permission Seeding Script for Dynamic RBAC System
-This script populates the database with 56 granular permissions and assigns them
+This script populates the database with 59 granular permissions and assigns them
to the appropriate dynamic roles (not the old enum roles).
Usage:
@@ -33,7 +33,7 @@ engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# ============================================================
-# Permission Definitions (56 permissions across 9 modules)
+# Permission Definitions (59 permissions across 10 modules)
# ============================================================
PERMISSIONS = [
@@ -60,13 +60,18 @@ PERMISSIONS = [
{"code": "events.rsvps", "name": "View Event RSVPs", "description": "View and manage event RSVPs", "module": "events"},
{"code": "events.calendar_export", "name": "Export Event Calendar", "description": "Export events to iCal format", "module": "events"},
- # ========== SUBSCRIPTIONS MODULE (6) ==========
+ # ========== SUBSCRIPTIONS MODULE (7) ==========
{"code": "subscriptions.view", "name": "View Subscriptions", "description": "View subscription list and details", "module": "subscriptions"},
{"code": "subscriptions.create", "name": "Create Subscriptions", "description": "Create manual subscriptions for users", "module": "subscriptions"},
{"code": "subscriptions.edit", "name": "Edit Subscriptions", "description": "Edit subscription details", "module": "subscriptions"},
{"code": "subscriptions.cancel", "name": "Cancel Subscriptions", "description": "Cancel user subscriptions", "module": "subscriptions"},
{"code": "subscriptions.activate", "name": "Activate Subscriptions", "description": "Manually activate subscriptions", "module": "subscriptions"},
{"code": "subscriptions.plans", "name": "Manage Subscription Plans", "description": "Create and edit subscription plans", "module": "subscriptions"},
+ {"code": "subscriptions.export", "name": "Export Subscriptions", "description": "Export subscription data to CSV", "module": "subscriptions"},
+
+ # ========== DONATIONS MODULE (2) ==========
+ {"code": "donations.view", "name": "View Donations", "description": "View donation list and details", "module": "donations"},
+ {"code": "donations.export", "name": "Export Donations", "description": "Export donation data to CSV", "module": "donations"},
# ========== FINANCIALS MODULE (6) ==========
{"code": "financials.view", "name": "View Financial Reports", "description": "View financial reports and dashboards", "module": "financials"},
@@ -129,6 +134,8 @@ DEFAULT_ROLE_PERMISSIONS = {
"financials.delete", "financials.export", "financials.payments",
"subscriptions.view", "subscriptions.create", "subscriptions.edit",
"subscriptions.cancel", "subscriptions.activate", "subscriptions.plans",
+ "subscriptions.export",
+ "donations.view", "donations.export",
],
"admin": [
@@ -140,6 +147,8 @@ DEFAULT_ROLE_PERMISSIONS = {
"events.attendance", "events.rsvps", "events.calendar_export",
"subscriptions.view", "subscriptions.create", "subscriptions.edit",
"subscriptions.cancel", "subscriptions.activate", "subscriptions.plans",
+ "subscriptions.export",
+ "donations.view", "donations.export",
"financials.view", "financials.create", "financials.edit", "financials.delete",
"financials.export", "financials.payments",
"newsletters.view", "newsletters.create", "newsletters.edit", "newsletters.delete",
diff --git a/server.py b/server.py
index 75d92c6..0c2978f 100644
--- a/server.py
+++ b/server.py
@@ -17,7 +17,7 @@ import csv
import io
from database import engine, get_db, Base
-from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus
+from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, Donation, DonationType, DonationStatus
from auth import (
get_password_hash,
verify_password,
@@ -208,6 +208,8 @@ class UserResponse(BaseModel):
role: str
email_verified: bool
created_at: datetime
+ # Profile
+ profile_photo_url: Optional[str] = None
# Subscription info (optional)
subscription_start_date: Optional[datetime] = None
subscription_end_date: Optional[datetime] = None
@@ -764,6 +766,20 @@ async def get_my_permissions(
"role": get_user_role_code(current_user)
}
+@api_router.get("/config")
+async def get_config():
+ """
+ Get public configuration values
+ Returns: max_file_size_bytes, max_file_size_mb
+ """
+ max_file_size_bytes = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) # Default 50MB
+ max_file_size_mb = max_file_size_bytes / (1024 * 1024)
+
+ return {
+ "max_file_size_bytes": max_file_size_bytes,
+ "max_file_size_mb": int(max_file_size_mb)
+ }
+
# User Profile Routes
@api_router.get("/users/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)):
@@ -2104,6 +2120,7 @@ async def get_user_by_id(
"state": user.state,
"zipcode": user.zipcode,
"date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None,
+ "profile_photo_url": user.profile_photo_url,
"partner_first_name": user.partner_first_name,
"partner_last_name": user.partner_last_name,
"partner_is_member": user.partner_is_member,
@@ -2194,6 +2211,52 @@ async def update_user_status(
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status")
+@api_router.post("/admin/users/{user_id}/reject")
+async def reject_user(
+ user_id: str,
+ rejection_data: dict,
+ current_user: User = Depends(require_permission("users.approve")),
+ db: Session = Depends(get_db)
+):
+ """Reject a user's membership application with mandatory reason"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ reason = rejection_data.get("reason", "").strip()
+ if not reason:
+ raise HTTPException(status_code=400, detail="Rejection reason is required")
+
+ # Update user status to rejected
+ user.status = UserStatus.rejected
+ user.rejection_reason = reason
+ user.rejected_at = datetime.now(timezone.utc)
+ user.rejected_by = current_user.id
+ user.updated_at = datetime.now(timezone.utc)
+
+ db.commit()
+
+ # Send rejection email
+ try:
+ from email_service import send_rejection_email
+ await send_rejection_email(
+ user.email,
+ user.first_name,
+ reason
+ )
+ logger.info(f"Rejection email sent to {user.email}")
+ except Exception as e:
+ logger.error(f"Failed to send rejection email to {user.email}: {str(e)}")
+ # Don't fail the request if email fails
+
+ logger.info(f"Admin {current_user.email} rejected user {user.email}")
+
+ return {
+ "message": "User rejected successfully",
+ "user_id": str(user.id),
+ "status": user.status.value
+ }
+
@api_router.post("/admin/users/{user_id}/activate-payment")
async def activate_payment_manually(
user_id: str,
@@ -2356,6 +2419,114 @@ async def admin_resend_verification(
return {"message": f"Verification email resent to {user.email}"}
+@api_router.post("/admin/users/{user_id}/upload-photo")
+async def admin_upload_user_profile_photo(
+ user_id: str,
+ file: UploadFile = File(...),
+ current_user: User = Depends(require_permission("users.edit")),
+ db: Session = Depends(get_db)
+):
+ """Admin uploads profile photo for a specific user"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User 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))
+
+ # Delete old profile photo if exists
+ if user.profile_photo_url:
+ old_key = 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)
+ storage.total_bytes_used -= old_size
+ except:
+ pass
+
+ # Upload new photo
+ try:
+ public_url, object_key, file_size = await r2.upload_file(
+ file=file,
+ folder="profiles",
+ max_size_bytes=max_file_size
+ )
+
+ user.profile_photo_url = public_url
+ storage.total_bytes_used += file_size
+ storage.last_updated = datetime.now(timezone.utc)
+
+ db.commit()
+
+ logger.info(f"Admin {current_user.email} uploaded profile photo for user {user.email}: {file_size} bytes")
+
+ return {
+ "message": "Profile photo uploaded successfully",
+ "profile_photo_url": public_url
+ }
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
+
+@api_router.delete("/admin/users/{user_id}/delete-photo")
+async def admin_delete_user_profile_photo(
+ user_id: str,
+ current_user: User = Depends(require_permission("users.edit")),
+ db: Session = Depends(get_db)
+):
+ """Admin deletes profile photo for a specific user"""
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ if not user.profile_photo_url:
+ raise HTTPException(status_code=404, detail="User has no profile photo")
+
+ r2 = get_r2_storage()
+
+ # Extract object key from URL
+ object_key = user.profile_photo_url.split('/')[-1]
+ object_key = f"profiles/{object_key}"
+
+ try:
+ # Get file size before deletion for storage tracking
+ storage = db.query(StorageUsage).first()
+ if storage:
+ try:
+ file_size = await r2.get_file_size(object_key)
+ storage.total_bytes_used -= file_size
+ storage.last_updated = datetime.now(timezone.utc)
+ except:
+ pass
+
+ # Delete from R2
+ await r2.delete_file(object_key)
+
+ # Remove URL from user record
+ user.profile_photo_url = None
+ db.commit()
+
+ logger.info(f"Admin {current_user.email} deleted profile photo for user {user.email}")
+
+ return {"message": "Profile photo deleted successfully"}
+ except Exception as e:
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
+
# ============================================================
# User Creation & Invitation Endpoints
# ============================================================
@@ -3648,6 +3819,287 @@ async def cancel_subscription(
return {"message": "Subscription cancelled successfully"}
+@api_router.get("/admin/subscriptions/export")
+async def export_subscriptions(
+ status: Optional[str] = None,
+ plan_id: Optional[str] = None,
+ search: Optional[str] = None,
+ current_user: User = Depends(require_permission("subscriptions.export")),
+ db: Session = Depends(get_db)
+):
+ """Export subscriptions to CSV for financial records"""
+
+ # Build query with same logic as get_all_subscriptions
+ query = db.query(Subscription).join(Subscription.user).join(Subscription.plan)
+
+ # Apply filters
+ if status:
+ query = query.filter(Subscription.status == status)
+ if plan_id:
+ query = query.filter(Subscription.plan_id == plan_id)
+ if search:
+ search_term = f"%{search}%"
+ query = query.filter(
+ (User.first_name.ilike(search_term)) |
+ (User.last_name.ilike(search_term)) |
+ (User.email.ilike(search_term))
+ )
+
+ subscriptions = query.order_by(Subscription.created_at.desc()).all()
+
+ # Create CSV
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ # Header row
+ writer.writerow([
+ 'Subscription ID', 'Member Name', 'Email', 'Plan Name', 'Billing Cycle',
+ 'Status', 'Base Amount', 'Donation Amount', 'Total Amount', 'Payment Method',
+ 'Start Date', 'End Date', 'Stripe Subscription ID', 'Created At', 'Updated At'
+ ])
+
+ # Data rows
+ for sub in subscriptions:
+ user = sub.user
+ plan = sub.plan
+ writer.writerow([
+ str(sub.id),
+ f"{user.first_name} {user.last_name}",
+ user.email,
+ plan.name,
+ plan.billing_cycle,
+ sub.status.value,
+ f"${sub.base_subscription_cents / 100:.2f}",
+ f"${sub.donation_cents / 100:.2f}" if sub.donation_cents else "$0.00",
+ f"${sub.amount_paid_cents / 100:.2f}" if sub.amount_paid_cents else "$0.00",
+ sub.payment_method or 'Stripe',
+ sub.start_date.isoformat() if sub.start_date else '',
+ sub.end_date.isoformat() if sub.end_date else '',
+ sub.stripe_subscription_id or '',
+ sub.created_at.isoformat() if sub.created_at else '',
+ sub.updated_at.isoformat() if sub.updated_at else ''
+ ])
+
+ # Return CSV
+ filename = f"subscriptions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
+ return StreamingResponse(
+ iter([output.getvalue()]),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+# ============================================================================
+# Admin Donation Management Routes
+# ============================================================================
+
+@api_router.get("/admin/donations")
+async def get_donations(
+ donation_type: Optional[str] = None,
+ status: Optional[str] = None,
+ start_date: Optional[str] = None,
+ end_date: Optional[str] = None,
+ search: Optional[str] = None,
+ current_user: User = Depends(require_permission("donations.view")),
+ db: Session = Depends(get_db)
+):
+ """Get all donations with optional filters."""
+
+ query = db.query(Donation).outerjoin(User, Donation.user_id == User.id)
+
+ # Apply filters
+ if donation_type:
+ try:
+ query = query.filter(Donation.donation_type == DonationType[donation_type])
+ except KeyError:
+ raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}")
+
+ if status:
+ try:
+ query = query.filter(Donation.status == DonationStatus[status])
+ except KeyError:
+ raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
+
+ if start_date:
+ try:
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
+ query = query.filter(Donation.created_at >= start_dt)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid start_date format")
+
+ if end_date:
+ try:
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
+ query = query.filter(Donation.created_at <= end_dt)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid end_date format")
+
+ if search:
+ search_term = f"%{search}%"
+ query = query.filter(
+ (Donation.donor_email.ilike(search_term)) |
+ (Donation.donor_name.ilike(search_term)) |
+ (User.first_name.ilike(search_term)) |
+ (User.last_name.ilike(search_term))
+ )
+
+ donations = query.order_by(Donation.created_at.desc()).all()
+
+ return [{
+ "id": str(d.id),
+ "amount_cents": d.amount_cents,
+ "amount": f"${d.amount_cents / 100:.2f}",
+ "donation_type": d.donation_type.value,
+ "status": d.status.value,
+ "donor_name": d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name),
+ "donor_email": d.donor_email or (d.user.email if d.user else None),
+ "payment_method": d.payment_method,
+ "notes": d.notes,
+ "created_at": d.created_at.isoformat()
+ } for d in donations]
+
+@api_router.get("/admin/donations/stats")
+async def get_donation_stats(
+ current_user: User = Depends(require_permission("donations.view")),
+ db: Session = Depends(get_db)
+):
+ """Get donation statistics."""
+ from sqlalchemy import func
+
+ # Total donations
+ total_donations = db.query(Donation).filter(
+ Donation.status == DonationStatus.completed
+ ).count()
+
+ # Member donations
+ member_donations = db.query(Donation).filter(
+ Donation.status == DonationStatus.completed,
+ Donation.donation_type == DonationType.member
+ ).count()
+
+ # Public donations
+ public_donations = db.query(Donation).filter(
+ Donation.status == DonationStatus.completed,
+ Donation.donation_type == DonationType.public
+ ).count()
+
+ # Total amount
+ total_amount = db.query(func.sum(Donation.amount_cents)).filter(
+ Donation.status == DonationStatus.completed
+ ).scalar() or 0
+
+ # This month
+ now = datetime.now(timezone.utc)
+ this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ this_month_amount = db.query(func.sum(Donation.amount_cents)).filter(
+ Donation.status == DonationStatus.completed,
+ Donation.created_at >= this_month_start
+ ).scalar() or 0
+
+ this_month_count = db.query(Donation).filter(
+ Donation.status == DonationStatus.completed,
+ Donation.created_at >= this_month_start
+ ).count()
+
+ return {
+ "total_donations": total_donations,
+ "member_donations": member_donations,
+ "public_donations": public_donations,
+ "total_amount_cents": total_amount,
+ "total_amount": f"${total_amount / 100:.2f}",
+ "this_month_amount_cents": this_month_amount,
+ "this_month_amount": f"${this_month_amount / 100:.2f}",
+ "this_month_count": this_month_count
+ }
+
+@api_router.get("/admin/donations/export")
+async def export_donations(
+ donation_type: Optional[str] = None,
+ status: Optional[str] = None,
+ start_date: Optional[str] = None,
+ end_date: Optional[str] = None,
+ search: Optional[str] = None,
+ current_user: User = Depends(require_permission("donations.export")),
+ db: Session = Depends(get_db)
+):
+ """Export donations to CSV."""
+ import io
+ import csv
+ from fastapi.responses import StreamingResponse
+
+ # Build query (same as get_donations)
+ query = db.query(Donation).outerjoin(User, Donation.user_id == User.id)
+
+ if donation_type:
+ try:
+ query = query.filter(Donation.donation_type == DonationType[donation_type])
+ except KeyError:
+ raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}")
+
+ if status:
+ try:
+ query = query.filter(Donation.status == DonationStatus[status])
+ except KeyError:
+ raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
+
+ if start_date:
+ try:
+ start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
+ query = query.filter(Donation.created_at >= start_dt)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid start_date format")
+
+ if end_date:
+ try:
+ end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
+ query = query.filter(Donation.created_at <= end_dt)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid end_date format")
+
+ if search:
+ search_term = f"%{search}%"
+ query = query.filter(
+ (Donation.donor_email.ilike(search_term)) |
+ (Donation.donor_name.ilike(search_term)) |
+ (User.first_name.ilike(search_term)) |
+ (User.last_name.ilike(search_term))
+ )
+
+ donations = query.order_by(Donation.created_at.desc()).all()
+
+ # Create CSV
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ writer.writerow([
+ 'Donation ID', 'Date', 'Donor Name', 'Donor Email', 'Type',
+ 'Amount', 'Status', 'Payment Method', 'Stripe Payment Intent',
+ 'Notes'
+ ])
+
+ for d in donations:
+ donor_name = d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name)
+ donor_email = d.donor_email or (d.user.email if d.user else '')
+
+ writer.writerow([
+ str(d.id),
+ d.created_at.strftime('%Y-%m-%d %H:%M:%S'),
+ donor_name or '',
+ donor_email,
+ d.donation_type.value,
+ f"${d.amount_cents / 100:.2f}",
+ d.status.value,
+ d.payment_method or '',
+ d.stripe_payment_intent_id or '',
+ d.notes or ''
+ ])
+
+ filename = f"donations_export_{datetime.now().strftime('%Y%m%d')}.csv"
+ return StreamingResponse(
+ iter([output.getvalue()]),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
# ============================================================================
# Admin Document Management Routes
# ============================================================================
@@ -4761,14 +5213,39 @@ async def create_checkout(
@api_router.post("/donations/checkout")
async def create_donation_checkout(
request: DonationCheckoutRequest,
- db: Session = Depends(get_db)
+ db: Session = Depends(get_db),
+ current_user: Optional[User] = Depends(lambda: None) # Optional authentication
):
"""Create Stripe Checkout session for one-time donation."""
# Get frontend URL from env
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
+ # Check if user is authenticated (from header if present)
try:
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+ from jose import jwt, JWTError
+
+ # Try to get token from request if available
+ # For now, we'll make this work for both authenticated and anonymous
+ pass
+ except:
+ pass
+
+ try:
+ # Create donation record first
+ donation = Donation(
+ amount_cents=request.amount_cents,
+ donation_type=DonationType.member if current_user else DonationType.public,
+ user_id=current_user.id if current_user else None,
+ donor_email=current_user.email if current_user else None,
+ donor_name=f"{current_user.first_name} {current_user.last_name}" if current_user else None,
+ status=DonationStatus.pending
+ )
+ db.add(donation)
+ db.commit()
+ db.refresh(donation)
+
# Create Stripe Checkout Session for one-time payment
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
@@ -4788,17 +5265,28 @@ async def create_donation_checkout(
}],
mode='payment', # One-time payment (not subscription)
success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}",
- cancel_url=f"{frontend_url}/membership/donate"
+ cancel_url=f"{frontend_url}/membership/donate",
+ metadata={
+ 'donation_id': str(donation.id),
+ 'donation_type': donation.donation_type.value,
+ 'user_id': str(current_user.id) if current_user else None
+ }
)
- logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}")
+ # Update donation with session ID
+ donation.stripe_checkout_session_id = checkout_session.id
+ db.commit()
+
+ logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f} (ID: {donation.id})")
return {"checkout_url": checkout_session.url}
except stripe.error.StripeError as e:
+ db.rollback()
logger.error(f"Stripe error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
except Exception as e:
+ db.rollback()
logger.error(f"Error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create donation checkout")
@@ -4911,64 +5399,95 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
# Handle checkout.session.completed event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
+ metadata = session.get("metadata", {})
- # Get metadata
- user_id = session["metadata"].get("user_id")
- plan_id = session["metadata"].get("plan_id")
- base_amount = int(session["metadata"].get("base_amount", 0))
- donation_amount = int(session["metadata"].get("donation_amount", 0))
- total_amount = int(session["metadata"].get("total_amount", session.get("amount_total", 0)))
-
- 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:
- # Calculate subscription period using custom billing cycle if enabled
- from payment_service import calculate_subscription_period
- start_date, end_date = calculate_subscription_period(plan)
-
- # Create subscription record with donation tracking
- 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=start_date,
- end_date=end_date,
- amount_paid_cents=total_amount,
- base_subscription_cents=base_amount or plan.minimum_price_cents,
- donation_cents=donation_amount,
- payment_method="stripe"
- )
- db.add(subscription)
-
- # Update user status and role
- user.status = UserStatus.active
- set_user_role(user, UserRole.member, db)
- user.updated_at = datetime.now(timezone.utc)
+ # Check if this is a donation (has donation_id in metadata)
+ if "donation_id" in metadata:
+ donation_id = uuid.UUID(metadata["donation_id"])
+ donation = db.query(Donation).filter(Donation.id == donation_id).first()
+ if donation:
+ donation.status = DonationStatus.completed
+ donation.stripe_payment_intent_id = session.get('payment_intent')
+ donation.payment_method = 'card'
+ donation.updated_at = datetime.now(timezone.utc)
db.commit()
- logger.info(
- f"Subscription created for user {user.email}: "
- f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
- )
+ # Send thank you email
+ try:
+ from email_service import send_donation_thank_you_email
+ donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
+ await send_donation_thank_you_email(
+ donation.donor_email,
+ donor_first_name,
+ donation.amount_cents
+ )
+ except Exception as e:
+ logger.error(f"Failed to send donation thank you email: {str(e)}")
+
+ logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
else:
- logger.info(f"Subscription already exists for session {session.get('id')}")
+ logger.error(f"Donation not found: {donation_id}")
+
+ # Otherwise handle subscription payment (existing logic)
else:
- logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}")
+ # Get metadata
+ user_id = metadata.get("user_id")
+ plan_id = metadata.get("plan_id")
+ base_amount = int(metadata.get("base_amount", 0))
+ donation_amount = int(metadata.get("donation_amount", 0))
+ total_amount = int(metadata.get("total_amount", session.get("amount_total", 0)))
+
+ 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:
+ # Calculate subscription period using custom billing cycle if enabled
+ from payment_service import calculate_subscription_period
+ start_date, end_date = calculate_subscription_period(plan)
+
+ # Create subscription record with donation tracking
+ 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=start_date,
+ end_date=end_date,
+ amount_paid_cents=total_amount,
+ base_subscription_cents=base_amount or plan.minimum_price_cents,
+ donation_cents=donation_amount,
+ payment_method="stripe"
+ )
+ db.add(subscription)
+
+ # Update user status and role
+ user.status = UserStatus.active
+ set_user_role(user, UserRole.member, db)
+ user.updated_at = datetime.now(timezone.utc)
+
+ db.commit()
+
+ logger.info(
+ f"Subscription created for user {user.email}: "
+ f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
+ )
+ 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"}