diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..9a73e96 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,129 @@ +# ============================================ +# Monaco USA Portal - Production Configuration +# ============================================ +# Copy this file to .env and configure the values below. +# Then run ./setup.sh to generate secrets and kong.yml. +# +# Variables marked [AUTO-GENERATED] will be created by setup.sh +# if left empty or containing placeholder text. +# ============================================ + +# ============================================ +# DOMAIN CONFIGURATION (Required) +# ============================================ +# Your domain name (without https://) +DOMAIN=portal.monacousa.org + +# Email for Let's Encrypt SSL certificates +ACME_EMAIL=admin@monacousa.org + +# ============================================ +# DATABASE CONFIGURATION +# ============================================ +# PostgreSQL settings +POSTGRES_USER=postgres +POSTGRES_DB=postgres + +# [AUTO-GENERATED] Database password - leave as placeholder for auto-generation +# To generate manually: openssl rand -base64 32 +POSTGRES_PASSWORD=CHANGE_ME_RUN_SETUP_SH + +# ============================================ +# JWT CONFIGURATION +# ============================================ +# [AUTO-GENERATED] JWT secret for Supabase auth - leave as placeholder +# To generate manually: openssl rand -base64 32 +JWT_SECRET=CHANGE_ME_RUN_SETUP_SH + +# JWT token expiry in seconds (default: 1 hour) +JWT_EXPIRY=3600 + +# [AUTO-GENERATED] Anonymous API key - leave as placeholder +# This is a JWT signed with JWT_SECRET with role=anon +ANON_KEY=your-anon-key-will-be-generated + +# [AUTO-GENERATED] Service role API key - leave as placeholder +# This is a JWT signed with JWT_SECRET with role=service_role +SERVICE_ROLE_KEY=your-service-role-key-will-be-generated + +# These are aliases used by the portal app +PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY} +SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY} + +# ============================================ +# REALTIME CONFIGURATION +# ============================================ +# [AUTO-GENERATED] Secret key for realtime service +# To generate manually: openssl rand -base64 64 +SECRET_KEY_BASE=generate-a-64-char-secret-key-run-setup-sh + +# ============================================ +# EMAIL CONFIGURATION (Optional but Recommended) +# ============================================ +# SMTP settings for sending emails (password resets, invitations, etc.) +# Leave empty to disable email functionality (users won't receive confirmation emails) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_ADMIN_EMAIL=noreply@monacousa.org +SMTP_SENDER_NAME=Monaco USA + +# Set to true to auto-confirm emails (not recommended for production) +ENABLE_EMAIL_AUTOCONFIRM=false + +# Rate limit for emails sent per hour +RATE_LIMIT_EMAIL_SENT=100 + +# ============================================ +# PORTAL APPLICATION CONFIGURATION +# ============================================ +# Docker image for the portal app +# Change this to use a different registry or version +PORTAL_IMAGE=code.letsbe.solutions/letsbe/monacousa-portal:latest + +# Maximum file upload size in bytes (default: 50MB) +BODY_SIZE_LIMIT=52428800 + +# Disable public signup (true = only admin can create accounts) +DISABLE_SIGNUP=false + +# Additional redirect URLs for OAuth (comma-separated) +ADDITIONAL_REDIRECT_URLS= + +# ============================================ +# POSTGREST CONFIGURATION +# ============================================ +# Database schemas exposed via REST API +PGRST_DB_SCHEMAS=public,storage,graphql_public + +# ============================================ +# SECURITY - DASHBOARD ACCESS (Optional) +# ============================================ +# Basic auth for Traefik dashboard (format: user:password-hash) +# Generate with: htpasswd -nB admin +# Example: admin:$apr1$xyz... +TRAEFIK_DASHBOARD_AUTH= + +# Basic auth for Supabase Studio (format: user:password-hash) +# Generate with: htpasswd -nB admin +STUDIO_AUTH= + +# ============================================ +# NOTES +# ============================================ +# 1. After configuring this file, run: ./setup.sh +# This will: +# - Generate any missing secrets +# - Create kong.yml from template +# - Validate your configuration +# +# 2. Start the services: docker compose up -d +# +# 3. Check status: docker compose ps +# +# 4. View logs: docker compose logs -f +# +# 5. First visit to https://DOMAIN will redirect to /setup +# to create the initial admin account. +# ============================================ diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..3d86ac6 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,276 @@ +# Monaco USA Portal - Standalone Production Deployment + +This is a standalone deployment package for the Monaco USA Portal. No source code cloning required. + +## Prerequisites + +- Linux server (Ubuntu 22.04+ recommended) +- Docker Engine 24.0+ +- Docker Compose v2.20+ +- Domain name with DNS pointing to your server +- Ports 80 and 443 open + +## Quick Start + +### 1. Download the deployment files + +Create a directory and download the deployment files: + +```bash +mkdir -p /opt/monacousa +cd /opt/monacousa + +# Download files from your deployment source +# Example: scp, git clone, or direct download +``` + +You need these files: +- `docker-compose.yml` +- `.env.example` +- `init.sql` +- `kong.yml.template` +- `setup.sh` + +### 2. Configure environment + +```bash +# Copy the example environment file +cp .env.example .env + +# Edit with your settings +nano .env +``` + +At minimum, configure: +- `DOMAIN` - Your domain name (e.g., `portal.monacousa.org`) +- `ACME_EMAIL` - Email for SSL certificates +- SMTP settings (optional but recommended for emails) + +### 3. Run setup script + +```bash +# Make setup script executable +chmod +x setup.sh + +# Run setup - this generates secrets and kong.yml +./setup.sh +``` + +The setup script will: +- Generate secure random passwords and JWT tokens +- Create `kong.yml` from the template with your API keys +- Validate your configuration + +### 4. Start the services + +```bash +docker compose up -d +``` + +### 5. Verify deployment + +```bash +# Check all containers are running +docker compose ps + +# Check database initialization +docker compose logs db + +# Check for any errors +docker compose logs -f +``` + +### 6. Access the portal + +Open `https://your-domain.com` in your browser. On first visit, you'll be redirected to `/setup` to create the initial admin account. + +## Architecture + +``` +Internet + │ + ├─► :80/:443 ──► Traefik (SSL/Reverse Proxy) + │ │ + │ ├─► portal.domain.com ──► Portal (SvelteKit) + │ ├─► api.domain.com ──► Kong ──► Auth/REST/Storage + │ └─► studio.domain.com ──► Studio (Dashboard) + │ +Internal Network + │ + ├─► Kong API Gateway + │ ├─► Auth (GoTrue) + │ ├─► REST (PostgREST) + │ ├─► Storage API + │ └─► Realtime + │ + └─► PostgreSQL Database +``` + +## Files Description + +| File | Purpose | +|------|---------| +| `docker-compose.yml` | All service definitions | +| `.env` | Your configuration (from .env.example) | +| `init.sql` | Database schema and migrations | +| `kong.yml.template` | API gateway config template | +| `kong.yml` | Generated API gateway config (created by setup.sh) | +| `setup.sh` | Setup script for secrets and validation | + +## Management Commands + +```bash +# Start all services +docker compose up -d + +# Stop all services +docker compose down + +# View logs +docker compose logs -f + +# View specific service logs +docker compose logs -f portal +docker compose logs -f db + +# Restart a specific service +docker compose restart portal + +# Check resource usage +docker stats + +# Enter database shell +docker compose exec db psql -U postgres +``` + +## Updating + +To update the portal to a new version: + +```bash +# Pull the latest image +docker compose pull portal + +# Restart the portal service +docker compose up -d portal +``` + +## Backup + +### Database backup + +```bash +# Create backup +docker compose exec db pg_dump -U postgres postgres > backup_$(date +%Y%m%d).sql + +# Restore backup +docker compose exec -T db psql -U postgres postgres < backup_YYYYMMDD.sql +``` + +### Full backup (including storage) + +```bash +# Stop services first for consistent backup +docker compose stop + +# Backup volumes +docker run --rm -v monacousa_db-data:/data -v $(pwd):/backup alpine \ + tar czf /backup/db-data-backup.tar.gz -C /data . + +docker run --rm -v monacousa_storage-data:/data -v $(pwd):/backup alpine \ + tar czf /backup/storage-data-backup.tar.gz -C /data . + +# Start services +docker compose up -d +``` + +## Troubleshooting + +### Containers not starting + +```bash +# Check logs for errors +docker compose logs + +# Check if ports are in use +netstat -tlnp | grep -E ':(80|443)' +``` + +### SSL certificate issues + +```bash +# Check Traefik logs +docker compose logs traefik + +# Verify DNS is pointing to server +dig +short your-domain.com +``` + +### Database connection errors + +```bash +# Check database is healthy +docker compose ps db + +# Check database logs +docker compose logs db + +# Verify database is accepting connections +docker compose exec db pg_isready -U postgres +``` + +### API 401 Unauthorized errors + +This usually means the API keys don't match. Run setup again: + +```bash +./setup.sh +docker compose restart kong +``` + +### Portal not loading + +```bash +# Check portal logs +docker compose logs portal + +# Verify kong is routing correctly +docker compose exec portal wget -qO- http://kong:8000/rest/v1/ || echo "Kong not reachable" +``` + +## Security Recommendations + +1. **Secure your .env file** + ```bash + chmod 600 .env + ``` + +2. **Enable dashboard authentication** + ```bash + # Generate password hash + htpasswd -nB admin + # Add to .env as STUDIO_AUTH and TRAEFIK_DASHBOARD_AUTH + ``` + +3. **Set up firewall** + ```bash + ufw allow 80/tcp + ufw allow 443/tcp + ufw allow 22/tcp + ufw enable + ``` + +4. **Regular updates** + - Keep Docker and host OS updated + - Regularly pull latest portal images + +5. **Monitor logs** + - Set up log rotation (configured in docker-compose.yml) + - Consider centralized logging (ELK, Loki, etc.) + +## Support + +For issues and questions: +- Check logs: `docker compose logs -f` +- GitHub issues: [Project Repository] +- Email: support@monacousa.org diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..9fdde11 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,437 @@ +# Monaco USA Portal - Standalone Production Docker Compose +# ======================================================== +# This is a standalone deployment configuration. +# No source code cloning required - just this file and .env +# +# Usage: +# 1. Copy this file and .env.example to your server +# 2. Copy .env.example to .env and configure +# 3. Run ./setup.sh to generate secrets and kong.yml +# 4. Run: docker compose up -d +# +# Prerequisites: +# - Docker and Docker Compose installed +# - Domain DNS pointing to server IP +# - Ports 80 and 443 open + +services: + # ============================================ + # Traefik Reverse Proxy (SSL/HTTPS) + # ============================================ + traefik: + image: traefik:v3.0 + container_name: monacousa-traefik + restart: unless-stopped + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" + - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--log.level=INFO" + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-certs:/letsencrypt + networks: + - monacousa-network + labels: + # Traefik dashboard (optional - remove in production if not needed) + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)" + - "traefik.http.routers.traefik.entrypoints=websecure" + - "traefik.http.routers.traefik.tls.certresolver=letsencrypt" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.middlewares=traefik-auth" + - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH:-}" + + # ============================================ + # PostgreSQL Database + # ============================================ + db: + image: supabase/postgres:15.8.1.060 + container_name: monacousa-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY:-3600} + volumes: + - db-data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - monacousa-network + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Supabase Studio (Dashboard) - Optional + # ============================================ + studio: + image: supabase/studio:20241202-71e5240 + container_name: monacousa-studio + restart: unless-stopped + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DEFAULT_ORGANIZATION_NAME: Monaco USA + DEFAULT_PROJECT_NAME: Monaco USA Portal + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: https://api.${DOMAIN} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + depends_on: + meta: + condition: service_healthy + networks: + - monacousa-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.studio.rule=Host(`studio.${DOMAIN}`)" + - "traefik.http.routers.studio.entrypoints=websecure" + - "traefik.http.routers.studio.tls.certresolver=letsencrypt" + - "traefik.http.services.studio.loadbalancer.server.port=3000" + - "traefik.http.routers.studio.middlewares=studio-auth" + - "traefik.http.middlewares.studio-auth.basicauth.users=${STUDIO_AUTH:-}" + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Kong API Gateway + # ============================================ + kong: + image: kong:2.8.1 + container_name: monacousa-kong + restart: unless-stopped + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + volumes: + - ./kong.yml:/var/lib/kong/kong.yml:ro + depends_on: + auth: + condition: service_healthy + networks: + - monacousa-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.kong.rule=Host(`api.${DOMAIN}`)" + - "traefik.http.routers.kong.entrypoints=websecure" + - "traefik.http.routers.kong.tls.certresolver=letsencrypt" + - "traefik.http.services.kong.loadbalancer.server.port=8000" + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # GoTrue (Auth) + # ============================================ + auth: + image: supabase/gotrue:v2.164.0 + container_name: monacousa-auth + restart: unless-stopped + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: https://api.${DOMAIN} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?search_path=auth + + GOTRUE_SITE_URL: https://${DOMAIN} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: true + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false} + + GOTRUE_SMTP_HOST: ${SMTP_HOST:-} + GOTRUE_SMTP_PORT: ${SMTP_PORT:-587} + GOTRUE_SMTP_USER: ${SMTP_USER:-} + GOTRUE_SMTP_PASS: ${SMTP_PASS:-} + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Monaco USA} + GOTRUE_MAILER_URLPATHS_INVITE: /auth/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/verify + + GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT:-100} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - monacousa-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # PostgREST (REST API) + # ============================================ + rest: + image: postgrest/postgrest:v12.2.0 + container_name: monacousa-rest + restart: unless-stopped + environment: + PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS:-public,storage,graphql_public} + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exit 0"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - monacousa-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Realtime + # ============================================ + realtime: + image: supabase/realtime:v2.33.58 + container_name: monacousa-realtime + restart: unless-stopped + environment: + PORT: 4000 + DB_HOST: db + DB_PORT: 5432 + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: true + depends_on: + db: + condition: service_healthy + networks: + - monacousa-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Storage API + # ============================================ + storage: + image: supabase/storage-api:v1.11.13 + container_name: monacousa-storage + restart: unless-stopped + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:8080 + volumes: + - storage-data:/var/lib/storage + depends_on: + db: + condition: service_healthy + rest: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - monacousa-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Image Proxy (for storage transformations) + # ============================================ + imgproxy: + image: darthsim/imgproxy:v3.8.0 + container_name: monacousa-imgproxy + restart: unless-stopped + environment: + IMGPROXY_BIND: ":8080" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + volumes: + - storage-data:/var/lib/storage + healthcheck: + test: ["CMD", "imgproxy", "health"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - monacousa-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Postgres Meta (for Studio) + # ============================================ + meta: + image: supabase/postgres-meta:v0.84.2 + container_name: monacousa-meta + restart: unless-stopped + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exit 0"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - monacousa-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ============================================ + # Monaco USA Portal (SvelteKit App) + # ============================================ + portal: + image: ${PORTAL_IMAGE:-code.letsbe.solutions/letsbe/monacousa-portal:latest} + container_name: monacousa-portal + restart: unless-stopped + environment: + PUBLIC_SUPABASE_URL: https://api.${DOMAIN} + PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_INTERNAL_URL: http://kong:8000 + NODE_ENV: production + ORIGIN: https://${DOMAIN} + BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-52428800} + depends_on: + kong: + condition: service_started + db: + condition: service_healthy + networks: + - monacousa-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.portal.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.portal.entrypoints=websecure" + - "traefik.http.routers.portal.tls.certresolver=letsencrypt" + - "traefik.http.services.portal.loadbalancer.server.port=3000" + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +# ============================================ +# Networks +# ============================================ +networks: + monacousa-network: + driver: bridge + +# ============================================ +# Volumes +# ============================================ +volumes: + db-data: + driver: local + storage-data: + driver: local + traefik-certs: + driver: local diff --git a/deploy/init.sql b/deploy/init.sql new file mode 100644 index 0000000..2c63fff --- /dev/null +++ b/deploy/init.sql @@ -0,0 +1,1410 @@ +-- Monaco USA Portal - Combined Database Initialization +-- This file combines all schema initialization and migrations for production deployment +-- Generated from: 00-init-schemas.sql + migrations 001-016 +-- ================================================================================ + +-- ============================================ +-- INITIALIZATION: Roles and Schemas +-- ============================================ + +-- Create roles if they don't exist +DO $$ +BEGIN + -- Create anon role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN NOINHERIT; + END IF; + + -- Create authenticated role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN NOINHERIT; + END IF; + + -- Create service_role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS; + END IF; + + -- Create supabase_admin role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'supabase_admin') THEN + CREATE ROLE supabase_admin LOGIN SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS; + END IF; + + -- Create authenticator role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator NOINHERIT LOGIN; + END IF; + + -- Create supabase_auth_admin role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'supabase_auth_admin') THEN + CREATE ROLE supabase_auth_admin NOLOGIN NOINHERIT; + END IF; + + -- Create supabase_storage_admin role + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'supabase_storage_admin') THEN + CREATE ROLE supabase_storage_admin NOLOGIN NOINHERIT; + END IF; +END +$$; + +-- Grant roles +GRANT anon TO authenticator; +GRANT authenticated TO authenticator; +GRANT service_role TO authenticator; +GRANT supabase_admin TO postgres; + +-- Set passwords (use the same as postgres password from env) +ALTER ROLE supabase_admin WITH PASSWORD 'postgres'; +ALTER ROLE authenticator WITH PASSWORD 'postgres'; + +-- Create schemas +CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_auth_admin; +CREATE SCHEMA IF NOT EXISTS storage AUTHORIZATION supabase_storage_admin; +CREATE SCHEMA IF NOT EXISTS extensions; +CREATE SCHEMA IF NOT EXISTS _realtime; +CREATE SCHEMA IF NOT EXISTS graphql; +CREATE SCHEMA IF NOT EXISTS graphql_public; + +-- Grant schema usage +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role, supabase_auth_admin; +GRANT USAGE ON SCHEMA storage TO anon, authenticated, service_role, supabase_storage_admin; +GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA graphql_public TO anon, authenticated, service_role; + +-- Grant auth schema to supabase_auth_admin +GRANT ALL ON SCHEMA auth TO supabase_auth_admin; +GRANT ALL ON ALL TABLES IN SCHEMA auth TO supabase_auth_admin; +GRANT ALL ON ALL SEQUENCES IN SCHEMA auth TO supabase_auth_admin; +GRANT ALL ON ALL ROUTINES IN SCHEMA auth TO supabase_auth_admin; + +-- Grant storage schema to supabase_storage_admin +GRANT ALL ON SCHEMA storage TO supabase_storage_admin; +GRANT ALL ON ALL TABLES IN SCHEMA storage TO supabase_storage_admin; +GRANT ALL ON ALL SEQUENCES IN SCHEMA storage TO supabase_storage_admin; +GRANT ALL ON ALL ROUTINES IN SCHEMA storage TO supabase_storage_admin; + +-- Set default privileges +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO supabase_auth_admin; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO supabase_auth_admin; +ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT ALL ON TABLES TO supabase_storage_admin; +ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT ALL ON SEQUENCES TO supabase_storage_admin; + +-- Set search path +ALTER DATABASE postgres SET search_path TO public, extensions; + +-- Create extensions in extensions schema +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; +CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA extensions; + +-- ============================================ +-- MIGRATION 001: Initial Schema +-- ============================================ + +-- MEMBERSHIP STATUSES (Admin-configurable) +CREATE TABLE public.membership_statuses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6b7280', + description TEXT, + is_default BOOLEAN DEFAULT FALSE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default statuses +INSERT INTO public.membership_statuses (name, display_name, color, description, is_default, sort_order) VALUES + ('pending', 'Pending', '#eab308', 'New member, awaiting dues payment', true, 1), + ('active', 'Active', '#22c55e', 'Dues paid, full access', false, 2), + ('inactive', 'Inactive', '#6b7280', 'Lapsed membership or suspended', false, 3), + ('expired', 'Expired', '#ef4444', 'Membership terminated', false, 4); + +-- MEMBERSHIP TYPES (Admin-configurable pricing) +CREATE TABLE public.membership_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + annual_dues DECIMAL(10,2) NOT NULL, + description TEXT, + is_default BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default membership types +INSERT INTO public.membership_types (name, display_name, annual_dues, description, is_default, sort_order) VALUES + ('regular', 'Regular Member', 50.00, 'Standard individual membership', true, 1), + ('student', 'Student', 25.00, 'For students with valid ID', false, 2), + ('senior', 'Senior (65+)', 35.00, 'For members 65 years and older', false, 3), + ('family', 'Family', 75.00, 'Household membership', false, 4), + ('honorary', 'Honorary Member', 0.00, 'Granted by the board', false, 5); + +-- MEMBERS TABLE +CREATE TABLE public.members ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + member_id TEXT UNIQUE NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + phone TEXT NOT NULL, + date_of_birth DATE NOT NULL, + address TEXT NOT NULL, + nationality TEXT[] NOT NULL DEFAULT '{}', + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'board', 'admin')), + membership_status_id UUID REFERENCES public.membership_statuses(id), + membership_type_id UUID REFERENCES public.membership_types(id), + member_since DATE DEFAULT CURRENT_DATE, + avatar_url TEXT, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Auto-generate member_id trigger +CREATE OR REPLACE FUNCTION generate_member_id() +RETURNS TRIGGER AS $$ +DECLARE + next_num INTEGER; +BEGIN + SELECT COALESCE(MAX(CAST(SUBSTRING(member_id FROM 6) AS INTEGER)), 0) + 1 + INTO next_num + FROM public.members; + + NEW.member_id := 'MUSA-' || LPAD(next_num::TEXT, 4, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_member_id + BEFORE INSERT ON public.members + FOR EACH ROW + WHEN (NEW.member_id IS NULL) + EXECUTE FUNCTION generate_member_id(); + +-- Update timestamp trigger +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER members_updated_at + BEFORE UPDATE ON public.members + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- DUES PAYMENTS +CREATE TABLE public.dues_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + amount DECIMAL(10,2) NOT NULL, + currency TEXT DEFAULT 'EUR', + payment_date DATE NOT NULL, + due_date DATE NOT NULL, + payment_method TEXT DEFAULT 'bank_transfer', + reference TEXT, + notes TEXT, + recorded_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Auto-calculate due_date (1 year from payment) +CREATE OR REPLACE FUNCTION calculate_due_date() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.due_date IS NULL THEN + NEW.due_date := NEW.payment_date + INTERVAL '1 year'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_due_date + BEFORE INSERT ON public.dues_payments + FOR EACH ROW + EXECUTE FUNCTION calculate_due_date(); + +-- Auto-update member status to active after payment +CREATE OR REPLACE FUNCTION update_member_status_on_payment() +RETURNS TRIGGER AS $$ +DECLARE + active_status_id UUID; +BEGIN + SELECT id INTO active_status_id + FROM public.membership_statuses + WHERE name = 'active'; + + UPDATE public.members + SET membership_status_id = active_status_id, + updated_at = NOW() + WHERE id = NEW.member_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER activate_member_on_payment + AFTER INSERT ON public.dues_payments + FOR EACH ROW + EXECUTE FUNCTION update_member_status_on_payment(); + +-- EVENT TYPES (Admin-configurable) +CREATE TABLE public.event_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#3b82f6', + icon TEXT, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default event types +INSERT INTO public.event_types (name, display_name, color, icon, sort_order) VALUES + ('social', 'Social Event', '#10b981', 'party-popper', 1), + ('meeting', 'Meeting', '#6366f1', 'users', 2), + ('fundraiser', 'Fundraiser', '#f59e0b', 'heart-handshake', 3), + ('workshop', 'Workshop', '#8b5cf6', 'graduation-cap', 4), + ('gala', 'Gala/Formal', '#ec4899', 'sparkles', 5), + ('other', 'Other', '#6b7280', 'calendar', 6); + +-- EVENTS +CREATE TABLE public.events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + event_type_id UUID REFERENCES public.event_types(id), + start_datetime TIMESTAMPTZ NOT NULL, + end_datetime TIMESTAMPTZ NOT NULL, + all_day BOOLEAN DEFAULT FALSE, + timezone TEXT DEFAULT 'Europe/Monaco', + location TEXT, + location_url TEXT, + max_attendees INTEGER, + max_guests_per_member INTEGER DEFAULT 1, + is_paid BOOLEAN DEFAULT FALSE, + member_price DECIMAL(10,2) DEFAULT 0, + non_member_price DECIMAL(10,2) DEFAULT 0, + pricing_notes TEXT, + visibility TEXT NOT NULL DEFAULT 'members' + CHECK (visibility IN ('public', 'members', 'board', 'admin')), + status TEXT NOT NULL DEFAULT 'published' + CHECK (status IN ('draft', 'published', 'cancelled', 'completed')), + cover_image_url TEXT, + created_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TRIGGER events_updated_at + BEFORE UPDATE ON public.events + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- EVENT RSVPs (Members) +CREATE TABLE public.event_rsvps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')), + guest_count INTEGER DEFAULT 0, + guest_names TEXT[], + notes TEXT, + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + payment_reference TEXT, + payment_amount DECIMAL(10,2), + attended BOOLEAN DEFAULT FALSE, + checked_in_at TIMESTAMPTZ, + checked_in_by UUID REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(event_id, member_id) +); + +CREATE TRIGGER event_rsvps_updated_at + BEFORE UPDATE ON public.event_rsvps + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- EVENT RSVPs (Public/Non-members) +CREATE TABLE public.event_rsvps_public ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + full_name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + status TEXT NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')), + guest_count INTEGER DEFAULT 0, + guest_names TEXT[], + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + payment_reference TEXT, + payment_amount DECIMAL(10,2), + attended BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(event_id, email) +); + +CREATE TRIGGER event_rsvps_public_updated_at + BEFORE UPDATE ON public.event_rsvps_public + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- DOCUMENT CATEGORIES +CREATE TABLE public.document_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + icon TEXT, + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default categories +INSERT INTO public.document_categories (name, display_name, icon, sort_order) VALUES + ('meeting_minutes', 'Meeting Minutes', 'file-text', 1), + ('governance', 'Governance & Bylaws', 'scale', 2), + ('legal', 'Legal Documents', 'briefcase', 3), + ('financial', 'Financial Reports', 'dollar-sign', 4), + ('member_resources', 'Member Resources', 'book-open', 5), + ('forms', 'Forms & Templates', 'clipboard', 6), + ('other', 'Other Documents', 'file', 7); + +-- DOCUMENTS +CREATE TABLE public.documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + category_id UUID REFERENCES public.document_categories(id), + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + mime_type TEXT NOT NULL, + visibility TEXT NOT NULL DEFAULT 'members' + CHECK (visibility IN ('public', 'members', 'board', 'admin')), + allowed_member_ids UUID[], + version INTEGER DEFAULT 1, + replaces_document_id UUID REFERENCES public.documents(id), + meeting_date DATE, + meeting_attendees UUID[], + uploaded_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TRIGGER documents_updated_at + BEFORE UPDATE ON public.documents + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- APP SETTINGS (Unified key-value store) +CREATE TABLE public.app_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category TEXT NOT NULL, + setting_key TEXT NOT NULL, + setting_value JSONB NOT NULL, + setting_type TEXT NOT NULL DEFAULT 'text' + CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'array')), + display_name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN DEFAULT FALSE, + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES public.members(id), + UNIQUE(category, setting_key) +); + +-- Default settings +INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES + ('organization', 'association_name', '"Monaco USA"', 'text', 'Association Name', 'Official name of the association', true), + ('organization', 'tagline', '"Americans in Monaco"', 'text', 'Tagline', 'Association tagline shown on login', true), + ('organization', 'contact_email', '"contact@monacousa.org"', 'text', 'Contact Email', 'Public contact email', true), + ('organization', 'primary_color', '"#dc2626"', 'text', 'Primary Color', 'Brand primary color (hex)', true), + ('dues', 'payment_iban', '"MC58 1756 9000 0104 0050 1001 860"', 'text', 'Payment IBAN', 'Bank IBAN for dues', false), + ('dues', 'payment_account_holder', '"ASSOCIATION MONACO USA"', 'text', 'Account Holder', 'Bank account holder name', false), + ('dues', 'payment_bank_name', '"Credit Foncier de Monaco"', 'text', 'Bank Name', 'Name of the bank', false), + ('dues', 'reminder_days_before', '[30, 7, 1]', 'array', 'Reminder Days', 'Days before due to send reminders', false), + ('dues', 'grace_period_days', '30', 'number', 'Grace Period', 'Days after due before auto-inactive', false), + ('dues', 'auto_inactive_enabled', 'true', 'boolean', 'Auto Inactive', 'Auto set inactive after grace period', false), + ('system', 'maintenance_mode', 'false', 'boolean', 'Maintenance Mode', 'Put portal in maintenance mode', false), + ('system', 'max_upload_size_mb', '50', 'number', 'Max Upload Size', 'Maximum file upload size in MB', false); + +-- EMAIL TEMPLATES +CREATE TABLE public.email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_key TEXT UNIQUE NOT NULL, + template_name TEXT NOT NULL, + category TEXT NOT NULL, + subject TEXT NOT NULL, + body_html TEXT NOT NULL, + body_text TEXT, + is_active BOOLEAN DEFAULT TRUE, + is_system BOOLEAN DEFAULT FALSE, + variables_schema JSONB, + preview_data JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES public.members(id) +); + +CREATE TRIGGER email_templates_updated_at + BEFORE UPDATE ON public.email_templates + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- EMAIL LOGS +CREATE TABLE public.email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_id UUID REFERENCES public.members(id), + recipient_email TEXT NOT NULL, + recipient_name TEXT, + template_key TEXT, + subject TEXT NOT NULL, + email_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed')), + provider TEXT, + provider_message_id TEXT, + opened_at TIMESTAMPTZ, + clicked_at TIMESTAMPTZ, + error_message TEXT, + retry_count INTEGER DEFAULT 0, + template_variables JSONB, + sent_by UUID REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ +); + +-- VIEWS + +-- Members with dues status +CREATE VIEW public.members_with_dues AS +SELECT + m.*, + ms.name as status_name, + ms.display_name as status_display_name, + ms.color as status_color, + mt.display_name as membership_type_name, + mt.annual_dues, + dp.last_payment_date, + dp.current_due_date, + CASE + WHEN dp.current_due_date IS NULL THEN 'never_paid' + WHEN dp.current_due_date < CURRENT_DATE THEN 'overdue' + WHEN dp.current_due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon' + ELSE 'current' + END as dues_status, + CASE + WHEN dp.current_due_date < CURRENT_DATE + THEN (CURRENT_DATE - dp.current_due_date)::INTEGER + ELSE NULL + END as days_overdue, + CASE + WHEN dp.current_due_date >= CURRENT_DATE + THEN (dp.current_due_date - CURRENT_DATE)::INTEGER + ELSE NULL + END as days_until_due +FROM public.members m +LEFT JOIN public.membership_statuses ms ON m.membership_status_id = ms.id +LEFT JOIN public.membership_types mt ON m.membership_type_id = mt.id +LEFT JOIN LATERAL ( + SELECT + payment_date as last_payment_date, + due_date as current_due_date + FROM public.dues_payments + WHERE member_id = m.id + ORDER BY due_date DESC + LIMIT 1 +) dp ON true; + +-- Events with attendee counts +CREATE VIEW public.events_with_counts AS +SELECT + e.*, + et.display_name as event_type_name, + et.color as event_type_color, + et.icon as event_type_icon, + COALESCE(member_rsvps.confirmed_count, 0) + + COALESCE(member_rsvps.guest_count, 0) + + COALESCE(public_rsvps.confirmed_count, 0) + + COALESCE(public_rsvps.guest_count, 0) as total_attendees, + COALESCE(member_rsvps.confirmed_count, 0) as member_count, + COALESCE(public_rsvps.confirmed_count, 0) as non_member_count, + COALESCE(member_rsvps.waitlist_count, 0) + + COALESCE(public_rsvps.waitlist_count, 0) as waitlist_count, + CASE + WHEN e.max_attendees IS NULL THEN FALSE + WHEN (COALESCE(member_rsvps.confirmed_count, 0) + + COALESCE(member_rsvps.guest_count, 0) + + COALESCE(public_rsvps.confirmed_count, 0) + + COALESCE(public_rsvps.guest_count, 0)) >= e.max_attendees THEN TRUE + ELSE FALSE + END as is_full +FROM public.events e +LEFT JOIN public.event_types et ON e.event_type_id = et.id +LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count, + COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count, + COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count + FROM public.event_rsvps + WHERE event_id = e.id +) member_rsvps ON true +LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count, + COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count, + COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count + FROM public.event_rsvps_public + WHERE event_id = e.id +) public_rsvps ON true; + +-- ROW LEVEL SECURITY + +-- Enable RLS on all tables +ALTER TABLE public.members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.dues_payments ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.events ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.event_rsvps ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.event_rsvps_public ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.email_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.email_logs ENABLE ROW LEVEL SECURITY; + +-- MEMBERS POLICIES +CREATE POLICY "Members viewable by authenticated users" + ON public.members FOR SELECT + TO authenticated + USING (true); + +CREATE POLICY "Users can update own profile" + ON public.members FOR UPDATE + TO authenticated + USING (auth.uid() = id); + +CREATE POLICY "Admins can insert members" + ON public.members FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + OR auth.uid() = id + ); + +CREATE POLICY "Admins can delete members" + ON public.members FOR DELETE + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +-- DUES PAYMENTS POLICIES +CREATE POLICY "Own payments viewable" + ON public.dues_payments FOR SELECT + TO authenticated + USING ( + member_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Board can record payments" + ON public.dues_payments FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +-- EVENTS POLICIES +CREATE POLICY "Events viewable based on visibility" + ON public.events FOR SELECT + TO authenticated + USING ( + visibility = 'members' + OR visibility = 'public' + OR (visibility = 'board' AND EXISTS ( + SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin') + )) + OR (visibility = 'admin' AND EXISTS ( + SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin' + )) + ); + +CREATE POLICY "Public events viewable by anyone" + ON public.events FOR SELECT + TO anon + USING (visibility = 'public' AND status = 'published'); + +CREATE POLICY "Board can manage events" + ON public.events FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +-- EVENT RSVPs POLICIES +CREATE POLICY "RSVPs viewable by member and board" + ON public.event_rsvps FOR SELECT + TO authenticated + USING ( + member_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Members can manage own RSVPs" + ON public.event_rsvps FOR ALL + TO authenticated + USING (member_id = auth.uid()) + WITH CHECK (member_id = auth.uid()); + +CREATE POLICY "Board can manage all RSVPs" + ON public.event_rsvps FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +-- PUBLIC RSVPs POLICIES +CREATE POLICY "Public RSVPs viewable by board" + ON public.event_rsvps_public FOR SELECT + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Anyone can create public RSVP" + ON public.event_rsvps_public FOR INSERT + TO anon, authenticated + WITH CHECK (true); + +CREATE POLICY "Board can manage public RSVPs" + ON public.event_rsvps_public FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +-- DOCUMENTS POLICIES +CREATE POLICY "Documents viewable based on visibility" + ON public.documents FOR SELECT + TO authenticated + USING ( + visibility = 'members' + OR visibility = 'public' + OR (visibility = 'board' AND EXISTS ( + SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin') + )) + OR (visibility = 'admin' AND EXISTS ( + SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin' + )) + OR (allowed_member_ids IS NOT NULL AND auth.uid() = ANY(allowed_member_ids)) + ); + +CREATE POLICY "Board can upload documents" + ON public.documents FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Admin can manage all documents" + ON public.documents FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +-- APP SETTINGS POLICIES +CREATE POLICY "Public settings viewable by anyone" + ON public.app_settings FOR SELECT + USING (is_public = true); + +CREATE POLICY "All settings viewable by admin" + ON public.app_settings FOR SELECT + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +CREATE POLICY "Admin can manage settings" + ON public.app_settings FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +-- EMAIL TEMPLATES POLICIES +CREATE POLICY "Admin can manage email templates" + ON public.email_templates FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +-- EMAIL LOGS POLICIES +CREATE POLICY "Own email logs viewable" + ON public.email_logs FOR SELECT + TO authenticated + USING ( + recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +CREATE POLICY "Admin can manage email logs" + ON public.email_logs FOR ALL + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +-- INDEXES +CREATE INDEX idx_members_email ON public.members(email); +CREATE INDEX idx_members_member_id ON public.members(member_id); +CREATE INDEX idx_members_role ON public.members(role); +CREATE INDEX idx_members_status ON public.members(membership_status_id); + +CREATE INDEX idx_dues_payments_member ON public.dues_payments(member_id); +CREATE INDEX idx_dues_payments_date ON public.dues_payments(payment_date DESC); + +CREATE INDEX idx_events_start ON public.events(start_datetime); +CREATE INDEX idx_events_visibility ON public.events(visibility); +CREATE INDEX idx_events_status ON public.events(status); + +CREATE INDEX idx_event_rsvps_event ON public.event_rsvps(event_id); +CREATE INDEX idx_event_rsvps_member ON public.event_rsvps(member_id); + +CREATE INDEX idx_documents_category ON public.documents(category_id); +CREATE INDEX idx_documents_visibility ON public.documents(visibility); + +CREATE INDEX idx_app_settings_category ON public.app_settings(category, setting_key); + +CREATE INDEX idx_email_logs_recipient ON public.email_logs(recipient_id); +CREATE INDEX idx_email_logs_status ON public.email_logs(status); +CREATE INDEX idx_email_logs_created ON public.email_logs(created_at DESC); + +-- ============================================ +-- MIGRATION 002: Admin Integration Settings +-- ============================================ + +INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES + ('email', 'smtp_host', '""', 'text', 'SMTP Host', 'SMTP server hostname (e.g., smtp.gmail.com)', false), + ('email', 'smtp_port', '587', 'number', 'SMTP Port', 'SMTP server port (25, 465, 587)', false), + ('email', 'smtp_secure', 'true', 'boolean', 'Use TLS/SSL', 'Enable secure connection (recommended)', false), + ('email', 'smtp_username', '""', 'text', 'SMTP Username', 'SMTP authentication username', false), + ('email', 'smtp_password', '""', 'text', 'SMTP Password', 'SMTP authentication password', false), + ('email', 'smtp_from_address', '"noreply@monacousa.org"', 'text', 'From Address', 'Default sender email address', false), + ('email', 'smtp_from_name', '"Monaco USA"', 'text', 'From Name', 'Default sender display name', false), + ('email', 'smtp_reply_to', '"contact@monacousa.org"', 'text', 'Reply-To Address', 'Reply-to email address', false), + ('email', 'smtp_enabled', 'false', 'boolean', 'Enable Email', 'Enable sending emails via SMTP', false), + ('storage', 's3_endpoint', '""', 'text', 'S3 Endpoint', 'S3-compatible endpoint URL (e.g., http://minio:9000)', false), + ('storage', 's3_bucket', '"monacousa-documents"', 'text', 'Bucket Name', 'S3 bucket name for file storage', false), + ('storage', 's3_access_key', '""', 'text', 'Access Key', 'S3 access key ID', false), + ('storage', 's3_secret_key', '""', 'text', 'Secret Key', 'S3 secret access key', false), + ('storage', 's3_region', '"us-east-1"', 'text', 'Region', 'S3 region (use us-east-1 for MinIO)', false), + ('storage', 's3_use_ssl', 'false', 'boolean', 'Use SSL', 'Enable SSL for S3 connections', false), + ('storage', 's3_force_path_style', 'true', 'boolean', 'Force Path Style', 'Use path-style URLs (required for MinIO)', false), + ('storage', 's3_enabled', 'false', 'boolean', 'Enable S3 Storage', 'Use external S3 instead of Supabase Storage', false), + ('system', 'session_timeout_hours', '168', 'number', 'Session Timeout', 'Hours until session expires (default: 7 days)', false), + ('system', 'allowed_file_types', '["pdf","doc","docx","xls","xlsx","ppt","pptx","txt","jpg","jpeg","png","webp"]', 'array', 'Allowed File Types', 'Allowed file extensions for uploads', false), + ('system', 'maintenance_message', '"The portal is currently undergoing maintenance. Please check back soon."', 'text', 'Maintenance Message', 'Message shown during maintenance', false), + ('system', 'enable_public_events', 'true', 'boolean', 'Enable Public Events', 'Allow non-members to view public events', false), + ('system', 'enable_public_rsvp', 'true', 'boolean', 'Enable Public RSVP', 'Allow non-members to RSVP to public events', false) +ON CONFLICT (category, setting_key) DO NOTHING; + +-- ============================================ +-- MIGRATION 003: Storage Buckets and Audit +-- ============================================ + +-- Documents bucket +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'documents', + 'documents', + true, + 52428800, + ARRAY['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/json', 'image/jpeg', 'image/png', 'image/webp', 'image/gif'] +) +ON CONFLICT (id) DO UPDATE SET + public = true, + file_size_limit = EXCLUDED.file_size_limit, + allowed_mime_types = EXCLUDED.allowed_mime_types; + +-- Avatars bucket +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'avatars', + 'avatars', + true, + 5242880, + ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif'] +) +ON CONFLICT (id) DO UPDATE SET + public = true, + file_size_limit = EXCLUDED.file_size_limit, + allowed_mime_types = EXCLUDED.allowed_mime_types; + +-- Event images bucket +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'event-images', + 'event-images', + true, + 10485760, + ARRAY['image/jpeg', 'image/png', 'image/webp'] +) +ON CONFLICT (id) DO UPDATE SET + public = true, + file_size_limit = EXCLUDED.file_size_limit, + allowed_mime_types = EXCLUDED.allowed_mime_types; + +-- Storage policies +DROP POLICY IF EXISTS "documents_read_policy" ON storage.objects; +CREATE POLICY "documents_read_policy" ON storage.objects FOR SELECT +USING (bucket_id = 'documents' AND auth.role() = 'authenticated'); + +DROP POLICY IF EXISTS "documents_insert_policy" ON storage.objects; +CREATE POLICY "documents_insert_policy" ON storage.objects FOR INSERT +WITH CHECK ( + bucket_id = 'documents' + AND auth.role() = 'authenticated' + AND EXISTS ( + SELECT 1 FROM public.members + WHERE id = auth.uid() + AND role IN ('board', 'admin') + ) +); + +DROP POLICY IF EXISTS "documents_delete_policy" ON storage.objects; +CREATE POLICY "documents_delete_policy" ON storage.objects FOR DELETE +USING ( + bucket_id = 'documents' + AND EXISTS ( + SELECT 1 FROM public.members + WHERE id = auth.uid() + AND role = 'admin' + ) +); + +DROP POLICY IF EXISTS "avatars_read_policy" ON storage.objects; +CREATE POLICY "avatars_read_policy" ON storage.objects FOR SELECT +USING (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "avatars_insert_policy" ON storage.objects; +CREATE POLICY "avatars_insert_policy" ON storage.objects FOR INSERT +TO authenticated +WITH CHECK (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "avatars_update_policy" ON storage.objects; +CREATE POLICY "avatars_update_policy" ON storage.objects FOR UPDATE +TO authenticated +USING (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "avatars_delete_policy" ON storage.objects; +CREATE POLICY "avatars_delete_policy" ON storage.objects FOR DELETE +TO authenticated +USING (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "event_images_read_policy" ON storage.objects; +CREATE POLICY "event_images_read_policy" ON storage.objects FOR SELECT +USING (bucket_id = 'event-images'); + +DROP POLICY IF EXISTS "event_images_insert_policy" ON storage.objects; +CREATE POLICY "event_images_insert_policy" ON storage.objects FOR INSERT +WITH CHECK ( + bucket_id = 'event-images' + AND auth.role() = 'authenticated' + AND EXISTS ( + SELECT 1 FROM public.members + WHERE id = auth.uid() + AND role IN ('board', 'admin') + ) +); + +DROP POLICY IF EXISTS "event_images_delete_policy" ON storage.objects; +CREATE POLICY "event_images_delete_policy" ON storage.objects FOR DELETE +USING ( + bucket_id = 'event-images' + AND EXISTS ( + SELECT 1 FROM public.members + WHERE id = auth.uid() + AND role IN ('board', 'admin') + ) +); + +-- AUDIT LOGS TABLE +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + user_email TEXT, + action TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + details JSONB DEFAULT '{}', + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_type ON audit_logs(resource_type); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC); + +ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "audit_logs_read_admin" ON audit_logs; +CREATE POLICY "audit_logs_read_admin" ON audit_logs FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM public.members + WHERE id = auth.uid() + AND role = 'admin' + ) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON audit_logs TO authenticated; +GRANT ALL ON audit_logs TO service_role; + +-- ============================================ +-- MIGRATION 004: User Notification Preferences +-- ============================================ + +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE UNIQUE, + email_event_rsvp_confirmation BOOLEAN DEFAULT true, + email_event_reminder BOOLEAN DEFAULT true, + email_event_updates BOOLEAN DEFAULT true, + email_waitlist_promotion BOOLEAN DEFAULT true, + email_dues_reminder BOOLEAN DEFAULT true, + email_payment_confirmation BOOLEAN DEFAULT true, + email_membership_updates BOOLEAN DEFAULT true, + email_announcements BOOLEAN DEFAULT true, + email_newsletter BOOLEAN DEFAULT true, + newsletter_frequency TEXT DEFAULT 'monthly' CHECK (newsletter_frequency IN ('weekly', 'monthly', 'quarterly', 'never')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notification_prefs_member ON user_notification_preferences(member_id); + +ALTER TABLE user_notification_preferences ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Members can view own notification preferences" +ON user_notification_preferences FOR SELECT +USING (member_id = auth.uid()); + +CREATE POLICY "Members can insert own notification preferences" +ON user_notification_preferences FOR INSERT +WITH CHECK (member_id = auth.uid()); + +CREATE POLICY "Members can update own notification preferences" +ON user_notification_preferences FOR UPDATE +USING (member_id = auth.uid()) +WITH CHECK (member_id = auth.uid()); + +CREATE POLICY "Admins can view all notification preferences" +ON user_notification_preferences FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM members + WHERE members.id = auth.uid() + AND members.role = 'admin' + ) +); + +CREATE OR REPLACE FUNCTION create_default_notification_preferences() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO user_notification_preferences (member_id) + VALUES (NEW.id) + ON CONFLICT (member_id) DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS on_member_created_create_notification_prefs ON members; +CREATE TRIGGER on_member_created_create_notification_prefs + AFTER INSERT ON members + FOR EACH ROW + EXECUTE FUNCTION create_default_notification_preferences(); + +CREATE OR REPLACE FUNCTION update_notification_prefs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS set_notification_prefs_updated_at ON user_notification_preferences; +CREATE TRIGGER set_notification_prefs_updated_at + BEFORE UPDATE ON user_notification_preferences + FOR EACH ROW + EXECUTE FUNCTION update_notification_prefs_updated_at(); + +-- ============================================ +-- MIGRATION 006: Document Folders +-- ============================================ + +CREATE TABLE public.document_folders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + parent_id UUID REFERENCES public.document_folders(id) ON DELETE CASCADE, + path TEXT, + visibility TEXT NOT NULL DEFAULT 'members' + CHECK (visibility IN ('public', 'members', 'board', 'admin')), + created_by UUID REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(name, parent_id) +); + +CREATE TRIGGER document_folders_updated_at + BEFORE UPDATE ON public.document_folders + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +ALTER TABLE public.documents +ADD COLUMN folder_id UUID REFERENCES public.document_folders(id) ON DELETE SET NULL; + +CREATE OR REPLACE FUNCTION update_folder_path() +RETURNS TRIGGER AS $$ +DECLARE + parent_path TEXT; +BEGIN + IF NEW.parent_id IS NULL THEN + NEW.path = NEW.name; + ELSE + SELECT path INTO parent_path + FROM public.document_folders + WHERE id = NEW.parent_id; + + NEW.path = parent_path || '/' || NEW.name; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER folder_path_trigger + BEFORE INSERT OR UPDATE ON public.document_folders + FOR EACH ROW + EXECUTE FUNCTION update_folder_path(); + +ALTER TABLE public.document_folders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Folders visible based on visibility" ON public.document_folders + FOR SELECT USING ( + visibility = 'public' OR + (visibility = 'members' AND auth.uid() IS NOT NULL) OR + (visibility = 'board' AND EXISTS ( + SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin') + )) OR + (visibility = 'admin' AND EXISTS ( + SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin' + )) + ); + +CREATE POLICY "Board/admin can create folders" ON public.document_folders + FOR INSERT WITH CHECK ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Board/admin can update folders" ON public.document_folders + FOR UPDATE USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Admin can delete folders" ON public.document_folders + FOR DELETE USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +CREATE INDEX idx_document_folders_parent ON public.document_folders(parent_id); +CREATE INDEX idx_documents_folder ON public.documents(folder_id); + +-- ============================================ +-- MIGRATION 007: Dues Reminders +-- ============================================ + +CREATE TABLE public.dues_reminder_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + reminder_type TEXT NOT NULL CHECK (reminder_type IN ('due_soon_30', 'due_soon_7', 'due_soon_1', 'overdue', 'grace_period', 'inactive_notice', 'onboarding_welcome', 'onboarding_reminder_7', 'onboarding_reminder_1', 'onboarding_expired')), + due_date DATE NOT NULL, + sent_at TIMESTAMPTZ DEFAULT NOW(), + email_log_id UUID REFERENCES public.email_logs(id), + UNIQUE(member_id, reminder_type, due_date) +); + +ALTER TABLE public.dues_reminder_logs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Board/admin can view reminder logs" ON public.dues_reminder_logs + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Service role can manage reminder logs" ON public.dues_reminder_logs + FOR ALL USING (true) + WITH CHECK (true); + +CREATE INDEX idx_reminder_logs_member_date ON public.dues_reminder_logs(member_id, due_date); +CREATE INDEX idx_reminder_logs_type_sent ON public.dues_reminder_logs(reminder_type, sent_at); + +CREATE OR REPLACE FUNCTION get_dues_settings() +RETURNS TABLE ( + reminder_days_before INTEGER[], + grace_period_days INTEGER, + auto_inactive_enabled BOOLEAN, + payment_iban TEXT, + payment_account_holder TEXT, + payment_bank_name TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + COALESCE((SELECT (setting_value)::INTEGER[] FROM app_settings WHERE category = 'dues' AND setting_key = 'reminder_days_before'), ARRAY[30, 7, 1])::INTEGER[], + COALESCE((SELECT (setting_value)::INTEGER FROM app_settings WHERE category = 'dues' AND setting_key = 'grace_period_days'), 30)::INTEGER, + COALESCE((SELECT (setting_value)::BOOLEAN FROM app_settings WHERE category = 'dues' AND setting_key = 'auto_inactive_enabled'), true)::BOOLEAN, + COALESCE((SELECT setting_value::TEXT FROM app_settings WHERE category = 'dues' AND setting_key = 'payment_iban'), '')::TEXT, + COALESCE((SELECT setting_value::TEXT FROM app_settings WHERE category = 'dues' AND setting_key = 'payment_account_holder'), '')::TEXT, + COALESCE((SELECT setting_value::TEXT FROM app_settings WHERE category = 'dues' AND setting_key = 'payment_bank_name'), '')::TEXT; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ============================================ +-- MIGRATION 008: S3 Public Endpoint +-- ============================================ + +INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES + ('storage', 's3_public_endpoint', '""', 'text', 'Public Endpoint URL', 'Browser-accessible S3 URL (e.g., http://localhost:9000). Leave empty to use the same as S3 Endpoint.', false) +ON CONFLICT (category, setting_key) DO NOTHING; + +-- ============================================ +-- MIGRATION 009: Dual Avatar URLs +-- ============================================ + +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_url_local TEXT; +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_url_s3 TEXT; +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_path TEXT; + +COMMENT ON COLUMN public.members.avatar_url IS 'Current active avatar URL (computed based on storage setting)'; +COMMENT ON COLUMN public.members.avatar_url_local IS 'Avatar URL when stored in Supabase Storage'; +COMMENT ON COLUMN public.members.avatar_url_s3 IS 'Avatar URL when stored in S3/MinIO'; +COMMENT ON COLUMN public.members.avatar_path IS 'Storage path for avatar file (e.g., member_id/avatar.jpg)'; + +-- ============================================ +-- MIGRATION 010: Storage Service Role Policies +-- ============================================ + +DROP POLICY IF EXISTS "service_role_insert_avatars" ON storage.objects; +DROP POLICY IF EXISTS "service_role_update_avatars" ON storage.objects; +DROP POLICY IF EXISTS "service_role_delete_avatars" ON storage.objects; +DROP POLICY IF EXISTS "service_role_select_avatars" ON storage.objects; + +CREATE POLICY "service_role_insert_avatars" ON storage.objects +FOR INSERT TO service_role +WITH CHECK (bucket_id = 'avatars'); + +CREATE POLICY "service_role_update_avatars" ON storage.objects +FOR UPDATE TO service_role +USING (bucket_id = 'avatars'); + +CREATE POLICY "service_role_delete_avatars" ON storage.objects +FOR DELETE TO service_role +USING (bucket_id = 'avatars'); + +CREATE POLICY "service_role_select_avatars" ON storage.objects +FOR SELECT TO service_role +USING (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "service_role_insert_documents" ON storage.objects; +DROP POLICY IF EXISTS "service_role_update_documents" ON storage.objects; +DROP POLICY IF EXISTS "service_role_delete_documents" ON storage.objects; +DROP POLICY IF EXISTS "service_role_select_documents" ON storage.objects; + +CREATE POLICY "service_role_insert_documents" ON storage.objects +FOR INSERT TO service_role +WITH CHECK (bucket_id = 'documents'); + +CREATE POLICY "service_role_update_documents" ON storage.objects +FOR UPDATE TO service_role +USING (bucket_id = 'documents'); + +CREATE POLICY "service_role_delete_documents" ON storage.objects +FOR DELETE TO service_role +USING (bucket_id = 'documents'); + +CREATE POLICY "service_role_select_documents" ON storage.objects +FOR SELECT TO service_role +USING (bucket_id = 'documents'); + +DROP POLICY IF EXISTS "service_role_insert_event_images" ON storage.objects; +DROP POLICY IF EXISTS "service_role_update_event_images" ON storage.objects; +DROP POLICY IF EXISTS "service_role_delete_event_images" ON storage.objects; +DROP POLICY IF EXISTS "service_role_select_event_images" ON storage.objects; + +CREATE POLICY "service_role_insert_event_images" ON storage.objects +FOR INSERT TO service_role +WITH CHECK (bucket_id = 'event-images'); + +CREATE POLICY "service_role_update_event_images" ON storage.objects +FOR UPDATE TO service_role +USING (bucket_id = 'event-images'); + +CREATE POLICY "service_role_delete_event_images" ON storage.objects +FOR DELETE TO service_role +USING (bucket_id = 'event-images'); + +CREATE POLICY "service_role_select_event_images" ON storage.objects +FOR SELECT TO service_role +USING (bucket_id = 'event-images'); + +-- ============================================ +-- MIGRATION 011: Fix Service Role RLS +-- ============================================ + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role' AND NOT rolbypassrls) THEN + ALTER ROLE service_role BYPASSRLS; + END IF; +EXCEPTION + WHEN insufficient_privilege THEN + RAISE NOTICE 'Could not grant BYPASSRLS - using explicit policies'; + WHEN OTHERS THEN + RAISE NOTICE 'Error granting BYPASSRLS: %', SQLERRM; +END $$; + +DROP POLICY IF EXISTS "service_role_all_select" ON storage.objects; +DROP POLICY IF EXISTS "service_role_all_insert" ON storage.objects; +DROP POLICY IF EXISTS "service_role_all_update" ON storage.objects; +DROP POLICY IF EXISTS "service_role_all_delete" ON storage.objects; + +CREATE POLICY "service_role_all_select" ON storage.objects +FOR SELECT TO service_role +USING (true); + +CREATE POLICY "service_role_all_insert" ON storage.objects +FOR INSERT TO service_role +WITH CHECK (true); + +CREATE POLICY "service_role_all_update" ON storage.objects +FOR UPDATE TO service_role +USING (true); + +CREATE POLICY "service_role_all_delete" ON storage.objects +FOR DELETE TO service_role +USING (true); + +GRANT ALL ON storage.objects TO service_role; +GRANT ALL ON storage.buckets TO service_role; +GRANT USAGE ON SCHEMA storage TO service_role; + +-- ============================================ +-- MIGRATION 012: Dual Document URLs +-- ============================================ + +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS file_url_local TEXT; +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS file_url_s3 TEXT; +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_path TEXT; + +COMMENT ON COLUMN public.documents.file_path IS 'Current active file URL (computed based on storage setting) - kept for backwards compatibility'; +COMMENT ON COLUMN public.documents.file_url_local IS 'File URL when stored in Supabase Storage'; +COMMENT ON COLUMN public.documents.file_url_s3 IS 'File URL when stored in S3/MinIO'; +COMMENT ON COLUMN public.documents.storage_path IS 'Storage path for file (e.g., timestamp-random-filename.pdf)'; + +CREATE INDEX IF NOT EXISTS idx_documents_storage_path ON public.documents(storage_path); + +-- ============================================ +-- MIGRATION 014: Event Reminders +-- ============================================ + +CREATE TABLE public.event_reminder_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + rsvp_id UUID NOT NULL REFERENCES public.event_rsvps(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + reminder_type TEXT NOT NULL DEFAULT '24hr' CHECK (reminder_type IN ('24hr', '1hr', 'day_of')), + sent_at TIMESTAMPTZ DEFAULT NOW(), + email_log_id UUID REFERENCES public.email_logs(id), + UNIQUE(event_id, member_id, reminder_type) +); + +ALTER TABLE public.event_reminder_logs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Board/admin can view event reminder logs" ON public.event_reminder_logs + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')) + ); + +CREATE POLICY "Service role can manage event reminder logs" ON public.event_reminder_logs + FOR ALL USING (true) + WITH CHECK (true); + +CREATE INDEX idx_event_reminder_logs_event ON public.event_reminder_logs(event_id); +CREATE INDEX idx_event_reminder_logs_member ON public.event_reminder_logs(member_id); +CREATE INDEX idx_event_reminder_logs_sent ON public.event_reminder_logs(sent_at); + +INSERT INTO public.app_settings (category, setting_key, setting_value, display_name, description, is_public) +VALUES + ('events', 'event_reminders_enabled', 'true', 'Event Reminders Enabled', 'Enable automated event reminder emails', false), + ('events', 'event_reminder_hours_before', '24', 'Reminder Hours Before', 'Hours before event to send reminder', false) +ON CONFLICT (category, setting_key) DO NOTHING; + +CREATE OR REPLACE VIEW public.events_needing_reminders AS +SELECT + e.id AS event_id, + e.title AS event_title, + e.start_datetime, + e.end_datetime, + e.location, + e.timezone, + r.id AS rsvp_id, + r.member_id, + r.guest_count, + r.status AS rsvp_status, + m.first_name, + m.last_name, + m.email +FROM public.events e +JOIN public.event_rsvps r ON r.event_id = e.id +JOIN public.members m ON m.id = r.member_id +WHERE + e.status = 'published' + AND e.start_datetime > NOW() + AND e.start_datetime <= NOW() + INTERVAL '25 hours' + AND e.start_datetime > NOW() + INTERVAL '23 hours' + AND r.status = 'confirmed' + AND NOT EXISTS ( + SELECT 1 FROM public.event_reminder_logs erl + WHERE erl.event_id = e.id + AND erl.member_id = r.member_id + AND erl.reminder_type = '24hr' + ) + AND m.email IS NOT NULL; + +-- ============================================ +-- MIGRATION 016: Onboarding Payment Tracking +-- ============================================ + +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ; +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_members_payment_deadline ON public.members(payment_deadline) + WHERE payment_deadline IS NOT NULL; + +-- ============================================ +-- END OF COMBINED MIGRATIONS +-- ============================================ diff --git a/deploy/kong.yml.template b/deploy/kong.yml.template new file mode 100644 index 0000000..c5ebb86 --- /dev/null +++ b/deploy/kong.yml.template @@ -0,0 +1,196 @@ +_format_version: "2.1" +_transform: true + +### +### Consumers / Users +### +consumers: + - username: ANON + keyauth_credentials: + - key: __ANON_KEY__ + - username: SERVICE_ROLE + keyauth_credentials: + - key: __SERVICE_ROLE_KEY__ + +### +### Access Control Lists +### +acls: + - consumer: ANON + group: anon + - consumer: SERVICE_ROLE + group: admin + +### +### API Routes +### +services: + ## Redirect /auth/verify to SvelteKit app for email links + - name: auth-verify-redirect + url: http://portal:3000/auth/verify + routes: + - name: auth-verify-redirect + strip_path: false + paths: + - /auth/verify + preserve_host: false + plugins: + - name: cors + + ## Auth Service (GoTrue) + - name: auth-v1-open + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + + - name: auth-v1-open-callback + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + + - name: auth-v1-open-authorize + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1 + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## REST Service (PostgREST) + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1 + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Realtime Service + - name: realtime-v1-ws + url: http://realtime:4000/socket + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/websocket + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + - name: realtime-v1 + url: http://realtime:4000/ + routes: + - name: realtime-v1 + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Storage Service - Public objects (no auth required) + - name: storage-v1-public + url: http://storage:5000/object/public + routes: + - name: storage-v1-public + strip_path: true + paths: + - /storage/v1/object/public + plugins: + - name: cors + + ## Storage Service - All other operations (auth required) + - name: storage-v1 + url: http://storage:5000/ + routes: + - name: storage-v1 + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## PostgreSQL Meta (for Studio) + - name: meta + url: http://meta:8080/ + routes: + - name: meta + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100644 index 0000000..fd9848d --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,234 @@ +#!/bin/bash +# Monaco USA Portal - Production Setup Script +# This script prepares the deployment environment by: +# 1. Generating missing secrets in .env +# 2. Generating kong.yml from template with actual API keys +# 3. Validating the configuration +set -e + +echo "========================================" +echo "Monaco USA Portal - Production Setup" +echo "========================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to generate random string +generate_secret() { + local length=${1:-32} + openssl rand -base64 $length | tr -d '\n' +} + +# Function to generate JWT token +generate_jwt() { + local role=$1 + local secret=$2 + + # JWT Header (base64url encoded) + local header='{"alg":"HS256","typ":"JWT"}' + local header_b64=$(echo -n "$header" | base64 | tr '+/' '-_' | tr -d '=') + + # JWT Payload - 100 years expiry + local exp=$(($(date +%s) + 3153600000)) + local payload="{\"role\":\"$role\",\"iss\":\"supabase\",\"iat\":$(date +%s),\"exp\":$exp}" + local payload_b64=$(echo -n "$payload" | base64 | tr '+/' '-_' | tr -d '=') + + # Create signature + local signature=$(echo -n "${header_b64}.${payload_b64}" | openssl dgst -sha256 -hmac "$secret" -binary | base64 | tr '+/' '-_' | tr -d '=') + + echo "${header_b64}.${payload_b64}.${signature}" +} + +# Check if .env exists +if [ ! -f .env ]; then + if [ -f .env.example ]; then + echo -e "${YELLOW}No .env file found. Creating from .env.example...${NC}" + cp .env.example .env + echo -e "${GREEN}Created .env from template.${NC}" + else + echo -e "${RED}Error: No .env or .env.example file found.${NC}" + echo "Please create a .env file with your configuration." + exit 1 + fi +fi + +# Load environment +set -a +source .env +set +a + +echo "" +echo "Checking and generating secrets..." +echo "" + +# Track if we made changes +CHANGES_MADE=false + +# Generate POSTGRES_PASSWORD if not set or is placeholder +if [ -z "$POSTGRES_PASSWORD" ] || [[ "$POSTGRES_PASSWORD" == *"CHANGE_ME"* ]] || [[ "$POSTGRES_PASSWORD" == *"change-this"* ]]; then + NEW_POSTGRES_PASSWORD=$(generate_secret 32) + sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$NEW_POSTGRES_PASSWORD|" .env + POSTGRES_PASSWORD=$NEW_POSTGRES_PASSWORD + echo -e "${GREEN}[Generated]${NC} POSTGRES_PASSWORD" + CHANGES_MADE=true +else + echo -e "[OK] POSTGRES_PASSWORD is set" +fi + +# Generate JWT_SECRET if not set or is placeholder +if [ -z "$JWT_SECRET" ] || [[ "$JWT_SECRET" == *"CHANGE_ME"* ]] || [[ "$JWT_SECRET" == *"generate"* ]]; then + NEW_JWT_SECRET=$(generate_secret 32) + sed -i.bak "s|^JWT_SECRET=.*|JWT_SECRET=$NEW_JWT_SECRET|" .env + JWT_SECRET=$NEW_JWT_SECRET + echo -e "${GREEN}[Generated]${NC} JWT_SECRET" + CHANGES_MADE=true +else + echo -e "[OK] JWT_SECRET is set" +fi + +# Generate SECRET_KEY_BASE if not set or is placeholder +if [ -z "$SECRET_KEY_BASE" ] || [[ "$SECRET_KEY_BASE" == *"CHANGE_ME"* ]] || [[ "$SECRET_KEY_BASE" == *"generate"* ]]; then + NEW_SECRET_KEY_BASE=$(generate_secret 64) + sed -i.bak "s|^SECRET_KEY_BASE=.*|SECRET_KEY_BASE=$NEW_SECRET_KEY_BASE|" .env + SECRET_KEY_BASE=$NEW_SECRET_KEY_BASE + echo -e "${GREEN}[Generated]${NC} SECRET_KEY_BASE" + CHANGES_MADE=true +else + echo -e "[OK] SECRET_KEY_BASE is set" +fi + +# Generate ANON_KEY if not set or is placeholder +if [ -z "$ANON_KEY" ] || [[ "$ANON_KEY" == *"CHANGE_ME"* ]] || [[ "$ANON_KEY" == *"your-"* ]]; then + NEW_ANON_KEY=$(generate_jwt "anon" "$JWT_SECRET") + sed -i.bak "s|^ANON_KEY=.*|ANON_KEY=$NEW_ANON_KEY|" .env + ANON_KEY=$NEW_ANON_KEY + echo -e "${GREEN}[Generated]${NC} ANON_KEY (JWT with role=anon)" + CHANGES_MADE=true +else + echo -e "[OK] ANON_KEY is set" +fi + +# Generate SERVICE_ROLE_KEY if not set or is placeholder +if [ -z "$SERVICE_ROLE_KEY" ] || [[ "$SERVICE_ROLE_KEY" == *"CHANGE_ME"* ]] || [[ "$SERVICE_ROLE_KEY" == *"your-"* ]]; then + NEW_SERVICE_ROLE_KEY=$(generate_jwt "service_role" "$JWT_SECRET") + sed -i.bak "s|^SERVICE_ROLE_KEY=.*|SERVICE_ROLE_KEY=$NEW_SERVICE_ROLE_KEY|" .env + SERVICE_ROLE_KEY=$NEW_SERVICE_ROLE_KEY + echo -e "${GREEN}[Generated]${NC} SERVICE_ROLE_KEY (JWT with role=service_role)" + CHANGES_MADE=true +else + echo -e "[OK] SERVICE_ROLE_KEY is set" +fi + +# Also update PUBLIC_SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY if they exist +if grep -q "^PUBLIC_SUPABASE_ANON_KEY=" .env; then + sed -i.bak "s|^PUBLIC_SUPABASE_ANON_KEY=.*|PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY|" .env +fi +if grep -q "^SUPABASE_SERVICE_ROLE_KEY=" .env; then + sed -i.bak "s|^SUPABASE_SERVICE_ROLE_KEY=.*|SUPABASE_SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY|" .env +fi + +# Clean up backup files +rm -f .env.bak + +echo "" + +# Reload environment after changes +set -a +source .env +set +a + +# Validate required variables +echo "Validating required variables..." +REQUIRED_VARS=( + "DOMAIN" + "POSTGRES_USER" + "POSTGRES_PASSWORD" + "POSTGRES_DB" + "JWT_SECRET" + "ANON_KEY" + "SERVICE_ROLE_KEY" + "SECRET_KEY_BASE" +) + +MISSING_VARS=() +for var in "${REQUIRED_VARS[@]}"; do + if [ -z "${!var}" ]; then + MISSING_VARS+=("$var") + fi +done + +if [ ${#MISSING_VARS[@]} -ne 0 ]; then + echo "" + echo -e "${RED}Error: The following required variables are not set in .env:${NC}" + for var in "${MISSING_VARS[@]}"; do + echo " - $var" + done + echo "" + echo "Please edit .env and set these values, then run this script again." + exit 1 +fi + +echo -e "${GREEN}All required variables are set.${NC}" +echo "" + +# Check for optional but recommended variables +OPTIONAL_VARS=( + "ACME_EMAIL" + "SMTP_HOST" + "SMTP_USER" +) + +echo "Checking optional variables..." +for var in "${OPTIONAL_VARS[@]}"; do + if [ -z "${!var}" ]; then + echo -e "${YELLOW}[Warning]${NC} $var is not set (optional)" + else + echo -e "[OK] $var is set" + fi +done + +echo "" + +# Generate kong.yml from template +echo "Generating kong.yml from template..." + +if [ ! -f kong.yml.template ]; then + echo -e "${RED}Error: kong.yml.template not found.${NC}" + exit 1 +fi + +# Use sed to replace placeholders +sed -e "s|__ANON_KEY__|$ANON_KEY|g" \ + -e "s|__SERVICE_ROLE_KEY__|$SERVICE_ROLE_KEY|g" \ + kong.yml.template > kong.yml + +echo -e "${GREEN}Generated kong.yml with API keys.${NC}" +echo "" + +# Summary +echo "========================================" +echo "Setup Complete!" +echo "========================================" +echo "" +if [ "$CHANGES_MADE" = true ]; then + echo -e "${YELLOW}IMPORTANT: New secrets were generated and saved to .env${NC}" + echo "Make sure to backup your .env file securely!" + echo "" +fi +echo "Next steps:" +echo " 1. Review .env and configure any remaining settings" +echo " 2. Start the services: docker compose up -d" +echo " 3. Wait for all containers to be healthy: docker compose ps" +echo " 4. Access the portal at: https://${DOMAIN:-your-domain.com}" +echo " 5. Create your admin account on first visit" +echo "" +echo "Useful commands:" +echo " docker compose ps - Check container status" +echo " docker compose logs -f - Follow all logs" +echo " docker compose logs db - Check database logs" +echo " docker compose down - Stop all services" +echo ""