diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..cd9da8f --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,287 @@ +# Deployment Guide - Dynamic RBAC System + +This guide covers deploying the dynamic Role-Based Access Control (RBAC) system to your dev server. + +## Overview + +The RBAC migration consists of 4 phases: +- **Phase 1**: Add new database tables and columns (schema changes) +- **Phase 2**: Seed system roles +- **Phase 3**: Migrate existing data +- **Phase 4**: System is fully operational with dynamic roles + +## Prerequisites + +- Database backup completed ✓ +- PostgreSQL access credentials +- Python 3.8+ environment +- All dependencies installed (`pip install -r requirements.txt`) + +--- + +## Step-by-Step Deployment + +### Step 1: Run Schema Migration (Phase 1) + +This creates the new `roles` table and adds `role_id` columns to `users` and `role_permissions` tables. + +```bash +# Connect to your database +psql -U -d -f migrations/006_add_dynamic_roles.sql +``` + +**What this does:** +- Creates `roles` table +- Adds `role_id` column to `users` (nullable) +- Adds `role_id` column to `role_permissions` (nullable) +- Legacy `role` enum columns remain for backward compatibility + +**Expected output:** +``` +Step 1 completed: roles table created +Step 2 completed: role_id column added to users table +Step 3 completed: role_id column added to role_permissions table +Migration 006 completed successfully! +``` + +--- + +### Step 2: Seed System Roles (Phase 2) + +This creates the 5 system roles: Superadmin, Admin, Finance, Member, Guest. + +```bash +cd backend +python3 roles_seed.py +``` + +**Expected output:** +``` +Starting roles seeding... +Created role: Superadmin (superadmin) - System role +Created role: Admin (admin) - System role +Created role: Finance (finance) - System role +Created role: Member (member) - System role +Created role: Guest (guest) - System role + +Roles seeding completed! +Total roles created: 5 +``` + +--- + +### Step 3: Migrate Existing Users (Phase 3a) + +This migrates all existing users from enum roles to the new dynamic role system. + +```bash +python3 migrate_users_to_dynamic_roles.py +``` + +**Expected output:** +``` +Starting user migration to dynamic roles... +Processing user: admin@loaf.org (superadmin) + ✓ Mapped to role: Superadmin +Processing user: finance@loaf.org (finance) + ✓ Mapped to role: Finance +... +User migration completed successfully! +Total users migrated: X +``` + +--- + +### Step 4: Migrate Role Permissions (Phase 3b) + +This migrates all existing role-permission mappings to use role_id. + +```bash +python3 migrate_role_permissions_to_dynamic_roles.py +``` + +**Expected output:** +``` +Starting role permissions migration to dynamic roles... +Migrating permissions for role: guest + ✓ Migrated: events.view (X permissions) +Migrating permissions for role: member + ✓ Migrated: events.create (X permissions) +... +Role permissions migration completed successfully! +Total role_permission records migrated: X +``` + +--- + +### Step 5: Verify Migration + +Run this verification script to ensure everything migrated correctly: + +```bash +python3 verify_admin_account.py +``` + +**Expected output:** +``` +================================================================================ +VERIFYING admin@loaf.org ACCOUNT +================================================================================ + +✅ User found: Admin User + Email: admin@loaf.org + Status: UserStatus.active + Email Verified: True + +📋 Legacy Role (enum): superadmin +✅ Dynamic Role: Superadmin (code: superadmin) + Role ID: + Is System Role: True + +🔐 Checking Permissions: + Total permissions assigned to role: 56 + +🎯 Access Check: + ✅ User should have admin access (based on legacy enum) + ✅ User should have admin access (based on dynamic role) + +================================================================================ +VERIFICATION COMPLETE +================================================================================ +``` + +--- + +### Step 6: Deploy Backend Code + +```bash +# Pull latest code +git pull origin main + +# Restart backend server +# (adjust based on your deployment method) +systemctl restart membership-backend +# OR +pm2 restart membership-backend +# OR +supervisorctl restart membership-backend +``` + +--- + +### Step 7: Verify API Endpoints + +Test the role management endpoints: + +```bash +# Get all roles +curl -H "Authorization: Bearer " \ + http://your-server/api/admin/roles + +# Get all permissions +curl -H "Authorization: Bearer " \ + http://your-server/api/admin/permissions + +# Test export (the issue we just fixed) +curl -H "Authorization: Bearer " \ + http://your-server/api/admin/users/export +``` + +--- + +## Rollback Plan (If Needed) + +If something goes wrong, you can rollback: + +```sql +BEGIN; + +-- Remove new columns +ALTER TABLE users DROP COLUMN IF EXISTS role_id; +ALTER TABLE role_permissions DROP COLUMN IF EXISTS role_id; + +-- Drop roles table +DROP TABLE IF EXISTS roles CASCADE; + +COMMIT; +``` + +Then restore from your backup if needed. + +--- + +## Post-Deployment Checklist + +- [ ] Schema migration completed without errors +- [ ] System roles seeded (5 roles created) +- [ ] All users migrated to dynamic roles +- [ ] All role permissions migrated +- [ ] Admin account verified +- [ ] Backend server restarted +- [ ] Export endpoint working (no 500 error) +- [ ] Admin can view roles in UI (/admin/permissions) +- [ ] Admin can create/edit roles +- [ ] Admin can assign permissions to roles +- [ ] Staff invitation uses dynamic roles + +--- + +## Troubleshooting + +### Issue: Migration script fails + +**Solution:** Check your `.env` file has correct `DATABASE_URL`: +``` +DATABASE_URL=postgresql://user:password@host:port/database +``` + +### Issue: "role_id column already exists" + +**Solution:** This is safe to ignore. The migration uses `IF NOT EXISTS` clauses. + +### Issue: "No roles found" when migrating users + +**Solution:** Make sure you ran Step 2 (roles_seed.py) before Step 3. + +### Issue: Export still returns 500 error + +**Solution:** +1. Verify backend code is latest version +2. Check server.py has export route BEFORE {user_id} route (around line 1965) +3. Restart backend server + +### Issue: Permission denied errors + +**Solution:** Make sure your database user has permissions: +```sql +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ; +``` + +--- + +## Files Involved + +### Migration Files +- `migrations/006_add_dynamic_roles.sql` - Schema changes +- `roles_seed.py` - Seed system roles +- `migrate_users_to_dynamic_roles.py` - Migrate user data +- `migrate_role_permissions_to_dynamic_roles.py` - Migrate permission data +- `verify_admin_account.py` - Verification script + +### Code Changes +- `server.py` - Route reordering (export before {user_id}) +- `auth.py` - get_user_role_code() helper +- `models.py` - Role model, role_id columns +- Frontend: AdminRoles.js, InviteStaffDialog.js, AdminStaff.js, Navbar.js, Login.js + +--- + +## Support + +If you encounter issues during deployment, check: +1. Backend logs: `tail -f /path/to/backend.log` +2. Database logs: Check PostgreSQL error logs +3. Frontend console: Browser developer tools + +For questions, refer to the CLAUDE.md file for system architecture details. diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc index b357148..6cbc913 100644 Binary files a/__pycache__/auth.cpython-312.pyc and b/__pycache__/auth.cpython-312.pyc differ diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc index a409ff7..4a6965b 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 0b9446a..9abfe4f 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 fde6485..095a938 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/auth.py b/auth.py index 9d92d08..3371604 100644 --- a/auth.py +++ b/auth.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Optional, List from jose import JWTError, jwt from passlib.context import CryptContext from fastapi import Depends, HTTPException, status @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session import os import secrets from database import get_db -from models import User, UserRole +from models import User, UserRole, Permission, RolePermission, Role pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") security = HTTPBearer() @@ -50,6 +50,24 @@ def verify_reset_token(token, db): return user +def get_user_role_code(user: User) -> str: + """ + Get user's role code from either dynamic role system or legacy enum. + Supports backward compatibility during migration (Phase 3). + + Args: + user: User object + + Returns: + Role code string (e.g., "superadmin", "admin", "member", "guest") + """ + # Prefer dynamic role if set (Phase 3+) + if user.role_id is not None and user.role_obj is not None: + return user.role_obj.code + + # Fallback to legacy enum (Phase 1-2) + return user.role.value + def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() if expires_delta: @@ -100,7 +118,9 @@ async def get_current_user( return user async def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User: - if current_user.role != UserRole.admin: + """Require user to be admin or superadmin""" + role_code = get_user_role_code(current_user) + if role_code not in ["admin", "superadmin"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" @@ -117,10 +137,113 @@ async def get_active_member(current_user: User = Depends(get_current_user)) -> U detail="Active membership required. Please complete payment." ) - if current_user.role not in [UserRole.member, UserRole.admin]: + role_code = get_user_role_code(current_user) + if role_code not in ["member", "admin", "superadmin"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Member access only" ) return current_user + + +# ============================================================ +# RBAC Permission System +# ============================================================ + +async def get_user_permissions(user: User, db: Session) -> List[str]: + """ + Get all permission codes for user's role. + Superadmin automatically gets all permissions. + Uses request-level caching to avoid repeated DB queries. + Supports both dynamic roles (role_id) and legacy enum (role). + + Args: + user: Current authenticated user + db: Database session + + Returns: + List of permission code strings (e.g., ["users.view", "events.create"]) + """ + # Check if permissions are already cached for this request + if hasattr(user, '_permission_cache'): + return user._permission_cache + + # Get role code using helper + role_code = get_user_role_code(user) + + # Superadmin gets all permissions automatically + if role_code == "superadmin": + all_perms = db.query(Permission.code).all() + permissions = [p[0] for p in all_perms] + else: + # Fetch permissions assigned to this role + # Prefer dynamic role_id, fallback to enum + if user.role_id is not None: + # Use role_id for dynamic roles + permissions = db.query(Permission.code)\ + .join(RolePermission)\ + .filter(RolePermission.role_id == user.role_id)\ + .all() + else: + # Fallback to legacy enum + permissions = db.query(Permission.code)\ + .join(RolePermission)\ + .filter(RolePermission.role == user.role)\ + .all() + permissions = [p[0] for p in permissions] + + # Cache permissions on user object for this request + user._permission_cache = permissions + return permissions + + +def require_permission(permission_code: str): + """ + Dependency injection for permission-based access control. + + Usage: + @app.get("/admin/users", dependencies=[Depends(require_permission("users.view"))]) + async def get_users(): + ... + + Args: + permission_code: Permission code to check (e.g., "users.create") + + Returns: + Async function that checks if current user has the permission + + Raises: + HTTPException 403 if user lacks the required permission + """ + async def permission_checker( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) + ) -> User: + # Get user's permissions + user_perms = await get_user_permissions(current_user, db) + + # Check if user has the required permission + if permission_code not in user_perms: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission required: {permission_code}" + ) + + return current_user + + return permission_checker + + +async def get_current_superadmin(current_user: User = Depends(get_current_user)) -> User: + """ + Require user to be superadmin. + Used for endpoints that should only be accessible to superadmins. + """ + role_code = get_user_role_code(current_user) + if role_code != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Superadmin access required" + ) + return current_user diff --git a/deploy_rbac.sh b/deploy_rbac.sh new file mode 100755 index 0000000..0d7e078 --- /dev/null +++ b/deploy_rbac.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Quick deployment script for RBAC system +# Run this on your dev server after pulling latest code + +set -e # Exit on any error + +echo "========================================" +echo "RBAC System Deployment Script" +echo "========================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if .env exists +if [ ! -f .env ]; then + echo -e "${RED}Error: .env file not found${NC}" + echo "Please create .env file with DATABASE_URL" + exit 1 +fi + +# Load environment variables +source .env + +# Function to check command success +check_success() { + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ $1${NC}" + else + echo -e "${RED}✗ $1 failed${NC}" + exit 1 + fi +} + +echo -e "${YELLOW}Step 1: Running schema migration...${NC}" +psql $DATABASE_URL -f migrations/006_add_dynamic_roles.sql +check_success "Schema migration" + +echo "" +echo -e "${YELLOW}Step 2: Seeding system roles...${NC}" +python3 roles_seed.py +check_success "Roles seeding" + +echo "" +echo -e "${YELLOW}Step 3: Migrating users to dynamic roles...${NC}" +python3 migrate_users_to_dynamic_roles.py +check_success "User migration" + +echo "" +echo -e "${YELLOW}Step 4: Migrating role permissions...${NC}" +python3 migrate_role_permissions_to_dynamic_roles.py +check_success "Role permissions migration" + +echo "" +echo -e "${YELLOW}Step 5: Verifying admin account...${NC}" +python3 verify_admin_account.py +check_success "Admin account verification" + +echo "" +echo "========================================" +echo -e "${GREEN}✓ RBAC System Deployment Complete!${NC}" +echo "========================================" +echo "" +echo "Next steps:" +echo "1. Restart your backend server" +echo "2. Test the /api/admin/users/export endpoint" +echo "3. Login as admin and check /admin/permissions page" +echo "" diff --git a/docs/status_definitions.md b/docs/status_definitions.md new file mode 100644 index 0000000..dcde749 --- /dev/null +++ b/docs/status_definitions.md @@ -0,0 +1,439 @@ +# Membership Status Definitions & Transitions + +This document defines all user membership statuses, their meanings, valid transitions, and automated rules. + +## Status Overview + +| Status | Type | Description | Member Access | +|--------|------|-------------|---------------| +| `pending_email` | Registration | User registered, awaiting email verification | None | +| `pending_validation` | Registration | Email verified, awaiting event attendance | Newsletter only | +| `pre_validated` | Registration | Attended event or referred, ready for admin validation | Newsletter only | +| `payment_pending` | Registration | Admin validated, awaiting payment | Newsletter only | +| `active` | Active | Payment completed, full member access | Full access | +| `inactive` | Inactive | Membership deactivated manually | None | +| `canceled` | Terminated | User or admin canceled membership | None | +| `expired` | Terminated | Subscription ended without renewal | Limited (historical) | +| `abandoned` | Terminated | Incomplete registration after reminders | None | + +--- + +## Detailed Status Definitions + +### 1. pending_email + +**Definition:** User has registered but not verified their email address. + +**How User Enters:** +- User completes registration form (Step 1-4) +- System creates user account with `pending_email` status + +**Valid Transitions:** +- → `pending_validation` (email verified) +- → `pre_validated` (email verified + referred by member) +- → `abandoned` (optional: 30 days without verification after reminders) + +**Member Access:** +- Cannot login +- Cannot access any member features +- Not subscribed to newsletter + +**Reminder Schedule:** +- Day 3: First reminder email +- Day 7: Second reminder email +- Day 14: Third reminder email +- Day 30: Final reminder (optional: transition to abandoned) + +**Admin Actions:** +- Can manually resend verification email +- Can manually verify email (bypass) +- Can delete user account + +--- + +### 2. pending_validation + +**Definition:** Email verified, user needs to attend an event within 90 days (per LOAF policy). + +**How User Enters:** +- Email verification successful (from `pending_email`) +- 90-day countdown timer starts + +**Valid Transitions:** +- → `pre_validated` (attended event marked by admin) +- → `abandoned` (90 days without event attendance - per policy) + +**Member Access:** +- Can login to view dashboard +- Subscribed to newsletter +- Cannot access member-only features +- Can view public events + +**Reminder Schedule:** +- Day 30: "You have 60 days remaining to attend an event" +- Day 60: "You have 30 days remaining to attend an event" +- Day 80: "Reminder: 10 days left to attend an event" +- Day 85: "Final reminder: 5 days left" +- Day 90: Transition to `abandoned`, remove from newsletter + +**Admin Actions:** +- Can mark event attendance (triggers transition to `pre_validated`) +- Can manually transition to `pre_validated` (bypass event requirement) +- Can extend deadline + +--- + +### 3. pre_validated + +**Definition:** User attended event or was referred, awaiting admin validation. + +**How User Enters:** +- Admin marked event attendance (from `pending_validation`) +- User registered with valid member referral (skipped `pending_validation`) + +**Valid Transitions:** +- → `payment_pending` (admin validates application) +- → `inactive` (admin rejects application - rare) + +**Member Access:** +- Can login to view dashboard +- Subscribed to newsletter +- Cannot access member-only features +- Can view public events + +**Automated Rules:** +- None (requires admin action) + +**Admin Actions:** +- Review application in Validation Queue +- Validate → transition to `payment_pending` (sends payment email) +- Reject → transition to `inactive` (sends rejection email) + +--- + +### 4. payment_pending + +**Definition:** Admin validated application, user needs to complete payment. + +**How User Enters:** +- Admin validates application (from `pre_validated`) +- Payment email sent with Stripe Checkout link + +**Valid Transitions:** +- → `active` (payment successful via Stripe webhook) +- → `abandoned` (optional: 60 days without payment after reminders) + +**Member Access:** +- Can login to view dashboard +- Subscribed to newsletter +- Cannot access member-only features +- Can view subscription plans page + +**Reminder Schedule:** +- Day 7: First payment reminder +- Day 14: Second payment reminder +- Day 21: Third payment reminder +- Day 30: Fourth payment reminder +- Day 45: Fifth payment reminder +- Day 60: Final reminder (optional: transition to abandoned) + +**Note:** Since admin already validated this user, consider keeping them in this status indefinitely rather than auto-abandoning. + +**Admin Actions:** +- Can manually activate membership (for offline payments: cash, check, bank transfer) +- Can resend payment email + +--- + +### 5. active + +**Definition:** Payment completed, full membership access granted. + +**How User Enters:** +- Stripe payment successful (from `payment_pending`) +- Admin manually activated (offline payment) + +**Valid Transitions:** +- → `expired` (subscription end date reached without renewal) +- → `canceled` (user or admin cancels membership) +- → `inactive` (admin manually deactivates) + +**Member Access:** +- Full member dashboard access +- All member-only features +- Event RSVP and attendance tracking +- Member directory listing +- Newsletter subscribed + +**Renewal Reminder Schedule:** +- 60 days before expiration: First renewal reminder +- 30 days before expiration: Second renewal reminder +- 14 days before expiration: Third renewal reminder +- 7 days before expiration: Final renewal reminder +- On expiration: Transition to `expired` + +**Admin Actions:** +- Can cancel membership → `canceled` +- Can manually deactivate → `inactive` +- Can extend subscription end_date + +--- + +### 6. inactive + +**Definition:** Membership manually deactivated by admin. + +**How User Enters:** +- Admin manually sets status to `inactive` +- Used for temporary suspensions or admin rejections + +**Valid Transitions:** +- → `active` (admin reactivates) +- → `payment_pending` (admin prompts for payment) + +**Member Access:** +- Can login but no member features +- Not subscribed to newsletter +- Cannot access member-only content + +**Automated Rules:** +- None (requires admin action to exit) + +**Admin Actions:** +- Reactivate membership → `active` +- Prompt for payment → `payment_pending` +- Delete user account + +--- + +### 7. canceled + +**Definition:** Membership canceled by user or admin. + +**How User Enters:** +- User cancels subscription via Stripe portal +- Admin cancels membership +- Stripe webhook: `customer.subscription.deleted` + +**Valid Transitions:** +- → `payment_pending` (user requests to rejoin) +- → `active` (admin reactivates with new subscription) + +**Member Access:** +- Can login to view dashboard (historical data) +- Not subscribed to newsletter +- Cannot access current member-only features +- Can view historical event attendance + +**Automated Rules:** +- Stripe webhook triggers automatic transition + +**Admin Actions:** +- Can invite user to rejoin → `payment_pending` +- Can manually reactivate → `active` (if subscription still valid) + +--- + +### 8. expired + +**Definition:** Subscription ended without renewal. + +**How User Enters:** +- Subscription `end_date` reached without renewal +- Automated check runs daily + +**Valid Transitions:** +- → `payment_pending` (user chooses to renew) +- → `active` (admin manually renews/extends) + +**Member Access:** +- Can login to view dashboard (historical data) +- Not subscribed to newsletter +- Cannot access current member-only features +- Can view historical event attendance +- Shown renewal prompts + +**Automated Rules:** +- Daily check for subscriptions past `end_date` → transition to `expired` +- Send renewal invitation email on transition + +**Post-Expiration Reminders:** +- Immediate: Expiration notification + renewal link +- 7 days after: Renewal reminder +- 30 days after: Final renewal reminder +- 90 days after: Optional cleanup/archive + +**Admin Actions:** +- Manually extend subscription → `active` +- Send renewal invitation → `payment_pending` + +--- + +### 9. abandoned + +**Definition:** User failed to complete registration process after multiple reminders. + +**How User Enters:** +- From `pending_email`: 30 days without verification (optional - after 4 reminders) +- From `pending_validation`: 90 days without event attendance (after 4 reminders) +- From `payment_pending`: 60 days without payment (optional - after 6 reminders) + +**Valid Transitions:** +- → `pending_email` (admin resets application, resends verification) +- → `pending_validation` (admin resets, manually verifies email) +- → `payment_pending` (admin resets, bypasses requirements) + +**Member Access:** +- Cannot login +- Not subscribed to newsletter +- All access revoked + +**Automated Rules:** +- Send "incomplete application" notification email on transition +- Optional: Purge from database after 180 days (configurable) + +**Admin Actions:** +- Can reset application → return to appropriate pending state +- Can delete user account +- Can view abandoned applications in admin dashboard + +--- + +## State Transition Diagram + +``` +┌──────────────┐ +│ Registration │ +│ (Guest) │ +└──────────────┘ + │ + ↓ +┌───────────────┐ (30 days) ┌──────────┐ +│ pending_email │──────────────────→│abandoned │ +└───────────────┘ └──────────┘ + │ ↑ + (verify email) │ + │ │ + ↓ │ +┌────────────────────┐ (90 days) │ +│pending_validation │───────────────────┘ +│ (or pre_validated) │ +└────────────────────┘ + │ + (event/admin) + │ + ↓ +┌────────────────┐ +│ pre_validated │ +└────────────────┘ + │ + (admin validates) + │ + ↓ +┌─────────────────┐ (60 days) ┌──────────┐ +│payment_pending │──────────────────→│abandoned │ +└─────────────────┘ └──────────┘ + │ + (payment) + │ + ↓ + ┌─────────┐ + │ active │←────────────┐ + └─────────┘ │ + │ │ + ├────(expires)────→┌─────────┐ + │ │expired │ + ├────(cancels)────→├─────────┤ + │ │canceled │ + └──(deactivate)───→├─────────┤ + │inactive │ + └─────────┘ + │ + (renew/reactivate) + │ + └──────────┘ +``` + +--- + +## Email Notification Summary + +| Trigger | Emails Sent | +|---------|-------------| +| Registration complete | Verification email (immediate) | +| pending_email day 3, 7, 14, 30 | Verification reminders | +| Email verified | Welcome + event attendance instructions | +| pending_validation day 30, 60, 80, 85 | Event attendance reminders | +| Admin validates | Payment instructions | +| payment_pending day 7, 14, 21, 30, 45, 60 | Payment reminders | +| Payment successful | Membership activation confirmation | +| active: 60, 30, 14, 7 days before expiry | Renewal reminders | +| Subscription expires | Expiration notice + renewal link | +| expired: 7, 30, 90 days after | Post-expiration renewal reminders | +| Status → abandoned | Incomplete application notice | +| Admin cancels | Cancellation confirmation | + +--- + +## Implementation Notes + +### Configuration Options + +All timeout periods should be configurable via environment variables: + +```bash +# Abandonment timeouts (in days, 0 = never auto-abandon) +EMAIL_VERIFICATION_TIMEOUT=30 +EVENT_ATTENDANCE_TIMEOUT=90 +PAYMENT_TIMEOUT=0 # Don't auto-abandon payment_pending + +# Reminder schedules (comma-separated days) +EMAIL_REMINDERS=3,7,14,30 +EVENT_REMINDERS=30,60,80,85 +PAYMENT_REMINDERS=7,14,21,30,45,60 +RENEWAL_REMINDERS=60,30,14,7 +EXPIRED_REMINDERS=7,30,90 +``` + +### Background Jobs Required + +1. **Daily Status Check** (runs at 00:00 UTC) + - Check for expired subscriptions → `expired` + - Check for abandonment timeouts (if enabled) + +2. **Hourly Reminder Check** (runs every hour) + - Calculate days since status change + - Send appropriate reminder emails based on schedule + +### Database Indexes + +```sql +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_updated_at ON users(updated_at); +CREATE INDEX idx_subscriptions_end_date ON subscriptions(end_date) WHERE status = 'active'; +``` + +--- + +## Testing Checklist + +- [ ] Reminder emails sent on correct schedule +- [ ] Abandonment timeouts respect configuration +- [ ] Manual status transitions work correctly +- [ ] Role updates on status change +- [ ] Newsletter subscription/unsubscription on status change +- [ ] Email notifications use correct templates +- [ ] Stripe webhook integration for cancellations/expirations +- [ ] Admin can bypass requirements and manually transition +- [ ] Users can complete registration even after reminders stop + +--- + +## Future Enhancements + +1. **Audit Logging**: Create `user_status_log` table to track all transitions +2. **Re-engagement Campaigns**: Target abandoned users with special offers +3. **Flexible Timeout Periods**: Per-user timeout overrides for special cases +4. **A/B Testing**: Test different reminder schedules for better completion rates +5. **SMS Reminders**: Optional SMS for critical reminders (payment due, expiration) diff --git a/email_service.py b/email_service.py index 735c27b..588086a 100644 --- a/email_service.py +++ b/email_service.py @@ -376,3 +376,77 @@ async def send_admin_password_reset_email( """ return await send_email(to_email, subject, html_content) + + +async def send_invitation_email( + to_email: str, + inviter_name: str, + invitation_url: str, + role: str +): + """Send invitation email to new user""" + subject = f"You've Been Invited to Join LOAF - {role.capitalize()} Access" + + role_descriptions = { + "member": "full member access to our community", + "admin": "administrative access to manage the platform", + "superadmin": "full administrative access with system-wide permissions" + } + + role_description = role_descriptions.get(role.lower(), "access to our platform") + + html_content = f""" + + + + + + +
+
+

🎉 You're Invited!

+
+
+

{inviter_name} has invited you to join the LOAF community with {role_description}.

+ +
+

Your Role: {role.capitalize()}

+

Invited By: {inviter_name}

+
+ +

Click the button below to accept your invitation and create your account:

+ +

+ Accept Invitation +

+ +
+

⏰ This invitation expires in 7 days.

+

If you didn't expect this invitation, you can safely ignore this email.

+
+ +

+ Or copy and paste this link into your browser:
+ {invitation_url} +

+ +

+ Questions? Contact us at support@loaf.org +

