- Profile Picture\

Donation Tracking\
Validation Rejection\
Subscription Data Export\
Admin Dashboard Logo\
Admin Navbar Reorganization
This commit is contained in:
Koncept Kit
2025-12-18 17:04:00 +07:00
parent b7ab1a897f
commit db13f0e9de
13 changed files with 1915 additions and 103 deletions

328
MIGRATIONS.md Normal file
View File

@@ -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

BIN
README.md

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,73 +1,203 @@
""" """
Create an admin user for testing. Create a superadmin user interactively.
Run this script to add an admin account to your database. Run this script to add a superadmin account to your database.
""" """
import getpass
import re
from database import SessionLocal from database import SessionLocal
from models import User, UserStatus, UserRole from models import User, UserStatus, UserRole, Role
from auth import get_password_hash from auth import get_password_hash
from datetime import datetime, timezone from datetime import datetime, timezone
import sys
def create_admin(): def validate_email(email):
"""Create an admin user""" """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() db = SessionLocal()
try: try:
# Check if admin already exists print("\n" + "="*60)
existing_admin = db.query(User).filter( print("🔧 LOAF Membership Platform - Superadmin Creation")
User.email == "admin@loaf.org" print("="*60 + "\n")
).first()
if existing_admin: # Get user information interactively
print(f"⚠️ Admin user already exists: {existing_admin.email}") print("📝 Please provide the superadmin account details:\n")
print(f" Role: {existing_admin.role.value}")
print(f" Status: {existing_admin.status.value}") 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() 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 return
print("Creating admin user...") password = get_password()
# Create admin user print("\n👤 Personal Information:\n")
admin_user = User(
email="admin@loaf.org", first_name = get_input("First name", required=True)
password_hash=get_password_hash("admin123"), # Change this password! last_name = get_input("Last name", required=True)
first_name="Admin", phone = get_input("Phone number", validator=validate_phone, required=True)
last_name="User",
phone="555-0001", print("\n📍 Address Information:\n")
address="123 Admin Street",
city="Admin City", address = get_input("Street address", required=True)
state="CA", city = get_input("City", required=True)
zipcode="90001", state = get_input("State (2-letter code)", required=True, default="CA")
date_of_birth=datetime(1990, 1, 1), 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, status=UserStatus.active,
role=UserRole.admin, role=UserRole.superadmin,
email_verified=True, email_verified=True,
newsletter_subscribed=False 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.commit()
db.refresh(admin_user) db.refresh(superadmin_user)
print("✅ Admin user created successfully!") print("\n" + "="*60)
print(f" Email: admin@loaf.org") print("✅ Superadmin user created successfully!")
print(f" Password: admin123") print("="*60)
print(f" Role: {admin_user.role.value}") print(f"\n📧 Email: {superadmin_user.email}")
print(f" User ID: {admin_user.id}") print(f"👤 Name: {superadmin_user.first_name} {superadmin_user.last_name}")
print("\n⚠️ IMPORTANT: Change the password after first login!") 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: except KeyboardInterrupt:
print(f"❌ Error creating admin user: {e}") print("\n\n❌ Operation cancelled by user.")
db.rollback() 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: finally:
db.close() db.close()
if __name__ == "__main__": if __name__ == "__main__":
create_admin() create_superadmin()

View File

@@ -450,3 +450,117 @@ async def send_invitation_email(
""" """
return await send_email(to_email, subject, html_content) 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"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; font-size: 32px; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.amount-box {{ background: #f1eef9; padding: 20px; border-radius: 8px; margin: 20px 0; border: 2px solid #ddd8eb; text-align: center; }}
.amount {{ color: #422268; font-size: 36px; font-weight: bold; margin: 10px 0; }}
.impact-box {{ background: #f9f5ff; border-left: 4px solid #81B29A; padding: 20px; margin: 24px 0; border-radius: 8px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>💜 Thank You!</h1>
</div>
<div class="content">
<p>Dear {first_name},</p>
<p>Thank you for your generous donation to LOAF!</p>
<div class="amount-box">
<p style="margin: 0; color: #664fa3; font-size: 16px;">Donation Amount</p>
<div class="amount">{amount}</div>
</div>
<div class="impact-box">
<p style="color: #422268; font-size: 16px; margin: 0;">
Your support helps us continue our mission to build and strengthen the LGBTQ+ community.
</p>
</div>
<p>Your donation is tax-deductible to the extent allowed by law. Please keep this email for your records.</p>
<p>We are deeply grateful for your commitment to our community and your belief in our work.</p>
<p style="margin-top: 30px;">
With gratitude,<br/>
<strong style="color: #422268;">The LOAF Team</strong>
</p>
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd8eb; color: #664fa3; font-size: 14px;">
Questions about your donation? Contact us at support@loaf.org
</p>
</div>
</div>
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.reason-box {{ background: #f9f5ff; border-left: 4px solid #ff9e77; padding: 20px; margin: 24px 0; border-radius: 8px; }}
.reason-box p {{ color: #422268; font-size: 14px; margin: 0; white-space: pre-wrap; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Membership Application Update</h1>
</div>
<div class="content">
<p>Dear {first_name},</p>
<p>Thank you for your interest in joining LOAF. After careful review, we are unable to approve your membership application at this time.</p>
<div class="reason-box">
<p><strong>Reason:</strong></p>
<p>{reason}</p>
</div>
<p>If you have questions or would like to discuss this decision, please don't hesitate to contact us at support@loaf.org.</p>
<p style="margin-top: 30px;">
Warm regards,<br/>
<strong style="color: #422268;">The LOAF Team</strong>
</p>
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd8eb; color: #664fa3; font-size: 14px;">
Questions? Contact us at support@loaf.org
</p>
</div>
</div>
</body>
</html>
"""
return await send_email(email, subject, html_content)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -16,6 +16,7 @@ class UserStatus(enum.Enum):
canceled = "canceled" # User or admin canceled membership canceled = "canceled" # User or admin canceled membership
expired = "expired" # Subscription ended without renewal expired = "expired" # Subscription ended without renewal
abandoned = "abandoned" # Incomplete registration (no verification/event/payment) abandoned = "abandoned" # Incomplete registration (no verification/event/payment)
rejected = "rejected" # Application rejected by admin
class UserRole(enum.Enum): class UserRole(enum.Enum):
guest = "guest" guest = "guest"
@@ -34,6 +35,15 @@ class SubscriptionStatus(enum.Enum):
expired = "expired" expired = "expired"
cancelled = "cancelled" cancelled = "cancelled"
class DonationType(enum.Enum):
member = "member"
public = "public"
class DonationStatus(enum.Enum):
pending = "pending"
completed = "completed"
failed = "failed"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -115,6 +125,11 @@ class User(Base):
renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent") 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") 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)) 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)) 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]) user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id])
plan = relationship("SubscriptionPlan", back_populates="subscriptions") 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): class EventGallery(Base):
__tablename__ = "event_galleries" __tablename__ = "event_galleries"

