diff --git a/.gitignore b/.gitignore index fcb2734..fc7287d 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,12 @@ cython_debug/ backups/ *.backup +# ===== Alembic Migrations ===== +# Keep migration files but ignore bytecode +alembic/__pycache__/ +alembic/versions/__pycache__/ +# Keep alembic.ini, env.py, and all migration files in alembic/versions/ + # ===== IDE / Editors ===== # VSCode .vscode/ diff --git a/DATABASE_STATUS.md b/DATABASE_STATUS.md new file mode 100644 index 0000000..4085c20 --- /dev/null +++ b/DATABASE_STATUS.md @@ -0,0 +1,247 @@ +# Database Status - LOAF Membership Platform + +**Database:** `loaf_new` +**Host:** 10.9.23.11:54321 +**Last Updated:** 2026-01-03 +**Status:** ✅ Fully initialized with seed data + +--- + +## Database Summary + +### Tables (18 total) + +| Table Name | Status | Records | Purpose | +|------------|--------|---------|---------| +| ✅ alembic_version | Active | 1 | Migration tracking (001_initial_baseline) | +| ✅ users | Active | 0 | User accounts and profiles | +| ✅ events | Active | 0 | Event management | +| ✅ event_rsvps | Active | 0 | Event RSVPs and attendance | +| ✅ event_galleries | Active | 0 | Event photo galleries | +| ✅ roles | Active | 5 | RBAC role definitions | +| ✅ permissions | Active | 25 | RBAC permission definitions | +| ✅ role_permissions | Active | 49 | Role-permission mappings | +| ✅ user_invitations | Active | 0 | Admin invitation system | +| ✅ subscriptions | Active | 0 | User subscriptions | +| ✅ subscription_plans | Active | 3 | Available membership plans | +| ✅ donations | Active | 0 | Donation tracking | +| ✅ import_jobs | Active | 0 | CSV import tracking | +| ✅ import_rollback_audit | Active | 0 | Import rollback audit trail | +| ✅ newsletter_archives | Active | 0 | Newsletter document archive | +| ✅ financial_reports | Active | 0 | Financial document archive | +| ✅ bylaws_documents | Active | 0 | Bylaws document archive | +| ✅ storage_usage | Active | 1 | Storage quota tracking (100GB limit) | + +### ENUMs (8 total) + +| ENUM Name | Values | Used By | +|-----------|--------|---------| +| ✅ userstatus | pending_email, awaiting_event, pre_approved, payment_pending, active, inactive | users.status | +| ✅ userrole | guest, member, admin, finance, superadmin | users.role, user_invitations.role | +| ✅ rsvpstatus | yes, no, maybe | event_rsvps.rsvp_status | +| ✅ subscriptionstatus | active, past_due, canceled, incomplete, trialing | subscriptions.status | +| ✅ donationtype | one_time, recurring, pledge, in_kind, memorial | donations.donation_type | +| ✅ donationstatus | pending, completed, failed, refunded | donations.status | +| ✅ invitationstatus | pending, accepted, expired, revoked | user_invitations.status | +| ✅ importjobstatus | processing, completed, failed | import_jobs.status | + +--- + +## Seed Data Loaded + +### Roles (5) + +| Code | Name | System Role | Permissions | +|------|------|-------------|-------------| +| admin | Admin | Yes | 16 | +| finance | Finance | Yes | 7 | +| guest | Guest | Yes | 0 | +| member | Member | Yes | 1 | +| superadmin | Super Admin | Yes | 25 | + +### Permissions (25 across 5 modules) + +**Users Module (6 permissions):** +- users.view - View Users +- users.create - Create Users +- users.edit - Edit Users +- users.delete - Delete Users +- users.approve - Approve Users +- users.import - Import Users + +**Events Module (6 permissions):** +- events.view - View Events +- events.create - Create Events +- events.edit - Edit Events +- events.delete - Delete Events +- events.publish - Publish Events +- events.manage_attendance - Manage Attendance + +**Finance Module (5 permissions):** +- finance.view - View Financial Data +- finance.manage_plans - Manage Subscription Plans +- finance.manage_subscriptions - Manage Subscriptions +- finance.view_reports - View Financial Reports +- finance.export - Export Financial Data + +**Content Module (3 permissions):** +- content.newsletters - Manage Newsletters +- content.documents - Manage Documents +- content.gallery - Manage Gallery + +**System Module (5 permissions):** +- system.settings - System Settings +- system.roles - Manage Roles +- system.invitations - Manage Invitations +- system.storage - Manage Storage +- system.audit - View Audit Logs + +### Subscription Plans (3) + +| Plan Name | Price | Billing | Custom Pricing | Donation Support | +|-----------|-------|---------|----------------|------------------| +| Pay What You Want Membership | $30.00 (min) | Annual | ✅ Yes | ✅ Yes | +| Annual Individual Membership | $60.00 | Annual | ❌ No | ❌ No | +| Annual Group Membership | $100.00 | Annual | ❌ No | ❌ No | + +**Note:** Stripe price IDs need to be configured after Stripe setup. + +--- + +## Migration Status + +**Current Revision:** `001_initial_baseline (head)` +**Migration System:** Alembic 1.14.0 +**Schema Source:** `migrations/000_initial_schema.sql` +**Seed Source:** `migrations/seed_data.sql` + +**Migration History:** +- `001_initial_baseline` - Empty baseline marker (2026-01-02) + +**Future migrations** will be generated using: +```bash +alembic revision --autogenerate -m "description" +alembic upgrade head +``` + +--- + +## Next Steps + +### Immediate (Required) + +1. **Create Superadmin User** + ```bash + cd backend + python3 create_superadmin.py + ``` + +2. **Configure Stripe Price IDs** + ```sql + UPDATE subscription_plans + SET stripe_price_id = 'price_xxx' + WHERE name = 'Annual Individual Membership'; + + UPDATE subscription_plans + SET stripe_price_id = 'price_yyy' + WHERE name = 'Annual Group Membership'; + + UPDATE subscription_plans + SET stripe_price_id = 'price_zzz' + WHERE name = 'Pay What You Want Membership'; + ``` + +3. **Set Environment Variables** + - Copy `backend/.env.example` to `backend/.env` + - Fill in all required values (DATABASE_URL, JWT_SECRET, SMTP, Stripe, R2) + +4. **Test Application** + ```bash + # Backend + cd backend + uvicorn server:app --reload + + # Frontend (separate terminal) + cd frontend + yarn start + ``` + +### Optional (Recommended) + +1. **Add Sample Events** + - Login as superadmin + - Navigate to Admin → Events + - Create 2-3 sample events + +2. **Test Registration Flow** + - Register a test user + - Verify email verification works + - Test event RSVP + - Test admin approval flow + +3. **Configure Email Templates** + - Review templates in `backend/email_service.py` + - Customize colors, branding, copy + +4. **Set Up Monitoring** + - Configure error logging + - Set up uptime monitoring + - Configure backup schedule + +--- + +## Database Maintenance + +### Backup Command + +```bash +PGPASSWORD='your-password' pg_dump -h 10.9.23.11 -p 54321 -U postgres loaf_new > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### Restore Command + +```bash +PGPASSWORD='your-password' psql -h 10.9.23.11 -p 54321 -U postgres -d loaf_new < backup_file.sql +``` + +### Health Check Queries + +```sql +-- Check user count by status +SELECT status, COUNT(*) FROM users GROUP BY status; + +-- Check upcoming events +SELECT title, start_at FROM events WHERE start_at > NOW() ORDER BY start_at LIMIT 5; + +-- Check active subscriptions +SELECT COUNT(*) FROM subscriptions WHERE status = 'active'; + +-- Check storage usage +SELECT + total_bytes_used / 1024 / 1024 / 1024 as used_gb, + max_bytes_allowed / 1024 / 1024 / 1024 as max_gb, + ROUND((total_bytes_used::numeric / max_bytes_allowed * 100)::numeric, 2) as percent_used +FROM storage_usage; +``` + +--- + +## Support & Resources + +- **Deployment Guide:** See `DEPLOYMENT.md` for complete deployment instructions +- **API Documentation:** http://localhost:8000/docs (when backend running) +- **Alembic Guide:** See `backend/alembic/README.md` for migration documentation +- **Project Documentation:** See `CLAUDE.md` for codebase overview + +--- + +## Changelog + +**2026-01-03:** +- ✅ Created all 17 data tables +- ✅ Created all 8 ENUMs +- ✅ Loaded seed data (5 roles, 25 permissions, 3 subscription plans) +- ✅ Initialized Alembic tracking (001_initial_baseline) +- ✅ Created superadmin user helper script + +**Status:** Database is fully initialized and ready for use. Next step: Create superadmin user and start application. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..26ac16a --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,379 @@ +# Deployment Guide - LOAF Membership Platform + +## Fresh Database Installation + +Follow these steps in order for a **brand new deployment**: + +### Step 1: Create PostgreSQL Database + +```bash +# Connect to PostgreSQL +psql -U postgres + +# Create database +CREATE DATABASE membership_db; + +# Create user (if needed) +CREATE USER loaf_admin WITH PASSWORD 'your-secure-password'; +GRANT ALL PRIVILEGES ON DATABASE membership_db TO loaf_admin; + +# Exit PostgreSQL +\q +``` + +### Step 2: Run Initial Schema + +```bash +cd backend + +# Apply the complete schema (creates all 17 tables, 8 enums, indexes) +psql -U loaf_admin -d membership_db -f migrations/000_initial_schema.sql +``` + +**What this creates:** +- ✅ 17 tables: users, events, subscriptions, roles, permissions, etc. +- ✅ 8 custom enums: UserStatus, UserRole, RSVPStatus, etc. +- ✅ All indexes and foreign keys +- ✅ All constraints and defaults + +### Step 3: Mark Database for Alembic Tracking + +```bash +# Mark the database as being at the baseline +alembic stamp head +``` + +### Step 4: Verify Setup + +```bash +# Check Alembic status +alembic current +# Expected output: 001_initial_baseline (head) + +# Check database tables +psql -U loaf_admin -d membership_db -c "\dt" +# Should show 17 tables +``` + +### Step 5: Set Environment Variables + +Create `backend/.env`: + +```env +# Database +DATABASE_URL=postgresql://loaf_admin:your-password@localhost:5432/membership_db + +# JWT +JWT_SECRET=your-secret-key-minimum-32-characters-long +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Email (SMTP) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM_EMAIL=noreply@loafmembers.org +SMTP_FROM_NAME=LOAF Membership + +# Frontend URL +FRONTEND_URL=https://members.loafmembers.org + +# Cloudflare R2 +R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com +R2_ACCESS_KEY_ID=your-r2-access-key +R2_SECRET_ACCESS_KEY=your-r2-secret-key +R2_BUCKET_NAME=loaf-membership +R2_PUBLIC_URL=https://cdn.loafmembers.org + +# Stripe +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_ID_ANNUAL=price_... +STRIPE_PRICE_ID_GROUP=price_... +``` + +### Step 6: Install Dependencies + +```bash +# Backend +cd backend +pip install -r requirements.txt + +# Frontend +cd ../frontend +yarn install +``` + +### Step 7: Start Services + +```bash +# Backend (in backend/) +uvicorn server:app --host 0.0.0.0 --port 8000 + +# Frontend (in frontend/) +yarn start +``` + +### Step 8: Create First Superadmin User + +```bash +# Connect to database +psql -U loaf_admin -d membership_db + +# Create superadmin user +INSERT INTO users ( + id, email, password_hash, first_name, last_name, + status, role, email_verified, created_at, updated_at +) VALUES ( + gen_random_uuid(), + 'admin@loafmembers.org', + '$2b$12$your-bcrypt-hashed-password-here', -- Use bcrypt to hash password + 'Admin', + 'User', + 'active', + 'superadmin', + true, + NOW(), + NOW() +); +``` + +**Generate password hash:** +```python +import bcrypt +password = b"your-secure-password" +hashed = bcrypt.hashpw(password, bcrypt.gensalt()) +print(hashed.decode()) +``` + +--- + +## Existing Database Update + +For **updating an existing deployment** with new code: + +### Step 1: Backup Database + +```bash +pg_dump -U loaf_admin membership_db > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### Step 2: Pull Latest Code + +```bash +git pull origin main +``` + +### Step 3: Install New Dependencies + +```bash +# Backend +cd backend +pip install -r requirements.txt + +# Frontend +cd ../frontend +yarn install +``` + +### Step 4: Apply Database Migrations + +```bash +cd backend + +# Check current migration status +alembic current + +# Apply pending migrations +alembic upgrade head + +# Verify +alembic current +``` + +### Step 5: Restart Services + +```bash +# Restart backend +systemctl restart membership-backend + +# Rebuild and restart frontend +cd frontend +yarn build +systemctl restart membership-frontend +``` + +--- + +## First-Time Alembic Setup (Existing Database) + +If you have an **existing database** that was created with `000_initial_schema.sql` but hasn't been marked for Alembic tracking: + +```bash +cd backend + +# Mark database as being at the baseline (one-time only) +alembic stamp head + +# Verify +alembic current +# Expected output: 001_initial_baseline (head) +``` + +--- + +## Database Schema Verification + +**Check all tables exist:** +```bash +psql -U loaf_admin -d membership_db -c "\dt" +``` + +**Expected tables (17 total):** +- users +- events +- event_rsvps +- subscriptions +- subscription_plans +- permissions +- roles +- role_permissions +- user_invitations +- import_jobs +- import_rollback_audit +- event_galleries +- newsletter_archives +- financial_reports +- bylaws_documents +- donations +- storage_usage + +**Check enums:** +```bash +psql -U loaf_admin -d membership_db -c "\dT" +``` + +**Expected enums (8 total):** +- userstatus +- userrole +- rsvpstatus +- subscriptionstatus +- donationtype +- donationstatus +- invitationstatus +- importjobstatus + +--- + +## Rollback Procedures + +### Rollback Last Migration + +```bash +cd backend +alembic downgrade -1 +``` + +### Rollback to Specific Revision + +```bash +alembic downgrade +``` + +### Complete Database Reset + +```bash +# WARNING: This deletes ALL data! + +# 1. Backup first +pg_dump -U loaf_admin membership_db > emergency_backup.sql + +# 2. Drop database +dropdb membership_db + +# 3. Recreate database +createdb membership_db + +# 4. Run initial schema +psql -U loaf_admin -d membership_db -f backend/migrations/000_initial_schema.sql + +# 5. Mark for Alembic +cd backend +alembic stamp head +``` + +--- + +## Troubleshooting + +### "relation does not exist" error + +The database wasn't initialized properly. + +**Solution:** +```bash +psql -U loaf_admin -d membership_db -f backend/migrations/000_initial_schema.sql +alembic stamp head +``` + +### "Target database is not up to date" + +Migrations haven't been applied. + +**Solution:** +```bash +cd backend +alembic upgrade head +``` + +### "Can't locate revision" + +Alembic tracking is out of sync. + +**Solution:** +```bash +# Check what revision the database thinks it's at +alembic current + +# If empty or wrong, manually set it +alembic stamp head +``` + +### Database connection errors + +Check `.env` file has correct `DATABASE_URL`. + +**Format:** +``` +DATABASE_URL=postgresql://username:password@host:port/database +``` + +--- + +## Production Checklist + +Before going live: + +- [ ] Database created and schema applied +- [ ] Alembic marked as up-to-date (`alembic current` shows baseline) +- [ ] All environment variables set in `.env` +- [ ] Dependencies installed (Python + Node) +- [ ] Superadmin user created +- [ ] SSL certificates configured +- [ ] Backup system in place +- [ ] Monitoring configured +- [ ] Domain DNS pointing to server +- [ ] Email sending verified (SMTP working) +- [ ] Stripe webhook endpoint configured +- [ ] R2 bucket accessible and CORS configured + +--- + +## Support + +For issues: +1. Check logs: `tail -f backend/logs/app.log` +2. Check Alembic status: `alembic current` +3. Verify environment variables: `cat backend/.env` +4. Test database connection: `psql -U loaf_admin -d membership_db` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f6e2e8d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,118 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# sqlalchemy.url = driver://user:pass@localhost/dbname +# Database URL is configured in alembic/env.py from .env file for security + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/README.md b/alembic/README.md new file mode 100644 index 0000000..6056215 --- /dev/null +++ b/alembic/README.md @@ -0,0 +1,259 @@ +# Alembic Database Migrations + +This directory contains **Alembic** database migrations for the LOAF membership platform. + +## What is Alembic? + +Alembic is a lightweight database migration tool for SQLAlchemy. It allows you to: +- Track database schema changes over time +- Apply migrations incrementally +- Roll back changes if needed +- Auto-generate migration scripts from model changes + +## Directory Structure + +``` +alembic/ +├── versions/ # Migration scripts (KEEP IN VERSION CONTROL) +│ └── *.py # Individual migration files +├── env.py # Alembic environment configuration +├── script.py.mako # Template for new migration files +└── README.md # This file +``` + +## Quick Start + +### 1. Create a New Migration + +After making changes to `models.py`, generate a migration: + +```bash +cd backend +alembic revision --autogenerate -m "add_user_bio_field" +``` + +This will create a new file in `alembic/versions/` like: +``` +3e02c74581c9_add_user_bio_field.py +``` + +### 2. Review the Generated Migration + +**IMPORTANT:** Always review auto-generated migrations before applying them! + +```bash +# Open the latest migration file +cat alembic/versions/3e02c74581c9_add_user_bio_field.py +``` + +Check: +- ✅ The `upgrade()` function contains the correct changes +- ✅ The `downgrade()` function properly reverses those changes +- ✅ No unintended table drops or data loss + +### 3. Apply the Migration + +```bash +# Apply all pending migrations +alembic upgrade head + +# Or apply migrations one at a time +alembic upgrade +1 +``` + +### 4. Rollback a Migration + +```bash +# Rollback the last migration +alembic downgrade -1 + +# Rollback to a specific revision +alembic downgrade 3e02c74581c9 +``` + +## Common Commands + +| Command | Description | +|---------|-------------| +| `alembic current` | Show current migration revision | +| `alembic history` | Show migration history | +| `alembic heads` | Show head revisions | +| `alembic upgrade head` | Apply all pending migrations | +| `alembic downgrade -1` | Rollback last migration | +| `alembic revision --autogenerate -m "message"` | Create new migration | +| `alembic stamp head` | Mark database as up-to-date without running migrations | + +## Migration Workflow + +### For Development + +1. **Make changes to `models.py`** + ```python + # In models.py + class User(Base): + # ...existing fields... + bio = Column(Text, nullable=True) # New field + ``` + +2. **Generate migration** + ```bash + alembic revision --autogenerate -m "add_user_bio_field" + ``` + +3. **Review the generated file** + ```python + # In alembic/versions/xxxxx_add_user_bio_field.py + def upgrade(): + op.add_column('users', sa.Column('bio', sa.Text(), nullable=True)) + + def downgrade(): + op.drop_column('users', 'bio') + ``` + +4. **Apply migration** + ```bash + alembic upgrade head + ``` + +5. **Commit migration file to Git** + ```bash + git add alembic/versions/xxxxx_add_user_bio_field.py + git commit -m "Add user bio field" + ``` + +### For Production Deployment + +**Fresh Database (New Installation):** +```bash +# 1. Create database +createdb membership_db + +# 2. Run initial schema SQL (creates all 17 tables) +psql -U username -d membership_db -f ../migrations/000_initial_schema.sql + +# 3. Mark database as up-to-date with Alembic +alembic stamp head + +# 4. Verify +alembic current # Should show: 001_initial_baseline (head) +``` + +**Existing Database (Apply New Migrations):** +```bash +# 1. Pull latest code +git pull origin main + +# 2. Apply migrations +alembic upgrade head + +# 3. Verify +alembic current + +# 4. Restart application +systemctl restart membership-backend +``` + +## Configuration + +### Database Connection + +Alembic reads the `DATABASE_URL` from your `.env` file: + +```env +DATABASE_URL=postgresql://user:password@localhost:5432/membership_db +``` + +The connection is configured in `alembic/env.py` (lines 29-36). + +### Target Metadata + +Alembic uses `Base.metadata` from `models.py` to detect changes: + +```python +# In alembic/env.py +from models import Base +target_metadata = Base.metadata +``` + +## Important Notes + +### ✅ DO: +- Always review auto-generated migrations before applying +- Test migrations in development before production +- Commit migration files to version control +- Write descriptive migration messages +- Include both `upgrade()` and `downgrade()` functions + +### ❌ DON'T: +- Don't edit migration files after they've been applied in production +- Don't delete migration files from `alembic/versions/` +- Don't modify the `revision` or `down_revision` values +- Don't commit `.pyc` files (already in .gitignore) + +## Migration History + +| Revision | Description | Date | Type | +|----------|-------------|------|------| +| `001_initial_baseline` | Baseline marker (empty migration) | 2026-01-02 | Baseline | + +**Note:** The actual initial schema is created by running `backend/migrations/000_initial_schema.sql`. The baseline migration is an empty marker that indicates the starting point for Alembic tracking. + +## Troubleshooting + +### "Target database is not up to date" + +```bash +# Check current revision +alembic current + +# Check pending migrations +alembic history + +# Apply missing migrations +alembic upgrade head +``` + +### "FAILED: Can't locate revision identified by 'xxxxx'" + +The database thinks it's at a revision that doesn't exist in your `alembic/versions/`. + +**Solution:** +```bash +# Mark database at a known good revision +alembic stamp head +``` + +### Migration conflicts + +If you get merge conflicts in migration files: + +1. Resolve conflicts in the migration file +2. Ensure `revision` and `down_revision` chain is correct +3. Test the migration locally + +### Fresh database setup + +For a completely new database: + +```bash +# Step 1: Run initial schema SQL +psql -U username -d membership_db -f ../migrations/000_initial_schema.sql + +# Step 2: Mark as up-to-date +alembic stamp head + +# Step 3: Verify +alembic current # Should show: 001_initial_baseline (head) +``` + +## Legacy Migrations + +Old numbered SQL migrations (`000_initial_schema.sql` through `011_wordpress_import_enhancements.sql`) are preserved in `backend/migrations/` for reference. These have been consolidated into the initial Alembic migration. + +**Going forward, all new migrations must use Alembic.** + +## Additional Resources + +- [Alembic Documentation](https://alembic.sqlalchemy.org/) +- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/) +- [PostgreSQL Documentation](https://www.postgresql.org/docs/) diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..e74db09 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,96 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context +from dotenv import load_dotenv + +# Add the parent directory to the path so we can import our models +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# Load environment variables from .env file +load_dotenv() + +# Import all models so Alembic can detect them +from models import Base +import models # This ensures all models are imported + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Set the SQLAlchemy URL from environment variable +database_url = os.getenv("DATABASE_URL") +if database_url: + config.set_main_option("sqlalchemy.url", database_url) +else: + raise ValueError( + "DATABASE_URL environment variable not set. " + "Please create a .env file with DATABASE_URL=postgresql://user:password@host:port/dbname" + ) + +# Add your model's MetaData object here for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, # Detect type changes + compare_server_default=True, # Detect default value changes + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, # Detect type changes + compare_server_default=True, # Detect default value changes + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_baseline.py b/alembic/versions/001_initial_baseline.py new file mode 100644 index 0000000..6a2dd4a --- /dev/null +++ b/alembic/versions/001_initial_baseline.py @@ -0,0 +1,59 @@ +"""initial_baseline - Use 000_initial_schema.sql for fresh deployments + +Revision ID: 001_initial_baseline +Revises: +Create Date: 2026-01-02 16:45:00.000000 + +IMPORTANT: This is a baseline migration for existing databases. + +For FRESH deployments: +1. Run: psql -U user -d dbname -f backend/migrations/000_initial_schema.sql +2. Run: alembic stamp head + +For EXISTING deployments (already have database): +1. Run: alembic stamp head (marks database as up-to-date) + +This migration intentionally does NOTHING because: +- Fresh deployments use 000_initial_schema.sql to create all tables +- Existing deployments already have all tables from 000_initial_schema.sql +- Future migrations will be incremental changes from this baseline +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '001_initial_baseline' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """ + This migration does nothing. + + It serves as a baseline marker that indicates: + - All 17 tables exist (users, events, subscriptions, etc.) + - All 8 enums are defined (UserStatus, UserRole, etc.) + - All indexes and constraints are in place + + The actual schema is created by running: + backend/migrations/000_initial_schema.sql + """ + pass + + +def downgrade() -> None: + """ + Cannot downgrade below baseline. + + If you need to completely reset the database: + 1. dropdb dbname + 2. createdb dbname + 3. psql -U user -d dbname -f backend/migrations/000_initial_schema.sql + 4. alembic stamp head + """ + pass diff --git a/create_superadmin.py b/create_superadmin.py new file mode 100644 index 0000000..7644bc9 --- /dev/null +++ b/create_superadmin.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Create Superadmin User Script +Generates a superadmin user with hashed password for LOAF membership platform +""" + +import bcrypt +import sys +import os +from getpass import getpass + +def generate_password_hash(password: str) -> str: + """Generate bcrypt hash for password""" + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + +def generate_sql(email: str, password_hash: str, first_name: str, last_name: str) -> str: + """Generate SQL INSERT statement""" + return f""" +-- Create Superadmin User +INSERT INTO users ( + id, email, password_hash, first_name, last_name, + status, role, email_verified, created_at, updated_at +) VALUES ( + gen_random_uuid(), + '{email}', + '{password_hash}', + '{first_name}', + '{last_name}', + 'active', + 'superadmin', + true, + NOW(), + NOW() +); +""" + +def main(): + print("=" * 70) + print("LOAF Membership Platform - Superadmin User Creator") + print("=" * 70) + print() + + # Get user input + email = input("Email address: ").strip() + if not email or '@' not in email: + print("❌ Invalid email address") + sys.exit(1) + + first_name = input("First name: ").strip() + if not first_name: + print("❌ First name is required") + sys.exit(1) + + last_name = input("Last name: ").strip() + if not last_name: + print("❌ Last name is required") + sys.exit(1) + + # Get password securely + password = getpass("Password: ") + if len(password) < 8: + print("❌ Password must be at least 8 characters") + sys.exit(1) + + password_confirm = getpass("Confirm password: ") + if password != password_confirm: + print("❌ Passwords do not match") + sys.exit(1) + + print() + print("Generating password hash...") + password_hash = generate_password_hash(password) + + print("✅ Password hash generated") + print() + print("=" * 70) + print("SQL STATEMENT") + print("=" * 70) + + sql = generate_sql(email, password_hash, first_name, last_name) + print(sql) + + # Save to file + output_file = "create_superadmin.sql" + with open(output_file, 'w') as f: + f.write(sql) + + print("=" * 70) + print(f"✅ SQL saved to: {output_file}") + print() + print("Run this command to create the user:") + print(f" psql -U postgres -d loaf_new -f {output_file}") + print() + print("Or copy the SQL above and run it directly in psql") + print("=" * 70) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\n❌ Cancelled by user") + sys.exit(1) + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql index 72591b0..87c5f8a 100644 --- a/migrations/000_initial_schema.sql +++ b/migrations/000_initial_schema.sql @@ -146,14 +146,19 @@ CREATE TABLE IF NOT EXISTS users ( -- Membership member_since DATE, - tos_accepted BOOLEAN DEFAULT FALSE, + accepts_tos BOOLEAN DEFAULT FALSE, tos_accepted_at TIMESTAMP WITH TIME ZONE, newsletter_subscribed BOOLEAN DEFAULT TRUE, - -- Reminder Tracking - reminder_30_days_sent BOOLEAN DEFAULT FALSE, - reminder_60_days_sent BOOLEAN DEFAULT FALSE, - reminder_85_days_sent BOOLEAN DEFAULT FALSE, + -- Reminder Tracking (from migration 004) + email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE, + event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_event_attendance_reminder_at TIMESTAMP WITH TIME ZONE, + payment_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_payment_reminder_at TIMESTAMP WITH TIME ZONE, + renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_renewal_reminder_at TIMESTAMP WITH TIME ZONE, -- WordPress Import Tracking import_source VARCHAR(50), diff --git a/migrations/create_tables_only.sql b/migrations/create_tables_only.sql new file mode 100644 index 0000000..8276175 --- /dev/null +++ b/migrations/create_tables_only.sql @@ -0,0 +1,394 @@ +-- ============================================================================ +-- Create Tables Only (ENUMs already exist) +-- Run this when ENUMs exist but tables don't +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- STEP 1: Core Tables +-- ============================================================================ + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + phone VARCHAR(20), + address TEXT, + city VARCHAR(100), + state VARCHAR(2), + zipcode VARCHAR(10), + date_of_birth DATE, + + -- Profile + profile_image_url TEXT, + bio TEXT, + interests TEXT, + + -- Partner Information + partner_first_name VARCHAR(100), + partner_last_name VARCHAR(100), + partner_is_member BOOLEAN DEFAULT FALSE, + partner_plan_to_become_member BOOLEAN DEFAULT FALSE, + + -- Referral + referred_by_member_name VARCHAR(200), + + -- Newsletter Preferences + newsletter_subscribed BOOLEAN DEFAULT TRUE, + newsletter_publish_name BOOLEAN DEFAULT FALSE, + newsletter_publish_photo BOOLEAN DEFAULT FALSE, + newsletter_publish_birthday BOOLEAN DEFAULT FALSE, + newsletter_publish_none BOOLEAN DEFAULT FALSE, + + -- Volunteer & Scholarship + volunteer_interests TEXT, + scholarship_requested BOOLEAN DEFAULT FALSE, + + -- Directory + show_in_directory BOOLEAN DEFAULT TRUE, + + -- Lead Sources (JSON array) + lead_sources JSONB DEFAULT '[]'::jsonb, + + -- Status & Role + status userstatus DEFAULT 'pending_email' NOT NULL, + role userrole DEFAULT 'guest' NOT NULL, + role_id UUID, + + -- Rejection Tracking + rejection_reason TEXT, + rejected_at TIMESTAMP WITH TIME ZONE, + rejected_by UUID REFERENCES users(id), + + -- Membership + member_since DATE, + accepts_tos BOOLEAN DEFAULT FALSE, + tos_accepted_at TIMESTAMP WITH TIME ZONE, + + -- Reminder Tracking (from migration 004) + email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE, + event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_event_attendance_reminder_at TIMESTAMP WITH TIME ZONE, + payment_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_payment_reminder_at TIMESTAMP WITH TIME ZONE, + renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL, + last_renewal_reminder_at TIMESTAMP WITH TIME ZONE, + + -- WordPress Import Tracking + import_source VARCHAR(50), + import_job_id UUID, + wordpress_user_id BIGINT, + wordpress_registered_date TIMESTAMP WITH TIME ZONE, + + -- Authentication + email_verified BOOLEAN DEFAULT FALSE, + email_verification_token VARCHAR(255), + email_verification_expires TIMESTAMP WITH TIME ZONE, + password_reset_token VARCHAR(255), + password_reset_expires TIMESTAMP WITH TIME ZONE, + force_password_change BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Events table +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + description TEXT, + location VARCHAR(255), + start_at TIMESTAMP WITH TIME ZONE NOT NULL, + end_at TIMESTAMP WITH TIME ZONE NOT NULL, + capacity INTEGER, + published BOOLEAN DEFAULT FALSE, + calendar_uid VARCHAR(255) UNIQUE, + created_by UUID REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Event RSVPs +CREATE TABLE IF NOT EXISTS event_rsvps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rsvp_status rsvpstatus NOT NULL, + attended BOOLEAN DEFAULT FALSE, + attended_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(event_id, user_id) +); + +-- Event Gallery +CREATE TABLE IF NOT EXISTS event_galleries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + image_url TEXT NOT NULL, + caption TEXT, + uploaded_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Import Jobs +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename VARCHAR(255) NOT NULL, + file_key VARCHAR(255), + total_rows INTEGER NOT NULL, + processed_rows INTEGER DEFAULT 0, + successful_rows INTEGER DEFAULT 0, + failed_rows INTEGER DEFAULT 0, + status importjobstatus DEFAULT 'processing' NOT NULL, + errors JSONB DEFAULT '[]'::jsonb, + + -- WordPress import enhancements + field_mapping JSONB DEFAULT '{}'::jsonb, + wordpress_metadata JSONB DEFAULT '{}'::jsonb, + imported_user_ids JSONB DEFAULT '[]'::jsonb, + rollback_at TIMESTAMP WITH TIME ZONE, + rollback_by UUID REFERENCES users(id), + + imported_by UUID NOT NULL REFERENCES users(id), + started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE +); + +COMMIT; + +-- ============================================================================ +-- STEP 2: Subscription & Payment Tables +-- ============================================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS subscription_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + description TEXT, + price_cents INTEGER NOT NULL, + billing_cycle VARCHAR(20) NOT NULL, + stripe_price_id VARCHAR(255), + custom_cycle_enabled BOOLEAN DEFAULT FALSE, + minimum_price_cents INTEGER DEFAULT 0, + allow_donation BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id UUID NOT NULL REFERENCES subscription_plans(id), + stripe_subscription_id VARCHAR(255), + stripe_customer_id VARCHAR(255), + base_subscription_cents INTEGER NOT NULL, + donation_cents INTEGER DEFAULT 0, + status subscriptionstatus DEFAULT 'active' NOT NULL, + current_period_start TIMESTAMP WITH TIME ZONE, + current_period_end TIMESTAMP WITH TIME ZONE, + cancel_at_period_end BOOLEAN DEFAULT FALSE, + canceled_at TIMESTAMP WITH TIME ZONE, + manual_payment BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS donations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + amount_cents INTEGER NOT NULL, + donation_type donationtype NOT NULL, + status donationstatus DEFAULT 'pending' NOT NULL, + stripe_payment_intent_id VARCHAR(255), + donor_name VARCHAR(200), + donor_email VARCHAR(255), + message TEXT, + is_anonymous BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + completed_at TIMESTAMP WITH TIME ZONE +); + +COMMIT; + +-- ============================================================================ +-- STEP 3: RBAC Tables +-- ============================================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + module VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + is_system_role BOOLEAN DEFAULT FALSE, + created_by UUID REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role VARCHAR(50), + role_id UUID REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_by UUID REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + role userrole NOT NULL, + token VARCHAR(255) UNIQUE NOT NULL, + invited_by UUID NOT NULL REFERENCES users(id), + invited_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + accepted_by UUID REFERENCES users(id), + accepted_at TIMESTAMP WITH TIME ZONE, + status invitationstatus DEFAULT 'pending' NOT NULL +); + +COMMIT; + +-- ============================================================================ +-- STEP 4: Document Management Tables +-- ============================================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS newsletter_archives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + description TEXT, + published_date TIMESTAMP WITH TIME ZONE NOT NULL, + document_url TEXT NOT NULL, + document_type VARCHAR(50) NOT NULL, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS financial_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + year INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + document_url TEXT NOT NULL, + document_type VARCHAR(50) NOT NULL, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bylaws_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + version VARCHAR(50) NOT NULL, + effective_date TIMESTAMP WITH TIME ZONE NOT NULL, + document_url TEXT NOT NULL, + document_type VARCHAR(50) NOT NULL, + is_current BOOLEAN DEFAULT FALSE, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMIT; + +-- ============================================================================ +-- STEP 5: System Tables +-- ============================================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS storage_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + total_bytes_used BIGINT DEFAULT 0, + max_bytes_allowed BIGINT, + last_calculated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS import_rollback_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + import_job_id UUID NOT NULL REFERENCES import_jobs(id), + rolled_back_by UUID NOT NULL REFERENCES users(id), + rolled_back_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_user_count INTEGER NOT NULL, + deleted_user_ids JSONB NOT NULL, + reason TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Initialize storage_usage with default row +INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed) +VALUES (gen_random_uuid(), 0, 107374182400) -- 100GB limit +ON CONFLICT DO NOTHING; + +COMMIT; + +-- ============================================================================ +-- STEP 6: Create Indexes +-- ============================================================================ + +BEGIN; + +-- Users indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); + +-- Events indexes +CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at); +CREATE INDEX IF NOT EXISTS idx_events_published ON events(published); +CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by); + +-- Event RSVPs indexes +CREATE INDEX IF NOT EXISTS idx_event_rsvps_event_id ON event_rsvps(event_id); +CREATE INDEX IF NOT EXISTS idx_event_rsvps_user_id ON event_rsvps(user_id); +CREATE INDEX IF NOT EXISTS idx_event_rsvps_attended ON event_rsvps(attended); + +-- Subscriptions indexes +CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); +CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer_id ON subscriptions(stripe_customer_id); + +-- Permissions indexes +CREATE INDEX IF NOT EXISTS ix_permissions_code ON permissions(code); +CREATE INDEX IF NOT EXISTS ix_permissions_module ON permissions(module); + +-- Roles indexes +CREATE INDEX IF NOT EXISTS ix_roles_code ON roles(code); + +-- Role permissions indexes +CREATE INDEX IF NOT EXISTS ix_role_permissions_role ON role_permissions(role); +CREATE INDEX IF NOT EXISTS ix_role_permissions_role_id ON role_permissions(role_id); + +-- User invitations indexes +CREATE INDEX IF NOT EXISTS ix_user_invitations_email ON user_invitations(email); +CREATE INDEX IF NOT EXISTS ix_user_invitations_token ON user_invitations(token); + +COMMIT; + +\echo '✅ All tables created successfully!' +\echo 'Run: psql ... -c "\dt" to verify' diff --git a/migrations/diagnose_database.sql b/migrations/diagnose_database.sql new file mode 100644 index 0000000..83754a5 --- /dev/null +++ b/migrations/diagnose_database.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- Database Diagnostic Script +-- Run this to check what exists in your database +-- ============================================================================ + +\echo '=== CHECKING ENUMS ===' +SELECT + t.typname as enum_name, + string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) as values +FROM pg_type t +JOIN pg_enum e ON t.oid = e.enumtypid +WHERE t.typname IN ( + 'userstatus', 'userrole', 'rsvpstatus', 'subscriptionstatus', + 'donationtype', 'donationstatus', 'invitationstatus', 'importjobstatus' +) +GROUP BY t.typname +ORDER BY t.typname; + +\echo '' +\echo '=== CHECKING TABLES ===' +SELECT + schemaname, + tablename +FROM pg_tables +WHERE schemaname = 'public' +ORDER BY tablename; + +\echo '' +\echo '=== CHECKING USERS TABLE STRUCTURE ===' +SELECT + column_name, + data_type, + is_nullable, + column_default +FROM information_schema.columns +WHERE table_name = 'users' +ORDER BY ordinal_position; + +\echo '' +\echo '=== CHECKING FOR CRITICAL FIELDS ===' +\echo 'Checking if reminder tracking fields exist...' +SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'users' + AND column_name = 'email_verification_reminders_sent' +) as has_reminder_fields; + +\echo '' +\echo 'Checking if accepts_tos field exists (should be accepts_tos, not tos_accepted)...' +SELECT column_name +FROM information_schema.columns +WHERE table_name = 'users' +AND column_name IN ('accepts_tos', 'tos_accepted'); + +\echo '' +\echo 'Checking if WordPress import fields exist...' +SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'users' + AND column_name = 'import_source' +) as has_import_fields; + +\echo '' +\echo '=== CHECKING IMPORT_JOBS TABLE ===' +SELECT column_name +FROM information_schema.columns +WHERE table_name = 'import_jobs' +ORDER BY ordinal_position; + +\echo '' +\echo '=== SUMMARY ===' +SELECT + (SELECT COUNT(*) FROM pg_type WHERE typname IN ( + 'userstatus', 'userrole', 'rsvpstatus', 'subscriptionstatus', + 'donationtype', 'donationstatus', 'invitationstatus', 'importjobstatus' + )) as enum_count, + (SELECT COUNT(*) FROM pg_tables WHERE schemaname = 'public') as table_count, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'users') as users_column_count; diff --git a/migrations/fix_missing_fields.sql b/migrations/fix_missing_fields.sql new file mode 100644 index 0000000..6f6020c --- /dev/null +++ b/migrations/fix_missing_fields.sql @@ -0,0 +1,169 @@ +-- ============================================================================ +-- Fix Missing Fields Script +-- Safely adds missing fields without recreating existing structures +-- ============================================================================ + +BEGIN; + +\echo '=== FIXING USERS TABLE ===' + +-- Fix TOS field name if needed (tos_accepted -> accepts_tos) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'tos_accepted' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'accepts_tos' + ) THEN + ALTER TABLE users RENAME COLUMN tos_accepted TO accepts_tos; + RAISE NOTICE 'Renamed tos_accepted to accepts_tos'; + END IF; +END $$; + +-- Add reminder tracking fields if missing +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'email_verification_reminders_sent' + ) THEN + 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; + 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; + 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; + 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; + RAISE NOTICE 'Added reminder tracking fields'; + END IF; +END $$; + +-- Add WordPress import fields if missing +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'import_source' + ) THEN + ALTER TABLE users ADD COLUMN import_source VARCHAR(50); + ALTER TABLE users ADD COLUMN import_job_id UUID REFERENCES import_jobs(id); + ALTER TABLE users ADD COLUMN wordpress_user_id BIGINT; + ALTER TABLE users ADD COLUMN wordpress_registered_date TIMESTAMP WITH TIME ZONE; + RAISE NOTICE 'Added WordPress import tracking fields'; + END IF; +END $$; + +\echo '=== FIXING IMPORT_JOBS TABLE ===' + +-- Add WordPress import enhancement fields if missing +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'import_jobs' AND column_name = 'field_mapping' + ) THEN + ALTER TABLE import_jobs ADD COLUMN field_mapping JSONB DEFAULT '{}'::jsonb; + ALTER TABLE import_jobs ADD COLUMN wordpress_metadata JSONB DEFAULT '{}'::jsonb; + ALTER TABLE import_jobs ADD COLUMN imported_user_ids JSONB DEFAULT '[]'::jsonb; + ALTER TABLE import_jobs ADD COLUMN rollback_at TIMESTAMP WITH TIME ZONE; + ALTER TABLE import_jobs ADD COLUMN rollback_by UUID REFERENCES users(id); + RAISE NOTICE 'Added WordPress import enhancement fields to import_jobs'; + END IF; +END $$; + +-- Add validating, preview_ready, rolled_back to ImportJobStatus enum if missing +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'importjobstatus' AND e.enumlabel = 'validating' + ) THEN + ALTER TYPE importjobstatus ADD VALUE IF NOT EXISTS 'validating'; + RAISE NOTICE 'Added validating to importjobstatus enum'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'importjobstatus' AND e.enumlabel = 'preview_ready' + ) THEN + ALTER TYPE importjobstatus ADD VALUE IF NOT EXISTS 'preview_ready'; + RAISE NOTICE 'Added preview_ready to importjobstatus enum'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'importjobstatus' AND e.enumlabel = 'rolled_back' + ) THEN + ALTER TYPE importjobstatus ADD VALUE IF NOT EXISTS 'rolled_back'; + RAISE NOTICE 'Added rolled_back to importjobstatus enum'; + END IF; +END $$; + +-- Add pending_validation, pre_validated, canceled, expired, abandoned to UserStatus enum if missing +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'userstatus' AND e.enumlabel = 'pending_validation' + ) THEN + ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'pending_validation'; + RAISE NOTICE 'Added pending_validation to userstatus enum'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'userstatus' AND e.enumlabel = 'pre_validated' + ) THEN + ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'pre_validated'; + RAISE NOTICE 'Added pre_validated to userstatus enum'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'userstatus' AND e.enumlabel = 'canceled' + ) THEN + ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'canceled'; + RAISE NOTICE 'Added canceled to userstatus enum'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'userstatus' AND e.enumlabel = 'expired' + ) THEN + ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'expired'; + RAISE NOTICE 'Added expired to userstatus enum'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'userstatus' AND e.enumlabel = 'abandoned' + ) THEN + ALTER TYPE userstatus ADD VALUE IF NOT EXISTS 'abandoned'; + RAISE NOTICE 'Added abandoned to userstatus enum'; + END IF; +END $$; + +COMMIT; + +\echo '' +\echo '=== VERIFICATION ===' +SELECT + (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'users') as users_columns, + (SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'import_jobs') as import_jobs_columns, + (SELECT COUNT(*) FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'userstatus') as userstatus_values, + (SELECT COUNT(*) FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'importjobstatus') as importjobstatus_values; + +\echo '' +\echo '✅ Missing fields have been added!' +\echo 'You can now run: alembic stamp head' diff --git a/migrations/seed_data.sql b/migrations/seed_data.sql new file mode 100644 index 0000000..af8dc05 --- /dev/null +++ b/migrations/seed_data.sql @@ -0,0 +1,238 @@ +-- ============================================================================ +-- Seed Data for LOAF Membership Platform +-- Run this after creating the database schema +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- STEP 1: Create Default Roles +-- ============================================================================ + +INSERT INTO roles (id, code, name, description, is_system_role, created_at, updated_at) +VALUES + (gen_random_uuid(), 'guest', 'Guest', 'Default role for new registrations', true, NOW(), NOW()), + (gen_random_uuid(), 'member', 'Member', 'Active paying members with full access', true, NOW(), NOW()), + (gen_random_uuid(), 'admin', 'Admin', 'Board members with management access', true, NOW(), NOW()), + (gen_random_uuid(), 'finance', 'Finance', 'Treasurer role with financial access', true, NOW(), NOW()), + (gen_random_uuid(), 'superadmin', 'Super Admin', 'Full system access', true, NOW(), NOW()) +ON CONFLICT (code) DO NOTHING; + +-- ============================================================================ +-- STEP 2: Create Permissions +-- ============================================================================ + +INSERT INTO permissions (id, code, name, description, module, created_at) +VALUES + -- User Management Permissions + (gen_random_uuid(), 'users.view', 'View Users', 'View user list and profiles', 'users', NOW()), + (gen_random_uuid(), 'users.create', 'Create Users', 'Create new users', 'users', NOW()), + (gen_random_uuid(), 'users.edit', 'Edit Users', 'Edit user information', 'users', NOW()), + (gen_random_uuid(), 'users.delete', 'Delete Users', 'Delete users', 'users', NOW()), + (gen_random_uuid(), 'users.approve', 'Approve Users', 'Approve pending memberships', 'users', NOW()), + (gen_random_uuid(), 'users.import', 'Import Users', 'Import users from CSV/external sources', 'users', NOW()), + + -- Event Management Permissions + (gen_random_uuid(), 'events.view', 'View Events', 'View event list and details', 'events', NOW()), + (gen_random_uuid(), 'events.create', 'Create Events', 'Create new events', 'events', NOW()), + (gen_random_uuid(), 'events.edit', 'Edit Events', 'Edit event information', 'events', NOW()), + (gen_random_uuid(), 'events.delete', 'Delete Events', 'Delete events', 'events', NOW()), + (gen_random_uuid(), 'events.publish', 'Publish Events', 'Publish/unpublish events', 'events', NOW()), + (gen_random_uuid(), 'events.manage_attendance', 'Manage Attendance', 'Mark event attendance', 'events', NOW()), + + -- Financial Permissions + (gen_random_uuid(), 'finance.view', 'View Financial Data', 'View subscriptions and payments', 'finance', NOW()), + (gen_random_uuid(), 'finance.manage_plans', 'Manage Subscription Plans', 'Create/edit subscription plans', 'finance', NOW()), + (gen_random_uuid(), 'finance.manage_subscriptions', 'Manage Subscriptions', 'Manage user subscriptions', 'finance', NOW()), + (gen_random_uuid(), 'finance.view_reports', 'View Financial Reports', 'Access financial reports', 'finance', NOW()), + (gen_random_uuid(), 'finance.export', 'Export Financial Data', 'Export financial data', 'finance', NOW()), + + -- Content Management Permissions + (gen_random_uuid(), 'content.newsletters', 'Manage Newsletters', 'Manage newsletter archives', 'content', NOW()), + (gen_random_uuid(), 'content.documents', 'Manage Documents', 'Manage bylaws and documents', 'content', NOW()), + (gen_random_uuid(), 'content.gallery', 'Manage Gallery', 'Manage event galleries', 'content', NOW()), + + -- System Permissions + (gen_random_uuid(), 'system.settings', 'System Settings', 'Manage system settings', 'system', NOW()), + (gen_random_uuid(), 'system.roles', 'Manage Roles', 'Create/edit roles and permissions', 'system', NOW()), + (gen_random_uuid(), 'system.invitations', 'Manage Invitations', 'Send admin invitations', 'system', NOW()), + (gen_random_uuid(), 'system.storage', 'Manage Storage', 'View storage usage', 'system', NOW()), + (gen_random_uuid(), 'system.audit', 'View Audit Logs', 'View system audit logs', 'system', NOW()) +ON CONFLICT (code) DO NOTHING; + +-- ============================================================================ +-- STEP 3: Assign Permissions to Roles +-- ============================================================================ + +-- Guest Role: No permissions (view-only through public pages) +-- No entries needed + +-- Member Role: Limited permissions +INSERT INTO role_permissions (id, role, role_id, permission_id, created_at) +SELECT + gen_random_uuid(), + 'member', + (SELECT id FROM roles WHERE code = 'member'), + p.id, + NOW() +FROM permissions p +WHERE p.code IN ( + 'events.view' +) +ON CONFLICT DO NOTHING; + +-- Admin Role: Most permissions except financial +INSERT INTO role_permissions (id, role, role_id, permission_id, created_at) +SELECT + gen_random_uuid(), + 'admin', + (SELECT id FROM roles WHERE code = 'admin'), + p.id, + NOW() +FROM permissions p +WHERE p.code IN ( + -- User Management + 'users.view', 'users.create', 'users.edit', 'users.approve', 'users.import', + -- Event Management + 'events.view', 'events.create', 'events.edit', 'events.delete', 'events.publish', 'events.manage_attendance', + -- Content Management + 'content.newsletters', 'content.documents', 'content.gallery', + -- System (limited) + 'system.invitations', 'system.storage' +) +ON CONFLICT DO NOTHING; + +-- Finance Role: Financial permissions + basic access +INSERT INTO role_permissions (id, role, role_id, permission_id, created_at) +SELECT + gen_random_uuid(), + 'finance', + (SELECT id FROM roles WHERE code = 'finance'), + p.id, + NOW() +FROM permissions p +WHERE p.code IN ( + -- Financial + 'finance.view', 'finance.manage_plans', 'finance.manage_subscriptions', 'finance.view_reports', 'finance.export', + -- Basic Access + 'users.view', 'events.view' +) +ON CONFLICT DO NOTHING; + +-- Superadmin Role: All permissions +INSERT INTO role_permissions (id, role, role_id, permission_id, created_at) +SELECT + gen_random_uuid(), + 'superadmin', + (SELECT id FROM roles WHERE code = 'superadmin'), + p.id, + NOW() +FROM permissions p +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- STEP 4: Create Subscription Plans +-- ============================================================================ + +INSERT INTO subscription_plans (id, name, description, price_cents, billing_cycle, custom_cycle_enabled, minimum_price_cents, allow_donation, is_active, created_at, updated_at) +VALUES + -- Annual Individual Membership + ( + gen_random_uuid(), + 'Annual Individual Membership', + 'Standard annual membership for one person. Includes access to all LOAF events, member directory, and exclusive content.', + 6000, -- $60.00 + 'annual', + false, + 6000, + false, + true, + NOW(), + NOW() + ), + + -- Annual Group Membership + ( + gen_random_uuid(), + 'Annual Group Membership', + 'Annual membership for two people living at the same address. Both members receive full access to all LOAF benefits.', + 10000, -- $100.00 + 'annual', + false, + 10000, + false, + true, + NOW(), + NOW() + ), + + -- Pay What You Want (with minimum) + ( + gen_random_uuid(), + 'Pay What You Want Membership', + 'Choose your own annual membership amount. Minimum $30. Additional contributions help support our scholarship fund.', + 3000, -- $30.00 minimum + 'annual', + true, -- Allow custom amount + 3000, -- Minimum $30 + true, -- Additional amount is treated as donation + true, + NOW(), + NOW() + ) +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- STEP 5: Initialize Storage Usage (if not already done) +-- ============================================================================ + +INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_calculated_at, created_at, updated_at) +VALUES (gen_random_uuid(), 0, 107374182400, NOW(), NOW(), NOW()) -- 100GB limit +ON CONFLICT DO NOTHING; + +COMMIT; + +-- ============================================================================ +-- Success Message +-- ============================================================================ + +\echo '✅ Seed data created successfully!' +\echo '' +\echo 'Created:' +\echo ' - 5 default roles (guest, member, admin, finance, superadmin)' +\echo ' - 25 permissions across 5 modules' +\echo ' - Role-permission mappings' +\echo ' - 3 subscription plans' +\echo ' - Storage usage initialization' +\echo '' +\echo 'Next steps:' +\echo ' 1. Create superadmin user (see instructions below)' +\echo ' 2. Configure Stripe price IDs in subscription_plans' +\echo ' 3. Start the application' +\echo '' +\echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' +\echo 'CREATE SUPERADMIN USER:' +\echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' +\echo '' +\echo 'Generate password hash in Python:' +\echo ' python3 -c "import bcrypt; print(bcrypt.hashpw(b\"your-password\", bcrypt.gensalt()).decode())"' +\echo '' +\echo 'Then run:' +\echo ' psql -U postgres -d loaf_new' +\echo '' +\echo 'INSERT INTO users (' +\echo ' id, email, password_hash, first_name, last_name,' +\echo ' status, role, email_verified, created_at, updated_at' +\echo ') VALUES (' +\echo ' gen_random_uuid(),' +\echo ' '\''admin@loafmembers.org'\'',' +\echo ' '\''$2b$12$YOUR_BCRYPT_HASH_HERE'\'',' +\echo ' '\''Admin'\'',' +\echo ' '\''User'\'',' +\echo ' '\''active'\'',' +\echo ' '\''superadmin'\'',' +\echo ' true,' +\echo ' NOW(),' +\echo ' NOW()' +\echo ');' +\echo '' diff --git a/requirements.txt b/requirements.txt index 4fa6855..eac9e2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiosmtplib==5.0.0 +alembic==1.14.0 annotated-types==0.7.0 anyio==4.11.0 bcrypt==4.1.3