+
+
+ + + """ + + return await send_email(to_email, subject, html_content) diff --git a/migrate_role_permissions_to_dynamic_roles.py b/migrate_role_permissions_to_dynamic_roles.py new file mode 100644 index 0000000..05b722d --- /dev/null +++ b/migrate_role_permissions_to_dynamic_roles.py @@ -0,0 +1,145 @@ +""" +Role Permissions Migration Script (Phase 3) + +This script migrates role_permissions from the legacy role enum to the new dynamic role system. +For each role_permission, it maps the current role enum value to the corresponding role_id. + +Usage: + python migrate_role_permissions_to_dynamic_roles.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import RolePermission, Role, UserRole +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def migrate_role_permissions(): + """Migrate role_permissions from enum role to role_id""" + db = SessionLocal() + + try: + print("🚀 Starting role_permissions migration (Phase 3)...") + print("="*60) + + # Step 1: Load all roles into a map + print("\n📋 Loading roles from database...") + roles = db.query(Role).all() + role_map = {role.code: role for role in roles} + + print(f"✓ Loaded {len(roles)} roles:") + for role in roles: + print(f" • {role.name} ({role.code}) - ID: {role.id}") + + # Step 2: Get all role_permissions + print("\n🔐 Loading role_permissions...") + role_permissions = db.query(RolePermission).all() + print(f"✓ Found {len(role_permissions)} role_permission records to migrate") + + if not role_permissions: + print("\n✅ No role_permissions to migrate!") + return + + # Step 3: Check if any role_permissions already have role_id set + perms_with_role_id = [rp for rp in role_permissions if rp.role_id is not None] + if perms_with_role_id: + print(f"\n⚠️ Warning: {len(perms_with_role_id)} role_permissions already have role_id set") + response = input("Do you want to re-migrate these records? (yes/no): ") + if response.lower() != 'yes': + print("Skipping role_permissions that already have role_id set...") + role_permissions = [rp for rp in role_permissions if rp.role_id is None] + print(f"Will migrate {len(role_permissions)} role_permissions without role_id") + + if not role_permissions: + print("\n✅ No role_permissions to migrate!") + return + + # Step 4: Migrate role_permissions + print(f"\n🔄 Migrating {len(role_permissions)} role_permission records...") + + migration_stats = { + UserRole.guest: 0, + UserRole.member: 0, + UserRole.admin: 0, + UserRole.superadmin: 0 + } + + for rp in role_permissions: + # Get the enum role code (e.g., "guest", "member", "admin", "superadmin") + role_code = rp.role.value + + # Find the matching role in the roles table + if role_code not in role_map: + print(f" ⚠️ Warning: No matching role found for '{role_code}' (permission_id: {rp.permission_id})") + continue + + # Set the role_id + rp.role_id = role_map[role_code].id + migration_stats[rp.role] = migration_stats.get(rp.role, 0) + 1 + + # Commit all changes + db.commit() + print(f"✓ Migrated {len(role_permissions)} role_permission records") + + # Step 5: Display migration summary + print("\n" + "="*60) + print("📊 Migration Summary:") + print("="*60) + print("\nRole permissions migrated by role:") + for role_enum, count in migration_stats.items(): + if count > 0: + print(f" • {role_enum.value}: {count} permissions") + + # Step 6: Verify migration + print("\n🔍 Verifying migration...") + perms_without_role_id = db.query(RolePermission).filter(RolePermission.role_id == None).count() + perms_with_role_id = db.query(RolePermission).filter(RolePermission.role_id != None).count() + + print(f" • Role permissions with role_id: {perms_with_role_id}") + print(f" • Role permissions without role_id: {perms_without_role_id}") + + if perms_without_role_id > 0: + print(f"\n⚠️ Warning: {perms_without_role_id} role_permissions still don't have role_id set!") + else: + print("\n✅ All role_permissions successfully migrated!") + + print("\n" + "="*60) + print("✅ Role permissions migration completed successfully!") + print("="*60) + + print("\n📝 Next Steps:") + print(" 1. Update auth.py to use dynamic roles") + print(" 2. Update server.py role checks") + print(" 3. Verify system still works with new roles") + print(" 4. In Phase 4, remove legacy enum columns") + + except Exception as e: + db.rollback() + print(f"\n❌ Error migrating role_permissions: {str(e)}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + migrate_role_permissions() diff --git a/migrate_users_to_dynamic_roles.py b/migrate_users_to_dynamic_roles.py new file mode 100644 index 0000000..75d7c78 --- /dev/null +++ b/migrate_users_to_dynamic_roles.py @@ -0,0 +1,141 @@ +""" +User Role Migration Script (Phase 3) + +This script migrates existing users from the legacy role enum to the new dynamic role system. +For each user, it maps their current role enum value to the corresponding role_id in the roles table. + +Usage: + python migrate_users_to_dynamic_roles.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import User, Role, UserRole +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def migrate_users(): + """Migrate users from enum role to role_id""" + db = SessionLocal() + + try: + print("🚀 Starting user role migration (Phase 3)...") + print("="*60) + + # Step 1: Load all roles into a map + print("\n📋 Loading roles from database...") + roles = db.query(Role).all() + role_map = {role.code: role for role in roles} + + print(f"✓ Loaded {len(roles)} roles:") + for role in roles: + print(f" • {role.name} ({role.code}) - ID: {role.id}") + + # Step 2: Get all users + print("\n👥 Loading users...") + users = db.query(User).all() + print(f"✓ Found {len(users)} users to migrate") + + # Step 3: Check if any users already have role_id set + users_with_role_id = [u for u in users if u.role_id is not None] + if users_with_role_id: + print(f"\n⚠️ Warning: {len(users_with_role_id)} users already have role_id set") + response = input("Do you want to re-migrate these users? (yes/no): ") + if response.lower() != 'yes': + print("Skipping users that already have role_id set...") + users = [u for u in users if u.role_id is None] + print(f"Will migrate {len(users)} users without role_id") + + if not users: + print("\n✅ No users to migrate!") + return + + # Step 4: Migrate users + print(f"\n🔄 Migrating {len(users)} users...") + + migration_stats = { + UserRole.guest: 0, + UserRole.member: 0, + UserRole.admin: 0, + UserRole.superadmin: 0 + } + + for user in users: + # Get the enum role code (e.g., "guest", "member", "admin", "superadmin") + role_code = user.role.value + + # Find the matching role in the roles table + if role_code not in role_map: + print(f" ⚠️ Warning: No matching role found for '{role_code}' (user: {user.email})") + continue + + # Set the role_id + user.role_id = role_map[role_code].id + migration_stats[user.role] = migration_stats.get(user.role, 0) + 1 + + # Commit all changes + db.commit() + print(f"✓ Migrated {len(users)} users") + + # Step 5: Display migration summary + print("\n" + "="*60) + print("📊 Migration Summary:") + print("="*60) + print("\nUsers migrated by role:") + for role_enum, count in migration_stats.items(): + if count > 0: + print(f" • {role_enum.value}: {count} users") + + # Step 6: Verify migration + print("\n🔍 Verifying migration...") + users_without_role_id = db.query(User).filter(User.role_id == None).count() + users_with_role_id = db.query(User).filter(User.role_id != None).count() + + print(f" • Users with role_id: {users_with_role_id}") + print(f" • Users without role_id: {users_without_role_id}") + + if users_without_role_id > 0: + print(f"\n⚠️ Warning: {users_without_role_id} users still don't have role_id set!") + else: + print("\n✅ All users successfully migrated!") + + print("\n" + "="*60) + print("✅ User migration completed successfully!") + print("="*60) + + print("\n📝 Next Steps:") + print(" 1. Migrate role_permissions table") + print(" 2. Update auth.py to use dynamic roles") + print(" 3. Update server.py role checks") + print(" 4. Verify system still works with new roles") + + except Exception as e: + db.rollback() + print(f"\n❌ Error migrating users: {str(e)}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + migrate_users() diff --git a/migrations/001_add_member_since_field.sql b/migrations/001_add_member_since_field.sql new file mode 100644 index 0000000..86aa105 --- /dev/null +++ b/migrations/001_add_member_since_field.sql @@ -0,0 +1,20 @@ +-- Migration: Add member_since field to users table +-- +-- This field allows admins to manually set historical membership dates +-- for users imported from the old WordPress site. +-- +-- For new users, it can be left NULL and will default to created_at when displayed. +-- For imported users, admins can set it to the actual date they became a member. + +-- Add member_since column (nullable timestamp with timezone) +ALTER TABLE users +ADD COLUMN member_since TIMESTAMP WITH TIME ZONE; + +-- Backfill existing active members: use created_at as default +-- This is reasonable since they became members when they created their account +UPDATE users +SET member_since = created_at +WHERE status = 'active' AND member_since IS NULL; + +-- Success message +SELECT 'Migration completed: member_since field added to users table' AS result; diff --git a/migrations/002_rename_approval_to_validation.sql b/migrations/002_rename_approval_to_validation.sql new file mode 100644 index 0000000..65d5d68 --- /dev/null +++ b/migrations/002_rename_approval_to_validation.sql @@ -0,0 +1,59 @@ +-- Migration: Rename approval terminology to validation in database +-- +-- Updates all user status values from: +-- - pending_approval → pending_validation +-- - pre_approved → pre_validated +-- +-- This migration aligns with the client's request to change all "approval" +-- terminology to "validation" throughout the application. +-- +-- IMPORTANT: This migration uses multiple transactions because PostgreSQL +-- requires enum values to be committed before they can be used. + +-- ============================================================ +-- TRANSACTION 1: Add new enum values +-- ============================================================ + +-- Add renamed values (approval → validation) +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'pending_validation'; +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'pre_validated'; + +-- Add new status types from Phase 4 (if they don't already exist) +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'canceled'; +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'expired'; +ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'abandoned'; + +-- Commit the enum additions so they can be used +COMMIT; + +-- Display progress +SELECT 'Step 1 completed: New enum values added' AS progress; + +-- ============================================================ +-- TRANSACTION 2: Update existing data +-- ============================================================ + +-- Start a new transaction +BEGIN; + +-- Update pending_approval to pending_validation +UPDATE users +SET status = 'pending_validation' +WHERE status = 'pending_approval'; + +-- Update pre_approved to pre_validated +UPDATE users +SET status = 'pre_validated' +WHERE status = 'pre_approved'; + +-- Commit the data updates +COMMIT; + +-- Success message +SELECT 'Migration completed: approval terminology updated to validation' AS result; + +-- Note: All API endpoints and frontend components must also be updated +-- to use 'validation' terminology instead of 'approval' +-- +-- Note: The old enum values 'pending_approval' and 'pre_approved' will remain +-- in the enum type but will not be used. This is normal PostgreSQL behavior. diff --git a/migrations/003_add_tos_acceptance.sql b/migrations/003_add_tos_acceptance.sql new file mode 100644 index 0000000..c151231 --- /dev/null +++ b/migrations/003_add_tos_acceptance.sql @@ -0,0 +1,23 @@ +-- Migration: Add Terms of Service acceptance fields to users table +-- +-- This migration adds: +-- - accepts_tos: Boolean field to track ToS acceptance +-- - tos_accepted_at: Timestamp of when user accepted ToS + +-- Add accepts_tos column (Boolean, default False) +ALTER TABLE users +ADD COLUMN accepts_tos BOOLEAN DEFAULT FALSE NOT NULL; + +-- Add tos_accepted_at column (nullable timestamp) +ALTER TABLE users +ADD COLUMN tos_accepted_at TIMESTAMP WITH TIME ZONE; + +-- Backfill existing users: mark as accepted with created_at date +-- This is reasonable since existing users registered before ToS requirement +UPDATE users +SET accepts_tos = TRUE, + tos_accepted_at = created_at +WHERE created_at IS NOT NULL; + +-- Success message +SELECT 'Migration completed: ToS acceptance fields added to users table' AS result; diff --git a/migrations/004_add_reminder_tracking_fields.sql b/migrations/004_add_reminder_tracking_fields.sql new file mode 100644 index 0000000..f258424 --- /dev/null +++ b/migrations/004_add_reminder_tracking_fields.sql @@ -0,0 +1,39 @@ +-- Migration: Add Reminder Tracking Fields to User Model +-- +-- This migration adds fields to track reminder emails sent to users, +-- allowing admins to see how many reminders each user has received +-- and when the last reminder was sent. +-- +-- This is especially helpful for older members who may need personal outreach. + +-- Add email verification reminder tracking +ALTER TABLE users +ADD COLUMN email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Add event attendance reminder tracking +ALTER TABLE users +ADD COLUMN event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_event_attendance_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Add payment reminder tracking +ALTER TABLE users +ADD COLUMN payment_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_payment_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Add renewal reminder tracking +ALTER TABLE users +ADD COLUMN renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL; + +ALTER TABLE users +ADD COLUMN last_renewal_reminder_at TIMESTAMP WITH TIME ZONE; + +-- Success message +SELECT 'Migration completed: Reminder tracking fields added to users table' AS result; +SELECT 'Admins can now track reminder counts in the dashboard' AS note; diff --git a/migrations/005_add_rbac_and_invitations.sql b/migrations/005_add_rbac_and_invitations.sql new file mode 100644 index 0000000..d4e2f6c --- /dev/null +++ b/migrations/005_add_rbac_and_invitations.sql @@ -0,0 +1,187 @@ +-- Migration 005: Add RBAC Permission Management, User Invitations, and Import Jobs +-- +-- This migration adds: +-- 1. Superadmin role to UserRole enum +-- 2. Permission and RolePermission tables for RBAC +-- 3. UserInvitation table for email-based invitations +-- 4. ImportJob table for CSV import tracking +-- +-- IMPORTANT: PostgreSQL requires enum values to be committed before they can be used, +-- so this migration uses multiple transactions. + +-- ============================================================ +-- TRANSACTION 1: Add new enum values +-- ============================================================ + +-- Add 'superadmin' to UserRole enum +ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'superadmin'; + +COMMIT; + +-- Display progress +SELECT 'Step 1 completed: UserRole enum updated with superadmin' AS progress; + +-- ============================================================ +-- TRANSACTION 2: Create new enum types +-- ============================================================ + +BEGIN; + +-- Create InvitationStatus enum +DO $$ BEGIN + CREATE TYPE invitationstatus AS ENUM ('pending', 'accepted', 'expired', 'revoked'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create ImportJobStatus enum +DO $$ BEGIN + CREATE TYPE importjobstatus AS ENUM ('processing', 'completed', 'failed', 'partial'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +COMMIT; + +-- Display progress +SELECT 'Step 2 completed: New enum types created' AS progress; + +-- ============================================================ +-- TRANSACTION 3: Create Permission and RolePermission tables +-- ============================================================ + +BEGIN; + +-- Create 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 +); + +-- Create indexes for permissions +CREATE INDEX IF NOT EXISTS idx_permissions_code ON permissions(code); +CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module); + +-- Create role_permissions junction table +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role userrole NOT NULL, + 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 +); + +-- Create indexes for role_permissions +CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role); +CREATE UNIQUE INDEX IF NOT EXISTS idx_role_permission ON role_permissions(role, permission_id); + +COMMIT; + +-- Display progress +SELECT 'Step 3 completed: Permission tables created' AS progress; + +-- ============================================================ +-- TRANSACTION 4: Create UserInvitation table +-- ============================================================ + +BEGIN; + +-- Create 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', + + -- Optional pre-filled information + first_name VARCHAR, + last_name VARCHAR, + phone VARCHAR, + + -- Invitation tracking + invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + accepted_at TIMESTAMP WITH TIME ZONE, + accepted_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- Create indexes for user_invitations +CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email); +CREATE INDEX IF NOT EXISTS idx_user_invitations_token ON user_invitations(token); +CREATE INDEX IF NOT EXISTS idx_user_invitations_status ON user_invitations(status); + +COMMIT; + +-- Display progress +SELECT 'Step 4 completed: UserInvitation table created' AS progress; + +-- ============================================================ +-- TRANSACTION 5: Create ImportJob table +-- ============================================================ + +BEGIN; + +-- Create import_jobs table +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename VARCHAR NOT NULL, + file_key VARCHAR, + total_rows INTEGER NOT NULL, + processed_rows INTEGER NOT NULL DEFAULT 0, + successful_rows INTEGER NOT NULL DEFAULT 0, + failed_rows INTEGER NOT NULL DEFAULT 0, + status importjobstatus NOT NULL DEFAULT 'processing', + errors JSONB NOT NULL DEFAULT '[]'::jsonb, + + -- Tracking + imported_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE +); + +-- Create indexes for import_jobs +CREATE INDEX IF NOT EXISTS idx_import_jobs_imported_by ON import_jobs(imported_by); +CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status); +CREATE INDEX IF NOT EXISTS idx_import_jobs_started_at ON import_jobs(started_at DESC); + +COMMIT; + +-- Success message +SELECT 'Migration 005 completed successfully: RBAC, Invitations, and Import Jobs tables created' AS result; + +-- ============================================================ +-- Verification Queries +-- ============================================================ + +-- Verify UserRole enum includes superadmin +SELECT enumlabel FROM pg_enum +WHERE enumtypid = 'userrole'::regtype +ORDER BY enumlabel; + +-- Verify new tables exist +SELECT table_name FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ('permissions', 'role_permissions', 'user_invitations', 'import_jobs') +ORDER BY table_name; + +-- ============================================================ +-- Rollback Instructions (if needed) +-- ============================================================ + +-- To rollback this migration, run: +-- +-- DROP TABLE IF EXISTS import_jobs CASCADE; +-- DROP TABLE IF EXISTS user_invitations CASCADE; +-- DROP TABLE IF EXISTS role_permissions CASCADE; +-- DROP TABLE IF EXISTS permissions CASCADE; +-- DROP TYPE IF EXISTS importjobstatus; +-- DROP TYPE IF EXISTS invitationstatus; +-- +-- Note: Cannot remove 'superadmin' from UserRole enum without recreating the entire enum +-- and updating all dependent tables. Only do this if no users have the superadmin role. diff --git a/migrations/006_add_dynamic_roles.sql b/migrations/006_add_dynamic_roles.sql new file mode 100644 index 0000000..b46ba5e --- /dev/null +++ b/migrations/006_add_dynamic_roles.sql @@ -0,0 +1,91 @@ +-- Migration 006: Add Dynamic Roles System (Phase 1) +-- +-- This migration adds support for dynamic role creation: +-- 1. Creates the 'roles' table for dynamic role management +-- 2. Adds 'role_id' column to 'users' table (nullable for backward compatibility) +-- 3. Adds 'role_id' column to 'role_permissions' table (nullable for backward compatibility) +-- +-- IMPORTANT: This is Phase 1 of the migration. The old 'role' enum columns are kept +-- for backward compatibility. They will be removed in Phase 4 after data migration. + +-- ============================================================ +-- TRANSACTION 1: Create roles table +-- ============================================================ + +BEGIN; + +-- Create roles table +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 +); + +-- Create indexes for roles +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); + +COMMIT; + +-- Display progress +SELECT 'Step 1 completed: roles table created' AS progress; + +-- ============================================================ +-- TRANSACTION 2: Add role_id column to users table +-- ============================================================ + +BEGIN; + +-- Add role_id column to users table (nullable for Phase 1) +ALTER TABLE users +ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES roles(id) ON DELETE SET NULL; + +-- Create index for role_id +CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id); + +COMMIT; + +-- Display progress +SELECT 'Step 2 completed: role_id column added to users table' AS progress; + +-- ============================================================ +-- TRANSACTION 3: Add role_id column to role_permissions table +-- ============================================================ + +BEGIN; + +-- Add role_id column to role_permissions table (nullable for Phase 1) +ALTER TABLE role_permissions +ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES roles(id) ON DELETE CASCADE; + +-- Create index for role_id +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); + +COMMIT; + +-- Display progress +SELECT 'Step 3 completed: role_id column added to role_permissions table' AS progress; + +-- ============================================================ +-- Migration Complete +-- ============================================================ + +SELECT ' +Migration 006 completed successfully! + +Next steps: +1. Run Phase 2: Create seed script to populate system roles (Superadmin, Finance, Member, Guest) +2. Run Phase 3: Migrate existing data from enum to role_id +3. Run Phase 4: Remove old enum columns (after verifying data migration) + +Current status: +- roles table created ✓ +- users.role_id added (nullable) ✓ +- role_permissions.role_id added (nullable) ✓ +- Legacy enum columns retained for backward compatibility ✓ +' AS migration_status; diff --git a/migrations/README.md b/migrations/README.md index 4670569..d2e49c7 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -136,3 +136,211 @@ DROP TABLE IF EXISTS financial_reports; DROP TABLE IF EXISTS bylaws_documents; DROP TABLE IF EXISTS storage_usage; ``` + +--- + +## Running Phase 1-4.5 Migrations (December 2025) + +These migrations add features from client feedback phases 1-4.5: +- Member Since field for imported users +- Approval → Validation terminology update +- Terms of Service acceptance tracking +- Reminder email tracking for admin dashboard + +### Quick Start + +Run all migrations at once: + +```bash +cd backend/migrations +psql $DATABASE_URL -f run_all_migrations.sql +``` + +### Individual Migration Files + +The migrations are numbered in the order they should be run: + +1. **001_add_member_since_field.sql** - Adds editable `member_since` field for imported users +2. **002_rename_approval_to_validation.sql** - Updates terminology from "approval" to "validation" +3. **003_add_tos_acceptance.sql** - Adds Terms of Service acceptance tracking +4. **004_add_reminder_tracking_fields.sql** - Adds reminder email tracking for admin dashboard + +### Run Individual Migrations + +```bash +cd backend/migrations + +# Run migrations one by one +psql $DATABASE_URL -f 001_add_member_since_field.sql +psql $DATABASE_URL -f 002_rename_approval_to_validation.sql +psql $DATABASE_URL -f 003_add_tos_acceptance.sql +psql $DATABASE_URL -f 004_add_reminder_tracking_fields.sql +``` + +### Using psql Interactive Mode + +```bash +# Connect to your database +psql $DATABASE_URL + +# Inside psql, run: +\i backend/migrations/001_add_member_since_field.sql +\i backend/migrations/002_rename_approval_to_validation.sql +\i backend/migrations/003_add_tos_acceptance.sql +\i backend/migrations/004_add_reminder_tracking_fields.sql +``` + +### What Each Migration Adds + +**Migration 001 - Member Since Field:** +- Adds `member_since` column (nullable timestamp) +- Backfills active members with their `created_at` date +- Allows admins to edit dates for imported users + +**Migration 002 - Approval → Validation Terminology:** +- Updates `pending_approval` → `pending_validation` +- Updates `pre_approved` → `pre_validated` +- Aligns database with client's terminology requirements + +**Migration 003 - ToS Acceptance:** +- Adds `accepts_tos` boolean field (default false) +- Adds `tos_accepted_at` timestamp field +- Backfills existing users as having accepted ToS + +**Migration 004 - Reminder Tracking:** +- Adds 8 fields to track reminder emails: + - `email_verification_reminders_sent` + `last_email_verification_reminder_at` + - `event_attendance_reminders_sent` + `last_event_attendance_reminder_at` + - `payment_reminders_sent` + `last_payment_reminder_at` + - `renewal_reminders_sent` + `last_renewal_reminder_at` +- Enables admin dashboard to show users needing personal outreach + +### Verification + +After running migrations, verify they completed successfully: + +```sql +-- Check if new columns exist +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'users' + AND column_name IN ( + 'member_since', + 'accepts_tos', + 'tos_accepted_at', + 'email_verification_reminders_sent', + 'last_email_verification_reminder_at', + 'event_attendance_reminders_sent', + 'last_event_attendance_reminder_at', + 'payment_reminders_sent', + 'last_payment_reminder_at', + 'renewal_reminders_sent', + 'last_renewal_reminder_at' + ) +ORDER BY column_name; + +-- Check status values were updated +SELECT status, COUNT(*) +FROM users +GROUP BY status; +``` + +### Rollback Phase 1-4.5 Migrations (If Needed) + +```sql +-- Rollback 004: Remove reminder tracking fields +ALTER TABLE users +DROP COLUMN IF EXISTS email_verification_reminders_sent, +DROP COLUMN IF EXISTS last_email_verification_reminder_at, +DROP COLUMN IF EXISTS event_attendance_reminders_sent, +DROP COLUMN IF EXISTS last_event_attendance_reminder_at, +DROP COLUMN IF EXISTS payment_reminders_sent, +DROP COLUMN IF EXISTS last_payment_reminder_at, +DROP COLUMN IF EXISTS renewal_reminders_sent, +DROP COLUMN IF EXISTS last_renewal_reminder_at; + +-- Rollback 003: Remove ToS fields +ALTER TABLE users +DROP COLUMN IF EXISTS accepts_tos, +DROP COLUMN IF EXISTS tos_accepted_at; + +-- Rollback 002: Revert validation to approval +UPDATE users SET status = 'pending_approval' WHERE status = 'pending_validation'; +UPDATE users SET status = 'pre_approved' WHERE status = 'pre_validated'; + +-- Rollback 001: Remove member_since field +ALTER TABLE users DROP COLUMN IF EXISTS member_since; +``` + +--- + +## Running Phase RBAC Migration (December 2025) + +This migration adds RBAC permission management, user invitations, and CSV import tracking capabilities. + +### Quick Start + +```bash +cd backend/migrations +psql $DATABASE_URL -f 005_add_rbac_and_invitations.sql +``` + +### What This Migration Adds + +**UserRole Enum Update:** +- Adds `superadmin` role to UserRole enum + +**New Tables:** +1. **permissions** - Granular permission definitions (60+ permissions) +2. **role_permissions** - Junction table linking roles to permissions +3. **user_invitations** - Email-based invitation tracking with tokens +4. **import_jobs** - CSV import job tracking with error logging + +**New Enum Types:** +- `invitationstatus` (pending, accepted, expired, revoked) +- `importjobstatus` (processing, completed, failed, partial) + +### Verification + +After running the migration, verify it completed successfully: + +```sql +-- Check if superadmin role exists +SELECT enumlabel FROM pg_enum +WHERE enumtypid = 'userrole'::regtype +ORDER BY enumlabel; + +-- Check if new tables exist +SELECT table_name FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ('permissions', 'role_permissions', 'user_invitations', 'import_jobs') +ORDER BY table_name; + +-- Check table structures +\d permissions +\d role_permissions +\d user_invitations +\d import_jobs +``` + +### Next Steps After Migration + +1. **Seed Permissions**: Run `permissions_seed.py` to populate default permissions +2. **Upgrade Admin to Superadmin**: Update existing admin users to superadmin role +3. **Assign Permissions**: Configure permissions for admin, member, and guest roles + +### Rollback (If Needed) + +```sql +-- Remove all RBAC tables and enums +DROP TABLE IF EXISTS import_jobs CASCADE; +DROP TABLE IF EXISTS user_invitations CASCADE; +DROP TABLE IF EXISTS role_permissions CASCADE; +DROP TABLE IF EXISTS permissions CASCADE; +DROP TYPE IF EXISTS importjobstatus; +DROP TYPE IF EXISTS invitationstatus; + +-- Note: Cannot remove 'superadmin' from UserRole enum without recreating +-- the entire enum. Only rollback if no users have the superadmin role. +``` + diff --git a/models.py b/models.py index 0e1fe35..79dedf9 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON +from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON, Index from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from datetime import datetime, timezone @@ -8,16 +8,20 @@ from database import Base class UserStatus(enum.Enum): pending_email = "pending_email" - pending_approval = "pending_approval" - pre_approved = "pre_approved" + pending_validation = "pending_validation" + pre_validated = "pre_validated" payment_pending = "payment_pending" active = "active" inactive = "inactive" + canceled = "canceled" # User or admin canceled membership + expired = "expired" # Subscription ended without renewal + abandoned = "abandoned" # Incomplete registration (no verification/event/payment) class UserRole(enum.Enum): guest = "guest" member = "member" admin = "admin" + superadmin = "superadmin" class RSVPStatus(enum.Enum): yes = "yes" @@ -50,7 +54,8 @@ class User(Base): partner_plan_to_become_member = Column(Boolean, default=False) referred_by_member_name = Column(String, nullable=True) status = Column(SQLEnum(UserStatus), default=UserStatus.pending_email, nullable=False) - role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) + role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) # Legacy enum, kept for backward compatibility + role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True) # New dynamic role FK email_verified = Column(Boolean, default=False) email_verification_token = Column(String, nullable=True) newsletter_subscribed = Column(Boolean, default=False) @@ -89,10 +94,31 @@ class User(Base): social_media_twitter = Column(String, nullable=True) social_media_linkedin = Column(String, nullable=True) + # Terms of Service Acceptance (Step 4) + accepts_tos = Column(Boolean, default=False, nullable=False) + tos_accepted_at = Column(DateTime, nullable=True) + + # Member Since Date - Editable by admins for imported users + member_since = Column(DateTime, nullable=True, comment="Date when user became a member - editable by admins for imported users") + + # Reminder Tracking - for admin dashboard visibility + email_verification_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of email verification reminders sent") + last_email_verification_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last verification reminder") + + event_attendance_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of event attendance reminders sent") + last_event_attendance_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last event attendance reminder") + + payment_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of payment reminders sent") + last_payment_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last payment reminder") + + 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") + 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)) # Relationships + role_obj = relationship("Role", back_populates="users", foreign_keys=[role_id]) events_created = relationship("Event", back_populates="creator") rsvps = relationship("EventRSVP", back_populates="user") subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") @@ -271,3 +297,128 @@ class StorageUsage(Base): total_bytes_used = Column(BigInteger, default=0) max_bytes_allowed = Column(BigInteger, nullable=False) # From .env last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + +# ============================================================ +# RBAC Permission Management Models +# ============================================================ + +class Permission(Base): + """Granular permissions for role-based access control""" + __tablename__ = "permissions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + code = Column(String, unique=True, nullable=False, index=True) # "users.create", "events.edit" + name = Column(String, nullable=False) # "Create Users", "Edit Events" + description = Column(Text, nullable=True) + module = Column(String, nullable=False, index=True) # "users", "events", "subscriptions" + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan") + +class Role(Base): + """Dynamic roles that can be created by admins""" + __tablename__ = "roles" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + code = Column(String, unique=True, nullable=False, index=True) # "superadmin", "finance", "custom_role_1" + name = Column(String, nullable=False) # "Superadmin", "Finance Manager", "Custom Role" + description = Column(Text, nullable=True) + is_system_role = Column(Boolean, default=False, nullable=False) # True for Superadmin, Member, Guest (non-deletable) + 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)) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + # Relationships + users = relationship("User", back_populates="role_obj", foreign_keys="User.role_id") + role_permissions = relationship("RolePermission", back_populates="role_obj", cascade="all, delete-orphan") + creator = relationship("User", foreign_keys=[created_by]) + +class RolePermission(Base): + """Junction table linking roles to permissions""" + __tablename__ = "role_permissions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + role = Column(SQLEnum(UserRole), nullable=False, index=True) # Legacy enum, kept for backward compatibility + role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True) # New dynamic role FK + permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id"), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + # Relationships + role_obj = relationship("Role", back_populates="role_permissions") + permission = relationship("Permission", back_populates="role_permissions") + creator = relationship("User", foreign_keys=[created_by]) + + # Composite unique index + __table_args__ = ( + Index('idx_role_permission', 'role', 'permission_id', unique=True), + ) + +# ============================================================ +# User Invitation Models +# ============================================================ + +class InvitationStatus(enum.Enum): + pending = "pending" + accepted = "accepted" + expired = "expired" + revoked = "revoked" + +class UserInvitation(Base): + """Email-based user invitations with tokens""" + __tablename__ = "user_invitations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String, nullable=False, index=True) + token = Column(String, unique=True, nullable=False, index=True) + role = Column(SQLEnum(UserRole), nullable=False) + status = Column(SQLEnum(InvitationStatus), default=InvitationStatus.pending, nullable=False) + + # Optional pre-filled information + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + phone = Column(String, nullable=True) + + # Invitation tracking + invited_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + invited_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + expires_at = Column(DateTime, nullable=False) + accepted_at = Column(DateTime, nullable=True) + accepted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + # Relationships + inviter = relationship("User", foreign_keys=[invited_by]) + accepted_user = relationship("User", foreign_keys=[accepted_by]) + +# ============================================================ +# CSV Import/Export Models +# ============================================================ + +class ImportJobStatus(enum.Enum): + processing = "processing" + completed = "completed" + failed = "failed" + partial = "partial" + +class ImportJob(Base): + """Track CSV import jobs with error handling""" + __tablename__ = "import_jobs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + filename = Column(String, nullable=False) + file_key = Column(String, nullable=True) # R2 object key for uploaded CSV + total_rows = Column(Integer, nullable=False) + processed_rows = Column(Integer, default=0, nullable=False) + successful_rows = Column(Integer, default=0, nullable=False) + failed_rows = Column(Integer, default=0, nullable=False) + status = Column(SQLEnum(ImportJobStatus), default=ImportJobStatus.processing, nullable=False) + errors = Column(JSON, default=list, nullable=False) # [{row: 5, field: "email", error: "Invalid format"}] + + # Tracking + imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + completed_at = Column(DateTime, nullable=True) + + # Relationships + importer = relationship("User", foreign_keys=[imported_by]) diff --git a/permissions_seed.py b/permissions_seed.py new file mode 100644 index 0000000..13e42f6 --- /dev/null +++ b/permissions_seed.py @@ -0,0 +1,549 @@ +""" +Permission Seeding Script + +This script populates the database with 60+ granular permissions for RBAC. +Permissions are organized into 9 modules: users, events, subscriptions, +financials, newsletters, bylaws, gallery, settings, and permissions. + +Usage: + python permissions_seed.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import Permission, RolePermission, UserRole +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# ============================================================ +# Permission Definitions +# ============================================================ + +PERMISSIONS = [ + # ========== USERS MODULE ========== + { + "code": "users.view", + "name": "View Users", + "description": "View user list and user profiles", + "module": "users" + }, + { + "code": "users.create", + "name": "Create Users", + "description": "Create new users and send invitations", + "module": "users" + }, + { + "code": "users.edit", + "name": "Edit Users", + "description": "Edit user profiles and information", + "module": "users" + }, + { + "code": "users.delete", + "name": "Delete Users", + "description": "Delete user accounts", + "module": "users" + }, + { + "code": "users.status", + "name": "Change User Status", + "description": "Change user status (active, inactive, etc.)", + "module": "users" + }, + { + "code": "users.approve", + "name": "Approve/Validate Users", + "description": "Approve or validate user applications", + "module": "users" + }, + { + "code": "users.export", + "name": "Export Users", + "description": "Export user data to CSV", + "module": "users" + }, + { + "code": "users.import", + "name": "Import Users", + "description": "Import users from CSV", + "module": "users" + }, + { + "code": "users.reset_password", + "name": "Reset User Password", + "description": "Reset user passwords via email", + "module": "users" + }, + { + "code": "users.resend_verification", + "name": "Resend Verification Email", + "description": "Resend email verification links", + "module": "users" + }, + + # ========== EVENTS MODULE ========== + { + "code": "events.view", + "name": "View Events", + "description": "View event list and event details", + "module": "events" + }, + { + "code": "events.create", + "name": "Create Events", + "description": "Create new events", + "module": "events" + }, + { + "code": "events.edit", + "name": "Edit Events", + "description": "Edit existing events", + "module": "events" + }, + { + "code": "events.delete", + "name": "Delete Events", + "description": "Delete events", + "module": "events" + }, + { + "code": "events.publish", + "name": "Publish Events", + "description": "Publish or unpublish events", + "module": "events" + }, + { + "code": "events.attendance", + "name": "Mark Event Attendance", + "description": "Mark user attendance for events", + "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" + }, + + # ========== SUBSCRIPTIONS MODULE ========== + { + "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" + }, + + # ========== FINANCIALS MODULE ========== + { + "code": "financials.view", + "name": "View Financial Reports", + "description": "View financial reports and dashboards", + "module": "financials" + }, + { + "code": "financials.create", + "name": "Create Financial Reports", + "description": "Upload and create financial reports", + "module": "financials" + }, + { + "code": "financials.edit", + "name": "Edit Financial Reports", + "description": "Edit existing financial reports", + "module": "financials" + }, + { + "code": "financials.delete", + "name": "Delete Financial Reports", + "description": "Delete financial reports", + "module": "financials" + }, + { + "code": "financials.export", + "name": "Export Financial Data", + "description": "Export financial data to CSV/PDF", + "module": "financials" + }, + { + "code": "financials.payments", + "name": "View Payment Details", + "description": "View detailed payment information", + "module": "financials" + }, + + # ========== NEWSLETTERS MODULE ========== + { + "code": "newsletters.view", + "name": "View Newsletters", + "description": "View newsletter archives", + "module": "newsletters" + }, + { + "code": "newsletters.create", + "name": "Create Newsletters", + "description": "Upload and create newsletters", + "module": "newsletters" + }, + { + "code": "newsletters.edit", + "name": "Edit Newsletters", + "description": "Edit existing newsletters", + "module": "newsletters" + }, + { + "code": "newsletters.delete", + "name": "Delete Newsletters", + "description": "Delete newsletter archives", + "module": "newsletters" + }, + { + "code": "newsletters.send", + "name": "Send Newsletters", + "description": "Send newsletter emails to subscribers", + "module": "newsletters" + }, + { + "code": "newsletters.subscribers", + "name": "Manage Newsletter Subscribers", + "description": "View and manage newsletter subscribers", + "module": "newsletters" + }, + + # ========== BYLAWS MODULE ========== + { + "code": "bylaws.view", + "name": "View Bylaws", + "description": "View organization bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.create", + "name": "Create Bylaws", + "description": "Upload new bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.edit", + "name": "Edit Bylaws", + "description": "Edit existing bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.delete", + "name": "Delete Bylaws", + "description": "Delete bylaws documents", + "module": "bylaws" + }, + { + "code": "bylaws.publish", + "name": "Publish Bylaws", + "description": "Mark bylaws as current/published version", + "module": "bylaws" + }, + + # ========== GALLERY MODULE ========== + { + "code": "gallery.view", + "name": "View Event Gallery", + "description": "View event gallery photos", + "module": "gallery" + }, + { + "code": "gallery.upload", + "name": "Upload Photos", + "description": "Upload photos to event galleries", + "module": "gallery" + }, + { + "code": "gallery.edit", + "name": "Edit Photos", + "description": "Edit photo captions and details", + "module": "gallery" + }, + { + "code": "gallery.delete", + "name": "Delete Photos", + "description": "Delete photos from galleries", + "module": "gallery" + }, + { + "code": "gallery.moderate", + "name": "Moderate Gallery Content", + "description": "Approve/reject uploaded photos", + "module": "gallery" + }, + + # ========== SETTINGS MODULE ========== + { + "code": "settings.view", + "name": "View Settings", + "description": "View application settings", + "module": "settings" + }, + { + "code": "settings.edit", + "name": "Edit Settings", + "description": "Edit application settings", + "module": "settings" + }, + { + "code": "settings.email_templates", + "name": "Manage Email Templates", + "description": "Edit email templates and notifications", + "module": "settings" + }, + { + "code": "settings.storage", + "name": "Manage Storage", + "description": "View and manage storage usage", + "module": "settings" + }, + { + "code": "settings.backup", + "name": "Backup & Restore", + "description": "Create and restore database backups", + "module": "settings" + }, + { + "code": "settings.logs", + "name": "View System Logs", + "description": "View application and audit logs", + "module": "settings" + }, + + # ========== PERMISSIONS MODULE (SUPERADMIN ONLY) ========== + { + "code": "permissions.view", + "name": "View Permissions", + "description": "View permission definitions and assignments", + "module": "permissions" + }, + { + "code": "permissions.assign", + "name": "Assign Permissions", + "description": "Assign permissions to roles (SUPERADMIN ONLY)", + "module": "permissions" + }, + { + "code": "permissions.manage_roles", + "name": "Manage Roles", + "description": "Create and manage user roles", + "module": "permissions" + }, + { + "code": "permissions.audit", + "name": "View Permission Audit Log", + "description": "View permission change audit logs", + "module": "permissions" + }, +] + +# Default permission assignments for each role +DEFAULT_ROLE_PERMISSIONS = { + UserRole.guest: [], # Guests have no admin permissions + + UserRole.member: [ + # Members can view public content + "events.view", + "events.rsvps", + "events.calendar_export", + "newsletters.view", + "bylaws.view", + "gallery.view", + ], + + UserRole.admin: [ + # Admins have most permissions except RBAC management + "users.view", + "users.create", + "users.edit", + "users.status", + "users.approve", + "users.export", + "users.import", + "users.reset_password", + "users.resend_verification", + "events.view", + "events.create", + "events.edit", + "events.delete", + "events.publish", + "events.attendance", + "events.rsvps", + "events.calendar_export", + "subscriptions.view", + "subscriptions.create", + "subscriptions.edit", + "subscriptions.cancel", + "subscriptions.activate", + "subscriptions.plans", + "financials.view", + "financials.create", + "financials.edit", + "financials.delete", + "financials.export", + "financials.payments", + "newsletters.view", + "newsletters.create", + "newsletters.edit", + "newsletters.delete", + "newsletters.send", + "newsletters.subscribers", + "bylaws.view", + "bylaws.create", + "bylaws.edit", + "bylaws.delete", + "bylaws.publish", + "gallery.view", + "gallery.upload", + "gallery.edit", + "gallery.delete", + "gallery.moderate", + "settings.view", + "settings.edit", + "settings.email_templates", + "settings.storage", + "settings.logs", + ], + + # Superadmin gets all permissions automatically in code, + # so we don't need to explicitly assign them + UserRole.superadmin: [] +} + + +def seed_permissions(): + """Seed permissions and default role assignments""" + db = SessionLocal() + + try: + print("🌱 Starting permission seeding...") + + # Step 1: Clear existing permissions and role_permissions + print("\n📦 Clearing existing permissions and role assignments...") + db.query(RolePermission).delete() + db.query(Permission).delete() + db.commit() + print("✓ Cleared existing data") + + # Step 2: Create permissions + print(f"\n📝 Creating {len(PERMISSIONS)} permissions...") + permission_map = {} # Map code to permission object + + for perm_data in PERMISSIONS: + permission = Permission( + code=perm_data["code"], + name=perm_data["name"], + description=perm_data["description"], + module=perm_data["module"] + ) + db.add(permission) + permission_map[perm_data["code"]] = permission + + db.commit() + print(f"✓ Created {len(PERMISSIONS)} permissions") + + # Step 3: Assign default permissions to roles + print("\n🔐 Assigning default permissions to roles...") + + for role, permission_codes in DEFAULT_ROLE_PERMISSIONS.items(): + if not permission_codes: + print(f" • {role.value}: No default permissions (handled in code)") + continue + + for code in permission_codes: + if code not in permission_map: + print(f" ⚠️ Warning: Permission '{code}' not found for role {role.value}") + continue + + role_permission = RolePermission( + role=role, + permission_id=permission_map[code].id + ) + db.add(role_permission) + + db.commit() + print(f" ✓ {role.value}: Assigned {len(permission_codes)} permissions") + + # Step 4: Summary + print("\n" + "="*60) + print("📊 Seeding Summary:") + print("="*60) + + # Count permissions by module + modules = {} + for perm in PERMISSIONS: + module = perm["module"] + modules[module] = modules.get(module, 0) + 1 + + print("\nPermissions by module:") + for module, count in sorted(modules.items()): + print(f" • {module.capitalize()}: {count} permissions") + + print(f"\nTotal permissions: {len(PERMISSIONS)}") + print("\n✅ Permission seeding completed successfully!") + + except Exception as e: + db.rollback() + print(f"\n❌ Error seeding permissions: {str(e)}") + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_permissions() diff --git a/reminder_emails.py b/reminder_emails.py new file mode 100644 index 0000000..e53e188 --- /dev/null +++ b/reminder_emails.py @@ -0,0 +1,487 @@ +""" +Reminder Email System + +This module handles all reminder emails sent before status transitions. +Ensures users receive multiple reminders before any auto-abandonment occurs. +""" + +from datetime import datetime, timezone, timedelta +from typing import Dict, List, Optional +import logging + +logger = logging.getLogger(__name__) + +# Reminder schedules (in days since status started) +REMINDER_SCHEDULES = { + 'email_verification': [3, 7, 14, 30], # Before potential abandonment + 'event_attendance': [30, 60, 80, 85], # Before 90-day deadline + 'payment_pending': [7, 14, 21, 30, 45, 60], # Before potential abandonment + 'renewal': [60, 30, 14, 7], # Before expiration + 'post_expiration': [7, 30, 90] # After expiration +} + + +def get_days_since_status_change(user, current_status: str) -> int: + """ + Calculate number of days since user entered current status. + + Args: + user: User object + current_status: Current status to check + + Returns: + Number of days since status change + """ + if not user.updated_at: + return 0 + + delta = datetime.now(timezone.utc) - user.updated_at + return delta.days + + +def should_send_reminder(days_elapsed: int, schedule: List[int], last_reminder_day: Optional[int] = None) -> Optional[int]: + """ + Determine if a reminder should be sent based on elapsed days. + + Args: + days_elapsed: Days since status change + schedule: List of reminder days + last_reminder_day: Day of last reminder sent (optional) + + Returns: + Reminder day if should send, None otherwise + """ + for reminder_day in schedule: + if days_elapsed >= reminder_day: + # Check if we haven't sent this reminder yet + if last_reminder_day is None or last_reminder_day < reminder_day: + return reminder_day + + return None + + +def send_email_verification_reminder(user, days_elapsed: int, email_service, db_session=None): + """ + Send email verification reminder. + + Args: + user: User object + days_elapsed: Days since registration + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + reminder_number = REMINDER_SCHEDULES['email_verification'].index(days_elapsed) + 1 if days_elapsed in REMINDER_SCHEDULES['email_verification'] else 0 + + subject = f"Reminder: Verify your email to complete registration" + + if reminder_number == 4: + # Final reminder + message = f""" +