View File

@@ -2,7 +2,7 @@
""" """
Permission Seeding Script for Dynamic RBAC System 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). to the appropriate dynamic roles (not the old enum roles).
Usage: Usage:
@@ -33,7 +33,7 @@ engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# ============================================================ # ============================================================
# Permission Definitions (56 permissions across 9 modules) # Permission Definitions (59 permissions across 10 modules)
# ============================================================ # ============================================================
PERMISSIONS = [ PERMISSIONS = [
@@ -60,13 +60,18 @@ PERMISSIONS = [
{"code": "events.rsvps", "name": "View Event RSVPs", "description": "View and manage event RSVPs", "module": "events"}, {"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"}, {"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.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.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.edit", "name": "Edit Subscriptions", "description": "Edit subscription details", "module": "subscriptions"},
{"code": "subscriptions.cancel", "name": "Cancel Subscriptions", "description": "Cancel user subscriptions", "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.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.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) ========== # ========== FINANCIALS MODULE (6) ==========
{"code": "financials.view", "name": "View Financial Reports", "description": "View financial reports and dashboards", "module": "financials"}, {"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", "financials.delete", "financials.export", "financials.payments",
"subscriptions.view", "subscriptions.create", "subscriptions.edit", "subscriptions.view", "subscriptions.create", "subscriptions.edit",
"subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans",
"subscriptions.export",
"donations.view", "donations.export",
], ],
"admin": [ "admin": [
@@ -140,6 +147,8 @@ DEFAULT_ROLE_PERMISSIONS = {
"events.attendance", "events.rsvps", "events.calendar_export", "events.attendance", "events.rsvps", "events.calendar_export",
"subscriptions.view", "subscriptions.create", "subscriptions.edit", "subscriptions.view", "subscriptions.create", "subscriptions.edit",
"subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans",
"subscriptions.export",
"donations.view", "donations.export",
"financials.view", "financials.create", "financials.edit", "financials.delete", "financials.view", "financials.create", "financials.edit", "financials.delete",
"financials.export", "financials.payments", "financials.export", "financials.payments",
"newsletters.view", "newsletters.create", "newsletters.edit", "newsletters.delete", "newsletters.view", "newsletters.create", "newsletters.edit", "newsletters.delete",

631
server.py
View File

@@ -17,7 +17,7 @@ import csv
import io import io
from database import engine, get_db, Base 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 ( from auth import (
get_password_hash, get_password_hash,
verify_password, verify_password,
@@ -208,6 +208,8 @@ class UserResponse(BaseModel):
role: str role: str
email_verified: bool email_verified: bool
created_at: datetime created_at: datetime
# Profile
profile_photo_url: Optional[str] = None
# Subscription info (optional) # Subscription info (optional)
subscription_start_date: Optional[datetime] = None subscription_start_date: Optional[datetime] = None
subscription_end_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) "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 # User Profile Routes
@api_router.get("/users/profile", response_model=UserResponse) @api_router.get("/users/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)): async def get_profile(current_user: User = Depends(get_current_user)):
@@ -2104,6 +2120,7 @@ async def get_user_by_id(
"state": user.state, "state": user.state,
"zipcode": user.zipcode, "zipcode": user.zipcode,
"date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None, "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_first_name": user.partner_first_name,
"partner_last_name": user.partner_last_name, "partner_last_name": user.partner_last_name,
"partner_is_member": user.partner_is_member, "partner_is_member": user.partner_is_member,
@@ -2194,6 +2211,52 @@ async def update_user_status(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid status") 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") @api_router.post("/admin/users/{user_id}/activate-payment")
async def activate_payment_manually( async def activate_payment_manually(
user_id: str, user_id: str,
@@ -2356,6 +2419,114 @@ async def admin_resend_verification(
return {"message": f"Verification email resent to {user.email}"} 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 # User Creation & Invitation Endpoints
# ============================================================ # ============================================================
@@ -3648,6 +3819,287 @@ async def cancel_subscription(
return {"message": "Subscription cancelled successfully"} 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 # Admin Document Management Routes
# ============================================================================ # ============================================================================
@@ -4761,14 +5213,39 @@ async def create_checkout(
@api_router.post("/donations/checkout") @api_router.post("/donations/checkout")
async def create_donation_checkout( async def create_donation_checkout(
request: DonationCheckoutRequest, 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.""" """Create Stripe Checkout session for one-time donation."""
# Get frontend URL from env # Get frontend URL from env
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
# Check if user is authenticated (from header if present)
try: 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 # Create Stripe Checkout Session for one-time payment
import stripe import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY") stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
@@ -4788,17 +5265,28 @@ async def create_donation_checkout(
}], }],
mode='payment', # One-time payment (not subscription) mode='payment', # One-time payment (not subscription)
success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}", 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} return {"checkout_url": checkout_session.url}
except stripe.error.StripeError as e: except stripe.error.StripeError as e:
db.rollback()
logger.error(f"Stripe error creating donation checkout: {str(e)}") logger.error(f"Stripe error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
except Exception as e: except Exception as e:
db.rollback()
logger.error(f"Error creating donation checkout: {str(e)}") logger.error(f"Error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create donation checkout") 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 # Handle checkout.session.completed event
if event["type"] == "checkout.session.completed": if event["type"] == "checkout.session.completed":
session = event["data"]["object"] session = event["data"]["object"]
metadata = session.get("metadata", {})
# Get metadata # Check if this is a donation (has donation_id in metadata)
user_id = session["metadata"].get("user_id") if "donation_id" in metadata:
plan_id = session["metadata"].get("plan_id") donation_id = uuid.UUID(metadata["donation_id"])
base_amount = int(session["metadata"].get("base_amount", 0)) donation = db.query(Donation).filter(Donation.id == donation_id).first()
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)
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() db.commit()
logger.info( # Send thank you email
f"Subscription created for user {user.email}: " try:
f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}" 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: 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: 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"} return {"status": "success"}