Final Reminder: Complete Your LOAF Registration

+

Hi {user.first_name},

+

This is your final reminder to verify your email address and complete your LOAF membership registration.

+

It's been {days_elapsed} days since you registered. If you don't verify your email soon, + your application will be marked as abandoned and you'll need to contact us to restart the process.

+

Click the link below to verify your email:

+

Verify Email Address

+

Need help? Reply to this email or contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + else: + message = f""" +

Reminder: Verify Your Email Address

+

Hi {user.first_name},

+

You registered for LOAF membership {days_elapsed} days ago but haven't verified your email yet.

+

Click the link below to verify your email and continue your membership journey:

+

Verify Email Address

+

Once verified, you'll receive our monthly newsletter with event announcements!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent email verification reminder #{reminder_number} to user {user.id} (day {days_elapsed})") + + # Track reminder in database for admin visibility + if db_session: + user.email_verification_reminders_sent = (user.email_verification_reminders_sent or 0) + 1 + user.last_email_verification_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.email_verification_reminders_sent} verification reminders") + + return True + except Exception as e: + logger.error(f"Failed to send email verification reminder to user {user.id}: {str(e)}") + return False + + +def send_event_attendance_reminder(user, days_elapsed: int, email_service, db_session=None): + """ + Send event attendance reminder. + + Args: + user: User object + days_elapsed: Days since email verification + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + days_remaining = 90 - days_elapsed + + subject = f"Reminder: Attend a LOAF event ({days_remaining} days remaining)" + + if days_elapsed >= 85: + # Final reminder (5 days left) + message = f""" +

Final Reminder: Only {days_remaining} Days to Attend an Event!

+

Hi {user.first_name},

+

Important: You have only {days_remaining} days left to attend a LOAF event + and complete your membership application.

+

If you don't attend an event within the 90-day period, your application will be marked as + abandoned per LOAF policy, and you'll need to contact us to restart.

+

Check out our upcoming events in the monthly newsletter or visit our events page!

+

Need help finding an event? Reply to this email or contact us at info@loaftx.org

+

We'd love to meet you soon!

+

Best regards,
LOAF Team

+ """ + elif days_elapsed >= 80: + # 10 days left + message = f""" +

Reminder: {days_remaining} Days to Attend a LOAF Event

+

Hi {user.first_name},

+

Just a friendly reminder that you have {days_remaining} days left to attend a LOAF event + and complete your membership application.

+

Per LOAF policy, new applicants must attend an event within 90 days of email verification + to continue the membership process.

+

Check your newsletter for upcoming events, and we look forward to meeting you soon!

+

Best regards,
LOAF Team

+ """ + elif days_elapsed >= 60: + # 30 days left + message = f""" +

Reminder: {days_remaining} Days to Attend a LOAF Event

+

Hi {user.first_name},

+

You have {days_remaining} days remaining to attend a LOAF event as part of your membership application.

+

Attending an event is a great way to meet other members and learn more about LOAF. + Check out the upcoming events in your monthly newsletter!

+

We look forward to seeing you soon!

+

Best regards,
LOAF Team

+ """ + else: + # 60 days left + message = f""" +

Reminder: Attend a LOAF Event (60 Days Remaining)

+

Hi {user.first_name},

+

Welcome to LOAF! As part of your membership application, you have 90 days to attend one of our events.

+

You have {days_remaining} days remaining to attend an event and continue your membership journey.

+

Check out the events listed in your monthly newsletter. We can't wait to meet you!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent event attendance reminder to user {user.id} (day {days_elapsed}, {days_remaining} days left)") + + # Track reminder in database for admin visibility + if db_session: + user.event_attendance_reminders_sent = (user.event_attendance_reminders_sent or 0) + 1 + user.last_event_attendance_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.event_attendance_reminders_sent} event attendance reminders") + + return True + except Exception as e: + logger.error(f"Failed to send event attendance reminder to user {user.id}: {str(e)}") + return False + + +def send_payment_reminder(user, days_elapsed: int, email_service, db_session=None): + """ + Send payment reminder. + + Args: + user: User object + days_elapsed: Days since admin validation + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + reminder_count = sum(1 for day in REMINDER_SCHEDULES['payment_pending'] if day <= days_elapsed) + + subject = f"Reminder: Complete your LOAF membership payment" + + if days_elapsed >= 60: + # Final reminder + message = f""" +

Final Payment Reminder

+

Hi {user.first_name},

+

Congratulations again on being validated for LOAF membership!

+

This is a final reminder to complete your membership payment. It's been {days_elapsed} days + since your application was validated.

+

Your payment link is still active. Click below to complete your payment and activate your membership:

+

Complete Payment

+

Once payment is complete, you'll gain full access to all member benefits!

+

Questions? Contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + elif days_elapsed >= 45: + message = f""" +

Payment Reminder - Complete Your Membership

+

Hi {user.first_name},

+

Your LOAF membership application was validated and is ready for payment!

+

Complete your payment to activate your membership and gain access to all member benefits:

+

Complete Payment

+

We're excited to welcome you as a full member!

+

Best regards,
LOAF Team

+ """ + else: + message = f""" +

Payment Reminder

+

Hi {user.first_name},

+

This is a friendly reminder to complete your LOAF membership payment.

+

Your application was validated {days_elapsed} days ago. Click below to complete payment:

+

Complete Payment

+

Questions about payment options? Contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent payment reminder #{reminder_count} to user {user.id} (day {days_elapsed})") + + # Track reminder in database for admin visibility + if db_session: + user.payment_reminders_sent = (user.payment_reminders_sent or 0) + 1 + user.last_payment_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.payment_reminders_sent} payment reminders") + + return True + except Exception as e: + logger.error(f"Failed to send payment reminder to user {user.id}: {str(e)}") + return False + + +def send_renewal_reminder(user, subscription, days_until_expiration: int, email_service, db_session=None): + """ + Send membership renewal reminder. + + Args: + user: User object + subscription: Subscription object + days_until_expiration: Days until subscription expires + email_service: Email service instance + db_session: Database session (optional, for tracking) + + Returns: + True if email sent successfully + """ + subject = f"Reminder: Your LOAF membership expires in {days_until_expiration} days" + + if days_until_expiration <= 7: + # Final reminder + message = f""" +

Final Reminder: Renew Your LOAF Membership

+

Hi {user.first_name},

+

Your LOAF membership expires in {days_until_expiration} days!

+

Don't lose access to member benefits. Renew now to continue enjoying:

+
    +
  • Exclusive member events
  • +
  • Member directory access
  • +
  • Monthly newsletter
  • +
  • Community connection
  • +
+

Renew Your Membership Now

+

Questions? Contact us at info@loaftx.org

+

Best regards,
LOAF Team

+ """ + else: + message = f""" +

Reminder: Renew Your LOAF Membership

+

Hi {user.first_name},

+

Your LOAF membership will expire in {days_until_expiration} days.

+

Renew now to continue enjoying all member benefits without interruption:

+

Renew Your Membership

+

Thank you for being part of the LOAF community!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent renewal reminder to user {user.id} ({days_until_expiration} days until expiration)") + + # Track reminder in database for admin visibility + if db_session: + user.renewal_reminders_sent = (user.renewal_reminders_sent or 0) + 1 + user.last_renewal_reminder_at = datetime.now(timezone.utc) + db_session.commit() + logger.info(f"Updated reminder tracking: user {user.id} has received {user.renewal_reminders_sent} renewal reminders") + + return True + except Exception as e: + logger.error(f"Failed to send renewal reminder to user {user.id}: {str(e)}") + return False + + +def send_post_expiration_reminder(user, days_since_expiration: int, email_service): + """ + Send reminder to renew after membership has expired. + + Args: + user: User object + days_since_expiration: Days since expiration + email_service: Email service instance + + Returns: + True if email sent successfully + """ + subject = "We'd love to have you back at LOAF!" + + if days_since_expiration >= 90: + # Final reminder + message = f""" +

We Miss You at LOAF!

+

Hi {user.first_name},

+

Your LOAF membership expired {days_since_expiration} days ago, and we'd love to have you back!

+

Rejoin the community and reconnect with friends:

+

Renew Your Membership

+

Questions? We're here to help: info@loaftx.org

+

Best regards,
LOAF Team

+ """ + elif days_since_expiration >= 30: + message = f""" +

Renew Your LOAF Membership

+

Hi {user.first_name},

+

Your LOAF membership expired {days_since_expiration} days ago.

+

We'd love to have you back! Renew today to regain access to:

+
    +
  • Member events and gatherings
  • +
  • Member directory
  • +
  • Community connection
  • +
+

Renew Your Membership

+

Best regards,
LOAF Team

+ """ + else: + # 7 days after expiration + message = f""" +

Your LOAF Membership Has Expired

+

Hi {user.first_name},

+

Your LOAF membership expired recently. We hope it was just an oversight!

+

Renew now to restore your access to all member benefits:

+

Renew Your Membership

+

We look forward to seeing you at upcoming events!

+

Best regards,
LOAF Team

+ """ + + try: + email_service.send_email(user.email, subject, message) + logger.info(f"Sent post-expiration reminder to user {user.id} ({days_since_expiration} days since expiration)") + return True + except Exception as e: + logger.error(f"Failed to send post-expiration reminder to user {user.id}: {str(e)}") + return False + + +# Background job for sending reminder emails +def process_reminder_emails(db_session, email_service): + """ + Process and send all due reminder emails. + + This should be run as an hourly background job. + + Args: + db_session: Database session + email_service: Email service instance + + Returns: + Dictionary with counts of emails sent + """ + from models import User, UserStatus, Subscription + from datetime import date + + results = { + 'email_verification': 0, + 'event_attendance': 0, + 'payment': 0, + 'renewal': 0, + 'post_expiration': 0 + } + + # 1. Email Verification Reminders + for reminder_day in REMINDER_SCHEDULES['email_verification']: + users = db_session.query(User).filter( + User.status == UserStatus.pending_email, + User.email_verified == False + ).all() + + for user in users: + days_elapsed = get_days_since_status_change(user, 'pending_email') + if days_elapsed == reminder_day: + if send_email_verification_reminder(user, days_elapsed, email_service, db_session): + results['email_verification'] += 1 + + # 2. Event Attendance Reminders + for reminder_day in REMINDER_SCHEDULES['event_attendance']: + users = db_session.query(User).filter( + User.status == UserStatus.pending_validation + ).all() + + for user in users: + days_elapsed = get_days_since_status_change(user, 'pending_validation') + if days_elapsed == reminder_day: + if send_event_attendance_reminder(user, days_elapsed, email_service, db_session): + results['event_attendance'] += 1 + + # 3. Payment Reminders + for reminder_day in REMINDER_SCHEDULES['payment_pending']: + users = db_session.query(User).filter( + User.status == UserStatus.payment_pending + ).all() + + for user in users: + days_elapsed = get_days_since_status_change(user, 'payment_pending') + if days_elapsed == reminder_day: + if send_payment_reminder(user, days_elapsed, email_service, db_session): + results['payment'] += 1 + + # 4. Renewal Reminders (before expiration) + for days_before in REMINDER_SCHEDULES['renewal']: + # Find active subscriptions expiring in X days + target_date = date.today() + timedelta(days=days_before) + + subscriptions = db_session.query(User, Subscription).join( + Subscription, User.id == Subscription.user_id + ).filter( + User.status == UserStatus.active, + Subscription.end_date == target_date + ).all() + + for user, subscription in subscriptions: + if send_renewal_reminder(user, subscription, days_before, email_service, db_session): + results['renewal'] += 1 + + # 5. Post-Expiration Reminders + for days_after in REMINDER_SCHEDULES['post_expiration']: + target_date = date.today() - timedelta(days=days_after) + + subscriptions = db_session.query(User, Subscription).join( + Subscription, User.id == Subscription.user_id + ).filter( + User.status == UserStatus.expired, + Subscription.end_date == target_date + ).all() + + for user, subscription in subscriptions: + if send_post_expiration_reminder(user, days_after, email_service): + results['post_expiration'] += 1 + + logger.info(f"Reminder email batch complete: {results}") + return results diff --git a/roles_seed.py b/roles_seed.py new file mode 100644 index 0000000..c824302 --- /dev/null +++ b/roles_seed.py @@ -0,0 +1,147 @@ +""" +Role Seeding Script + +This script populates the database with system roles for the dynamic RBAC system. +Creates 4 system roles: Superadmin, Finance, Member, and Guest. + +Usage: + python roles_seed.py + +Environment Variables: + DATABASE_URL - PostgreSQL connection string +""" + +import os +import sys +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from database import Base +from models import Role +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Database connection +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + print("Error: DATABASE_URL environment variable not set") + sys.exit(1) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# ============================================================ +# System Role Definitions +# ============================================================ + +SYSTEM_ROLES = [ + { + "code": "superadmin", + "name": "Superadmin", + "description": "Full system access with all permissions. Can manage roles, permissions, and all platform features.", + "is_system_role": True + }, + { + "code": "admin", + "name": "Admin", + "description": "Administrative access to most platform features. Can manage users, events, and content.", + "is_system_role": True + }, + { + "code": "finance", + "name": "Finance Manager", + "description": "Access to financial features including subscriptions, payments, and financial reports.", + "is_system_role": True + }, + { + "code": "member", + "name": "Member", + "description": "Standard member access. Can view events, manage profile, and participate in community features.", + "is_system_role": True + }, + { + "code": "guest", + "name": "Guest", + "description": "Limited access for unverified or pending users. Can view basic information and complete registration.", + "is_system_role": True + } +] + + +def seed_roles(): + """Seed system roles into the database""" + db = SessionLocal() + + try: + print("🌱 Starting role seeding...") + print("="*60) + + # Check if roles already exist + existing_roles = db.query(Role).filter(Role.is_system_role == True).all() + if existing_roles: + print(f"\n⚠️ Found {len(existing_roles)} existing system roles:") + for role in existing_roles: + print(f" • {role.name} ({role.code})") + + response = input("\nDo you want to recreate system roles? This will delete existing system roles. (yes/no): ") + if response.lower() != 'yes': + print("\n❌ Seeding cancelled by user") + return + + print("\n🗑️ Deleting existing system roles...") + for role in existing_roles: + db.delete(role) + db.commit() + print("✓ Deleted existing system roles") + + # Create system roles + print(f"\n📝 Creating {len(SYSTEM_ROLES)} system roles...") + created_roles = [] + + for role_data in SYSTEM_ROLES: + role = Role( + code=role_data["code"], + name=role_data["name"], + description=role_data["description"], + is_system_role=role_data["is_system_role"], + created_by=None # System roles have no creator + ) + db.add(role) + created_roles.append(role) + print(f" ✓ Created: {role.name} ({role.code})") + + db.commit() + print(f"\n✅ Created {len(created_roles)} system roles") + + # Display summary + print("\n" + "="*60) + print("📊 Seeding Summary:") + print("="*60) + print("\nSystem Roles Created:") + for role in created_roles: + print(f"\n • {role.name} ({role.code})") + print(f" {role.description}") + + print("\n" + "="*60) + print("✅ Role seeding completed successfully!") + print("="*60) + + print("\n📝 Next Steps:") + print(" 1. Migrate existing users to use role_id (Phase 3)") + print(" 2. Migrate role_permissions to use role_id (Phase 3)") + print(" 3. Update authentication logic to use dynamic roles (Phase 3)") + print(" 4. Remove legacy enum columns (Phase 4)") + + except Exception as e: + db.rollback() + print(f"\n❌ Error seeding roles: {str(e)}") + import traceback + traceback.print_exc() + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_roles() diff --git a/server.py b/server.py index e51023a..75d92c6 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form +from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form, Path as PathParam from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -13,18 +13,24 @@ import os import logging import uuid import secrets +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 +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 auth import ( get_password_hash, verify_password, create_access_token, get_current_user, get_current_admin_user, + get_current_superadmin, get_active_member, + get_user_permissions, + require_permission, create_password_reset_token, - verify_reset_token + verify_reset_token, + get_user_role_code ) from email_service import ( send_verification_email, @@ -72,7 +78,33 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# ============================================================ +# Helper Functions +# ============================================================ + +def set_user_role(user: User, role_enum: UserRole, db: Session): + """ + Set user's role in both legacy enum and dynamic role_id. + Ensures consistency between old and new role systems during Phase 3 migration. + + Args: + user: User object to update + role_enum: UserRole enum value + db: Database session + """ + # Set legacy enum + user.role = role_enum + + # Set dynamic role_id + role = db.query(Role).filter(Role.code == role_enum.value).first() + if role: + user.role_id = role.id + else: + logger.warning(f"Role not found for code: {role_enum.value}") + +# ============================================================ # Pydantic Models +# ============================================================ class RegisterRequest(BaseModel): # Step 1: Personal & Partner Information first_name: str @@ -111,6 +143,13 @@ class RegisterRequest(BaseModel): # Step 4: Account Credentials email: EmailStr password: str = Field(min_length=6) + accepts_tos: bool = False + + @validator('accepts_tos') + def tos_must_be_accepted(cls, v): + if not v: + raise ValueError('You must accept the Terms of Service to register') + return v @validator('newsletter_publish_none') def validate_newsletter_preferences(cls, v, values): @@ -342,6 +381,115 @@ class ManualPaymentRequest(BaseModel): raise ValueError('Amount must be at least $30 (3000 cents)') return v +# ============================================================ +# Permission Management Pydantic Models +# ============================================================ + +class PermissionResponse(BaseModel): + id: str + code: str + name: str + description: Optional[str] + module: str + created_at: datetime + + class Config: + from_attributes = True + +class AssignPermissionsRequest(BaseModel): + permission_codes: List[str] = Field(..., description="List of permission codes to assign to the role") + +# ============================================================ +# Role Management Pydantic Models +# ============================================================ + +class RoleResponse(BaseModel): + id: str + code: str + name: str + description: Optional[str] + is_system_role: bool + created_at: datetime + updated_at: datetime + permission_count: Optional[int] = 0 # Number of permissions assigned to this role + + class Config: + from_attributes = True + +class CreateRoleRequest(BaseModel): + code: str = Field(..., min_length=2, max_length=50, description="Unique role code (e.g., 'finance', 'editor')") + name: str = Field(..., min_length=2, max_length=100, description="Display name (e.g., 'Finance Manager')") + description: Optional[str] = Field(None, description="Role description") + permission_codes: List[str] = Field(default=[], description="List of permission codes to assign") + +class UpdateRoleRequest(BaseModel): + name: Optional[str] = Field(None, min_length=2, max_length=100) + description: Optional[str] = None + +class AssignRolePermissionsRequest(BaseModel): + permission_codes: List[str] = Field(..., description="List of permission codes to assign to the role") + +# ============================================================ +# User Creation & Invitation Pydantic Models +# ============================================================ + +class CreateUserRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + first_name: str + last_name: str + phone: str + role: str # "member", "admin", "superadmin" + + # Optional member fields + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + member_since: Optional[datetime] = None + +class InviteUserRequest(BaseModel): + email: EmailStr + role: str # "member", "admin", "superadmin" + + # Optional pre-fill information + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + +class InvitationResponse(BaseModel): + id: str + email: str + role: str + status: str + first_name: Optional[str] + last_name: Optional[str] + phone: Optional[str] + invited_by: str + invited_at: datetime + expires_at: datetime + accepted_at: Optional[datetime] + + class Config: + from_attributes = True + +class AcceptInvitationRequest(BaseModel): + token: str + password: str = Field(..., min_length=8) + + # Complete profile information + first_name: str + last_name: str + phone: str + + # Member-specific fields (optional for staff) + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + # Auth Routes @api_router.post("/auth/register") async def register(request: RegisterRequest, db: Session = Depends(get_db)): @@ -401,6 +549,10 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)): directory_dob=request.directory_dob, directory_partner_name=request.directory_partner_name, + # Terms of Service acceptance (Step 4) + accepts_tos=request.accepts_tos, + tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None, + # Status fields status=UserStatus.pending_email, role=UserRole.guest, @@ -448,11 +600,11 @@ async def verify_email(token: str, db: Session = Depends(get_db)): ).first() if referrer: - user.status = UserStatus.pre_approved + user.status = UserStatus.pre_validated else: - user.status = UserStatus.pending_approval + user.status = UserStatus.pending_validation else: - user.status = UserStatus.pending_approval + user.status = UserStatus.pending_validation user.email_verified = True # Don't clear token immediately - keeps endpoint idempotent for React StrictMode double-calls @@ -514,7 +666,7 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)): "first_name": user.first_name, "last_name": user.last_name, "status": user.status.value, - "role": user.role.value, + "role": get_user_role_code(user), "email_verified": user.email_verified, "force_password_change": user.force_password_change } @@ -589,7 +741,7 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D zipcode=current_user.zipcode, date_of_birth=current_user.date_of_birth, status=current_user.status.value, - role=current_user.role.value, + role=get_user_role_code(current_user), email_verified=current_user.email_verified, created_at=current_user.created_at, subscription_start_date=active_subscription.start_date if active_subscription else None, @@ -597,6 +749,21 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D subscription_status=active_subscription.status.value if active_subscription else None ) +@api_router.get("/auth/permissions") +async def get_my_permissions( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's permissions based on their role + Returns list of permission codes (e.g., ['users.view', 'events.create']) + """ + permissions = await get_user_permissions(current_user, db) + return { + "permissions": permissions, + "role": get_user_role_code(current_user) + } + # User Profile Routes @api_router.get("/users/profile", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): @@ -773,7 +940,7 @@ async def get_enhanced_profile( "directory_dob": current_user.directory_dob, "directory_partner_name": current_user.directory_partner_name, "status": current_user.status.value, - "role": current_user.role.value + "role": get_user_role_code(current_user) } @api_router.put("/members/profile") @@ -1018,7 +1185,7 @@ async def get_members_directory( @api_router.post("/admin/calendar/sync/{event_id}") async def sync_event_to_microsoft( event_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.edit")), db: Session = Depends(get_db) ): """Sync event to Microsoft Calendar""" @@ -1055,7 +1222,7 @@ async def sync_event_to_microsoft( @api_router.delete("/admin/calendar/unsync/{event_id}") async def unsync_event_from_microsoft( event_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.edit")), db: Session = Depends(get_db) ): """Remove event from Microsoft Calendar""" @@ -1155,7 +1322,7 @@ async def upload_event_gallery_image( event_id: str, file: UploadFile = File(...), caption: Optional[str] = None, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("gallery.upload")), db: Session = Depends(get_db) ): """Upload image to event gallery (Admin only)""" @@ -1236,7 +1403,7 @@ async def upload_event_gallery_image( @api_router.delete("/admin/event-gallery/{image_id}") async def delete_gallery_image( image_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("gallery.delete")), db: Session = Depends(get_db) ): """Delete image from event gallery (Admin only)""" @@ -1271,7 +1438,7 @@ async def delete_gallery_image( async def update_gallery_image_caption( image_id: str, caption: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("gallery.edit")), db: Session = Depends(get_db) ): """Update gallery image caption (Admin only)""" @@ -1659,7 +1826,7 @@ async def get_config_limits(): # ============================================================================ @api_router.get("/admin/storage/usage") async def get_storage_usage( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("settings.storage")), db: Session = Depends(get_db) ): """Get current storage usage statistics""" @@ -1688,7 +1855,7 @@ async def get_storage_usage( @api_router.get("/admin/storage/breakdown") async def get_storage_breakdown( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("settings.storage")), db: Session = Depends(get_db) ): """Get storage usage breakdown by category""" @@ -1696,7 +1863,8 @@ async def get_storage_breakdown( from models import User, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument # Count storage by category - profile_photos = db.query(func.coalesce(func.sum(User.profile_photo_size), 0)).scalar() or 0 + # Note: profile_photos removed - User.profile_photo_size field doesn't exist + # If needed in future, add profile_photo_size column to User model gallery_images = db.query(func.coalesce(func.sum(EventGallery.file_size_bytes), 0)).scalar() or 0 newsletters = db.query(func.coalesce(func.sum(NewsletterArchive.file_size_bytes), 0)).filter( NewsletterArchive.document_type == 'upload' @@ -1710,13 +1878,12 @@ async def get_storage_breakdown( return { "breakdown": { - "profile_photos": profile_photos, "gallery_images": gallery_images, "newsletters": newsletters, "financials": financials, "bylaws": bylaws }, - "total": profile_photos + gallery_images + newsletters + financials + bylaws + "total": gallery_images + newsletters + financials + bylaws } @@ -1724,7 +1891,7 @@ async def get_storage_breakdown( async def get_all_users( status: Optional[str] = None, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("users.view")) ): query = db.query(User) @@ -1745,7 +1912,7 @@ async def get_all_users( "last_name": user.last_name, "phone": user.phone, "status": user.status.value, - "role": user.role.value, + "role": get_user_role_code(user), "email_verified": user.email_verified, "created_at": user.created_at.isoformat(), "lead_sources": user.lead_sources, @@ -1754,11 +1921,172 @@ async def get_all_users( for user in users ] +# IMPORTANT: All specific routes (/create, /invite, /invitations, /export, /import) +# must be defined ABOVE this {user_id} route to avoid path conflicts + +@api_router.get("/admin/users/invitations") +async def get_invitations( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all invitations with optional status filter + Admin/Superadmin only + """ + query = db.query(UserInvitation) + + if status: + try: + status_enum = InvitationStatus[status] + query = query.filter(UserInvitation.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + invitations = query.order_by(UserInvitation.invited_at.desc()).all() + + return [ + { + "id": str(inv.id), + "email": inv.email, + "role": inv.role.value, + "status": inv.status.value, + "first_name": inv.first_name, + "last_name": inv.last_name, + "phone": inv.phone, + "invited_by": str(inv.invited_by), + "invited_at": inv.invited_at.isoformat(), + "expires_at": inv.expires_at.isoformat(), + "accepted_at": inv.accepted_at.isoformat() if inv.accepted_at else None + } + for inv in invitations + ] + +@api_router.get("/admin/users/export") +async def export_users_csv( + status: Optional[str] = None, + role: Optional[str] = None, + email_verified: Optional[bool] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("users.export")), + db: Session = Depends(get_db) +): + """ + Export users to CSV with optional filters + Admin/Superadmin only + Requires permission: users.export + """ + # Build query + query = db.query(User) + + # Apply filters + if status: + try: + status_enum = UserStatus[status] + query = query.filter(User.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + if role: + try: + role_enum = UserRole[role] + query = query.filter(User.role == role_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + if email_verified is not None: + query = query.filter(User.email_verified == email_verified) + + if search: + search_filter = or_( + User.email.ilike(f"%{search}%"), + User.first_name.ilike(f"%{search}%"), + User.last_name.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + # Get all matching users + users = query.order_by(User.created_at.desc()).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Email', + 'First Name', + 'Last Name', + 'Phone', + 'Role', + 'Status', + 'Email Verified', + 'Address', + 'City', + 'State', + 'Zipcode', + 'Date of Birth', + 'Member Since', + 'Partner First Name', + 'Partner Last Name', + 'Partner Is Member', + 'Partner Plan to Become Member', + 'Referred By Member Name', + 'Lead Sources', + 'Created At', + 'Updated At' + ]) + + # Write data rows + for user in users: + writer.writerow([ + str(user.id), + user.email, + user.first_name, + user.last_name, + user.phone, + get_user_role_code(user), + user.status.value, + 'Yes' if user.email_verified else 'No', + user.address or '', + user.city or '', + user.state or '', + user.zipcode or '', + user.date_of_birth.strftime('%Y-%m-%d') if user.date_of_birth else '', + user.member_since.strftime('%Y-%m-%d') if user.member_since else '', + user.partner_first_name or '', + user.partner_last_name or '', + 'Yes' if user.partner_is_member else 'No', + 'Yes' if user.partner_plan_to_become_member else 'No', + user.referred_by_member_name or '', + ','.join(user.lead_sources) if user.lead_sources else '', + user.created_at.strftime('%Y-%m-%d %H:%M:%S'), + user.updated_at.strftime('%Y-%m-%d %H:%M:%S') if user.updated_at else '' + ]) + + # Prepare response + output.seek(0) + + # Generate filename with timestamp + timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + filename = f"members_export_{timestamp}.csv" + + logger.info(f"Admin {current_user.email} exported {len(users)} users to CSV") + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + @api_router.get("/admin/users/{user_id}") async def get_user_by_id( user_id: str, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("users.view")) ): """Get specific user by ID (admin only)""" user = db.query(User).filter(User.id == user_id).first() @@ -1782,7 +2110,7 @@ async def get_user_by_id( "partner_plan_to_become_member": user.partner_plan_to_become_member, "referred_by_member_name": user.referred_by_member_name, "status": user.status.value, - "role": user.role.value, + "role": get_user_role_code(user), "email_verified": user.email_verified, "newsletter_subscribed": user.newsletter_subscribed, "lead_sources": user.lead_sources, @@ -1790,12 +2118,12 @@ async def get_user_by_id( "updated_at": user.updated_at.isoformat() if user.updated_at else None } -@api_router.put("/admin/users/{user_id}/approve") -async def approve_user( +@api_router.put("/admin/users/{user_id}/validate") +async def validate_user( user_id: str, bypass_email_verification: bool = False, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("users.approve")) ): user = db.query(User).filter(User.id == user_id).first() if not user: @@ -1816,14 +2144,14 @@ async def approve_user( ), User.status == UserStatus.active ).first() - user.status = UserStatus.pre_approved if referrer else UserStatus.pending_approval + user.status = UserStatus.pre_validated if referrer else UserStatus.pending_validation else: - user.status = UserStatus.pending_approval + user.status = UserStatus.pending_validation logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}") - # Validate user status - must be pending_approval or pre_approved - if user.status not in [UserStatus.pending_approval, UserStatus.pre_approved]: + # Validate user status - must be pending_validation or pre_validated + if user.status not in [UserStatus.pending_validation, UserStatus.pre_validated]: raise HTTPException( status_code=400, detail=f"User must have verified email first. Current: {user.status.value}" @@ -1839,16 +2167,16 @@ async def approve_user( # Send payment prompt email await send_payment_prompt_email(user.email, user.first_name) - logger.info(f"User validated and approved (payment pending): {user.email} by admin: {current_user.email}") + logger.info(f"User validated (payment pending): {user.email} by admin: {current_user.email}") - return {"message": "User approved - payment email sent"} + return {"message": "User validated - payment email sent"} @api_router.put("/admin/users/{user_id}/status") async def update_user_status( user_id: str, request: UpdateUserStatusRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("users.status")) ): user = db.query(User).filter(User.id == user_id).first() if not user: @@ -1871,7 +2199,7 @@ async def activate_payment_manually( user_id: str, request: ManualPaymentRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_admin_user) + current_user: User = Depends(require_permission("subscriptions.activate")) ): """Manually activate user who paid offline (cash, bank transfer, etc.)""" @@ -1941,7 +2269,7 @@ async def activate_payment_manually( # 6. Activate user user.status = UserStatus.active - user.role = UserRole.member + set_user_role(user, UserRole.member, db) user.updated_at = datetime.now(timezone.utc) # 7. Commit @@ -1965,7 +2293,7 @@ async def activate_payment_manually( async def admin_reset_user_password( user_id: str, request: AdminPasswordUpdateRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("users.reset_password")), db: Session = Depends(get_db) ): """Admin resets user password - generates temp password and emails it""" @@ -2000,7 +2328,7 @@ async def admin_reset_user_password( @api_router.post("/admin/users/{user_id}/resend-verification") async def admin_resend_verification( user_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("users.resend_verification")), db: Session = Depends(get_db) ): """Admin resends verification email for any user""" @@ -2028,10 +2356,627 @@ async def admin_resend_verification( return {"message": f"Verification email resent to {user.email}"} +# ============================================================ +# User Creation & Invitation Endpoints +# ============================================================ + +@api_router.post("/admin/users/create") +async def create_user_directly( + request: CreateUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Create user account directly (without invitation) + Admin/Superadmin only + """ + # Check if email already exists + existing_user = db.query(User).filter(User.email == request.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can create superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can create superadmin users") + + # Create user + new_user = User( + email=request.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=role_enum, + email_verified=True, # Admin-created users are pre-verified + status=UserStatus.active if role_enum in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional member fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + member_since=request.member_since, + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + logger.info(f"Admin {current_user.email} created user: {new_user.email} with role {request.role}") + + return { + "message": "User created successfully", + "user_id": str(new_user.id), + "email": new_user.email, + "role": get_user_role_code(new_user) + } + +@api_router.post("/admin/users/invite") +async def send_user_invitation( + request: InviteUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Send email invitation to new user + Admin/Superadmin only + """ + # Check if email already exists + existing_user = db.query(User).filter(User.email == request.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Check for pending invitation + existing_invitation = db.query(UserInvitation).filter( + UserInvitation.email == request.email, + UserInvitation.status == InvitationStatus.pending + ).first() + if existing_invitation: + raise HTTPException(status_code=400, detail="Pending invitation already exists for this email") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can invite superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can invite superadmin users") + + # Generate secure token + token = secrets.token_urlsafe(32) + + # Create invitation (expires in 7 days) + invitation = UserInvitation( + email=request.email, + token=token, + role=role_enum, + status=InvitationStatus.pending, + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + invited_by=current_user.id, + expires_at=datetime.now(timezone.utc) + timedelta(days=7) + ) + + db.add(invitation) + db.commit() + db.refresh(invitation) + + # Send invitation email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={token}" + + try: + await send_invitation_email( + to_email=request.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=request.role + ) + except Exception as e: + logger.error(f"Failed to send invitation email: {str(e)}") + # Continue anyway - admin can resend later + + logger.info(f"Admin {current_user.email} invited {request.email} as {request.role}") + + return { + "message": "Invitation sent successfully", + "invitation_id": str(invitation.id), + "email": invitation.email, + "expires_at": invitation.expires_at.isoformat(), + "invitation_url": invitation_url + } + +@api_router.post("/admin/users/invitations/{invitation_id}/resend") +async def resend_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Resend invitation email (extends expiry by 7 days) + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot resend invitation with status: {invitation.status.value}") + + # Extend expiry by 7 days from now + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=7) + db.commit() + + # Resend email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={invitation.token}" + + try: + await send_invitation_email( + to_email=invitation.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=invitation.role.value + ) + except Exception as e: + logger.error(f"Failed to resend invitation email: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to send email") + + logger.info(f"Admin {current_user.email} resent invitation to {invitation.email}") + + return { + "message": "Invitation resent successfully", + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.delete("/admin/users/invitations/{invitation_id}") +async def revoke_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Revoke pending invitation + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot revoke invitation with status: {invitation.status.value}") + + invitation.status = InvitationStatus.revoked + db.commit() + + logger.info(f"Admin {current_user.email} revoked invitation for {invitation.email}") + + return {"message": "Invitation revoked successfully"} + +# ============================================================ +# Public Invitation Endpoints +# ============================================================ + +@api_router.get("/invitations/verify/{token}") +async def verify_invitation_token( + token: str, + db: Session = Depends(get_db) +): + """ + Verify invitation token and return invitation details + Public endpoint - no authentication required + """ + invitation = db.query(UserInvitation).filter( + UserInvitation.token == token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + return { + "email": invitation.email, + "role": invitation.role.value, + "first_name": invitation.first_name, + "last_name": invitation.last_name, + "phone": invitation.phone, + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.post("/invitations/accept") +async def accept_invitation( + request: AcceptInvitationRequest, + db: Session = Depends(get_db) +): + """ + Accept invitation and create user account + Public endpoint - no authentication required + """ + # Verify invitation + invitation = db.query(UserInvitation).filter( + UserInvitation.token == request.token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + # Check if email already registered + existing_user = db.query(User).filter(User.email == invitation.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Create user account + new_user = User( + email=invitation.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=invitation.role, + email_verified=True, # Invited users are pre-verified + status=UserStatus.active if invitation.role in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + ) + + db.add(new_user) + + # Update invitation status + invitation.status = InvitationStatus.accepted + invitation.accepted_at = datetime.now(timezone.utc) + invitation.accepted_by = new_user.id + + db.commit() + db.refresh(new_user) + + # Generate JWT token for auto-login + access_token = create_access_token(data={"sub": str(new_user.id)}) + + logger.info(f"User {new_user.email} accepted invitation and created account with role {get_user_role_code(new_user)}") + + return { + "message": "Invitation accepted successfully", + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": str(new_user.id), + "email": new_user.email, + "first_name": new_user.first_name, + "last_name": new_user.last_name, + "role": get_user_role_code(new_user), + "status": new_user.status.value + } + } + + +# ============================================================ +# CSV IMPORT ENDPOINTS +# Note: Export endpoint has been moved above {user_id} route +# ============================================================ + +@api_router.post("/admin/users/import") +async def import_users_csv( + file: UploadFile = File(...), + update_existing: bool = Form(False), + current_user: User = Depends(require_permission("users.import")), + db: Session = Depends(get_db) +): + """ + Import users from CSV file + Admin/Superadmin only + Requires permission: users.import + + CSV Format: + Email,First Name,Last Name,Phone,Role,Status,Address,City,State,Zipcode,Date of Birth,Member Since + """ + # Validate file type + if not file.filename.endswith('.csv'): + raise HTTPException(status_code=400, detail="Only CSV files are supported") + + # Read file content + try: + contents = await file.read() + decoded = contents.decode('utf-8') + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to read CSV file: {str(e)}") + + # Parse CSV + csv_reader = csv.DictReader(io.StringIO(decoded)) + + # Validate required columns + required_columns = {'Email', 'First Name', 'Last Name', 'Phone', 'Role'} + if not required_columns.issubset(set(csv_reader.fieldnames or [])): + missing = required_columns - set(csv_reader.fieldnames or []) + raise HTTPException( + status_code=400, + detail=f"Missing required columns: {', '.join(missing)}" + ) + + # Count total rows + rows = list(csv_reader) + total_rows = len(rows) + + # Create import job + import_job = ImportJob( + filename=file.filename, + total_rows=total_rows, + imported_by=current_user.id, + status=ImportJobStatus.processing + ) + db.add(import_job) + db.commit() + db.refresh(import_job) + + # Process rows + successful_rows = 0 + failed_rows = 0 + errors = [] + + for idx, row in enumerate(rows, start=1): + try: + # Validate required fields + email = row.get('Email', '').strip() + first_name = row.get('First Name', '').strip() + last_name = row.get('Last Name', '').strip() + phone = row.get('Phone', '').strip() + role_str = row.get('Role', '').strip() + + if not all([email, first_name, last_name, phone, role_str]): + raise ValueError("Missing required fields") + + # Validate email format (basic check) + if '@' not in email: + raise ValueError("Invalid email format") + + # Validate role + try: + role_enum = UserRole[role_str.lower()] + except KeyError: + raise ValueError(f"Invalid role: {role_str}. Must be one of: guest, member, admin, superadmin") + + # Only superadmin can import superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise ValueError("Only superadmin can import superadmin users") + + # Check if user exists + existing_user = db.query(User).filter(User.email == email).first() + + if existing_user: + if update_existing: + # Update existing user + existing_user.first_name = first_name + existing_user.last_name = last_name + existing_user.phone = phone + set_user_role(existing_user, role_enum, db) + + # Update optional fields if provided + if row.get('Address'): + existing_user.address = row['Address'].strip() + if row.get('City'): + existing_user.city = row['City'].strip() + if row.get('State'): + existing_user.state = row['State'].strip() + if row.get('Zipcode'): + existing_user.zipcode = row['Zipcode'].strip() + if row.get('Status'): + try: + existing_user.status = UserStatus[row['Status'].strip().lower()] + except KeyError: + pass # Skip invalid status + if row.get('Date of Birth'): + try: + existing_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + if row.get('Member Since'): + try: + existing_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + + successful_rows += 1 + else: + # Skip duplicate + errors.append({ + "row": idx, + "email": email, + "error": "Email already exists (use update_existing=true to update)" + }) + failed_rows += 1 + continue + else: + # Create new user + # Generate temporary password (admin will reset it) + temp_password = secrets.token_urlsafe(16) + + new_user = User( + email=email, + password_hash=get_password_hash(temp_password), + first_name=first_name, + last_name=last_name, + phone=phone, + role=role_enum, + email_verified=True, # Imported users are pre-verified + status=UserStatus[row.get('Status', 'payment_pending').strip().lower()] if row.get('Status') else UserStatus.payment_pending, + address=row.get('Address', '').strip(), + city=row.get('City', '').strip(), + state=row.get('State', '').strip(), + zipcode=row.get('Zipcode', '').strip(), + ) + + # Parse optional dates + if row.get('Date of Birth'): + try: + new_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Use default + + if row.get('Member Since'): + try: + new_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Leave as None + + db.add(new_user) + successful_rows += 1 + + # Commit every 50 rows for performance + if idx % 50 == 0: + db.commit() + + except Exception as e: + failed_rows += 1 + errors.append({ + "row": idx, + "email": row.get('Email', 'N/A'), + "error": str(e) + }) + continue + + # Final commit + db.commit() + + # Update import job + import_job.processed_rows = total_rows + import_job.successful_rows = successful_rows + import_job.failed_rows = failed_rows + import_job.errors = errors + import_job.completed_at = datetime.now(timezone.utc) + + if failed_rows == 0: + import_job.status = ImportJobStatus.completed + elif successful_rows == 0: + import_job.status = ImportJobStatus.failed + else: + import_job.status = ImportJobStatus.partial + + db.commit() + db.refresh(import_job) + + logger.info(f"Admin {current_user.email} imported {successful_rows}/{total_rows} users from CSV") + + return { + "message": "Import completed", + "import_job_id": str(import_job.id), + "total_rows": total_rows, + "successful_rows": successful_rows, + "failed_rows": failed_rows, + "status": import_job.status.value, + "errors": errors[:10] # Return first 10 errors only (full list available in job details) + } + + +@api_router.get("/admin/users/import-jobs") +async def get_import_jobs( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all import jobs with optional status filter + Admin/Superadmin only + Requires permission: users.view + """ + query = db.query(ImportJob) + + if status: + try: + status_enum = ImportJobStatus[status] + query = query.filter(ImportJob.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + jobs = query.order_by(ImportJob.started_at.desc()).all() + + return [ + { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": str(job.imported_by), + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "error_count": len(job.errors) if job.errors else 0 + } + for job in jobs + ] + + +@api_router.get("/admin/users/import-jobs/{job_id}") +async def get_import_job_details( + job_id: str, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + Get detailed information about a specific import job + Admin/Superadmin only + Requires permission: users.view + """ + job = db.query(ImportJob).filter(ImportJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Import job not found") + + # Get importer details + importer = db.query(User).filter(User.id == job.imported_by).first() + + return { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": { + "id": str(importer.id), + "email": importer.email, + "name": f"{importer.first_name} {importer.last_name}" + } if importer else None, + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "errors": job.errors or [] # Full error list + } + + @api_router.post("/admin/events", response_model=EventResponse) async def create_event( request: EventCreate, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.create")), db: Session = Depends(get_db) ): event = Event( @@ -2069,7 +3014,7 @@ async def create_event( async def update_event( event_id: str, request: EventUpdate, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.edit")), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -2101,7 +3046,7 @@ async def update_event( @api_router.get("/admin/events/{event_id}/rsvps") async def get_event_rsvps( event_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.rsvps")), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -2129,7 +3074,7 @@ async def get_event_rsvps( async def mark_attendance( event_id: str, request: AttendanceUpdate, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.attendance")), db: Session = Depends(get_db) ): event = db.query(Event).filter(Event.id == event_id).first() @@ -2148,11 +3093,11 @@ async def mark_attendance( rsvp.attended_at = datetime.now(timezone.utc) if request.attended else None rsvp.updated_at = datetime.now(timezone.utc) - # If user attended and they were pending approval, update their status + # If user attended and they were pending validation, update their status if request.attended: user = db.query(User).filter(User.id == request.user_id).first() - if user and user.status == UserStatus.pending_approval: - user.status = UserStatus.pre_approved + if user and user.status == UserStatus.pending_validation: + user.status = UserStatus.pre_validated user.updated_at = datetime.now(timezone.utc) db.commit() @@ -2161,7 +3106,7 @@ async def mark_attendance( @api_router.get("/admin/events") async def get_admin_events( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.view")), db: Session = Depends(get_db) ): """Get all events for admin (including unpublished)""" @@ -2193,7 +3138,7 @@ async def get_admin_events( @api_router.delete("/admin/events/{event_id}") async def delete_event( event_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("events.delete")), db: Session = Depends(get_db) ): """Delete an event (cascade deletes RSVPs)""" @@ -2306,7 +3251,7 @@ async def get_subscription_plans(db: Session = Depends(get_db)): @api_router.get("/admin/subscriptions/plans") async def get_all_plans_admin( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get all subscription plans for admin (including inactive) with subscriber counts.""" @@ -2337,7 +3282,7 @@ async def get_all_plans_admin( @api_router.get("/admin/subscriptions/plans/{plan_id}") async def get_plan_admin( plan_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get single plan details with subscriber count.""" @@ -2367,7 +3312,7 @@ async def get_plan_admin( @api_router.post("/admin/subscriptions/plans") async def create_plan( request: PlanCreateRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.plans")), db: Session = Depends(get_db) ): """Create new subscription plan.""" @@ -2444,7 +3389,7 @@ async def create_plan( async def update_plan( plan_id: str, request: PlanCreateRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.plans")), db: Session = Depends(get_db) ): """Update subscription plan.""" @@ -2522,7 +3467,7 @@ async def update_plan( @api_router.delete("/admin/subscriptions/plans/{plan_id}") async def delete_plan( plan_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.plans")), db: Session = Depends(get_db) ): """Soft delete plan (set active = False).""" @@ -2559,7 +3504,8 @@ async def delete_plan( async def get_all_subscriptions( status: Optional[str] = None, plan_id: Optional[str] = None, - current_user: User = Depends(get_current_admin_user), + user_id: Optional[str] = None, + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get all subscriptions with optional filters.""" @@ -2570,6 +3516,8 @@ async def get_all_subscriptions( query = query.filter(Subscription.status == status) if plan_id: query = query.filter(Subscription.plan_id == plan_id) + if user_id: + query = query.filter(Subscription.user_id == user_id) subscriptions = query.order_by(Subscription.created_at.desc()).all() @@ -2600,7 +3548,7 @@ async def get_all_subscriptions( @api_router.get("/admin/subscriptions/stats") async def get_subscription_stats( - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.view")), db: Session = Depends(get_db) ): """Get subscription statistics for admin dashboard.""" @@ -2637,7 +3585,7 @@ async def get_subscription_stats( async def update_subscription( subscription_id: str, request: UpdateSubscriptionRequest, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.edit")), db: Session = Depends(get_db) ): """Update subscription details (status, dates).""" @@ -2674,7 +3622,7 @@ async def update_subscription( @api_router.post("/admin/subscriptions/{subscription_id}/cancel") async def cancel_subscription( subscription_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("subscriptions.cancel")), db: Session = Depends(get_db) ): """Cancel a subscription.""" @@ -2713,7 +3661,7 @@ async def create_newsletter( document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("newsletters.create")), db: Session = Depends(get_db) ): """ @@ -2773,7 +3721,7 @@ async def update_newsletter( document_type: str = Form("google_docs"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("newsletters.edit")), db: Session = Depends(get_db) ): """ @@ -2830,7 +3778,7 @@ async def update_newsletter( @api_router.delete("/admin/newsletters/{newsletter_id}") async def delete_newsletter( newsletter_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("newsletters.delete")), db: Session = Depends(get_db) ): """ @@ -2859,7 +3807,7 @@ async def create_financial_report( document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("financials.create")), db: Session = Depends(get_db) ): """ @@ -2917,7 +3865,7 @@ async def update_financial_report( document_type: str = Form("google_drive"), document_url: str = Form(None), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("financials.edit")), db: Session = Depends(get_db) ): """ @@ -2973,7 +3921,7 @@ async def update_financial_report( @api_router.delete("/admin/financials/{report_id}") async def delete_financial_report( report_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("financials.delete")), db: Session = Depends(get_db) ): """ @@ -3004,7 +3952,7 @@ async def create_bylaws( document_url: str = Form(None), is_current: bool = Form(True), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("bylaws.create")), db: Session = Depends(get_db) ): """ @@ -3071,7 +4019,7 @@ async def update_bylaws( document_url: str = Form(None), is_current: bool = Form(False), file: Optional[UploadFile] = File(None), - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("bylaws.edit")), db: Session = Depends(get_db) ): """ @@ -3135,7 +4083,7 @@ async def update_bylaws( @api_router.delete("/admin/bylaws/{bylaws_id}") async def delete_bylaws( bylaws_id: str, - current_user: User = Depends(get_current_admin_user), + current_user: User = Depends(require_permission("bylaws.delete")), db: Session = Depends(get_db) ): """ @@ -3156,6 +4104,524 @@ async def delete_bylaws( return {"message": "Bylaws deleted successfully"} +# ============================================================ +# Role Management Endpoints (Superadmin Only) +# ============================================================ + +@api_router.get("/admin/roles", response_model=List[RoleResponse]) +async def get_all_roles( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all roles in the system with permission counts + Superadmin only + """ + from sqlalchemy import func + + # Query roles with permission counts + roles_query = db.query( + Role, + func.count(RolePermission.id).label('permission_count') + ).outerjoin(RolePermission, Role.id == RolePermission.role_id)\ + .group_by(Role.id)\ + .order_by(Role.is_system_role.desc(), Role.name) + + roles_with_counts = roles_query.all() + + return [ + { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": count + } + for role, count in roles_with_counts + ] + +@api_router.post("/admin/roles", response_model=RoleResponse) +async def create_role( + request: CreateRoleRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Create a new custom role + Superadmin only + """ + # Check if role code already exists + existing_role = db.query(Role).filter(Role.code == request.code).first() + if existing_role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role with code '{request.code}' already exists" + ) + + # Create role + new_role = Role( + code=request.code.lower().strip(), + name=request.name.strip(), + description=request.description, + is_system_role=False, # Custom roles are never system roles + created_by=current_user.id + ) + db.add(new_role) + db.flush() # Flush to get the role ID + + # Assign permissions if provided + if request.permission_codes: + # Map role code to enum (for backward compatibility) + role_enum_map = { + 'guest': UserRole.guest, + 'member': UserRole.member, + 'admin': UserRole.admin, + 'superadmin': UserRole.superadmin + } + role_enum = role_enum_map.get(new_role.code, UserRole.guest) + + for perm_code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == perm_code).first() + if permission: + role_perm = RolePermission( + role=role_enum, # Set legacy enum for backward compatibility + role_id=new_role.id, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_perm) + + db.commit() + db.refresh(new_role) + + # Get permission count + perm_count = db.query(RolePermission).filter(RolePermission.role_id == new_role.id).count() + + return { + "id": str(new_role.id), + "code": new_role.code, + "name": new_role.name, + "description": new_role.description, + "is_system_role": new_role.is_system_role, + "created_at": new_role.created_at, + "updated_at": new_role.updated_at, + "permission_count": perm_count + } + +@api_router.get("/admin/roles/{role_id}", response_model=RoleResponse) +async def get_role( + role_id: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get role details by ID + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + perm_count = db.query(RolePermission).filter(RolePermission.role_id == role.id).count() + + return { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": perm_count + } + +@api_router.put("/admin/roles/{role_id}", response_model=RoleResponse) +async def update_role( + role_id: str, + request: UpdateRoleRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Update role details (name, description) + Cannot update system roles or role code + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + if role.is_system_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot update system roles" + ) + + # Update fields + if request.name: + role.name = request.name.strip() + if request.description is not None: + role.description = request.description + + role.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(role) + + perm_count = db.query(RolePermission).filter(RolePermission.role_id == role.id).count() + + return { + "id": str(role.id), + "code": role.code, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "created_at": role.created_at, + "updated_at": role.updated_at, + "permission_count": perm_count + } + +@api_router.delete("/admin/roles/{role_id}") +async def delete_role( + role_id: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Delete a custom role + Cannot delete system roles or roles assigned to users + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + if role.is_system_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete system roles" + ) + + # Check if any users have this role + users_with_role = db.query(User).filter(User.role_id == role_id).count() + if users_with_role > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete role: {users_with_role} user(s) are assigned this role" + ) + + # Delete role permissions first (CASCADE should handle this, but being explicit) + db.query(RolePermission).filter(RolePermission.role_id == role_id).delete() + + # Delete role + db.delete(role) + db.commit() + + return {"message": f"Role '{role.name}' deleted successfully"} + +@api_router.get("/admin/roles/{role_id}/permissions") +async def get_role_permissions( + role_id: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permissions assigned to a role + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + permissions = db.query(Permission)\ + .join(RolePermission)\ + .filter(RolePermission.role_id == role_id)\ + .order_by(Permission.module, Permission.code)\ + .all() + + return { + "role_id": str(role.id), + "role_name": role.name, + "permissions": [ + { + "id": str(perm.id), + "code": perm.code, + "name": perm.name, + "description": perm.description, + "module": perm.module + } + for perm in permissions + ] + } + +@api_router.put("/admin/roles/{role_id}/permissions") +async def assign_role_permissions( + role_id: str, + request: AssignRolePermissionsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Assign permissions to a role (replaces existing permissions) + Superadmin only + """ + role = db.query(Role).filter(Role.id == role_id).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + # Remove existing permissions + db.query(RolePermission).filter(RolePermission.role_id == role_id).delete() + + # Map role code to enum (for backward compatibility with legacy role column) + role_enum_map = { + 'guest': UserRole.guest, + 'member': UserRole.member, + 'admin': UserRole.admin, + 'superadmin': UserRole.superadmin + } + role_enum = role_enum_map.get(role.code, UserRole.guest) # Default to guest if custom role + + # Add new permissions + for perm_code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == perm_code).first() + if not permission: + logger.warning(f"Permission code '{perm_code}' not found, skipping") + continue + + role_perm = RolePermission( + role=role_enum, # Set legacy enum for backward compatibility + role_id=role.id, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_perm) + + db.commit() + + return { + "message": f"Assigned {len(request.permission_codes)} permissions to role '{role.name}'", + "role_id": str(role.id), + "permission_codes": request.permission_codes + } + +# ============================================================ +# Permission Management Endpoints (Superadmin Only) +# ============================================================ + +@api_router.get("/admin/permissions", response_model=List[PermissionResponse]) +async def get_all_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permissions in the system + Superadmin only + """ + permissions = db.query(Permission).order_by(Permission.module, Permission.code).all() + + return [ + { + "id": str(perm.id), + "code": perm.code, + "name": perm.name, + "description": perm.description, + "module": perm.module, + "created_at": perm.created_at + } + for perm in permissions + ] + +@api_router.get("/admin/permissions/modules") +async def get_permission_modules( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permission modules with permission counts + Superadmin only + """ + from sqlalchemy import func + + # Get all permissions grouped by module + modules = db.query( + Permission.module, + func.count(Permission.id).label('permission_count') + ).group_by(Permission.module).all() + + # Get permissions for each module + result = [] + for module_name, count in modules: + permissions = db.query(Permission)\ + .filter(Permission.module == module_name)\ + .order_by(Permission.code)\ + .all() + + result.append({ + "module": module_name, + "permission_count": count, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description + } + for p in permissions + ] + }) + + return result + +@api_router.get("/admin/permissions/roles/{role}") +async def get_role_permissions( + role: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get permissions assigned to a specific role + Superadmin only + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Superadmin always has all permissions (enforced in code, not stored) + if role_enum == UserRole.superadmin: + all_permissions = db.query(Permission).all() + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in all_permissions + ], + "note": "Superadmin automatically has all permissions" + } + + # Get permissions for other roles + permissions = db.query(Permission)\ + .join(RolePermission)\ + .filter(RolePermission.role == role_enum)\ + .order_by(Permission.module, Permission.code)\ + .all() + + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in permissions + ] + } + +@api_router.put("/admin/permissions/roles/{role}") +async def assign_role_permissions( + role: str, + request: AssignPermissionsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Assign permissions to a role (cherry-pick permissions) + Superadmin only + + This replaces all existing permissions for the role with the provided list. + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Prevent modifying superadmin permissions + if role_enum == UserRole.superadmin: + raise HTTPException( + status_code=403, + detail="Cannot modify superadmin permissions. Superadmin always has all permissions." + ) + + # Validate all permission codes exist + permissions_to_assign = [] + for code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == code).first() + if not permission: + raise HTTPException(status_code=400, detail=f"Invalid permission code: {code}") + permissions_to_assign.append(permission) + + # Remove existing permissions for this role + db.query(RolePermission).filter(RolePermission.role == role_enum).delete() + + # Add new permissions + for permission in permissions_to_assign: + role_permission = RolePermission( + role=role_enum, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_permission) + + db.commit() + + return { + "message": f"Successfully assigned {len(permissions_to_assign)} permissions to {role}", + "role": role, + "permission_count": len(permissions_to_assign) + } + +@api_router.post("/admin/permissions/seed") +async def seed_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Seed default permissions into the database + Superadmin only + + WARNING: This will clear all existing permissions and role assignments + """ + import subprocess + import sys + + try: + # Run the permissions_seed.py script + result = subprocess.run( + [sys.executable, "permissions_seed.py"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Failed to seed permissions: {result.stderr}" + ) + + return { + "message": "Permissions seeded successfully", + "output": result.stdout + } + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Permission seeding timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error seeding permissions: {str(e)}") + @api_router.post("/subscriptions/checkout") async def create_checkout( request: CheckoutRequest, @@ -3490,7 +4956,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): # Update user status and role user.status = UserStatus.active - user.role = UserRole.member + set_user_role(user, UserRole.member, db) user.updated_at = datetime.now(timezone.utc) db.commit() diff --git a/server.py.bak b/server.py.bak new file mode 100644 index 0000000..2de7645 --- /dev/null +++ b/server.py.bak @@ -0,0 +1,4652 @@ +from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from sqlalchemy import or_ +from pydantic import BaseModel, EmailStr, Field, validator +from typing import List, Optional, Literal +from datetime import datetime, timedelta, timezone +from dotenv import load_dotenv +from pathlib import Path +from contextlib import asynccontextmanager +import os +import logging +import uuid +import secrets +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 auth import ( + get_password_hash, + verify_password, + create_access_token, + get_current_user, + get_current_admin_user, + get_current_superadmin, + get_active_member, + get_user_permissions, + require_permission, + create_password_reset_token, + verify_reset_token, + get_user_role_code +) +from email_service import ( + send_verification_email, + send_approval_notification, + send_payment_prompt_email, + send_password_reset_email, + send_admin_password_reset_email +) +from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date +from r2_storage import get_r2_storage +from calendar_service import CalendarService + +# Load environment variables +ROOT_DIR = Path(__file__).parent +load_dotenv(ROOT_DIR / '.env') + +# Create database tables +Base.metadata.create_all(bind=engine) + +# Lifespan event handler (replaces deprecated on_event) +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info("Application started") + yield + # Shutdown + logger.info("Application shutdown") + +# Create the main app +app = FastAPI( + lifespan=lifespan, + root_path="/membership" # Configure for serving under /membership path +) + +# Create a router with the /api prefix +api_router = APIRouter(prefix="/api") + +# Initialize calendar service +calendar_service = CalendarService() + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# ============================================================ +# Helper Functions +# ============================================================ + +def set_user_role(user: User, role_enum: UserRole, db: Session): + """ + Set user's role in both legacy enum and dynamic role_id. + Ensures consistency between old and new role systems during Phase 3 migration. + + Args: + user: User object to update + role_enum: UserRole enum value + db: Database session + """ + # Set legacy enum + user.role = role_enum + + # Set dynamic role_id + role = db.query(Role).filter(Role.code == role_enum.value).first() + if role: + user.role_id = role.id + else: + logger.warning(f"Role not found for code: {role_enum.value}") + +# ============================================================ +# Pydantic Models +# ============================================================ +class RegisterRequest(BaseModel): + # Step 1: Personal & Partner Information + first_name: str + last_name: str + phone: str + address: str + city: str + state: str + zipcode: str + date_of_birth: datetime + lead_sources: List[str] + partner_first_name: Optional[str] = None + partner_last_name: Optional[str] = None + partner_is_member: Optional[bool] = False + partner_plan_to_become_member: Optional[bool] = False + + # Step 2: Newsletter, Volunteer & Scholarship + referred_by_member_name: Optional[str] = None + newsletter_publish_name: bool + newsletter_publish_photo: bool + newsletter_publish_birthday: bool + newsletter_publish_none: bool + volunteer_interests: List[str] = [] + scholarship_requested: bool = False + scholarship_reason: Optional[str] = None + + # Step 3: Directory Settings + show_in_directory: bool = False + directory_email: Optional[str] = None + directory_bio: Optional[str] = None + directory_address: Optional[str] = None + directory_phone: Optional[str] = None + directory_dob: Optional[datetime] = None + directory_partner_name: Optional[str] = None + + # Step 4: Account Credentials + email: EmailStr + password: str = Field(min_length=6) + accepts_tos: bool = False + + @validator('accepts_tos') + def tos_must_be_accepted(cls, v): + if not v: + raise ValueError('You must accept the Terms of Service to register') + return v + + @validator('newsletter_publish_none') + def validate_newsletter_preferences(cls, v, values): + """At least one newsletter preference must be selected""" + name = values.get('newsletter_publish_name', False) + photo = values.get('newsletter_publish_photo', False) + birthday = values.get('newsletter_publish_birthday', False) + + if not (name or photo or birthday or v): + raise ValueError('At least one newsletter publication preference must be selected') + return v + + @validator('scholarship_reason') + def validate_scholarship_reason(cls, v, values): + """If scholarship requested, reason must be provided""" + requested = values.get('scholarship_requested', False) + if requested and not v: + raise ValueError('Scholarship reason is required when requesting scholarship') + return v + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class LoginResponse(BaseModel): + access_token: str + token_type: str + user: dict + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str = Field(min_length=6) + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(min_length=6) + +class AdminPasswordUpdateRequest(BaseModel): + force_change: bool = True + +class UserResponse(BaseModel): + id: str + email: str + first_name: str + last_name: str + phone: str + address: str + city: str + state: str + zipcode: str + date_of_birth: datetime + status: str + role: str + email_verified: bool + created_at: datetime + # Subscription info (optional) + subscription_start_date: Optional[datetime] = None + subscription_end_date: Optional[datetime] = None + subscription_status: Optional[str] = None + # Partner information + partner_first_name: Optional[str] = None + partner_last_name: Optional[str] = None + partner_is_member: Optional[bool] = None + partner_plan_to_become_member: Optional[bool] = None + # Newsletter preferences + newsletter_publish_name: Optional[bool] = None + newsletter_publish_photo: Optional[bool] = None + newsletter_publish_birthday: Optional[bool] = None + newsletter_publish_none: Optional[bool] = None + # Volunteer interests + volunteer_interests: Optional[list] = None + # Directory settings + show_in_directory: Optional[bool] = None + directory_email: Optional[str] = None + directory_bio: Optional[str] = None + directory_address: Optional[str] = None + directory_phone: Optional[str] = None + directory_dob: Optional[datetime] = None + directory_partner_name: Optional[str] = None + + model_config = {"from_attributes": True} + + @validator('id', 'status', 'role', pre=True) + def convert_to_string(cls, v): + """Convert UUID and Enum types to strings""" + if hasattr(v, 'value'): + return v.value + return str(v) + +class UpdateProfileRequest(BaseModel): + # Basic personal information + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + + # Partner information + partner_first_name: Optional[str] = None + partner_last_name: Optional[str] = None + partner_is_member: Optional[bool] = None + partner_plan_to_become_member: Optional[bool] = None + + # Newsletter preferences + newsletter_publish_name: Optional[bool] = None + newsletter_publish_photo: Optional[bool] = None + newsletter_publish_birthday: Optional[bool] = None + newsletter_publish_none: Optional[bool] = None + + # Volunteer interests (array of strings) + volunteer_interests: Optional[list] = None + + # Directory settings + show_in_directory: Optional[bool] = None + directory_email: Optional[str] = None + directory_bio: Optional[str] = None + directory_address: Optional[str] = None + directory_phone: Optional[str] = None + directory_dob: Optional[datetime] = None + directory_partner_name: Optional[str] = None + + @validator('directory_dob', pre=True) + def empty_str_to_none(cls, v): + """Convert empty string to None for optional datetime field""" + if v == '' or v is None: + return None + return v + +class EnhancedProfileUpdateRequest(BaseModel): + """Members Only - Enhanced profile update with social media and directory settings""" + social_media_facebook: Optional[str] = None + social_media_instagram: Optional[str] = None + social_media_twitter: Optional[str] = None + social_media_linkedin: Optional[str] = None + show_in_directory: Optional[bool] = None + directory_email: Optional[str] = None + directory_bio: Optional[str] = None + directory_address: Optional[str] = None + directory_phone: Optional[str] = None + directory_dob: Optional[datetime] = None + directory_partner_name: Optional[str] = None + + @validator('directory_dob', pre=True) + def empty_str_to_none(cls, v): + """Convert empty string to None for optional datetime field""" + if v == '' or v is None: + return None + return v + +class CalendarEventResponse(BaseModel): + """Calendar view response with user RSVP status""" + id: str + title: str + description: Optional[str] + start_at: datetime + end_at: datetime + location: str + capacity: Optional[int] + user_rsvp_status: Optional[str] = None + microsoft_calendar_synced: bool + +class SyncEventRequest(BaseModel): + """Request to sync event to Microsoft Calendar""" + event_id: str + +class EventCreate(BaseModel): + title: str + description: Optional[str] = None + start_at: datetime + end_at: datetime + location: str + capacity: Optional[int] = None + published: bool = False + +class EventUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + start_at: Optional[datetime] = None + end_at: Optional[datetime] = None + location: Optional[str] = None + capacity: Optional[int] = None + published: Optional[bool] = None + +class EventResponse(BaseModel): + id: str + title: str + description: Optional[str] + start_at: datetime + end_at: datetime + location: str + capacity: Optional[int] + published: bool + created_by: str + created_at: datetime + rsvp_count: Optional[int] = 0 + user_rsvp_status: Optional[str] = None + + model_config = {"from_attributes": True} + +class RSVPRequest(BaseModel): + rsvp_status: str + +class AttendanceUpdate(BaseModel): + user_id: str + attended: bool + +class UpdateUserStatusRequest(BaseModel): + status: str + +class ManualPaymentRequest(BaseModel): + plan_id: str = Field(..., description="Subscription plan ID") + amount_cents: int = Field(..., ge=3000, description="Payment amount in cents (minimum $30)") + payment_date: datetime = Field(..., description="Date payment was received") + payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other") + use_custom_period: bool = Field(False, description="Whether to use custom dates instead of plan's billing cycle") + custom_period_start: Optional[datetime] = Field(None, description="Custom subscription start date") + custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date") + override_plan_dates: bool = Field(False, description="Override plan's custom billing cycle with admin-specified dates") + notes: Optional[str] = Field(None, description="Admin notes about payment") + + @validator('amount_cents') + def validate_amount(cls, v): + if v < 3000: + raise ValueError('Amount must be at least $30 (3000 cents)') + return v + +# ============================================================ +# Permission Management Pydantic Models +# ============================================================ + +class PermissionResponse(BaseModel): + id: str + code: str + name: str + description: Optional[str] + module: str + created_at: datetime + + class Config: + from_attributes = True + +class AssignPermissionsRequest(BaseModel): + permission_codes: List[str] = Field(..., description="List of permission codes to assign to the role") + +# ============================================================ +# User Creation & Invitation Pydantic Models +# ============================================================ + +class CreateUserRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + first_name: str + last_name: str + phone: str + role: str # "member", "admin", "superadmin" + + # Optional member fields + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + member_since: Optional[datetime] = None + +class InviteUserRequest(BaseModel): + email: EmailStr + role: str # "member", "admin", "superadmin" + + # Optional pre-fill information + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + +class InvitationResponse(BaseModel): + id: str + email: str + role: str + status: str + first_name: Optional[str] + last_name: Optional[str] + phone: Optional[str] + invited_by: str + invited_at: datetime + expires_at: datetime + accepted_at: Optional[datetime] + + class Config: + from_attributes = True + +class AcceptInvitationRequest(BaseModel): + token: str + password: str = Field(..., min_length=8) + + # Complete profile information + first_name: str + last_name: str + phone: str + + # Member-specific fields (optional for staff) + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + +# Auth Routes +@api_router.post("/auth/register") +async def register(request: RegisterRequest, db: Session = Depends(get_db)): + # Check if email already exists + existing_user = db.query(User).filter(User.email == request.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Generate verification token + verification_token = secrets.token_urlsafe(32) + + # Create user + user = User( + # Account credentials (Step 4) + email=request.email, + password_hash=get_password_hash(request.password), + + # Personal information (Step 1) + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + address=request.address, + city=request.city, + state=request.state, + zipcode=request.zipcode, + date_of_birth=request.date_of_birth, + lead_sources=request.lead_sources, + + # Partner information (Step 1) + partner_first_name=request.partner_first_name, + partner_last_name=request.partner_last_name, + partner_is_member=request.partner_is_member, + partner_plan_to_become_member=request.partner_plan_to_become_member, + + # Referral (Step 2) + referred_by_member_name=request.referred_by_member_name, + + # Newsletter publication preferences (Step 2) + newsletter_publish_name=request.newsletter_publish_name, + newsletter_publish_photo=request.newsletter_publish_photo, + newsletter_publish_birthday=request.newsletter_publish_birthday, + newsletter_publish_none=request.newsletter_publish_none, + + # Volunteer interests (Step 2) + volunteer_interests=request.volunteer_interests, + + # Scholarship (Step 2) + scholarship_requested=request.scholarship_requested, + scholarship_reason=request.scholarship_reason, + + # Directory settings (Step 3) + show_in_directory=request.show_in_directory, + directory_email=request.directory_email, + directory_bio=request.directory_bio, + directory_address=request.directory_address, + directory_phone=request.directory_phone, + directory_dob=request.directory_dob, + directory_partner_name=request.directory_partner_name, + + # Terms of Service acceptance (Step 4) + accepts_tos=request.accepts_tos, + tos_accepted_at=datetime.now(timezone.utc) if request.accepts_tos else None, + + # Status fields + status=UserStatus.pending_email, + role=UserRole.guest, + email_verified=False, + email_verification_token=verification_token + ) + + db.add(user) + db.commit() + db.refresh(user) + + # Send verification email + await send_verification_email(user.email, verification_token) + + logger.info(f"User registered: {user.email}") + + return {"message": "Registration successful. Please check your email to verify your account."} + +@api_router.get("/auth/verify-email") +async def verify_email(token: str, db: Session = Depends(get_db)): + """Verify user email with token (idempotent - safe to call multiple times)""" + user = db.query(User).filter(User.email_verification_token == token).first() + + if not user: + raise HTTPException(status_code=400, detail="Invalid verification token") + + # If user is already verified, return success (idempotent behavior) + # This handles React Strict Mode's double-execution in development + if user.email_verified: + logger.info(f"Email already verified for user: {user.email}") + return { + "message": "Email is already verified", + "status": user.status.value + } + + # Proceed with first-time verification + # Check if referred by current member - skip validation requirement + if user.referred_by_member_name: + referrer = db.query(User).filter( + or_( + User.first_name + ' ' + User.last_name == user.referred_by_member_name, + User.email == user.referred_by_member_name + ), + User.status == UserStatus.active + ).first() + + if referrer: + user.status = UserStatus.pre_validated + else: + user.status = UserStatus.pending_validation + else: + user.status = UserStatus.pending_validation + + user.email_verified = True + # Don't clear token immediately - keeps endpoint idempotent for React StrictMode double-calls + # Token will be cleared on first successful login + + db.commit() + db.refresh(user) + + logger.info(f"Email verified for user: {user.email}") + + return {"message": "Email verified successfully", "status": user.status.value} + +@api_router.post("/auth/resend-verification-email") +async def resend_verification_email( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """User requests to resend their verification email""" + + # Check if email already verified + if current_user.email_verified: + raise HTTPException(status_code=400, detail="Email is already verified") + + # Generate new token + verification_token = secrets.token_urlsafe(32) + current_user.email_verification_token = verification_token + db.commit() + + # Send verification email + await send_verification_email(current_user.email, verification_token) + + logger.info(f"Verification email resent to: {current_user.email}") + + return {"message": "Verification email has been resent. Please check your inbox."} + +@api_router.post("/auth/login", response_model=LoginResponse) +async def login(request: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == request.email).first() + + if not user or not verify_password(request.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password" + ) + + access_token = create_access_token(data={"sub": str(user.id)}) + + # Clear verification token on first successful login after verification + if user.email_verified and user.email_verification_token: + user.email_verification_token = None + db.commit() + + return { + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "status": user.status.value, + "role": get_user_role_code(user), + "email_verified": user.email_verified, + "force_password_change": user.force_password_change + } + } + +@api_router.post("/auth/forgot-password") +async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): + """Request password reset - sends email with reset link""" + user = db.query(User).filter(User.email == request.email).first() + + # Always return success (security: don't reveal if email exists) + if user: + token = create_password_reset_token(user, db) + reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}" + + await send_password_reset_email(user.email, user.first_name, reset_url) + + return {"message": "If email exists, reset link has been sent"} + +@api_router.post("/auth/reset-password") +async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)): + """Complete password reset using token""" + user = verify_reset_token(request.token, db) + + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + # Update password + user.password_hash = get_password_hash(request.new_password) + user.password_reset_token = None + user.password_reset_expires = None + user.force_password_change = False # Reset flag if it was set + db.commit() + + return {"message": "Password reset successful"} + +@api_router.put("/users/change-password") +async def change_password( + request: ChangePasswordRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """User changes their own password""" + # Verify current password + if not verify_password(request.current_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + # Update password + current_user.password_hash = get_password_hash(request.new_password) + current_user.force_password_change = False # Clear flag if set + db.commit() + + return {"message": "Password changed successfully"} + +@api_router.get("/auth/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + # Get user's active subscription if exists + active_subscription = db.query(Subscription).filter( + Subscription.user_id == current_user.id, + Subscription.status == SubscriptionStatus.active + ).first() + + return UserResponse( + id=str(current_user.id), + email=current_user.email, + first_name=current_user.first_name, + last_name=current_user.last_name, + phone=current_user.phone, + address=current_user.address, + city=current_user.city, + state=current_user.state, + zipcode=current_user.zipcode, + date_of_birth=current_user.date_of_birth, + status=current_user.status.value, + role=current_user.role.value, + email_verified=current_user.email_verified, + created_at=current_user.created_at, + subscription_start_date=active_subscription.start_date if active_subscription else None, + subscription_end_date=active_subscription.end_date if active_subscription else None, + subscription_status=active_subscription.status.value if active_subscription else None + ) + +@api_router.get("/auth/permissions") +async def get_my_permissions( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Get current user's permissions based on their role + Returns list of permission codes (e.g., ['users.view', 'events.create']) + """ + permissions = await get_user_permissions(current_user, db) + return { + "permissions": permissions, + "role": current_user.role.value + } + +# User Profile Routes +@api_router.get("/users/profile", response_model=UserResponse) +async def get_profile(current_user: User = Depends(get_current_user)): + # Use from_attributes to automatically map all User fields to UserResponse + return UserResponse.model_validate(current_user) + +@api_router.put("/users/profile") +async def update_profile( + request: UpdateProfileRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Update user profile with basic info, partner details, newsletter prefs, volunteer interests, and directory settings.""" + + # Basic personal information + if request.first_name is not None: + current_user.first_name = request.first_name + if request.last_name is not None: + current_user.last_name = request.last_name + if request.phone is not None: + current_user.phone = request.phone + if request.address is not None: + current_user.address = request.address + if request.city is not None: + current_user.city = request.city + if request.state is not None: + current_user.state = request.state + if request.zipcode is not None: + current_user.zipcode = request.zipcode + + # Partner information + if request.partner_first_name is not None: + current_user.partner_first_name = request.partner_first_name + if request.partner_last_name is not None: + current_user.partner_last_name = request.partner_last_name + if request.partner_is_member is not None: + current_user.partner_is_member = request.partner_is_member + if request.partner_plan_to_become_member is not None: + current_user.partner_plan_to_become_member = request.partner_plan_to_become_member + + # Newsletter preferences + if request.newsletter_publish_name is not None: + current_user.newsletter_publish_name = request.newsletter_publish_name + if request.newsletter_publish_photo is not None: + current_user.newsletter_publish_photo = request.newsletter_publish_photo + if request.newsletter_publish_birthday is not None: + current_user.newsletter_publish_birthday = request.newsletter_publish_birthday + if request.newsletter_publish_none is not None: + current_user.newsletter_publish_none = request.newsletter_publish_none + + # Volunteer interests (array) + if request.volunteer_interests is not None: + current_user.volunteer_interests = request.volunteer_interests + + # Directory settings + if request.show_in_directory is not None: + current_user.show_in_directory = request.show_in_directory + if request.directory_email is not None: + current_user.directory_email = request.directory_email + if request.directory_bio is not None: + current_user.directory_bio = request.directory_bio + if request.directory_address is not None: + current_user.directory_address = request.directory_address + if request.directory_phone is not None: + current_user.directory_phone = request.directory_phone + if request.directory_dob is not None: + current_user.directory_dob = request.directory_dob + if request.directory_partner_name is not None: + current_user.directory_partner_name = request.directory_partner_name + + current_user.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(current_user) + + return {"message": "Profile updated successfully"} + +# ==================== MEMBERS ONLY ROUTES ==================== + +# Member Directory Routes +@api_router.get("/members/directory") +async def get_member_directory( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get list of all members who opted into the directory""" + directory_members = db.query(User).filter( + User.show_in_directory == True, + User.role == UserRole.member, + User.status == UserStatus.active + ).all() + + return [{ + "id": str(member.id), + "first_name": member.first_name, + "last_name": member.last_name, + "profile_photo_url": member.profile_photo_url, + "directory_email": member.directory_email, + "directory_bio": member.directory_bio, + "directory_address": member.directory_address, + "directory_phone": member.directory_phone, + "directory_dob": member.directory_dob, + "directory_partner_name": member.directory_partner_name, + "volunteer_interests": member.volunteer_interests or [], + "social_media_facebook": member.social_media_facebook, + "social_media_instagram": member.social_media_instagram, + "social_media_twitter": member.social_media_twitter, + "social_media_linkedin": member.social_media_linkedin + } for member in directory_members] + +@api_router.get("/members/directory/{user_id}") +async def get_directory_member_profile( + user_id: str, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get public directory profile of a specific member""" + member = db.query(User).filter( + User.id == user_id, + User.show_in_directory == True, + User.role == UserRole.member, + User.status == UserStatus.active + ).first() + + if not member: + raise HTTPException(status_code=404, detail="Member not found in directory") + + return { + "id": str(member.id), + "first_name": member.first_name, + "last_name": member.last_name, + "profile_photo_url": member.profile_photo_url, + "directory_email": member.directory_email, + "directory_bio": member.directory_bio, + "directory_address": member.directory_address, + "directory_phone": member.directory_phone, + "directory_dob": member.directory_dob, + "directory_partner_name": member.directory_partner_name, + "volunteer_interests": member.volunteer_interests or [], + "social_media_facebook": member.social_media_facebook, + "social_media_instagram": member.social_media_instagram, + "social_media_twitter": member.social_media_twitter, + "social_media_linkedin": member.social_media_linkedin + } + +# Enhanced Profile Routes (Active Members Only) +@api_router.get("/members/profile") +async def get_enhanced_profile( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get enhanced profile with all member-only fields""" + return { + "id": str(current_user.id), + "email": current_user.email, + "first_name": current_user.first_name, + "last_name": current_user.last_name, + "phone": current_user.phone, + "address": current_user.address, + "city": current_user.city, + "state": current_user.state, + "zipcode": current_user.zipcode, + "date_of_birth": current_user.date_of_birth, + "profile_photo_url": current_user.profile_photo_url, + "social_media_facebook": current_user.social_media_facebook, + "social_media_instagram": current_user.social_media_instagram, + "social_media_twitter": current_user.social_media_twitter, + "social_media_linkedin": current_user.social_media_linkedin, + "show_in_directory": current_user.show_in_directory, + "directory_email": current_user.directory_email, + "directory_bio": current_user.directory_bio, + "directory_address": current_user.directory_address, + "directory_phone": current_user.directory_phone, + "directory_dob": current_user.directory_dob, + "directory_partner_name": current_user.directory_partner_name, + "status": current_user.status.value, + "role": current_user.role.value + } + +@api_router.put("/members/profile") +async def update_enhanced_profile( + request: EnhancedProfileUpdateRequest, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Update enhanced profile with social media and directory settings""" + if request.social_media_facebook is not None: + current_user.social_media_facebook = request.social_media_facebook + if request.social_media_instagram is not None: + current_user.social_media_instagram = request.social_media_instagram + if request.social_media_twitter is not None: + current_user.social_media_twitter = request.social_media_twitter + if request.social_media_linkedin is not None: + current_user.social_media_linkedin = request.social_media_linkedin + if request.show_in_directory is not None: + current_user.show_in_directory = request.show_in_directory + if request.directory_email is not None: + current_user.directory_email = request.directory_email + if request.directory_bio is not None: + current_user.directory_bio = request.directory_bio + if request.directory_address is not None: + current_user.directory_address = request.directory_address + if request.directory_phone is not None: + current_user.directory_phone = request.directory_phone + if request.directory_dob is not None: + current_user.directory_dob = request.directory_dob + if request.directory_partner_name is not None: + current_user.directory_partner_name = request.directory_partner_name + + current_user.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(current_user) + + return {"message": "Enhanced profile updated successfully"} + +@api_router.post("/members/profile/upload-photo") +async def upload_profile_photo( + file: UploadFile = File(...), + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Upload profile photo to Cloudflare R2""" + r2 = get_r2_storage() + + # Get storage quota + storage = db.query(StorageUsage).first() + if not storage: + # Initialize storage tracking + storage = StorageUsage( + total_bytes_used=0, + max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) + ) + db.add(storage) + db.commit() + db.refresh(storage) + + # Get max file size from env + max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) + + # Delete old profile photo if exists + if current_user.profile_photo_url: + # Extract object key from URL + old_key = current_user.profile_photo_url.split('/')[-1] + old_key = f"profiles/{old_key}" + try: + old_size = await r2.get_file_size(old_key) + await r2.delete_file(old_key) + # Update storage usage + storage.total_bytes_used -= old_size + except: + pass # File might not exist + + # Upload new photo + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="profiles", + allowed_types=r2.ALLOWED_IMAGE_TYPES, + max_size_bytes=max_file_size + ) + + # Check storage quota + if storage.total_bytes_used + file_size > storage.max_bytes_allowed: + # Rollback upload + await r2.delete_file(object_key) + raise HTTPException( + status_code=507, + detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" + ) + + # Update user profile + current_user.profile_photo_url = public_url + current_user.updated_at = datetime.now(timezone.utc) + + # Update storage usage + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + + db.commit() + db.refresh(current_user) + + logger.info(f"Profile photo uploaded for user {current_user.email}: {file_size} bytes") + + return { + "message": "Profile photo uploaded successfully", + "profile_photo_url": public_url + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error uploading profile photo: {str(e)}") + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + +@api_router.delete("/members/profile/delete-photo") +async def delete_profile_photo( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Delete profile photo from R2 and profile""" + if not current_user.profile_photo_url: + raise HTTPException(status_code=404, detail="No profile photo to delete") + + r2 = get_r2_storage() + storage = db.query(StorageUsage).first() + + # Extract object key from URL + object_key = current_user.profile_photo_url.split('/')[-1] + object_key = f"profiles/{object_key}" + + try: + file_size = await r2.get_file_size(object_key) + await r2.delete_file(object_key) + + # Update storage usage + if storage: + storage.total_bytes_used -= file_size + storage.last_updated = datetime.now(timezone.utc) + + # Update user profile + current_user.profile_photo_url = None + current_user.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Profile photo deleted for user {current_user.email}") + + return {"message": "Profile photo deleted successfully"} + except Exception as e: + logger.error(f"Error deleting profile photo: {str(e)}") + raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") + +# Calendar Routes (Active Members Only) +@api_router.get("/members/calendar/events", response_model=List[CalendarEventResponse]) +async def get_calendar_events( + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get calendar events with user RSVP status""" + query = db.query(Event).filter(Event.published == True) + + if start_date: + query = query.filter(Event.start_at >= start_date) + if end_date: + query = query.filter(Event.end_at <= end_date) + + events = query.order_by(Event.start_at).all() + + result = [] + for event in events: + # Get user's RSVP status for this event + rsvp = db.query(EventRSVP).filter( + EventRSVP.event_id == event.id, + EventRSVP.user_id == current_user.id + ).first() + + user_rsvp_status = rsvp.rsvp_status.value if rsvp else None + + result.append(CalendarEventResponse( + id=str(event.id), + title=event.title, + description=event.description, + start_at=event.start_at, + end_at=event.end_at, + location=event.location, + capacity=event.capacity, + user_rsvp_status=user_rsvp_status, + microsoft_calendar_synced=event.microsoft_calendar_sync_enabled + )) + + return result + +# Members Directory Route +@api_router.get("/members/directory") +async def get_members_directory( + search: Optional[str] = None, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get members directory - only shows active members who opted in""" + query = db.query(User).filter( + User.show_in_directory == True, + User.status == UserStatus.active + ) + + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + User.directory_bio.ilike(search_term) + ) + ) + + members = query.order_by(User.first_name, User.last_name).all() + + return [ + { + "id": str(member.id), + "first_name": member.first_name, + "last_name": member.last_name, + "profile_photo_url": member.profile_photo_url, + "directory_email": member.directory_email, + "directory_bio": member.directory_bio, + "directory_address": member.directory_address, + "directory_phone": member.directory_phone, + "directory_partner_name": member.directory_partner_name, + "social_media_facebook": member.social_media_facebook, + "social_media_instagram": member.social_media_instagram, + "social_media_twitter": member.social_media_twitter, + "social_media_linkedin": member.social_media_linkedin + } + for member in members + ] + +# Admin Calendar Sync Routes +@api_router.post("/admin/calendar/sync/{event_id}") +async def sync_event_to_microsoft( + event_id: str, + current_user: User = Depends(require_permission("events.edit")), + db: Session = Depends(get_db) +): + """Sync event to Microsoft Calendar""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + ms_calendar = get_ms_calendar_service() + + try: + # Sync event + ms_event_id = await ms_calendar.sync_event( + loaf_event=event, + existing_ms_event_id=event.microsoft_calendar_id + ) + + # Update event with MS Calendar ID + event.microsoft_calendar_id = ms_event_id + event.microsoft_calendar_sync_enabled = True + event.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Event {event.title} synced to Microsoft Calendar by {current_user.email}") + + return { + "message": "Event synced to Microsoft Calendar successfully", + "microsoft_calendar_id": ms_event_id + } + except Exception as e: + logger.error(f"Error syncing event to Microsoft Calendar: {str(e)}") + raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}") + +@api_router.delete("/admin/calendar/unsync/{event_id}") +async def unsync_event_from_microsoft( + event_id: str, + current_user: User = Depends(require_permission("events.edit")), + db: Session = Depends(get_db) +): + """Remove event from Microsoft Calendar""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + if not event.microsoft_calendar_id: + raise HTTPException(status_code=400, detail="Event is not synced to Microsoft Calendar") + + ms_calendar = get_ms_calendar_service() + + try: + # Delete from Microsoft Calendar + await ms_calendar.delete_event(event.microsoft_calendar_id) + + # Update event + event.microsoft_calendar_id = None + event.microsoft_calendar_sync_enabled = False + event.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Event {event.title} unsynced from Microsoft Calendar by {current_user.email}") + + return {"message": "Event removed from Microsoft Calendar successfully"} + except Exception as e: + logger.error(f"Error removing event from Microsoft Calendar: {str(e)}") + raise HTTPException(status_code=500, detail=f"Unsync failed: {str(e)}") + +# Event Gallery Routes (Members Only) +@api_router.get("/members/gallery") +async def get_events_with_galleries( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get all events that have gallery images""" + # Get events that have at least one gallery image + events_with_galleries = db.query(Event).join(EventGallery).filter( + Event.published == True + ).distinct().order_by(Event.start_at.desc()).all() + + result = [] + for event in events_with_galleries: + gallery_count = db.query(EventGallery).filter( + EventGallery.event_id == event.id + ).count() + + # Get first image as thumbnail + first_image = db.query(EventGallery).filter( + EventGallery.event_id == event.id + ).order_by(EventGallery.created_at).first() + + result.append({ + "id": str(event.id), + "title": event.title, + "description": event.description, + "start_at": event.start_at, + "location": event.location, + "gallery_count": gallery_count, + "thumbnail_url": first_image.image_url if first_image else None + }) + + return result + +@api_router.get("/events/{event_id}/gallery") +async def get_event_gallery( + event_id: str, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get all gallery images for a specific event""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + gallery_images = db.query(EventGallery).filter( + EventGallery.event_id == event_id + ).order_by(EventGallery.created_at.desc()).all() + + return [ + { + "id": str(img.id), + "image_url": img.image_url, + "image_key": img.image_key, + "caption": img.caption, + "uploaded_by": str(img.uploaded_by), + "file_size_bytes": img.file_size_bytes, + "created_at": img.created_at + } + for img in gallery_images + ] + +# Admin Event Gallery Routes +@api_router.post("/admin/events/{event_id}/gallery") +async def upload_event_gallery_image( + event_id: str, + file: UploadFile = File(...), + caption: Optional[str] = None, + current_user: User = Depends(require_permission("gallery.upload")), + db: Session = Depends(get_db) +): + """Upload image to event gallery (Admin only)""" + # Validate event exists + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + r2 = get_r2_storage() + + # Get storage quota + storage = db.query(StorageUsage).first() + if not storage: + storage = StorageUsage( + total_bytes_used=0, + max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) + ) + db.add(storage) + db.commit() + db.refresh(storage) + + # Get max file size from env + max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) + + try: + # Upload to R2 + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder=f"gallery/{event_id}", + allowed_types=r2.ALLOWED_IMAGE_TYPES, + max_size_bytes=max_file_size + ) + + # Check storage quota + if storage.total_bytes_used + file_size > storage.max_bytes_allowed: + # Rollback upload + await r2.delete_file(object_key) + raise HTTPException( + status_code=507, + detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" + ) + + # Create gallery record + gallery_image = EventGallery( + event_id=event.id, + image_url=public_url, + image_key=object_key, + caption=caption, + uploaded_by=current_user.id, + file_size_bytes=file_size + ) + db.add(gallery_image) + + # Update storage usage + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + + db.commit() + db.refresh(gallery_image) + + logger.info(f"Gallery image uploaded for event {event.title} by {current_user.email}: {file_size} bytes") + + return { + "message": "Image uploaded successfully", + "image": { + "id": str(gallery_image.id), + "image_url": gallery_image.image_url, + "caption": gallery_image.caption, + "file_size_bytes": gallery_image.file_size_bytes + } + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error uploading gallery image: {str(e)}") + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + +@api_router.delete("/admin/event-gallery/{image_id}") +async def delete_gallery_image( + image_id: str, + current_user: User = Depends(require_permission("gallery.delete")), + db: Session = Depends(get_db) +): + """Delete image from event gallery (Admin only)""" + gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() + if not gallery_image: + raise HTTPException(status_code=404, detail="Gallery image not found") + + r2 = get_r2_storage() + storage = db.query(StorageUsage).first() + + try: + # Delete from R2 + await r2.delete_file(gallery_image.image_key) + + # Update storage usage + if storage: + storage.total_bytes_used -= gallery_image.file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + + # Delete from database + db.delete(gallery_image) + db.commit() + + logger.info(f"Gallery image deleted by {current_user.email}: {gallery_image.image_key}") + + return {"message": "Image deleted successfully"} + except Exception as e: + logger.error(f"Error deleting gallery image: {str(e)}") + raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") + +@api_router.put("/admin/event-gallery/{image_id}") +async def update_gallery_image_caption( + image_id: str, + caption: str, + current_user: User = Depends(require_permission("gallery.edit")), + db: Session = Depends(get_db) +): + """Update gallery image caption (Admin only)""" + gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() + if not gallery_image: + raise HTTPException(status_code=404, detail="Gallery image not found") + + gallery_image.caption = caption + db.commit() + db.refresh(gallery_image) + + return { + "message": "Caption updated successfully", + "image": { + "id": str(gallery_image.id), + "caption": gallery_image.caption + } + } + +# Event Routes +@api_router.get("/events", response_model=List[EventResponse]) +async def get_events( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + # Get published events for all users + events = db.query(Event).filter(Event.published == True).order_by(Event.start_at).all() + + result = [] + for event in events: + rsvp_count = db.query(EventRSVP).filter( + EventRSVP.event_id == event.id, + EventRSVP.rsvp_status == RSVPStatus.yes + ).count() + + # No user_rsvp_status in public endpoint + result.append(EventResponse( + id=str(event.id), + title=event.title, + description=event.description, + start_at=event.start_at, + end_at=event.end_at, + location=event.location, + capacity=event.capacity, + published=event.published, + created_by=str(event.created_by), + created_at=event.created_at, + rsvp_count=rsvp_count, + user_rsvp_status=None + )) + + return result + +@api_router.get("/events/{event_id}", response_model=EventResponse) +async def get_event( + event_id: str, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + rsvp_count = db.query(EventRSVP).filter( + EventRSVP.event_id == event.id, + EventRSVP.rsvp_status == RSVPStatus.yes + ).count() + + # No user_rsvp_status in public endpoint + user_rsvp = None + + return EventResponse( + id=str(event.id), + title=event.title, + description=event.description, + start_at=event.start_at, + end_at=event.end_at, + location=event.location, + capacity=event.capacity, + published=event.published, + created_by=str(event.created_by), + created_at=event.created_at, + rsvp_count=rsvp_count, + user_rsvp_status=user_rsvp + ) + +@api_router.post("/events/{event_id}/rsvp") +async def rsvp_to_event( + event_id: str, + request: RSVPRequest, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check if RSVP already exists + existing_rsvp = db.query(EventRSVP).filter( + EventRSVP.event_id == event_id, + EventRSVP.user_id == current_user.id + ).first() + + if existing_rsvp: + existing_rsvp.rsvp_status = RSVPStatus(request.rsvp_status) + existing_rsvp.updated_at = datetime.now(timezone.utc) + else: + rsvp = EventRSVP( + event_id=event.id, + user_id=current_user.id, + rsvp_status=RSVPStatus(request.rsvp_status) + ) + db.add(rsvp) + + db.commit() + + return {"message": "RSVP updated successfully"} + +# ============================================================================ +# Calendar Export Endpoints (Universal iCalendar .ics format) +# ============================================================================ + +@api_router.get("/events/{event_id}/download.ics") +async def download_event_ics( + event_id: str, + db: Session = Depends(get_db) +): + """ + Download single event as .ics file (RFC 5545 iCalendar format) + No authentication required for published events + Works with Google Calendar, Apple Calendar, Microsoft Outlook, etc. + """ + event = db.query(Event).filter( + Event.id == event_id, + Event.published == True + ).first() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Generate UID if not exists + if not event.calendar_uid: + event.calendar_uid = calendar_service.generate_event_uid() + db.commit() + + ics_content = calendar_service.create_single_event_calendar(event) + + # Sanitize filename + safe_filename = "".join(c for c in event.title if c.isalnum() or c in (' ', '-', '_')).rstrip() + safe_filename = safe_filename.replace(' ', '_') or 'event' + + return StreamingResponse( + iter([ics_content]), + media_type="text/calendar", + headers={ + "Content-Disposition": f"attachment; filename={safe_filename}.ics", + "Cache-Control": "public, max-age=300" # Cache for 5 minutes + } + ) + +@api_router.get("/calendars/subscribe.ics") +async def subscribe_calendar( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Subscribe to user's RSVP'd events (live calendar feed) + Auto-syncs events marked as "Yes" RSVP + Use webcal:// protocol for auto-sync in calendar apps + """ + # Get all upcoming events user RSVP'd "yes" to + rsvps = db.query(EventRSVP).filter( + EventRSVP.user_id == current_user.id, + EventRSVP.rsvp_status == RSVPStatus.yes + ).join(Event).filter( + Event.start_at > datetime.now(timezone.utc), + Event.published == True + ).all() + + events = [rsvp.event for rsvp in rsvps] + + # Generate UIDs for events that don't have them + for event in events: + if not event.calendar_uid: + event.calendar_uid = calendar_service.generate_event_uid() + db.commit() + + feed_name = f"{current_user.first_name}'s LOAF Events" + ics_content = calendar_service.create_subscription_feed(events, feed_name) + + return StreamingResponse( + iter([ics_content]), + media_type="text/calendar", + headers={ + "Content-Disposition": "inline; filename=loaf-events.ics", + "Cache-Control": "public, max-age=3600", # Cache for 1 hour + "ETag": f'"{hash(ics_content)}"' + } + ) + +@api_router.get("/calendars/all-events.ics") +async def download_all_events( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Download all upcoming published events as .ics file (one-time download) + Useful for importing all events at once + """ + events = db.query(Event).filter( + Event.published == True, + Event.start_at > datetime.now(timezone.utc) + ).order_by(Event.start_at).all() + + # Generate UIDs + for event in events: + if not event.calendar_uid: + event.calendar_uid = calendar_service.generate_event_uid() + db.commit() + + ics_content = calendar_service.create_subscription_feed(events, "All LOAF Events") + + return StreamingResponse( + iter([ics_content]), + media_type="text/calendar", + headers={ + "Content-Disposition": "attachment; filename=loaf-all-events.ics", + "Cache-Control": "public, max-age=600" # Cache for 10 minutes + } + ) + +# ============================================================================ +# Newsletter Archive Routes (Members Only) +# ============================================================================ +@api_router.get("/newsletters") +async def get_newsletters( + year: Optional[int] = None, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get all newsletters, optionally filtered by year + Members only + """ + from models import NewsletterArchive + + query = db.query(NewsletterArchive) + + if year: + query = query.filter( + db.func.extract('year', NewsletterArchive.published_date) == year + ) + + newsletters = query.order_by(NewsletterArchive.published_date.desc()).all() + + return [{ + "id": str(n.id), + "title": n.title, + "description": n.description, + "published_date": n.published_date.isoformat(), + "document_url": n.document_url, + "document_type": n.document_type, + "file_size_bytes": n.file_size_bytes, + "created_at": n.created_at.isoformat() + } for n in newsletters] + +@api_router.get("/newsletters/years") +async def get_newsletter_years( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get list of years that have newsletters + Members only + """ + from models import NewsletterArchive + + years = db.query( + db.func.extract('year', NewsletterArchive.published_date).label('year') + ).distinct().order_by(db.text('year DESC')).all() + + return [int(y.year) for y in years] + +# ============================================================================ +# Financial Reports Routes (Members Only) +# ============================================================================ +@api_router.get("/financials") +async def get_financial_reports( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get all financial reports sorted by year (newest first) + Members only + """ + from models import FinancialReport + + reports = db.query(FinancialReport).order_by( + FinancialReport.year.desc() + ).all() + + return [{ + "id": str(r.id), + "year": r.year, + "title": r.title, + "document_url": r.document_url, + "document_type": r.document_type, + "file_size_bytes": r.file_size_bytes, + "created_at": r.created_at.isoformat() + } for r in reports] + +# ============================================================================ +# Bylaws Routes (Members Only) +# ============================================================================ +@api_router.get("/bylaws/current") +async def get_current_bylaws( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get current bylaws document + Members only + """ + from models import BylawsDocument + + bylaws = db.query(BylawsDocument).filter( + BylawsDocument.is_current == True + ).first() + + if not bylaws: + raise HTTPException(status_code=404, detail="No current bylaws found") + + return { + "id": str(bylaws.id), + "title": bylaws.title, + "version": bylaws.version, + "effective_date": bylaws.effective_date.isoformat(), + "document_url": bylaws.document_url, + "document_type": bylaws.document_type, + "file_size_bytes": bylaws.file_size_bytes, + "is_current": bylaws.is_current, + "created_at": bylaws.created_at.isoformat() + } + +@api_router.get("/bylaws/history") +async def get_bylaws_history( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get all bylaws versions (historical) + Members only + """ + from models import BylawsDocument + + history = db.query(BylawsDocument).order_by( + BylawsDocument.effective_date.desc() + ).all() + + return [{ + "id": str(b.id), + "title": b.title, + "version": b.version, + "effective_date": b.effective_date.isoformat(), + "document_url": b.document_url, + "document_type": b.document_type, + "file_size_bytes": b.file_size_bytes, + "is_current": b.is_current, + "created_at": b.created_at.isoformat() + } for b in history] + +# ============================================================================ +# Configuration Endpoints +# ============================================================================ +@api_router.get("/config/limits") +async def get_config_limits(): + """Get configuration limits for file uploads""" + return { + "max_file_size_bytes": int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)), + "max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) + } + +# ============================================================================ +# Admin Routes +# ============================================================================ +@api_router.get("/admin/storage/usage") +async def get_storage_usage( + current_user: User = Depends(require_permission("settings.storage")), + db: Session = Depends(get_db) +): + """Get current storage usage statistics""" + from models import StorageUsage + + storage = db.query(StorageUsage).first() + + if not storage: + # Initialize if doesn't exist + storage = StorageUsage( + total_bytes_used=0, + max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) + ) + db.add(storage) + db.commit() + db.refresh(storage) + + percentage = (storage.total_bytes_used / storage.max_bytes_allowed) * 100 if storage.max_bytes_allowed > 0 else 0 + + return { + "total_bytes_used": storage.total_bytes_used, + "max_bytes_allowed": storage.max_bytes_allowed, + "percentage": round(percentage, 2), + "available_bytes": storage.max_bytes_allowed - storage.total_bytes_used + } + +@api_router.get("/admin/storage/breakdown") +async def get_storage_breakdown( + current_user: User = Depends(require_permission("settings.storage")), + db: Session = Depends(get_db) +): + """Get storage usage breakdown by category""" + from sqlalchemy import func + from models import User, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument + + # Count storage by category + # Note: profile_photos removed - User.profile_photo_size field doesn't exist + # If needed in future, add profile_photo_size column to User model + gallery_images = db.query(func.coalesce(func.sum(EventGallery.file_size_bytes), 0)).scalar() or 0 + newsletters = db.query(func.coalesce(func.sum(NewsletterArchive.file_size_bytes), 0)).filter( + NewsletterArchive.document_type == 'upload' + ).scalar() or 0 + financials = db.query(func.coalesce(func.sum(FinancialReport.file_size_bytes), 0)).filter( + FinancialReport.document_type == 'upload' + ).scalar() or 0 + bylaws = db.query(func.coalesce(func.sum(BylawsDocument.file_size_bytes), 0)).filter( + BylawsDocument.document_type == 'upload' + ).scalar() or 0 + + return { + "breakdown": { + "gallery_images": gallery_images, + "newsletters": newsletters, + "financials": financials, + "bylaws": bylaws + }, + "total": gallery_images + newsletters + financials + bylaws + } + + +@api_router.get("/admin/users") +async def get_all_users( + status: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.view")) +): + query = db.query(User) + + if status: + try: + status_enum = UserStatus(status) + query = query.filter(User.status == status_enum) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid status") + + users = query.order_by(User.created_at.desc()).all() + + return [ + { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "phone": user.phone, + "status": user.status.value, + "role": user.role.value, + "email_verified": user.email_verified, + "created_at": user.created_at.isoformat(), + "lead_sources": user.lead_sources, + "referred_by_member_name": user.referred_by_member_name + } + for user in users + ] + +# IMPORTANT: All specific routes (/create, /invite, /invitations, /export, /import) +# must be defined ABOVE this {user_id} route to avoid path conflicts + +@api_router.get("/admin/users/invitations") +async def get_invitations( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all invitations with optional status filter + Admin/Superadmin only + """ + query = db.query(UserInvitation) + + if status: + try: + status_enum = InvitationStatus[status] + query = query.filter(UserInvitation.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + invitations = query.order_by(UserInvitation.invited_at.desc()).all() + + return [ + { + "id": str(inv.id), + "email": inv.email, + "role": inv.role.value, + "status": inv.status.value, + "first_name": inv.first_name, + "last_name": inv.last_name, + "phone": inv.phone, + "invited_by": str(inv.invited_by), + "invited_at": inv.invited_at.isoformat(), + "expires_at": inv.expires_at.isoformat(), + "accepted_at": inv.accepted_at.isoformat() if inv.accepted_at else None + } + for inv in invitations + ] + +@api_router.get("/admin/users/{user_id}") +async def get_user_by_id( + user_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.view")) +): + """Get specific user by ID (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "phone": user.phone, + "address": user.address, + "city": user.city, + "state": user.state, + "zipcode": user.zipcode, + "date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None, + "partner_first_name": user.partner_first_name, + "partner_last_name": user.partner_last_name, + "partner_is_member": user.partner_is_member, + "partner_plan_to_become_member": user.partner_plan_to_become_member, + "referred_by_member_name": user.referred_by_member_name, + "status": user.status.value, + "role": user.role.value, + "email_verified": user.email_verified, + "newsletter_subscribed": user.newsletter_subscribed, + "lead_sources": user.lead_sources, + "created_at": user.created_at.isoformat() if user.created_at else None, + "updated_at": user.updated_at.isoformat() if user.updated_at else None + } + +@api_router.put("/admin/users/{user_id}/validate") +async def validate_user( + user_id: str, + bypass_email_verification: bool = False, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.approve")) +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Handle bypass email verification for pending_email users + if bypass_email_verification and user.status == UserStatus.pending_email: + # Verify email manually + user.email_verified = True + user.email_verification_token = None + + # Determine status based on referral + if user.referred_by_member_name: + referrer = db.query(User).filter( + or_( + User.first_name + ' ' + User.last_name == user.referred_by_member_name, + User.email == user.referred_by_member_name + ), + User.status == UserStatus.active + ).first() + user.status = UserStatus.pre_validated if referrer else UserStatus.pending_validation + else: + user.status = UserStatus.pending_validation + + logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}") + + # Validate user status - must be pending_validation or pre_validated + if user.status not in [UserStatus.pending_validation, UserStatus.pre_validated]: + raise HTTPException( + status_code=400, + detail=f"User must have verified email first. Current: {user.status.value}" + ) + + # Set to payment_pending - user becomes active after payment via webhook + user.status = UserStatus.payment_pending + user.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(user) + + # Send payment prompt email + await send_payment_prompt_email(user.email, user.first_name) + + logger.info(f"User validated (payment pending): {user.email} by admin: {current_user.email}") + + return {"message": "User validated - payment email sent"} + +@api_router.put("/admin/users/{user_id}/status") +async def update_user_status( + user_id: str, + request: UpdateUserStatusRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.status")) +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + try: + new_status = UserStatus(request.status) + user.status = new_status + user.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(user) + + return {"message": "User status updated successfully"} + except ValueError: + raise HTTPException(status_code=400, detail="Invalid status") + +@api_router.post("/admin/users/{user_id}/activate-payment") +async def activate_payment_manually( + user_id: str, + request: ManualPaymentRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("subscriptions.activate")) +): + """Manually activate user who paid offline (cash, bank transfer, etc.)""" + + # 1. Find user + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # 2. Validate status + if user.status != UserStatus.payment_pending: + raise HTTPException( + status_code=400, + detail=f"User must be in payment_pending status. Current: {user.status.value}" + ) + + # 3. Get subscription plan + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == request.plan_id).first() + if not plan: + raise HTTPException(status_code=404, detail="Subscription plan not found") + + # 4. Validate amount against plan minimum + if request.amount_cents < plan.minimum_price_cents: + raise HTTPException( + status_code=400, + detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}" + ) + + # 5. Calculate donation split + base_amount = plan.minimum_price_cents + donation_amount = request.amount_cents - base_amount + + # 6. Calculate subscription period + from payment_service import calculate_subscription_period + + if request.use_custom_period or request.override_plan_dates: + # Admin-specified custom dates override everything + if not request.custom_period_start or not request.custom_period_end: + raise HTTPException( + status_code=400, + detail="Custom period start and end dates are required when use_custom_period or override_plan_dates is true" + ) + period_start = request.custom_period_start + period_end = request.custom_period_end + else: + # Use plan's custom cycle or billing cycle + period_start, period_end = calculate_subscription_period(plan) + + # 7. Create subscription record (manual payment) with donation tracking + subscription = Subscription( + user_id=user.id, + plan_id=plan.id, + stripe_subscription_id=None, # No Stripe involvement + stripe_customer_id=None, + status=SubscriptionStatus.active, + start_date=period_start, + end_date=period_end, + amount_paid_cents=request.amount_cents, + base_subscription_cents=base_amount, + donation_cents=donation_amount, + payment_method=request.payment_method, + manual_payment=True, + manual_payment_notes=request.notes, + manual_payment_admin_id=current_user.id, + manual_payment_date=request.payment_date + ) + db.add(subscription) + + # 6. Activate user + user.status = UserStatus.active + set_user_role(user, UserRole.member, db) + user.updated_at = datetime.now(timezone.utc) + + # 7. Commit + db.commit() + db.refresh(subscription) + + # 8. Log admin action + logger.info( + f"Admin {current_user.email} manually activated payment for user {user.email} " + f"via {request.payment_method} for ${request.amount_cents/100:.2f} " + f"with plan {plan.name} ({period_start.date()} to {period_end.date()})" + ) + + return { + "message": "User payment activated successfully", + "user_id": str(user.id), + "subscription_id": str(subscription.id) + } + +@api_router.put("/admin/users/{user_id}/reset-password") +async def admin_reset_user_password( + user_id: str, + request: AdminPasswordUpdateRequest, + current_user: User = Depends(require_permission("users.reset_password")), + db: Session = Depends(get_db) +): + """Admin resets user password - generates temp password and emails it""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate random temporary password + temp_password = secrets.token_urlsafe(12) + + # Update user + user.password_hash = get_password_hash(temp_password) + user.force_password_change = request.force_change + db.commit() + + # Email user the temporary password + await send_admin_password_reset_email( + user.email, + user.first_name, + temp_password, + request.force_change + ) + + # Log admin action + logger.info( + f"Admin {current_user.email} reset password for user {user.email} " + f"(force_change={request.force_change})" + ) + + return {"message": f"Password reset for {user.email}. Temporary password emailed."} + +@api_router.post("/admin/users/{user_id}/resend-verification") +async def admin_resend_verification( + user_id: str, + current_user: User = Depends(require_permission("users.resend_verification")), + db: Session = Depends(get_db) +): + """Admin resends verification email for any user""" + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Check if email already verified + if user.email_verified: + raise HTTPException(status_code=400, detail="User's email is already verified") + + # Generate new token + verification_token = secrets.token_urlsafe(32) + user.email_verification_token = verification_token + db.commit() + + # Send verification email + await send_verification_email(user.email, verification_token) + + # Log admin action + logger.info( + f"Admin {current_user.email} resent verification email to user {user.email}" + ) + + return {"message": f"Verification email resent to {user.email}"} + +# ============================================================ +# User Creation & Invitation Endpoints +# ============================================================ + +@api_router.post("/admin/users/create") +async def create_user_directly( + request: CreateUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Create user account directly (without invitation) + Admin/Superadmin only + """ + # Check if email already exists + existing_user = db.query(User).filter(User.email == request.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can create superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can create superadmin users") + + # Create user + new_user = User( + email=request.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=role_enum, + email_verified=True, # Admin-created users are pre-verified + status=UserStatus.active if role_enum in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional member fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + member_since=request.member_since, + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + logger.info(f"Admin {current_user.email} created user: {new_user.email} with role {request.role}") + + return { + "message": "User created successfully", + "user_id": str(new_user.id), + "email": new_user.email, + "role": new_user.role.value + } + +@api_router.post("/admin/users/invite") +async def send_user_invitation( + request: InviteUserRequest, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Send email invitation to new user + Admin/Superadmin only + """ + # Check if email already exists + existing_user = db.query(User).filter(User.email == request.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Check for pending invitation + existing_invitation = db.query(UserInvitation).filter( + UserInvitation.email == request.email, + UserInvitation.status == InvitationStatus.pending + ).first() + if existing_invitation: + raise HTTPException(status_code=400, detail="Pending invitation already exists for this email") + + # Validate role + try: + role_enum = UserRole[request.role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {request.role}") + + # Only superadmin can invite superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise HTTPException(status_code=403, detail="Only superadmin can invite superadmin users") + + # Generate secure token + token = secrets.token_urlsafe(32) + + # Create invitation (expires in 7 days) + invitation = UserInvitation( + email=request.email, + token=token, + role=role_enum, + status=InvitationStatus.pending, + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + invited_by=current_user.id, + expires_at=datetime.now(timezone.utc) + timedelta(days=7) + ) + + db.add(invitation) + db.commit() + db.refresh(invitation) + + # Send invitation email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={token}" + + try: + await send_invitation_email( + to_email=request.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=request.role + ) + except Exception as e: + logger.error(f"Failed to send invitation email: {str(e)}") + # Continue anyway - admin can resend later + + logger.info(f"Admin {current_user.email} invited {request.email} as {request.role}") + + return { + "message": "Invitation sent successfully", + "invitation_id": str(invitation.id), + "email": invitation.email, + "expires_at": invitation.expires_at.isoformat(), + "invitation_url": invitation_url + } + +@api_router.post("/admin/users/invitations/{invitation_id}/resend") +async def resend_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Resend invitation email (extends expiry by 7 days) + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot resend invitation with status: {invitation.status.value}") + + # Extend expiry by 7 days from now + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=7) + db.commit() + + # Resend email + from email_service import send_invitation_email + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + invitation_url = f"{frontend_url}/accept-invitation?token={invitation.token}" + + try: + await send_invitation_email( + to_email=invitation.email, + inviter_name=f"{current_user.first_name} {current_user.last_name}", + invitation_url=invitation_url, + role=invitation.role.value + ) + except Exception as e: + logger.error(f"Failed to resend invitation email: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to send email") + + logger.info(f"Admin {current_user.email} resent invitation to {invitation.email}") + + return { + "message": "Invitation resent successfully", + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.delete("/admin/users/invitations/{invitation_id}") +async def revoke_invitation( + invitation_id: str, + current_user: User = Depends(require_permission("users.create")), + db: Session = Depends(get_db) +): + """ + Revoke pending invitation + Admin/Superadmin only + """ + invitation = db.query(UserInvitation).filter(UserInvitation.id == invitation_id).first() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.status != InvitationStatus.pending: + raise HTTPException(status_code=400, detail=f"Cannot revoke invitation with status: {invitation.status.value}") + + invitation.status = InvitationStatus.revoked + db.commit() + + logger.info(f"Admin {current_user.email} revoked invitation for {invitation.email}") + + return {"message": "Invitation revoked successfully"} + +# ============================================================ +# Public Invitation Endpoints +# ============================================================ + +@api_router.get("/invitations/verify/{token}") +async def verify_invitation_token( + token: str, + db: Session = Depends(get_db) +): + """ + Verify invitation token and return invitation details + Public endpoint - no authentication required + """ + invitation = db.query(UserInvitation).filter( + UserInvitation.token == token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + return { + "email": invitation.email, + "role": invitation.role.value, + "first_name": invitation.first_name, + "last_name": invitation.last_name, + "phone": invitation.phone, + "expires_at": invitation.expires_at.isoformat() + } + +@api_router.post("/invitations/accept") +async def accept_invitation( + request: AcceptInvitationRequest, + db: Session = Depends(get_db) +): + """ + Accept invitation and create user account + Public endpoint - no authentication required + """ + # Verify invitation + invitation = db.query(UserInvitation).filter( + UserInvitation.token == request.token, + UserInvitation.status == InvitationStatus.pending + ).first() + + if not invitation: + raise HTTPException(status_code=404, detail="Invalid or expired invitation token") + + # Check expiry + if invitation.expires_at < datetime.now(timezone.utc): + invitation.status = InvitationStatus.expired + db.commit() + raise HTTPException(status_code=400, detail="Invitation has expired") + + # Check if email already registered + existing_user = db.query(User).filter(User.email == invitation.email).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + # Create user account + new_user = User( + email=invitation.email, + password_hash=get_password_hash(request.password), + first_name=request.first_name, + last_name=request.last_name, + phone=request.phone, + role=invitation.role, + email_verified=True, # Invited users are pre-verified + status=UserStatus.active if invitation.role in [UserRole.admin, UserRole.superadmin] else UserStatus.payment_pending, + + # Optional fields + address=request.address or "", + city=request.city or "", + state=request.state or "", + zipcode=request.zipcode or "", + date_of_birth=request.date_of_birth or datetime.now(timezone.utc), + ) + + db.add(new_user) + + # Update invitation status + invitation.status = InvitationStatus.accepted + invitation.accepted_at = datetime.now(timezone.utc) + invitation.accepted_by = new_user.id + + db.commit() + db.refresh(new_user) + + # Generate JWT token for auto-login + access_token = create_access_token(data={"sub": str(new_user.id)}) + + logger.info(f"User {new_user.email} accepted invitation and created account with role {new_user.role.value}") + + return { + "message": "Invitation accepted successfully", + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": str(new_user.id), + "email": new_user.email, + "first_name": new_user.first_name, + "last_name": new_user.last_name, + "role": new_user.role.value, + "status": new_user.status.value + } + } + + +# ============================================================ +# CSV EXPORT/IMPORT ENDPOINTS +# ============================================================ + +@api_router.get("/admin/users/export") +async def export_users_csv( + status: Optional[str] = None, + role: Optional[str] = None, + email_verified: Optional[bool] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("users.export")), + db: Session = Depends(get_db) +): + """ + Export users to CSV with optional filters + Admin/Superadmin only + Requires permission: users.export + """ + # Build query + query = db.query(User) + + # Apply filters + if status: + try: + status_enum = UserStatus[status] + query = query.filter(User.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + if role: + try: + role_enum = UserRole[role] + query = query.filter(User.role == role_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + if email_verified is not None: + query = query.filter(User.email_verified == email_verified) + + if search: + search_filter = or_( + User.email.ilike(f"%{search}%"), + User.first_name.ilike(f"%{search}%"), + User.last_name.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + # Get all matching users + users = query.order_by(User.created_at.desc()).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Email', + 'First Name', + 'Last Name', + 'Phone', + 'Role', + 'Status', + 'Email Verified', + 'Address', + 'City', + 'State', + 'Zipcode', + 'Date of Birth', + 'Member Since', + 'Partner First Name', + 'Partner Last Name', + 'Partner Is Member', + 'Partner Plan to Become Member', + 'Referred By Member Name', + 'Lead Sources', + 'Created At', + 'Updated At' + ]) + + # Write data rows + for user in users: + writer.writerow([ + str(user.id), + user.email, + user.first_name, + user.last_name, + user.phone, + user.role.value, + user.status.value, + 'Yes' if user.email_verified else 'No', + user.address or '', + user.city or '', + user.state or '', + user.zipcode or '', + user.date_of_birth.strftime('%Y-%m-%d') if user.date_of_birth else '', + user.member_since.strftime('%Y-%m-%d') if user.member_since else '', + user.partner_first_name or '', + user.partner_last_name or '', + 'Yes' if user.partner_is_member else 'No', + 'Yes' if user.partner_plan_to_become_member else 'No', + user.referred_by_member_name or '', + ','.join(user.lead_sources) if user.lead_sources else '', + user.created_at.strftime('%Y-%m-%d %H:%M:%S'), + user.updated_at.strftime('%Y-%m-%d %H:%M:%S') if user.updated_at else '' + ]) + + # Prepare response + output.seek(0) + + # Generate filename with timestamp + timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') + filename = f"members_export_{timestamp}.csv" + + logger.info(f"Admin {current_user.email} exported {len(users)} users to CSV") + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + + +@api_router.post("/admin/users/import") +async def import_users_csv( + file: UploadFile = File(...), + update_existing: bool = Form(False), + current_user: User = Depends(require_permission("users.import")), + db: Session = Depends(get_db) +): + """ + Import users from CSV file + Admin/Superadmin only + Requires permission: users.import + + CSV Format: + Email,First Name,Last Name,Phone,Role,Status,Address,City,State,Zipcode,Date of Birth,Member Since + """ + # Validate file type + if not file.filename.endswith('.csv'): + raise HTTPException(status_code=400, detail="Only CSV files are supported") + + # Read file content + try: + contents = await file.read() + decoded = contents.decode('utf-8') + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to read CSV file: {str(e)}") + + # Parse CSV + csv_reader = csv.DictReader(io.StringIO(decoded)) + + # Validate required columns + required_columns = {'Email', 'First Name', 'Last Name', 'Phone', 'Role'} + if not required_columns.issubset(set(csv_reader.fieldnames or [])): + missing = required_columns - set(csv_reader.fieldnames or []) + raise HTTPException( + status_code=400, + detail=f"Missing required columns: {', '.join(missing)}" + ) + + # Count total rows + rows = list(csv_reader) + total_rows = len(rows) + + # Create import job + import_job = ImportJob( + filename=file.filename, + total_rows=total_rows, + imported_by=current_user.id, + status=ImportJobStatus.processing + ) + db.add(import_job) + db.commit() + db.refresh(import_job) + + # Process rows + successful_rows = 0 + failed_rows = 0 + errors = [] + + for idx, row in enumerate(rows, start=1): + try: + # Validate required fields + email = row.get('Email', '').strip() + first_name = row.get('First Name', '').strip() + last_name = row.get('Last Name', '').strip() + phone = row.get('Phone', '').strip() + role_str = row.get('Role', '').strip() + + if not all([email, first_name, last_name, phone, role_str]): + raise ValueError("Missing required fields") + + # Validate email format (basic check) + if '@' not in email: + raise ValueError("Invalid email format") + + # Validate role + try: + role_enum = UserRole[role_str.lower()] + except KeyError: + raise ValueError(f"Invalid role: {role_str}. Must be one of: guest, member, admin, superadmin") + + # Only superadmin can import superadmin users + if role_enum == UserRole.superadmin and current_user.role != UserRole.superadmin: + raise ValueError("Only superadmin can import superadmin users") + + # Check if user exists + existing_user = db.query(User).filter(User.email == email).first() + + if existing_user: + if update_existing: + # Update existing user + existing_user.first_name = first_name + existing_user.last_name = last_name + existing_user.phone = phone + set_user_role(existing_user, role_enum, db) + + # Update optional fields if provided + if row.get('Address'): + existing_user.address = row['Address'].strip() + if row.get('City'): + existing_user.city = row['City'].strip() + if row.get('State'): + existing_user.state = row['State'].strip() + if row.get('Zipcode'): + existing_user.zipcode = row['Zipcode'].strip() + if row.get('Status'): + try: + existing_user.status = UserStatus[row['Status'].strip().lower()] + except KeyError: + pass # Skip invalid status + if row.get('Date of Birth'): + try: + existing_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + if row.get('Member Since'): + try: + existing_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Skip invalid date + + successful_rows += 1 + else: + # Skip duplicate + errors.append({ + "row": idx, + "email": email, + "error": "Email already exists (use update_existing=true to update)" + }) + failed_rows += 1 + continue + else: + # Create new user + # Generate temporary password (admin will reset it) + temp_password = secrets.token_urlsafe(16) + + new_user = User( + email=email, + password_hash=get_password_hash(temp_password), + first_name=first_name, + last_name=last_name, + phone=phone, + role=role_enum, + email_verified=True, # Imported users are pre-verified + status=UserStatus[row.get('Status', 'payment_pending').strip().lower()] if row.get('Status') else UserStatus.payment_pending, + address=row.get('Address', '').strip(), + city=row.get('City', '').strip(), + state=row.get('State', '').strip(), + zipcode=row.get('Zipcode', '').strip(), + ) + + # Parse optional dates + if row.get('Date of Birth'): + try: + new_user.date_of_birth = datetime.strptime(row['Date of Birth'].strip(), '%Y-%m-%d') + except ValueError: + pass # Use default + + if row.get('Member Since'): + try: + new_user.member_since = datetime.strptime(row['Member Since'].strip(), '%Y-%m-%d') + except ValueError: + pass # Leave as None + + db.add(new_user) + successful_rows += 1 + + # Commit every 50 rows for performance + if idx % 50 == 0: + db.commit() + + except Exception as e: + failed_rows += 1 + errors.append({ + "row": idx, + "email": row.get('Email', 'N/A'), + "error": str(e) + }) + continue + + # Final commit + db.commit() + + # Update import job + import_job.processed_rows = total_rows + import_job.successful_rows = successful_rows + import_job.failed_rows = failed_rows + import_job.errors = errors + import_job.completed_at = datetime.now(timezone.utc) + + if failed_rows == 0: + import_job.status = ImportJobStatus.completed + elif successful_rows == 0: + import_job.status = ImportJobStatus.failed + else: + import_job.status = ImportJobStatus.partial + + db.commit() + db.refresh(import_job) + + logger.info(f"Admin {current_user.email} imported {successful_rows}/{total_rows} users from CSV") + + return { + "message": "Import completed", + "import_job_id": str(import_job.id), + "total_rows": total_rows, + "successful_rows": successful_rows, + "failed_rows": failed_rows, + "status": import_job.status.value, + "errors": errors[:10] # Return first 10 errors only (full list available in job details) + } + + +@api_router.get("/admin/users/import-jobs") +async def get_import_jobs( + status: Optional[str] = None, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + List all import jobs with optional status filter + Admin/Superadmin only + Requires permission: users.view + """ + query = db.query(ImportJob) + + if status: + try: + status_enum = ImportJobStatus[status] + query = query.filter(ImportJob.status == status_enum) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + jobs = query.order_by(ImportJob.started_at.desc()).all() + + return [ + { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": str(job.imported_by), + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "error_count": len(job.errors) if job.errors else 0 + } + for job in jobs + ] + + +@api_router.get("/admin/users/import-jobs/{job_id}") +async def get_import_job_details( + job_id: str, + current_user: User = Depends(require_permission("users.view")), + db: Session = Depends(get_db) +): + """ + Get detailed information about a specific import job + Admin/Superadmin only + Requires permission: users.view + """ + job = db.query(ImportJob).filter(ImportJob.id == job_id).first() + if not job: + raise HTTPException(status_code=404, detail="Import job not found") + + # Get importer details + importer = db.query(User).filter(User.id == job.imported_by).first() + + return { + "id": str(job.id), + "filename": job.filename, + "total_rows": job.total_rows, + "processed_rows": job.processed_rows, + "successful_rows": job.successful_rows, + "failed_rows": job.failed_rows, + "status": job.status.value, + "imported_by": { + "id": str(importer.id), + "email": importer.email, + "name": f"{importer.first_name} {importer.last_name}" + } if importer else None, + "started_at": job.started_at.isoformat(), + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "errors": job.errors or [] # Full error list + } + + +@api_router.post("/admin/events", response_model=EventResponse) +async def create_event( + request: EventCreate, + current_user: User = Depends(require_permission("events.create")), + db: Session = Depends(get_db) +): + event = Event( + title=request.title, + description=request.description, + start_at=request.start_at, + end_at=request.end_at, + location=request.location, + capacity=request.capacity, + published=request.published, + created_by=current_user.id + ) + + db.add(event) + db.commit() + db.refresh(event) + + logger.info(f"Event created: {event.title} by {current_user.email}") + + return EventResponse( + id=str(event.id), + title=event.title, + description=event.description, + start_at=event.start_at, + end_at=event.end_at, + location=event.location, + capacity=event.capacity, + published=event.published, + created_by=str(event.created_by), + created_at=event.created_at, + rsvp_count=0 + ) + +@api_router.put("/admin/events/{event_id}") +async def update_event( + event_id: str, + request: EventUpdate, + current_user: User = Depends(require_permission("events.edit")), + db: Session = Depends(get_db) +): + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + if request.title: + event.title = request.title + if request.description is not None: + event.description = request.description + if request.start_at: + event.start_at = request.start_at + if request.end_at: + event.end_at = request.end_at + if request.location: + event.location = request.location + if request.capacity is not None: + event.capacity = request.capacity + if request.published is not None: + event.published = request.published + + event.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(event) + + return {"message": "Event updated successfully"} + +@api_router.get("/admin/events/{event_id}/rsvps") +async def get_event_rsvps( + event_id: str, + current_user: User = Depends(require_permission("events.rsvps")), + db: Session = Depends(get_db) +): + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all() + + result = [] + for rsvp in rsvps: + user = db.query(User).filter(User.id == rsvp.user_id).first() + result.append({ + "id": str(rsvp.id), + "user_id": str(rsvp.user_id), + "user_name": f"{user.first_name} {user.last_name}", + "user_email": user.email, + "rsvp_status": rsvp.rsvp_status.value, + "attended": rsvp.attended, + "attended_at": rsvp.attended_at.isoformat() if rsvp.attended_at else None + }) + + return result + +@api_router.put("/admin/events/{event_id}/attendance") +async def mark_attendance( + event_id: str, + request: AttendanceUpdate, + current_user: User = Depends(require_permission("events.attendance")), + db: Session = Depends(get_db) +): + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + rsvp = db.query(EventRSVP).filter( + EventRSVP.event_id == event_id, + EventRSVP.user_id == request.user_id + ).first() + + if not rsvp: + raise HTTPException(status_code=404, detail="RSVP not found") + + rsvp.attended = request.attended + rsvp.attended_at = datetime.now(timezone.utc) if request.attended else None + rsvp.updated_at = datetime.now(timezone.utc) + + # If user attended and they were pending validation, update their status + if request.attended: + user = db.query(User).filter(User.id == request.user_id).first() + if user and user.status == UserStatus.pending_validation: + user.status = UserStatus.pre_validated + user.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Attendance marked successfully"} + +@api_router.get("/admin/events") +async def get_admin_events( + current_user: User = Depends(require_permission("events.view")), + db: Session = Depends(get_db) +): + """Get all events for admin (including unpublished)""" + events = db.query(Event).order_by(Event.start_at.desc()).all() + + result = [] + for event in events: + rsvp_count = db.query(EventRSVP).filter( + EventRSVP.event_id == event.id, + EventRSVP.rsvp_status == RSVPStatus.yes + ).count() + + result.append({ + "id": str(event.id), + "title": event.title, + "description": event.description, + "start_at": event.start_at, + "end_at": event.end_at, + "location": event.location, + "capacity": event.capacity, + "published": event.published, + "created_by": str(event.created_by), + "created_at": event.created_at, + "rsvp_count": rsvp_count + }) + + return result + +@api_router.delete("/admin/events/{event_id}") +async def delete_event( + event_id: str, + current_user: User = Depends(require_permission("events.delete")), + db: Session = Depends(get_db) +): + """Delete an event (cascade deletes RSVPs)""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + db.delete(event) + db.commit() + + return {"message": "Event deleted successfully"} + +# ==================== PAYMENT & SUBSCRIPTION ENDPOINTS ==================== + +# Pydantic model for checkout request +class CheckoutRequest(BaseModel): + plan_id: str + amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)") + + @validator('amount_cents') + def validate_amount(cls, v): + if v < 3000: + raise ValueError('Amount must be at least $30 (3000 cents)') + return v + +# Pydantic model for plan CRUD +class PlanCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility + billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"] + stripe_price_id: Optional[str] = None # Deprecated, no longer required + active: bool = True + + # Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31) + custom_cycle_enabled: bool = False + custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12) + custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31) + custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12) + custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31) + + # Dynamic pricing fields + minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum + suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000) + allow_donation: bool = True + + @validator('name') + def validate_name(cls, v): + if not v.strip(): + raise ValueError('Name cannot be empty or whitespace') + return v.strip() + + @validator('custom_cycle_start_month', 'custom_cycle_end_month') + def validate_months(cls, v): + if v is not None and (v < 1 or v > 12): + raise ValueError('Month must be between 1 and 12') + return v + + @validator('custom_cycle_start_day', 'custom_cycle_end_day') + def validate_days(cls, v): + if v is not None and (v < 1 or v > 31): + raise ValueError('Day must be between 1 and 31') + return v + + @validator('suggested_price_cents') + def validate_suggested_price(cls, v, values): + if v is not None and 'minimum_price_cents' in values: + if v < values['minimum_price_cents']: + raise ValueError('Suggested price must be >= minimum price') + return v + +# Pydantic model for updating subscriptions +class UpdateSubscriptionRequest(BaseModel): + status: Optional[str] = Field(None, pattern="^(active|expired|cancelled)$") + end_date: Optional[datetime] = None + +# Pydantic model for donation checkout +class DonationCheckoutRequest(BaseModel): + amount_cents: int = Field(..., ge=100, description="Donation amount in cents (minimum $1.00)") + + @validator('amount_cents') + def validate_amount(cls, v): + if v < 100: + raise ValueError('Donation must be at least $1.00 (100 cents)') + return v + +# Pydantic model for contact form +class ContactFormRequest(BaseModel): + first_name: str = Field(..., min_length=1, max_length=100) + last_name: str = Field(..., min_length=1, max_length=100) + email: str = Field(..., min_length=1, max_length=255) + subject: str = Field(..., min_length=1, max_length=200) + message: str = Field(..., min_length=1, max_length=2000) + + @validator('email') + def validate_email(cls, v): + import re + email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_regex, v): + raise ValueError('Invalid email address') + return v + +@api_router.get("/subscriptions/plans") +async def get_subscription_plans(db: Session = Depends(get_db)): + """Get all active subscription plans.""" + plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.active == True).all() + return plans + +# ==================== ADMIN PLAN CRUD ENDPOINTS ==================== + +@api_router.get("/admin/subscriptions/plans") +async def get_all_plans_admin( + current_user: User = Depends(require_permission("subscriptions.view")), + db: Session = Depends(get_db) +): + """Get all subscription plans for admin (including inactive) with subscriber counts.""" + plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.created_at.desc()).all() + + result = [] + for plan in plans: + subscriber_count = db.query(Subscription).filter( + Subscription.plan_id == plan.id, + Subscription.status == SubscriptionStatus.active + ).count() + + result.append({ + "id": str(plan.id), + "name": plan.name, + "description": plan.description, + "price_cents": plan.price_cents, + "billing_cycle": plan.billing_cycle, + "stripe_price_id": plan.stripe_price_id, + "active": plan.active, + "subscriber_count": subscriber_count, + "created_at": plan.created_at, + "updated_at": plan.updated_at + }) + + return result + +@api_router.get("/admin/subscriptions/plans/{plan_id}") +async def get_plan_admin( + plan_id: str, + current_user: User = Depends(require_permission("subscriptions.view")), + db: Session = Depends(get_db) +): + """Get single plan details with subscriber count.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() + + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + subscriber_count = db.query(Subscription).filter( + Subscription.plan_id == plan.id, + Subscription.status == SubscriptionStatus.active + ).count() + + return { + "id": str(plan.id), + "name": plan.name, + "description": plan.description, + "price_cents": plan.price_cents, + "billing_cycle": plan.billing_cycle, + "stripe_price_id": plan.stripe_price_id, + "active": plan.active, + "subscriber_count": subscriber_count, + "created_at": plan.created_at, + "updated_at": plan.updated_at + } + +@api_router.post("/admin/subscriptions/plans") +async def create_plan( + request: PlanCreateRequest, + current_user: User = Depends(require_permission("subscriptions.plans")), + db: Session = Depends(get_db) +): + """Create new subscription plan.""" + # Check for duplicate name + existing = db.query(SubscriptionPlan).filter( + SubscriptionPlan.name == request.name + ).first() + if existing: + raise HTTPException( + status_code=400, + detail="A plan with this name already exists" + ) + + # Validate custom cycle dates if enabled + if request.custom_cycle_enabled: + if not all([ + request.custom_cycle_start_month, + request.custom_cycle_start_day, + request.custom_cycle_end_month, + request.custom_cycle_end_day + ]): + raise HTTPException( + status_code=400, + detail="All custom cycle date fields must be provided when custom_cycle_enabled is true" + ) + + plan = SubscriptionPlan( + name=request.name, + description=request.description, + price_cents=request.price_cents, # Legacy field + billing_cycle=request.billing_cycle, + stripe_price_id=request.stripe_price_id, # Deprecated + active=request.active, + # Custom billing cycle fields + custom_cycle_enabled=request.custom_cycle_enabled, + custom_cycle_start_month=request.custom_cycle_start_month, + custom_cycle_start_day=request.custom_cycle_start_day, + custom_cycle_end_month=request.custom_cycle_end_month, + custom_cycle_end_day=request.custom_cycle_end_day, + # Dynamic pricing fields + minimum_price_cents=request.minimum_price_cents, + suggested_price_cents=request.suggested_price_cents, + allow_donation=request.allow_donation + ) + + db.add(plan) + db.commit() + db.refresh(plan) + + logger.info(f"Admin {current_user.email} created plan: {plan.name}") + + return { + "id": str(plan.id), + "name": plan.name, + "description": plan.description, + "price_cents": plan.price_cents, + "billing_cycle": plan.billing_cycle, + "stripe_price_id": plan.stripe_price_id, + "active": plan.active, + "custom_cycle_enabled": plan.custom_cycle_enabled, + "custom_cycle_start_month": plan.custom_cycle_start_month, + "custom_cycle_start_day": plan.custom_cycle_start_day, + "custom_cycle_end_month": plan.custom_cycle_end_month, + "custom_cycle_end_day": plan.custom_cycle_end_day, + "minimum_price_cents": plan.minimum_price_cents, + "suggested_price_cents": plan.suggested_price_cents, + "allow_donation": plan.allow_donation, + "subscriber_count": 0, + "created_at": plan.created_at, + "updated_at": plan.updated_at + } + +@api_router.put("/admin/subscriptions/plans/{plan_id}") +async def update_plan( + plan_id: str, + request: PlanCreateRequest, + current_user: User = Depends(require_permission("subscriptions.plans")), + db: Session = Depends(get_db) +): + """Update subscription plan.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() + + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + # Check for duplicate name (excluding current plan) + existing = db.query(SubscriptionPlan).filter( + SubscriptionPlan.name == request.name, + SubscriptionPlan.id != plan_id + ).first() + if existing: + raise HTTPException( + status_code=400, + detail="A plan with this name already exists" + ) + + # Validate custom cycle dates if enabled + if request.custom_cycle_enabled: + if not all([ + request.custom_cycle_start_month, + request.custom_cycle_start_day, + request.custom_cycle_end_month, + request.custom_cycle_end_day + ]): + raise HTTPException( + status_code=400, + detail="All custom cycle date fields must be provided when custom_cycle_enabled is true" + ) + + # Update fields + plan.name = request.name + plan.description = request.description + plan.price_cents = request.price_cents # Legacy field + plan.billing_cycle = request.billing_cycle + plan.stripe_price_id = request.stripe_price_id # Deprecated + plan.active = request.active + # Custom billing cycle fields + plan.custom_cycle_enabled = request.custom_cycle_enabled + plan.custom_cycle_start_month = request.custom_cycle_start_month + plan.custom_cycle_start_day = request.custom_cycle_start_day + plan.custom_cycle_end_month = request.custom_cycle_end_month + plan.custom_cycle_end_day = request.custom_cycle_end_day + # Dynamic pricing fields + plan.minimum_price_cents = request.minimum_price_cents + plan.suggested_price_cents = request.suggested_price_cents + plan.allow_donation = request.allow_donation + plan.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(plan) + + logger.info(f"Admin {current_user.email} updated plan: {plan.name}") + + subscriber_count = db.query(Subscription).filter( + Subscription.plan_id == plan.id, + Subscription.status == SubscriptionStatus.active + ).count() + + return { + "id": str(plan.id), + "name": plan.name, + "description": plan.description, + "price_cents": plan.price_cents, + "billing_cycle": plan.billing_cycle, + "stripe_price_id": plan.stripe_price_id, + "active": plan.active, + "subscriber_count": subscriber_count, + "created_at": plan.created_at, + "updated_at": plan.updated_at + } + +@api_router.delete("/admin/subscriptions/plans/{plan_id}") +async def delete_plan( + plan_id: str, + current_user: User = Depends(require_permission("subscriptions.plans")), + db: Session = Depends(get_db) +): + """Soft delete plan (set active = False).""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() + + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + # Check if plan has active subscriptions + active_subs = db.query(Subscription).filter( + Subscription.plan_id == plan_id, + Subscription.status == SubscriptionStatus.active + ).count() + + if active_subs > 0: + raise HTTPException( + status_code=400, + detail=f"Cannot delete plan with {active_subs} active subscriptions" + ) + + plan.active = False + plan.updated_at = datetime.now(timezone.utc) + db.commit() + + logger.info(f"Admin {current_user.email} deactivated plan: {plan.name}") + + return {"message": "Plan deactivated successfully"} + +# ============================================================================ +# Admin Subscription Management Routes +# ============================================================================ + +@api_router.get("/admin/subscriptions") +async def get_all_subscriptions( + status: Optional[str] = None, + plan_id: Optional[str] = None, + user_id: Optional[str] = None, + current_user: User = Depends(require_permission("subscriptions.view")), + db: Session = Depends(get_db) +): + """Get all subscriptions with optional filters.""" + # Use explicit join to avoid ambiguous foreign key error + query = db.query(Subscription).join(Subscription.user).join(Subscription.plan) + + if status: + query = query.filter(Subscription.status == status) + if plan_id: + query = query.filter(Subscription.plan_id == plan_id) + if user_id: + query = query.filter(Subscription.user_id == user_id) + + subscriptions = query.order_by(Subscription.created_at.desc()).all() + + return [{ + "id": str(sub.id), + "user": { + "id": str(sub.user.id), + "first_name": sub.user.first_name, + "last_name": sub.user.last_name, + "email": sub.user.email + }, + "plan": { + "id": str(sub.plan.id), + "name": sub.plan.name, + "billing_cycle": sub.plan.billing_cycle + }, + "status": sub.status.value, + "start_date": sub.start_date, + "end_date": sub.end_date, + "amount_paid_cents": sub.amount_paid_cents, + "base_subscription_cents": sub.base_subscription_cents, + "donation_cents": sub.donation_cents, + "payment_method": sub.payment_method, + "stripe_subscription_id": sub.stripe_subscription_id, + "created_at": sub.created_at, + "updated_at": sub.updated_at + } for sub in subscriptions] + +@api_router.get("/admin/subscriptions/stats") +async def get_subscription_stats( + current_user: User = Depends(require_permission("subscriptions.view")), + db: Session = Depends(get_db) +): + """Get subscription statistics for admin dashboard.""" + from sqlalchemy import func + + total = db.query(Subscription).count() + active = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.active + ).count() + cancelled = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.cancelled + ).count() + expired = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.expired + ).count() + + revenue_data = db.query( + func.sum(Subscription.amount_paid_cents).label('total_revenue'), + func.sum(Subscription.base_subscription_cents).label('total_base'), + func.sum(Subscription.donation_cents).label('total_donations') + ).first() + + return { + "total": total, + "active": active, + "cancelled": cancelled, + "expired": expired, + "total_revenue": revenue_data.total_revenue or 0, + "total_base": revenue_data.total_base or 0, + "total_donations": revenue_data.total_donations or 0 + } + +@api_router.put("/admin/subscriptions/{subscription_id}") +async def update_subscription( + subscription_id: str, + request: UpdateSubscriptionRequest, + current_user: User = Depends(require_permission("subscriptions.edit")), + db: Session = Depends(get_db) +): + """Update subscription details (status, dates).""" + subscription = db.query(Subscription).filter( + Subscription.id == subscription_id + ).first() + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + # Update fields if provided + if request.status: + subscription.status = SubscriptionStatus[request.status] + if request.end_date: + subscription.end_date = request.end_date + + subscription.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(subscription) + + logger.info(f"Admin {current_user.email} updated subscription {subscription_id}") + + return { + "id": str(subscription.id), + "user_id": str(subscription.user_id), + "plan_id": str(subscription.plan_id), + "status": subscription.status.value, + "start_date": subscription.start_date, + "end_date": subscription.end_date, + "amount_paid_cents": subscription.amount_paid_cents, + "updated_at": subscription.updated_at + } + +@api_router.post("/admin/subscriptions/{subscription_id}/cancel") +async def cancel_subscription( + subscription_id: str, + current_user: User = Depends(require_permission("subscriptions.cancel")), + db: Session = Depends(get_db) +): + """Cancel a subscription.""" + subscription = db.query(Subscription).filter( + Subscription.id == subscription_id + ).first() + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + subscription.status = SubscriptionStatus.cancelled + subscription.updated_at = datetime.now(timezone.utc) + + # Also update user status if currently active + user = subscription.user + if user.status == UserStatus.active: + user.status = UserStatus.inactive + user.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Admin {current_user.email} cancelled subscription {subscription_id} for user {user.email}") + + return {"message": "Subscription cancelled successfully"} + +# ============================================================================ +# Admin Document Management Routes +# ============================================================================ + +# Newsletter Archive Admin Routes +@api_router.post("/admin/newsletters") +async def create_newsletter( + title: str = Form(...), + description: str = Form(None), + published_date: str = Form(...), + document_type: str = Form("google_docs"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("newsletters.create")), + db: Session = Depends(get_db) +): + """ + Create newsletter record + Admin only - supports both URL links and file uploads + """ + from models import NewsletterArchive, StorageUsage + from r2_storage import get_r2_storage + + final_url = document_url + file_size = None + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="newsletters", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + file_size = file_size_bytes + + # Update storage usage + storage = db.query(StorageUsage).first() + if storage: + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + newsletter = NewsletterArchive( + title=title, + description=description, + published_date=datetime.fromisoformat(published_date.replace('Z', '+00:00')), + document_url=final_url, + document_type=document_type, + file_size_bytes=file_size, + created_by=current_user.id + ) + + db.add(newsletter) + db.commit() + db.refresh(newsletter) + + return { + "id": str(newsletter.id), + "message": "Newsletter created successfully" + } + +@api_router.put("/admin/newsletters/{newsletter_id}") +async def update_newsletter( + newsletter_id: str, + title: str = Form(...), + description: str = Form(None), + published_date: str = Form(...), + document_type: str = Form("google_docs"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("newsletters.edit")), + db: Session = Depends(get_db) +): + """ + Update newsletter record + Admin only - supports both URL links and file uploads + """ + from models import NewsletterArchive, StorageUsage + from r2_storage import get_r2_storage + + newsletter = db.query(NewsletterArchive).filter( + NewsletterArchive.id == newsletter_id + ).first() + + if not newsletter: + raise HTTPException(status_code=404, detail="Newsletter not found") + + final_url = document_url + file_size = newsletter.file_size_bytes + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="newsletters", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + + # Update storage usage (subtract old, add new) + storage = db.query(StorageUsage).first() + if storage and newsletter.file_size_bytes: + storage.total_bytes_used -= newsletter.file_size_bytes + if storage: + storage.total_bytes_used += file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + file_size = file_size_bytes + + newsletter.title = title + newsletter.description = description + newsletter.published_date = datetime.fromisoformat(published_date.replace('Z', '+00:00')) + newsletter.document_url = final_url + newsletter.document_type = document_type + newsletter.file_size_bytes = file_size + newsletter.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Newsletter updated successfully"} + +@api_router.delete("/admin/newsletters/{newsletter_id}") +async def delete_newsletter( + newsletter_id: str, + current_user: User = Depends(require_permission("newsletters.delete")), + db: Session = Depends(get_db) +): + """ + Delete newsletter record + Admin only + """ + from models import NewsletterArchive + + newsletter = db.query(NewsletterArchive).filter( + NewsletterArchive.id == newsletter_id + ).first() + + if not newsletter: + raise HTTPException(status_code=404, detail="Newsletter not found") + + db.delete(newsletter) + db.commit() + + return {"message": "Newsletter deleted successfully"} + +# Financial Reports Admin Routes +@api_router.post("/admin/financials") +async def create_financial_report( + year: int = Form(...), + title: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("financials.create")), + db: Session = Depends(get_db) +): + """ + Create financial report record + Admin only - supports both URL links and file uploads + """ + from models import FinancialReport, StorageUsage + from r2_storage import get_r2_storage + + final_url = document_url + file_size = None + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="financials", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + file_size = file_size_bytes + + # Update storage usage + storage = db.query(StorageUsage).first() + if storage: + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + report = FinancialReport( + year=year, + title=title, + document_url=final_url, + document_type=document_type, + file_size_bytes=file_size, + created_by=current_user.id + ) + + db.add(report) + db.commit() + db.refresh(report) + + return { + "id": str(report.id), + "message": "Financial report created successfully" + } + +@api_router.put("/admin/financials/{report_id}") +async def update_financial_report( + report_id: str, + year: int = Form(...), + title: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("financials.edit")), + db: Session = Depends(get_db) +): + """ + Update financial report record + Admin only - supports both URL links and file uploads + """ + from models import FinancialReport, StorageUsage + from r2_storage import get_r2_storage + + report = db.query(FinancialReport).filter( + FinancialReport.id == report_id + ).first() + + if not report: + raise HTTPException(status_code=404, detail="Financial report not found") + + final_url = document_url + file_size = report.file_size_bytes + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="financials", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + + # Update storage usage (subtract old, add new) + storage = db.query(StorageUsage).first() + if storage and report.file_size_bytes: + storage.total_bytes_used -= report.file_size_bytes + if storage: + storage.total_bytes_used += file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + file_size = file_size_bytes + + report.year = year + report.title = title + report.document_url = final_url + report.document_type = document_type + report.file_size_bytes = file_size + report.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Financial report updated successfully"} + +@api_router.delete("/admin/financials/{report_id}") +async def delete_financial_report( + report_id: str, + current_user: User = Depends(require_permission("financials.delete")), + db: Session = Depends(get_db) +): + """ + Delete financial report record + Admin only + """ + from models import FinancialReport + + report = db.query(FinancialReport).filter( + FinancialReport.id == report_id + ).first() + + if not report: + raise HTTPException(status_code=404, detail="Financial report not found") + + db.delete(report) + db.commit() + + return {"message": "Financial report deleted successfully"} + +# Bylaws Admin Routes +@api_router.post("/admin/bylaws") +async def create_bylaws( + title: str = Form(...), + version: str = Form(...), + effective_date: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + is_current: bool = Form(True), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("bylaws.create")), + db: Session = Depends(get_db) +): + """ + Create bylaws document + If is_current=True, sets all others to is_current=False + Admin only - supports both URL links and file uploads + """ + from models import BylawsDocument, StorageUsage + from r2_storage import get_r2_storage + + final_url = document_url + file_size = None + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="bylaws", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + file_size = file_size_bytes + + # Update storage usage + storage = db.query(StorageUsage).first() + if storage: + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + if is_current: + # Set all other bylaws to not current + db.query(BylawsDocument).update({"is_current": False}) + + bylaws = BylawsDocument( + title=title, + version=version, + effective_date=datetime.fromisoformat(effective_date.replace('Z', '+00:00')), + document_url=final_url, + document_type=document_type, + is_current=is_current, + file_size_bytes=file_size, + created_by=current_user.id + ) + + db.add(bylaws) + db.commit() + db.refresh(bylaws) + + return { + "id": str(bylaws.id), + "message": "Bylaws created successfully" + } + +@api_router.put("/admin/bylaws/{bylaws_id}") +async def update_bylaws( + bylaws_id: str, + title: str = Form(...), + version: str = Form(...), + effective_date: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + is_current: bool = Form(False), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(require_permission("bylaws.edit")), + db: Session = Depends(get_db) +): + """ + Update bylaws document + If is_current=True, sets all others to is_current=False + Admin only - supports both URL links and file uploads + """ + from models import BylawsDocument, StorageUsage + from r2_storage import get_r2_storage + + bylaws = db.query(BylawsDocument).filter( + BylawsDocument.id == bylaws_id + ).first() + + if not bylaws: + raise HTTPException(status_code=404, detail="Bylaws not found") + + final_url = document_url + file_size = bylaws.file_size_bytes + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="bylaws", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + + # Update storage usage (subtract old, add new) + storage = db.query(StorageUsage).first() + if storage and bylaws.file_size_bytes: + storage.total_bytes_used -= bylaws.file_size_bytes + if storage: + storage.total_bytes_used += file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + file_size = file_size_bytes + + if is_current: + # Set all other bylaws to not current + db.query(BylawsDocument).filter( + BylawsDocument.id != bylaws_id + ).update({"is_current": False}) + + bylaws.title = title + bylaws.version = version + bylaws.effective_date = datetime.fromisoformat(effective_date.replace('Z', '+00:00')) + bylaws.document_url = final_url + bylaws.document_type = document_type + bylaws.is_current = is_current + bylaws.file_size_bytes = file_size + + db.commit() + + return {"message": "Bylaws updated successfully"} + +@api_router.delete("/admin/bylaws/{bylaws_id}") +async def delete_bylaws( + bylaws_id: str, + current_user: User = Depends(require_permission("bylaws.delete")), + db: Session = Depends(get_db) +): + """ + Delete bylaws document + Admin only + """ + from models import BylawsDocument + + bylaws = db.query(BylawsDocument).filter( + BylawsDocument.id == bylaws_id + ).first() + + if not bylaws: + raise HTTPException(status_code=404, detail="Bylaws not found") + + db.delete(bylaws) + db.commit() + + return {"message": "Bylaws deleted successfully"} + +# ============================================================ +# Permission Management Endpoints (Superadmin Only) +# ============================================================ + +@api_router.get("/admin/permissions", response_model=List[PermissionResponse]) +async def get_all_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permissions in the system + Superadmin only + """ + permissions = db.query(Permission).order_by(Permission.module, Permission.code).all() + + return [ + { + "id": str(perm.id), + "code": perm.code, + "name": perm.name, + "description": perm.description, + "module": perm.module, + "created_at": perm.created_at + } + for perm in permissions + ] + +@api_router.get("/admin/permissions/modules") +async def get_permission_modules( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get all permission modules with permission counts + Superadmin only + """ + from sqlalchemy import func + + # Get all permissions grouped by module + modules = db.query( + Permission.module, + func.count(Permission.id).label('permission_count') + ).group_by(Permission.module).all() + + # Get permissions for each module + result = [] + for module_name, count in modules: + permissions = db.query(Permission)\ + .filter(Permission.module == module_name)\ + .order_by(Permission.code)\ + .all() + + result.append({ + "module": module_name, + "permission_count": count, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description + } + for p in permissions + ] + }) + + return result + +@api_router.get("/admin/permissions/roles/{role}") +async def get_role_permissions( + role: str, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get permissions assigned to a specific role + Superadmin only + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Superadmin always has all permissions (enforced in code, not stored) + if role_enum == UserRole.superadmin: + all_permissions = db.query(Permission).all() + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in all_permissions + ], + "note": "Superadmin automatically has all permissions" + } + + # Get permissions for other roles + permissions = db.query(Permission)\ + .join(RolePermission)\ + .filter(RolePermission.role == role_enum)\ + .order_by(Permission.module, Permission.code)\ + .all() + + return { + "role": role, + "permissions": [ + { + "id": str(p.id), + "code": p.code, + "name": p.name, + "description": p.description, + "module": p.module + } + for p in permissions + ] + } + +@api_router.put("/admin/permissions/roles/{role}") +async def assign_role_permissions( + role: str, + request: AssignPermissionsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Assign permissions to a role (cherry-pick permissions) + Superadmin only + + This replaces all existing permissions for the role with the provided list. + """ + # Validate role exists + try: + role_enum = UserRole[role] + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid role: {role}") + + # Prevent modifying superadmin permissions + if role_enum == UserRole.superadmin: + raise HTTPException( + status_code=403, + detail="Cannot modify superadmin permissions. Superadmin always has all permissions." + ) + + # Validate all permission codes exist + permissions_to_assign = [] + for code in request.permission_codes: + permission = db.query(Permission).filter(Permission.code == code).first() + if not permission: + raise HTTPException(status_code=400, detail=f"Invalid permission code: {code}") + permissions_to_assign.append(permission) + + # Remove existing permissions for this role + db.query(RolePermission).filter(RolePermission.role == role_enum).delete() + + # Add new permissions + for permission in permissions_to_assign: + role_permission = RolePermission( + role=role_enum, + permission_id=permission.id, + created_by=current_user.id + ) + db.add(role_permission) + + db.commit() + + return { + "message": f"Successfully assigned {len(permissions_to_assign)} permissions to {role}", + "role": role, + "permission_count": len(permissions_to_assign) + } + +@api_router.post("/admin/permissions/seed") +async def seed_permissions( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Seed default permissions into the database + Superadmin only + + WARNING: This will clear all existing permissions and role assignments + """ + import subprocess + import sys + + try: + # Run the permissions_seed.py script + result = subprocess.run( + [sys.executable, "permissions_seed.py"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Failed to seed permissions: {result.stderr}" + ) + + return { + "message": "Permissions seeded successfully", + "output": result.stdout + } + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Permission seeding timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error seeding permissions: {str(e)}") + +@api_router.post("/subscriptions/checkout") +async def create_checkout( + request: CheckoutRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Create Stripe Checkout session with dynamic pricing and donation tracking.""" + + # Status validation - only allow payment_pending and inactive users + allowed_statuses = [UserStatus.payment_pending, UserStatus.inactive] + if current_user.status not in allowed_statuses: + raise HTTPException( + status_code=403, + detail=f"Cannot proceed with payment. User status is '{current_user.status.value}'. " + f"Please complete email verification and admin approval first." + ) + + # Get plan + plan = db.query(SubscriptionPlan).filter( + SubscriptionPlan.id == request.plan_id + ).first() + + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + if not plan.active: + raise HTTPException(status_code=400, detail="This plan is no longer available for subscription") + + # Validate amount against plan minimum + if request.amount_cents < plan.minimum_price_cents: + raise HTTPException( + status_code=400, + detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}" + ) + + # Calculate donation split + base_amount = plan.minimum_price_cents + donation_amount = request.amount_cents - base_amount + + # Check if plan allows donations + if donation_amount > 0 and not plan.allow_donation: + raise HTTPException( + status_code=400, + detail="This plan does not accept donations above the minimum price" + ) + + # Get frontend URL from env + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + + try: + # Build line items for Stripe checkout + line_items = [] + + # Add base subscription line item with dynamic pricing + from payment_service import get_stripe_interval + stripe_interval = get_stripe_interval(plan.billing_cycle) + + if stripe_interval: # Recurring subscription + line_items.append({ + "price_data": { + "currency": "usd", + "unit_amount": base_amount, + "recurring": {"interval": stripe_interval}, + "product_data": { + "name": plan.name, + "description": plan.description or f"{plan.name} membership" + } + }, + "quantity": 1 + }) + else: # One-time payment (lifetime) + line_items.append({ + "price_data": { + "currency": "usd", + "unit_amount": base_amount, + "product_data": { + "name": plan.name, + "description": plan.description or f"{plan.name} membership" + } + }, + "quantity": 1 + }) + + # Add donation line item if applicable + if donation_amount > 0: + line_items.append({ + "price_data": { + "currency": "usd", + "unit_amount": donation_amount, + "product_data": { + "name": "Donation", + "description": f"Additional donation to support {plan.name}" + } + }, + "quantity": 1 + }) + + # Create Stripe Checkout Session + import stripe + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + + mode = "subscription" if stripe_interval else "payment" + + session = stripe.checkout.Session.create( + customer_email=current_user.email, + payment_method_types=["card"], + line_items=line_items, + mode=mode, + success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=f"{frontend_url}/payment-cancel", + metadata={ + "user_id": str(current_user.id), + "plan_id": str(plan.id), + "base_amount": str(base_amount), + "donation_amount": str(donation_amount), + "total_amount": str(request.amount_cents) + }, + subscription_data={ + "metadata": { + "user_id": str(current_user.id), + "plan_id": str(plan.id), + "base_amount": str(base_amount), + "donation_amount": str(donation_amount) + } + } if mode == "subscription" else None + ) + + return {"checkout_url": session.url} + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating checkout session: {str(e)}") + raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") + except Exception as e: + logger.error(f"Error creating checkout session: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create checkout session") + +@api_router.post("/donations/checkout") +async def create_donation_checkout( + request: DonationCheckoutRequest, + db: Session = Depends(get_db) +): + """Create Stripe Checkout session for one-time donation.""" + + # Get frontend URL from env + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + + try: + # Create Stripe Checkout Session for one-time payment + import stripe + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=[{ + 'price_data': { + 'currency': 'usd', + 'unit_amount': request.amount_cents, + 'product_data': { + 'name': 'Donation to LOAF', + 'description': 'Thank you for supporting our community!' + } + }, + 'quantity': 1 + }], + 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" + ) + + logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}") + + return {"checkout_url": checkout_session.url} + + except stripe.error.StripeError as e: + 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: + logger.error(f"Error creating donation checkout: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create donation checkout") + +@api_router.post("/contact") +async def submit_contact_form( + request: ContactFormRequest, + db: Session = Depends(get_db) +): + """Handle contact form submission and send email to admin.""" + + try: + # Get admin email from environment or use default + admin_email = os.getenv("ADMIN_EMAIL", "info@loaftx.org") + + # Create email content + subject = f"New Contact Form Submission: {request.subject}" + + html_content = f""" + + + + + + +
+
+

New Contact Form Submission

+
+
+
+
From:
+
{request.first_name} {request.last_name}
+
+ +
+
Email:
+
{request.email}
+
+ +
+
Subject:
+
{request.subject}
+
+ +
+
Message:
+
{request.message}
+
+ +

+ Reply directly to this email to respond to {request.first_name}. +

+
+
+ + + """ + + # Import send_email from email_service + from email_service import send_email + + # Send email to admin + email_sent = await send_email(admin_email, subject, html_content) + + if not email_sent: + logger.error(f"Failed to send contact form email from {request.email}") + raise HTTPException(status_code=500, detail="Failed to send contact form. Please try again later.") + + logger.info(f"Contact form submitted by {request.first_name} {request.last_name} ({request.email})") + + return { + "message": "Contact form submitted successfully. We'll get back to you soon!", + "success": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing contact form: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to process contact form") + +@app.post("/api/webhooks/stripe") +async def stripe_webhook(request: Request, db: Session = Depends(get_db)): + """Handle Stripe webhook events. Note: This endpoint is NOT on the api_router to avoid /api/api prefix.""" + + # Get raw payload and signature + payload = await request.body() + sig_header = request.headers.get("stripe-signature") + + if not sig_header: + raise HTTPException(status_code=400, detail="Missing stripe-signature header") + + try: + # Verify webhook signature + event = verify_webhook_signature(payload, sig_header) + except ValueError as e: + logger.error(f"Webhook signature verification failed: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + + # Handle checkout.session.completed event + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + + # Get metadata + user_id = session["metadata"].get("user_id") + plan_id = session["metadata"].get("plan_id") + 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) + + 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"} + +# Include the router in the main app +app.include_router(api_router) + +app.add_middleware( + CORSMiddleware, + allow_credentials=True, + allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','), + allow_methods=["*"], + allow_headers=["*"], +) \ No newline at end of file diff --git a/status_transitions.py b/status_transitions.py new file mode 100644 index 0000000..8949048 --- /dev/null +++ b/status_transitions.py @@ -0,0 +1,624 @@ +""" +Membership Status Transition Logic + +This module handles all user status transitions, validation, and automated rules. +Ensures state machine integrity and prevents invalid status changes. +""" + +from models import UserStatus, UserRole +from typing import Optional, Dict, List +from datetime import datetime, timezone +import logging + +# Configure logging +logger = logging.getLogger(__name__) + +# Define valid status transitions (state machine) +ALLOWED_TRANSITIONS: Dict[UserStatus, List[UserStatus]] = { + UserStatus.pending_email: [ + UserStatus.pending_validation, # Email verified (normal flow) + UserStatus.pre_validated, # Email verified + referred by member + UserStatus.abandoned, # Timeout without verification (optional) + ], + UserStatus.pending_validation: [ + UserStatus.pre_validated, # Attended event + UserStatus.abandoned, # 90-day timeout without event + ], + UserStatus.pre_validated: [ + UserStatus.payment_pending, # Admin validates application + UserStatus.inactive, # Admin rejects (rare) + ], + UserStatus.payment_pending: [ + UserStatus.active, # Payment successful + UserStatus.abandoned, # Timeout without payment (optional) + ], + UserStatus.active: [ + UserStatus.expired, # Subscription ended + UserStatus.canceled, # User/admin cancels + UserStatus.inactive, # Admin deactivates + ], + UserStatus.inactive: [ + UserStatus.active, # Admin reactivates + UserStatus.payment_pending, # Admin prompts for payment + ], + UserStatus.canceled: [ + UserStatus.payment_pending, # User requests to rejoin + UserStatus.active, # Admin reactivates with subscription + ], + UserStatus.expired: [ + UserStatus.payment_pending, # User chooses to renew + UserStatus.active, # Admin manually renews + ], + UserStatus.abandoned: [ + UserStatus.pending_email, # Admin resets - resend verification + UserStatus.pending_validation, # Admin resets - manual email verify + UserStatus.payment_pending, # Admin resets - bypass requirements + ], +} + +# Define role mappings for each status +STATUS_ROLE_MAP: Dict[UserStatus, UserRole] = { + UserStatus.pending_email: UserRole.guest, + UserStatus.pending_validation: UserRole.guest, + UserStatus.pre_validated: UserRole.guest, + UserStatus.payment_pending: UserRole.guest, + UserStatus.active: UserRole.member, + UserStatus.inactive: UserRole.guest, + UserStatus.canceled: UserRole.guest, + UserStatus.expired: UserRole.guest, + UserStatus.abandoned: UserRole.guest, +} + +# Define newsletter subscription rules for each status +NEWSLETTER_SUBSCRIBED_STATUSES = { + UserStatus.pending_validation, + UserStatus.pre_validated, + UserStatus.payment_pending, + UserStatus.active, +} + + +class StatusTransitionError(Exception): + """Raised when an invalid status transition is attempted""" + pass + + +def is_transition_allowed(from_status: UserStatus, to_status: UserStatus) -> bool: + """ + Check if a status transition is allowed by the state machine. + + Args: + from_status: Current user status + to_status: Target user status + + Returns: + True if transition is allowed, False otherwise + """ + if from_status not in ALLOWED_TRANSITIONS: + logger.warning(f"Unknown source status: {from_status}") + return False + + return to_status in ALLOWED_TRANSITIONS[from_status] + + +def get_allowed_transitions(current_status: UserStatus) -> List[UserStatus]: + """ + Get list of allowed next statuses for the current status. + + Args: + current_status: Current user status + + Returns: + List of allowed target statuses + """ + return ALLOWED_TRANSITIONS.get(current_status, []) + + +def get_role_for_status(status: UserStatus) -> UserRole: + """ + Get the appropriate role for a given status. + + Args: + status: User status + + Returns: + Corresponding UserRole + """ + return STATUS_ROLE_MAP.get(status, UserRole.guest) + + +def should_subscribe_newsletter(status: UserStatus) -> bool: + """ + Determine if user should be subscribed to newsletter for given status. + + Args: + status: User status + + Returns: + True if user should receive newsletter + """ + return status in NEWSLETTER_SUBSCRIBED_STATUSES + + +def transition_user_status( + user, + new_status: UserStatus, + reason: Optional[str] = None, + admin_id: Optional[str] = None, + db_session = None, + send_notification: bool = True +) -> Dict[str, any]: + """ + Transition a user to a new status with validation and side effects. + + Args: + user: User object (SQLAlchemy model instance) + new_status: Target status to transition to + reason: Optional reason for the transition + admin_id: Optional admin user ID if transition is manual + db_session: SQLAlchemy database session + send_notification: Whether to send email notification (default True) + + Returns: + Dictionary with transition details: + { + 'success': bool, + 'old_status': str, + 'new_status': str, + 'role_changed': bool, + 'newsletter_changed': bool, + 'message': str + } + + Raises: + StatusTransitionError: If transition is not allowed + """ + old_status = user.status + old_role = user.role + old_newsletter = user.newsletter_subscribed + + # Validate transition + if not is_transition_allowed(old_status, new_status): + allowed = get_allowed_transitions(old_status) + allowed_names = [s.value for s in allowed] + error_msg = ( + f"Invalid status transition: {old_status.value} → {new_status.value}. " + f"Allowed transitions from {old_status.value}: {allowed_names}" + ) + logger.error(error_msg) + raise StatusTransitionError(error_msg) + + # Update status + user.status = new_status + + # Update role based on new status + new_role = get_role_for_status(new_status) + role_changed = new_role != old_role + if role_changed: + user.role = new_role + + # Update newsletter subscription + should_subscribe = should_subscribe_newsletter(new_status) + newsletter_changed = should_subscribe != old_newsletter + if newsletter_changed: + user.newsletter_subscribed = should_subscribe + + # Update timestamp + user.updated_at = datetime.now(timezone.utc) + + # Log the transition + logger.info( + f"Status transition: user_id={user.id}, " + f"{old_status.value} → {new_status.value}, " + f"reason={reason}, admin_id={admin_id}" + ) + + # Commit to database if session provided + if db_session: + db_session.commit() + + # Prepare notification email (actual sending should be done by caller) + # This is just a flag - the API endpoint should handle the actual email + notification_needed = send_notification + + # Build result + result = { + 'success': True, + 'old_status': old_status.value, + 'new_status': new_status.value, + 'old_role': old_role.value, + 'new_role': new_role.value, + 'role_changed': role_changed, + 'old_newsletter': old_newsletter, + 'new_newsletter': should_subscribe, + 'newsletter_changed': newsletter_changed, + 'message': f'Successfully transitioned from {old_status.value} to {new_status.value}', + 'notification_needed': notification_needed, + 'reason': reason, + 'admin_id': admin_id + } + + logger.info(f"Transition result: {result}") + return result + + +def get_status_metadata(status: UserStatus) -> Dict[str, any]: + """ + Get metadata about a status including permissions and properties. + + Args: + status: User status + + Returns: + Dictionary with status metadata + """ + return { + 'status': status.value, + 'role': get_role_for_status(status).value, + 'newsletter_subscribed': should_subscribe_newsletter(status), + 'allowed_transitions': [s.value for s in get_allowed_transitions(status)], + 'can_login': status != UserStatus.pending_email and status != UserStatus.abandoned, + 'has_member_access': status == UserStatus.active, + 'is_pending': status in { + UserStatus.pending_email, + UserStatus.pending_validation, + UserStatus.pre_validated, + UserStatus.payment_pending + }, + 'is_terminated': status in { + UserStatus.canceled, + UserStatus.expired, + UserStatus.abandoned, + UserStatus.inactive + } + } + + +# Helper functions for common transitions + +def verify_email(user, db_session=None, is_referred: bool = False): + """ + Transition user after email verification. + + Args: + user: User object + db_session: Database session + is_referred: Whether user was referred by a member + + Returns: + Transition result dict + """ + target_status = UserStatus.pre_validated if is_referred else UserStatus.pending_validation + return transition_user_status( + user=user, + new_status=target_status, + reason="Email verified" + (" (referred by member)" if is_referred else ""), + db_session=db_session, + send_notification=True + ) + + +def mark_event_attendance(user, admin_id: str, db_session=None): + """ + Transition user after attending an event. + + Args: + user: User object + admin_id: ID of admin marking attendance + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.pre_validated, + reason="Attended event", + admin_id=admin_id, + db_session=db_session, + send_notification=False # Event attendance doesn't need immediate email + ) + + +def validate_application(user, admin_id: str, db_session=None): + """ + Admin validates user application (formerly "approve"). + + Args: + user: User object + admin_id: ID of admin validating application + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.payment_pending, + reason="Application validated by admin", + admin_id=admin_id, + db_session=db_session, + send_notification=True # Send payment instructions email + ) + + +def activate_membership(user, admin_id: Optional[str] = None, db_session=None): + """ + Activate membership after payment or manual activation. + + Args: + user: User object + admin_id: Optional ID of admin (for manual activation) + db_session: Database session + + Returns: + Transition result dict + """ + reason = "Payment successful" if not admin_id else "Manually activated by admin" + return transition_user_status( + user=user, + new_status=UserStatus.active, + reason=reason, + admin_id=admin_id, + db_session=db_session, + send_notification=True # Send welcome email + ) + + +def cancel_membership(user, admin_id: Optional[str] = None, reason: str = None, db_session=None): + """ + Cancel membership. + + Args: + user: User object + admin_id: Optional ID of admin (if admin canceled) + reason: Optional cancellation reason + db_session: Database session + + Returns: + Transition result dict + """ + cancel_reason = reason or ("Canceled by admin" if admin_id else "Canceled by user") + return transition_user_status( + user=user, + new_status=UserStatus.canceled, + reason=cancel_reason, + admin_id=admin_id, + db_session=db_session, + send_notification=True # Send cancellation confirmation + ) + + +def expire_membership(user, db_session=None): + """ + Expire membership when subscription ends. + + Args: + user: User object + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.expired, + reason="Subscription ended", + db_session=db_session, + send_notification=True # Send renewal prompt email + ) + + +def abandon_application(user, reason: str, db_session=None): + """ + Mark application as abandoned due to timeout. + + Args: + user: User object + reason: Reason for abandonment (e.g., "Email verification timeout") + db_session: Database session + + Returns: + Transition result dict + """ + return transition_user_status( + user=user, + new_status=UserStatus.abandoned, + reason=reason, + db_session=db_session, + send_notification=True # Send "incomplete application" notice + ) + + +def reactivate_user(user, target_status: UserStatus, admin_id: str, reason: str = None, db_session=None): + """ + Reactivate user from terminated status (admin action). + + Args: + user: User object + target_status: Status to transition to + admin_id: ID of admin performing reactivation + reason: Optional reason for reactivation + db_session: Database session + + Returns: + Transition result dict + """ + reactivation_reason = reason or f"Reactivated by admin to {target_status.value}" + return transition_user_status( + user=user, + new_status=target_status, + reason=reactivation_reason, + admin_id=admin_id, + db_session=db_session, + send_notification=True + ) + + +# Background job functions (to be called by scheduler) + +def check_pending_email_timeouts(db_session, timeout_days: int = 30): + """ + Check for users in pending_email status past timeout and transition to abandoned. + + This should be run as a daily background job. + + Args: + db_session: Database session + timeout_days: Number of days before abandonment (0 = disabled) + + Returns: + Number of users transitioned + """ + if timeout_days <= 0: + return 0 + + from datetime import timedelta + from models import User + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=timeout_days) + + # Find users in pending_email status created before cutoff + timeout_users = db_session.query(User).filter( + User.status == UserStatus.pending_email, + User.created_at < cutoff_date, + User.email_verified == False + ).all() + + count = 0 + for user in timeout_users: + try: + abandon_application( + user=user, + reason=f"Email verification timeout ({timeout_days} days)", + db_session=db_session + ) + count += 1 + logger.info(f"Abandoned user {user.id} due to email verification timeout") + except Exception as e: + logger.error(f"Error abandoning user {user.id}: {str(e)}") + + return count + + +def check_event_attendance_timeouts(db_session, timeout_days: int = 90): + """ + Check for users in pending_validation status past 90-day timeout. + + This should be run as a daily background job. + + Args: + db_session: Database session + timeout_days: Number of days before abandonment (default 90 per policy) + + Returns: + Number of users transitioned + """ + from datetime import timedelta + from models import User + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=timeout_days) + + # Find users in pending_validation status past deadline + # Note: We check updated_at (when they entered this status) not created_at + timeout_users = db_session.query(User).filter( + User.status == UserStatus.pending_validation, + User.updated_at < cutoff_date + ).all() + + count = 0 + for user in timeout_users: + try: + abandon_application( + user=user, + reason=f"Event attendance timeout ({timeout_days} days)", + db_session=db_session + ) + count += 1 + logger.info(f"Abandoned user {user.id} due to event attendance timeout") + except Exception as e: + logger.error(f"Error abandoning user {user.id}: {str(e)}") + + return count + + +def check_payment_timeouts(db_session, timeout_days: int = 0): + """ + Check for users in payment_pending status past timeout. + + This should be run as a daily background job. + Default timeout_days=0 means never auto-abandon (recommended). + + Args: + db_session: Database session + timeout_days: Number of days before abandonment (0 = disabled) + + Returns: + Number of users transitioned + """ + if timeout_days <= 0: + return 0 # Disabled by default + + from datetime import timedelta + from models import User + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=timeout_days) + + timeout_users = db_session.query(User).filter( + User.status == UserStatus.payment_pending, + User.updated_at < cutoff_date + ).all() + + count = 0 + for user in timeout_users: + try: + abandon_application( + user=user, + reason=f"Payment timeout ({timeout_days} days)", + db_session=db_session + ) + count += 1 + logger.info(f"Abandoned user {user.id} due to payment timeout") + except Exception as e: + logger.error(f"Error abandoning user {user.id}: {str(e)}") + + return count + + +def check_subscription_expirations(db_session): + """ + Check for active subscriptions past end_date and transition to expired. + + This should be run as a daily background job. + + Args: + db_session: Database session + + Returns: + Number of users transitioned + """ + from models import User, Subscription + from sqlalchemy import and_ + + today = datetime.now(timezone.utc).date() + + # Find active users with expired subscriptions + expired_subs = db_session.query(User, Subscription).join( + Subscription, User.id == Subscription.user_id + ).filter( + and_( + User.status == UserStatus.active, + Subscription.end_date < today + ) + ).all() + + count = 0 + for user, subscription in expired_subs: + try: + expire_membership(user=user, db_session=db_session) + count += 1 + logger.info(f"Expired user {user.id} - subscription ended {subscription.end_date}") + except Exception as e: + logger.error(f"Error expiring user {user.id}: {str(e)}") + + return count diff --git a/update_permissions.py b/update_permissions.py new file mode 100644 index 0000000..c177c7b --- /dev/null +++ b/update_permissions.py @@ -0,0 +1,115 @@ +""" +Script to update admin endpoints with permission checks +Replaces get_current_admin_user with require_permission calls +""" + +import re + +# Mapping of endpoint patterns to permissions +ENDPOINT_PERMISSIONS = { + # Calendar + r'POST /admin/calendar/sync': 'events.edit', + r'DELETE /admin/calendar/unsync': 'events.edit', + + # Event Gallery + r'POST /admin/events/\{event_id\}/gallery': 'gallery.upload', + r'DELETE /admin/event-gallery': 'gallery.delete', + r'PUT /admin/event-gallery': 'gallery.edit', + + # Storage + r'GET /admin/storage/usage': 'settings.storage', + r'GET /admin/storage/breakdown': 'settings.storage', + + # User Management (remaining) + r'PUT /admin/users/\{user_id\}/reset-password': 'users.reset_password', + r'POST /admin/users/\{user_id\}/resend-verification': 'users.resend_verification', + + # Events + r'POST /admin/events(?!/)': 'events.create', # Not followed by / + r'PUT /admin/events/\{event_id\}': 'events.edit', + r'GET /admin/events/\{event_id\}/rsvps': 'events.rsvps', + r'PUT /admin/events/\{event_id\}/attendance': 'events.attendance', + r'GET /admin/events(?!/)': 'events.view', # Not followed by / + r'DELETE /admin/events': 'events.delete', + + # Subscriptions + r'GET /admin/subscriptions/plans(?!/)': 'subscriptions.view', + r'GET /admin/subscriptions/plans/\{plan_id\}': 'subscriptions.view', + r'POST /admin/subscriptions/plans': 'subscriptions.plans', + r'PUT /admin/subscriptions/plans': 'subscriptions.plans', + r'DELETE /admin/subscriptions/plans': 'subscriptions.plans', + r'GET /admin/subscriptions/stats': 'subscriptions.view', + r'GET /admin/subscriptions(?!/)': 'subscriptions.view', + r'PUT /admin/subscriptions/\{subscription_id\}': 'subscriptions.edit', + r'POST /admin/subscriptions/\{subscription_id\}/cancel': 'subscriptions.cancel', + + # Newsletters + r'POST /admin/newsletters': 'newsletters.create', + r'PUT /admin/newsletters': 'newsletters.edit', + r'DELETE /admin/newsletters': 'newsletters.delete', + + # Financials + r'POST /admin/financials': 'financials.create', + r'PUT /admin/financials': 'financials.edit', + r'DELETE /admin/financials': 'financials.delete', + + # Bylaws + r'POST /admin/bylaws': 'bylaws.create', + r'PUT /admin/bylaws': 'bylaws.edit', + r'DELETE /admin/bylaws': 'bylaws.delete', +} + +def update_server_file(): + """Read server.py, update permissions, write back""" + + with open('server.py', 'r') as f: + content = f.read() + + # Track changes + changes_made = 0 + + # Find all admin endpoints that still use get_current_admin_user + pattern = r'(@api_router\.(get|post|put|delete)\("(/admin/[^"]+)"\)[^@]+?)current_user: User = Depends\(get_current_admin_user\)' + + def replace_permission(match): + nonlocal changes_made + full_match = match.group(0) + method = match.group(2).upper() + route = match.group(3) + endpoint_pattern = f'{method} {route}' + + # Find matching permission + permission = None + for pattern_key, perm_value in ENDPOINT_PERMISSIONS.items(): + if re.search(pattern_key, endpoint_pattern): + permission = perm_value + break + + if permission: + changes_made += 1 + replacement = full_match.replace( + 'current_user: User = Depends(get_current_admin_user)', + f'current_user: User = Depends(require_permission("{permission}"))' + ) + print(f'✓ Updated {endpoint_pattern} → {permission}') + return replacement + else: + print(f'⚠ No permission mapping for: {endpoint_pattern}') + return full_match + + # Perform replacements + new_content = re.sub(pattern, replace_permission, content, flags=re.DOTALL) + + if changes_made > 0: + with open('server.py', 'w') as f: + f.write(new_content) + print(f'\n✅ Updated {changes_made} endpoints with permission checks') + else: + print('\n⚠ No changes made') + + return changes_made + +if __name__ == '__main__': + print('🔧 Updating admin endpoints with permission checks...\n') + changes = update_server_file() + print(f'\nDone! Updated {changes} endpoints.') diff --git a/verify_admin_account.py b/verify_admin_account.py new file mode 100644 index 0000000..2c02fe3 --- /dev/null +++ b/verify_admin_account.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Script to verify admin@loaf.org account configuration after RBAC migration +""" +import sys +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from dotenv import load_dotenv + +# Add parent directory to path to import models +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from models import User, Role, Permission, RolePermission +from database import DATABASE_URL + +# Load environment variables +load_dotenv() + +# Create database engine and session +engine = create_engine(DATABASE_URL) +Session = sessionmaker(bind=engine) +db = Session() + +def verify_admin_account(): + print("=" * 80) + print("VERIFYING admin@loaf.org ACCOUNT") + print("=" * 80) + + # Find the user + user = db.query(User).filter(User.email == "admin@loaf.org").first() + + if not user: + print("\n❌ ERROR: User 'admin@loaf.org' not found in database!") + return + + print(f"\n✅ User found: {user.first_name} {user.last_name}") + print(f" Email: {user.email}") + print(f" Status: {user.status}") + print(f" Email Verified: {user.email_verified}") + + # Check legacy role enum + print(f"\n📋 Legacy Role (enum): {user.role.value if user.role else 'None'}") + + # Check new dynamic role + if user.role_id: + role = db.query(Role).filter(Role.id == user.role_id).first() + if role: + print(f"✅ Dynamic Role: {role.name} (code: {role.code})") + print(f" Role ID: {role.id}") + print(f" Is System Role: {role.is_system_role}") + else: + print(f"❌ ERROR: role_id set to {user.role_id} but role not found!") + else: + print("⚠️ WARNING: No dynamic role_id set") + + # Check permissions + print("\n🔐 Checking Permissions:") + + # Get all permissions for this role + if user.role_id: + role_perms = db.query(RolePermission).filter( + RolePermission.role_id == user.role_id + ).all() + + print(f" Total permissions assigned to role: {len(role_perms)}") + + if len(role_perms) > 0: + print("\n Sample permissions:") + for rp in role_perms[:10]: # Show first 10 + perm = db.query(Permission).filter(Permission.id == rp.permission_id).first() + if perm: + print(f" - {perm.code}: {perm.name}") + if len(role_perms) > 10: + print(f" ... and {len(role_perms) - 10} more") + else: + print(" ⚠️ WARNING: No permissions assigned to this role!") + else: + # Check legacy role permissions + from auth import UserRole + role_enum = user.role + legacy_perms = db.query(RolePermission).filter( + RolePermission.role == role_enum + ).all() + print(f" Legacy permissions (via enum): {len(legacy_perms)}") + + # Check if user should have access + print("\n🎯 Access Check:") + if user.role and user.role.value in ['admin', 'superadmin']: + print(" ✅ User should have admin access (based on legacy enum)") + else: + print(" ❌ User does NOT have admin access (based on legacy enum)") + + if user.role_id: + role = db.query(Role).filter(Role.id == user.role_id).first() + if role and role.code in ['admin', 'superadmin']: + print(" ✅ User should have admin access (based on dynamic role)") + else: + print(" ❌ User does NOT have admin access (based on dynamic role)") + + print("\n" + "=" * 80) + print("VERIFICATION COMPLETE") + print("=" * 80) + +if __name__ == "__main__": + try: + verify_admin_account() + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + finally: + db.close()