commit e7338d1a70a7be733871ae375e68865bd3e8bd66 Author: Matt Date: Sun Jan 25 02:19:49 2026 +0100 Initial production deployment setup - Production docker-compose with nginx support - Nginx configuration for portal.monacousa.org - Deployment script with backup/restore - Gitea CI/CD workflow - Fix CountryFlag reactivity for dropdown flags Co-Authored-By: Claude Opus 4.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..10254b3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules +.pnpm-store + +# Build output +build +.svelte-kit + +# Environment files (we pass these at runtime) +.env +.env.* +!.env.example + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test +coverage +.nyc_output + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Supabase local +supabase/.temp +supabase/.branches + +# Misc +*.md +LICENSE diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2de8f49 --- /dev/null +++ b/.env.example @@ -0,0 +1,89 @@ +# Monaco USA Portal - Docker Environment Configuration +# =================================================== +# Copy this file to .env and configure your values + +# =========================================== +# POSTGRES DATABASE +# =========================================== +POSTGRES_USER=postgres +POSTGRES_PASSWORD=change-this-to-a-secure-password +POSTGRES_DB=postgres +POSTGRES_PORT=5435 + +# =========================================== +# JWT CONFIGURATION +# =========================================== +# IMPORTANT: Generate a new secret for production! +# Use: openssl rand -base64 32 +JWT_SECRET=generate-a-new-secret-at-least-32-characters +JWT_EXPIRY=3600 + +# =========================================== +# API KEYS +# =========================================== +# Generate these at: https://supabase.com/docs/guides/self-hosting#api-keys +# They must be signed with your JWT_SECRET + +# Anonymous key - for public access (limited permissions) +ANON_KEY=your-generated-anon-key + +# Service role key - for admin access (full permissions, keep secret!) +SERVICE_ROLE_KEY=your-generated-service-role-key + +# =========================================== +# URLS & PORTS +# =========================================== +KONG_HTTP_PORT=7455 +KONG_HTTPS_PORT=7456 +STUDIO_PORT=7454 +PORTAL_PORT=7453 + +SITE_URL=http://localhost:7453 +API_EXTERNAL_URL=http://localhost:7455 +SUPABASE_PUBLIC_URL=http://localhost:7455 + +PUBLIC_SUPABASE_URL=http://localhost:7455 +PUBLIC_SUPABASE_ANON_KEY=same-as-anon-key-above + +# Service role key for admin operations (server-side only) +SUPABASE_SERVICE_ROLE_KEY=same-as-service-role-key-above + +# =========================================== +# AUTH CONFIGURATION +# =========================================== +DISABLE_SIGNUP=false +ENABLE_EMAIL_AUTOCONFIRM=true +ADDITIONAL_REDIRECT_URLS=http://localhost:7453/auth/callback + +# =========================================== +# SMTP EMAIL (Optional) +# =========================================== +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_ADMIN_EMAIL=noreply@example.org +SMTP_SENDER_NAME=Monaco USA + +MAILER_URLPATHS_INVITE=/auth/verify +MAILER_URLPATHS_CONFIRMATION=/auth/verify +MAILER_URLPATHS_RECOVERY=/auth/verify +MAILER_URLPATHS_EMAIL_CHANGE=/auth/verify +RATE_LIMIT_EMAIL_SENT=100 + +# =========================================== +# REALTIME +# =========================================== +SECRET_KEY_BASE=generate-a-new-secret-key-base + +# =========================================== +# POSTGREST +# =========================================== +PGRST_DB_SCHEMAS=public,storage,graphql_public + +# =========================================== +# SVELTEKIT CONFIGURATION +# =========================================== +# Body size limit for file uploads (avatars, documents) +# 50MB = 52428800 bytes +BODY_SIZE_LIMIT=52428800 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..34e88e6 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,130 @@ +# Gitea Actions - Monaco USA Portal Build & Deploy +# This workflow builds and optionally deploys the portal +# +# Triggers: +# - Push to main branch +# - Pull requests to main +# - Manual trigger (workflow_dispatch) +# +# Required Secrets (configure in Gitea repo settings): +# - DEPLOY_HOST: Production server hostname/IP +# - DEPLOY_USER: SSH username +# - DEPLOY_KEY: SSH private key for deployment +# - DEPLOY_PATH: Path to project on server (e.g., /opt/monacousa-portal) + +name: Build and Deploy + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + inputs: + deploy: + description: 'Deploy to production' + required: false + default: 'false' + +jobs: + # ============================================= + # Build Job - Builds Docker image + # ============================================= + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: false + load: true + tags: monacousa-portal:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + PUBLIC_SUPABASE_URL=https://api.portal.monacousa.org + PUBLIC_SUPABASE_ANON_KEY=placeholder + SUPABASE_SERVICE_ROLE_KEY=placeholder + + - name: Test Docker image starts + run: | + docker run -d --name test-portal \ + -e PUBLIC_SUPABASE_URL=https://api.portal.monacousa.org \ + -e PUBLIC_SUPABASE_ANON_KEY=placeholder \ + monacousa-portal:${{ github.sha }} + sleep 5 + docker logs test-portal + docker stop test-portal + + # ============================================= + # Lint Job - Code quality checks + # ============================================= + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run Svelte check + run: npm run check || true + + - name: Run ESLint + run: npm run lint || true + + # ============================================= + # Deploy Job - Deploys to production server + # ============================================= + deploy: + runs-on: ubuntu-latest + needs: [build, lint] + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy == 'true') + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to production + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_KEY }} + script: | + cd ${{ secrets.DEPLOY_PATH }} + git pull origin main + ./deploy.sh update + echo "Deployment completed at $(date)" + + - name: Notify deployment success + if: success() + run: | + echo "Successfully deployed to production!" + echo "Commit: ${{ github.sha }}" + echo "Branch: ${{ github.ref_name }}" + + - name: Notify deployment failure + if: failure() + run: | + echo "Deployment failed!" + echo "Check logs for details." + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6f79650 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,3710 @@ +# Monaco USA Portal 2026 - Complete Rebuild + +## Project Overview +Rebuild the Monaco USA member portal from scratch in `monacousa-portal-2026/` with modern architecture, beautiful UI, and improved functionality. + +--- + +# DETAILED FEATURE SPECIFICATIONS + +## 1. MEMBER SYSTEM (Detailed) + +### 1.1 Member ID Format +- **Format**: `MUSA-XXXX` (sequential 4-digit number) +- **Examples**: MUSA-0001, MUSA-0042, MUSA-1234 +- **Auto-generated** on member creation +- **Immutable** once assigned +- **Unique constraint** in database + +### 1.2 Membership Statuses (Admin-Configurable) +Admin can create, edit, and delete statuses via Settings. + +**Default Statuses (seeded on first run):** +| Status | Color | Description | Is Default | +|--------|-------|-------------|------------| +| `pending` | Yellow | New member, awaiting dues payment | Yes (for new signups) | +| `active` | Green | Dues paid, full access | No | +| `inactive` | Gray | Lapsed membership or suspended | No | +| `expired` | Red | Membership terminated | No | + +**Status Configuration Table:** +```sql +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', -- Tailwind gray-500 + description TEXT, + is_default BOOLEAN DEFAULT FALSE, -- Used for new signups + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 1.3 Roles/Tiers +**Fixed 3-tier system (not configurable):** +| Role | Access Level | Capabilities | +|------|--------------|--------------| +| `member` | Basic | View own profile, events, pay dues | +| `board` | Elevated | + Member directory, record payments, manage events | +| `admin` | Full | + User management, system settings, all data | + +### 1.4 Required Member Fields +All fields marked as required during signup: + +| Field | Type | Validation | Notes | +|-------|------|------------|-------| +| `first_name` | Text | Min 2 chars | Required | +| `last_name` | Text | Min 2 chars | Required | +| `email` | Email | Valid email format | Required, unique | +| `phone` | Text | International format | Required | +| `date_of_birth` | Date | Must be 18+ years old | Required | +| `address` | Text | Min 10 chars | Required | +| `nationality` | Array | At least 1 country | Required, multiple allowed | + +### 1.5 Optional Member Fields +| Field | Type | Notes | +|-------|------|-------| +| `avatar_url` | Text | Supabase Storage path | +| `membership_type_id` | UUID | Links to membership_types table | +| `notes` | Text | Admin-only notes about member | + +### 1.6 Nationality Handling +- **Multiple nationalities allowed** +- Stored as PostgreSQL `TEXT[]` array +- Uses ISO 3166-1 alpha-2 country codes: `['FR', 'US', 'MC']` +- UI shows country flags + names +- Searchable/filterable in directory + +### 1.7 Profile Features +- **Profile photo**: Upload via Supabase Storage + - Max size: 5MB + - Formats: JPG, PNG, WebP + - Auto-resized to 256x256 + - Stored at: `avatars/{member_id}/profile.{ext}` +- **No bio field** (simplified profile) +- Members can edit: name, phone, address, nationality, photo + +### 1.8 Member Directory +**Visibility controlled by admin settings:** + +```sql +CREATE TABLE public.directory_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + field_name TEXT NOT NULL UNIQUE, + visible_to_members BOOLEAN DEFAULT FALSE, + visible_to_board BOOLEAN DEFAULT TRUE, + visible_to_admin BOOLEAN DEFAULT TRUE, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default visibility settings +INSERT INTO directory_settings (field_name, visible_to_members, visible_to_board) VALUES + ('first_name', true, true), + ('last_name', true, true), + ('avatar_url', true, true), + ('nationality', true, true), + ('email', false, true), + ('phone', false, true), + ('address', false, true), + ('date_of_birth', false, true), + ('member_since', true, true), + ('membership_status', false, true); +``` + +### 1.9 Member Signup Flow +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ /signup │────▶│ Create Auth │────▶│ Email Verify│ +│ Form │ │ User + Member│ │ Link Sent │ +└─────────────┘ └──────────────┘ └─────────────┘ + │ + ▼ + ┌──────────────┐ ┌─────────────┐ + │ Status = │────▶│ Wait for │ + │ 'pending' │ │ Dues Payment│ + └──────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ Board/Admin │ + │ Records Dues│ + └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ Status = │ + │ 'active' │ + └─────────────┘ +``` + +**Key Points:** +- Email verification required +- Status starts as `pending` +- Member gains `active` status ONLY when first dues payment recorded +- Pending members can log in but see limited dashboard + +### 1.10 Admin Member Management +**Two ways to add members:** + +**Option A: Direct Add** +1. Admin fills out member form +2. Admin sets temporary password OR sends password setup email +3. Member record created with chosen status +4. Member can log in immediately + +**Option B: Invite** +1. Admin enters email + basic info +2. System sends invitation email with signup link +3. Invitee completes signup form +4. Status set based on invite settings + +### 1.11 Membership Types (Admin-Configurable) +Admin can create membership tiers with different pricing: + +```sql +CREATE TABLE public.membership_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, -- 'regular', 'student', 'senior' + display_name TEXT NOT NULL, -- 'Regular Member', 'Student' + annual_dues DECIMAL(10,2) NOT NULL, -- 50.00, 25.00, etc. + description TEXT, + is_default BOOLEAN DEFAULT FALSE, -- Default for new signups + is_active BOOLEAN DEFAULT TRUE, -- Can be assigned + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default membership types +INSERT INTO membership_types (name, display_name, annual_dues, is_default) VALUES + ('regular', 'Regular Member', 50.00, true), + ('student', 'Student', 25.00, false), + ('senior', 'Senior (65+)', 35.00, false), + ('family', 'Family', 75.00, false), + ('honorary', 'Honorary Member', 0.00, false); +``` + +### 1.12 Complete Member Schema + +```sql +CREATE TABLE public.members ( + -- Identity + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + member_id TEXT UNIQUE NOT NULL, -- MUSA-0001 format (auto-generated) + + -- Required Personal Info + 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 '{}', + + -- Membership + 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, + + -- Profile + avatar_url TEXT, + + -- Admin + notes TEXT, -- Admin-only notes + + -- Timestamps + 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(); +``` + +--- + +## 2. DUES/PAYMENTS SYSTEM (Detailed) + +### 2.1 Dues Cycle +- **Due date calculation**: Payment date + 365 days +- **Example**: Payment on Jan 15, 2026 → Due Jan 15, 2027 +- **No proration**: Full annual dues regardless of join date + +### 2.2 Payment Methods +**Bank transfer only** (no online payments): +- IBAN tracking +- Reference number for matching +- Manual recording by Board/Admin + +### 2.3 Payment Recording +**Who can record payments:** +- Board members +- Admins + +**Standard payment data tracked:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `member_id` | UUID | Yes | Which member | +| `amount` | Decimal | Yes | Payment amount (€) | +| `payment_date` | Date | Yes | When payment was made | +| `due_date` | Date | Yes | When this payment period ends (auto-calculated) | +| `reference` | Text | No | Bank transfer reference | +| `payment_method` | Text | Yes | Always 'bank_transfer' for now | +| `recorded_by` | UUID | Yes | Board/Admin who recorded | +| `notes` | Text | No | Optional notes | + +### 2.4 Dues Settings (Admin-Configurable) + +```sql +CREATE TABLE public.dues_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + setting_key TEXT UNIQUE NOT NULL, + setting_value TEXT NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES public.members(id) +); + +-- Default settings +INSERT INTO dues_settings (setting_key, setting_value, description) VALUES + ('reminder_days_before', '30,7', 'Days before due date to send reminders (comma-separated)'), + ('grace_period_days', '30', 'Days after due date before auto-inactive'), + ('overdue_reminder_interval', '14', 'Days between overdue reminder emails'), + ('payment_iban', 'MC58 1756 9000 0104 0050 1001 860', 'IBAN for dues payment'), + ('payment_account_holder', 'ASSOCIATION MONACO USA', 'Account holder name'), + ('payment_instructions', 'Please include your Member ID in the reference', 'Payment instructions'); +``` + +### 2.5 Automatic Reminders + +**Reminder Schedule (configurable via settings):** +1. **30 days before** due date: "Your dues are coming up" +2. **7 days before** due date: "Reminder: dues due in 1 week" +3. **On due date**: "Your dues are now due" +4. **Every 14 days overdue**: "Your dues are overdue" (until grace period ends) + +**Email Content Includes:** +- Member name +- Amount due (from membership_type) +- Due date +- IBAN and account holder +- Payment reference suggestion (Member ID) +- Link to portal + +**Technical Implementation:** +- Supabase Edge Function runs daily +- Checks all members for reminder triggers +- Logs sent emails in `email_logs` table +- Respects settings for intervals + +### 2.6 Overdue Handling + +**Grace Period Flow:** +``` +Due Date Passed + │ + ▼ +┌─────────────────────────────────────────┐ +│ GRACE PERIOD (configurable, default 30 days) │ +│ - Status remains 'active' │ +│ - Overdue reminders sent │ +│ - Flagged in dashboard │ +└─────────────────────────────────────────┘ + │ + ▼ (grace period ends) +┌─────────────────────────────────────────┐ +│ AUTO STATUS CHANGE │ +│ - Status → 'inactive' │ +│ - Final notification email │ +│ - Member loses active access │ +└─────────────────────────────────────────┘ +``` + +**Supabase Edge Function for Auto-Update:** +```typescript +// Runs daily via cron +async function updateOverdueMembers() { + const gracePeriodDays = await getSetting('grace_period_days'); + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays); + + // Find members past grace period + const { data: overdueMembers } = await supabase + .from('members_with_dues') + .select('*') + .eq('membership_status', 'active') + .lt('current_due_date', cutoffDate.toISOString()); + + // Update each to inactive + for (const member of overdueMembers) { + await supabase + .from('members') + .update({ membership_status_id: inactiveStatusId }) + .eq('id', member.id); + + // Send final notification + await sendEmail(member.email, 'membership_lapsed', { ... }); + } +} +``` + +### 2.7 Payment History (Member Visible) + +Members can see their complete payment history: + +**Display includes:** +- Payment date +- Amount paid +- Due date (period covered) +- Reference number +- Payment method + +**Members CANNOT see:** +- Who recorded the payment +- Internal notes +- Other members' payments + +### 2.8 Dues Dashboard (Board/Admin) + +**Overview Stats:** +- Total members with current dues +- Members with dues due soon (next 30 days) +- Overdue members count +- Total collected this year + +**Filterable Member List:** +- Filter by: status (current, due soon, overdue, never paid) +- Sort by: due date, days overdue, member name +- Quick actions: Record payment, Send reminder + +**Individual Member View:** +- Full payment history +- Current dues status +- Quick record payment form +- Send manual reminder button + +### 2.9 Complete Dues Schema + +```sql +-- Dues payments table +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, -- Calculated: payment_date + 1 year + payment_method TEXT DEFAULT 'bank_transfer', + reference TEXT, -- Bank transfer reference + notes TEXT, -- Internal notes + + recorded_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Trigger to auto-calculate due_date +CREATE OR REPLACE FUNCTION calculate_due_date() +RETURNS TRIGGER AS $$ +BEGIN + NEW.due_date := NEW.payment_date + INTERVAL '1 year'; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_due_date + BEFORE INSERT ON public.dues_payments + FOR EACH ROW + WHEN (NEW.due_date IS NULL) + EXECUTE FUNCTION calculate_due_date(); + +-- After payment: update member status to active +CREATE OR REPLACE FUNCTION update_member_status_on_payment() +RETURNS TRIGGER AS $$ +DECLARE + active_status_id UUID; +BEGIN + -- Get active status ID + SELECT id INTO active_status_id + FROM public.membership_statuses + WHERE name = 'active'; + + -- Update member status + 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(); + +-- Computed view for 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; +``` + +### 2.10 Email Templates for Dues + +**Types:** +1. `dues_reminder` - Upcoming dues reminder +2. `dues_due_today` - Dues due today +3. `dues_overdue` - Overdue reminder +4. `dues_lapsed` - Membership lapsed (grace period ended) +5. `dues_received` - Payment confirmation + +**Template Variables:** +- `{{member_name}}` - Full name +- `{{member_id}}` - MUSA-XXXX +- `{{amount}}` - Due amount +- `{{due_date}}` - Formatted date +- `{{days_until_due}}` or `{{days_overdue}}` +- `{{iban}}` - Payment IBAN +- `{{account_holder}}` - Account name +- `{{portal_link}}` - Link to portal + +--- + +## 3. EVENTS SYSTEM (Detailed) + +### 3.1 Event Types (Admin-Configurable) + +```sql +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', -- Tailwind blue-500 + icon TEXT, -- Lucide icon name + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default event types +INSERT INTO event_types (name, display_name, color, icon) VALUES + ('social', 'Social Event', '#10b981', 'party-popper'), + ('meeting', 'Meeting', '#6366f1', 'users'), + ('fundraiser', 'Fundraiser', '#f59e0b', 'heart-handshake'), + ('workshop', 'Workshop', '#8b5cf6', 'graduation-cap'), + ('gala', 'Gala/Formal', '#ec4899', 'sparkles'), + ('other', 'Other', '#6b7280', 'calendar'); +``` + +### 3.2 Event Visibility + +**Visibility Options:** +| Level | Who Can See | Description | +|-------|-------------|-------------| +| `public` | Anyone | Visible on public events page (no login) | +| `members` | All logged-in members | Default for most events | +| `board` | Board + Admin only | Board meetings, internal events | +| `admin` | Admin only | Administrative events | + +### 3.3 Event Pricing + +**Pricing Model:** +- Each event can be free or paid +- Paid events have **member price** and **non-member price** +- Member pricing determined by `membership_type_id` (if tiered pricing enabled) +- Non-members pay non-member price always + +**Pricing Fields:** +```sql +is_paid BOOLEAN DEFAULT FALSE, +member_price DECIMAL(10,2) DEFAULT 0, +non_member_price DECIMAL(10,2) DEFAULT 0, +pricing_notes TEXT -- "Includes dinner and drinks" +``` + +### 3.4 Guest/+1 Handling + +**Per-Event Configuration:** +- `max_guests_per_member` - 0, 1, 2, 3, or unlimited +- Each RSVP tracks guest count and guest names +- Guests count toward total capacity +- Non-members can bring guests too (if enabled) + +### 3.5 Non-Member (Public) RSVP + +**Flow for public events:** +``` +┌─────────────────┐ ┌──────────────────┐ +│ Public Events │────▶│ Event Detail │ +│ Page (no login) │ │ (public visible) │ +└─────────────────┘ └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ RSVP Form │ + │ (no account) │ + │ - Name │ + │ - Email │ + │ - Phone │ + │ - Guest count │ + │ - Guest names │ + └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Payment Info │ + │ (if paid event) │ + │ - IBAN shown │ + │ - Reference # │ + └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ RSVP Confirmed │ + │ (pending payment)│ + │ Email sent │ + └──────────────────┘ +``` + +**Non-Member RSVP Table:** +```sql +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, + + -- Contact info (required) + full_name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + + -- RSVP details + status TEXT NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')), + guest_count INTEGER DEFAULT 0, + guest_names TEXT[], + + -- Payment (for paid events) + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + payment_reference TEXT, + payment_amount DECIMAL(10,2), + + -- Attendance + attended BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(event_id, email) -- One RSVP per email per event +); +``` + +### 3.6 RSVP Status Options + +**For Members and Non-Members:** +| Status | Description | +|--------|-------------| +| `confirmed` | Attending the event | +| `declined` | Not attending | +| `maybe` | Tentative/undecided | +| `waitlist` | Event full, on waitlist | +| `cancelled` | Cancelled RSVP | + +### 3.7 Capacity & Waitlist + +**Capacity Management:** +- `max_attendees` - Total spots (null = unlimited) +- Includes members + guests + non-members + their guests +- When full, new RSVPs go to waitlist + +**Auto-Promote Waitlist:** +```typescript +// Trigger when RSVP is cancelled or declined +async function promoteFromWaitlist(eventId: string) { + // Get event capacity + const event = await getEvent(eventId); + const currentCount = await getCurrentAttendeeCount(eventId); + + if (event.max_attendees && currentCount >= event.max_attendees) { + return; // Still full + } + + // Get oldest waitlist entry + const waitlisted = await supabase + .from('event_rsvps') + .select('*') + .eq('event_id', eventId) + .eq('status', 'waitlist') + .order('created_at', { ascending: true }) + .limit(1) + .single(); + + if (waitlisted) { + // Promote to confirmed + await supabase + .from('event_rsvps') + .update({ status: 'confirmed' }) + .eq('id', waitlisted.id); + + // Send notification email + await sendEmail(waitlisted.member.email, 'waitlist_promoted', { + event_title: event.title, + event_date: event.start_datetime + }); + } +} +``` + +### 3.8 Attendance Tracking + +**Check-in System:** +- Board/Admin can mark attendance after event +- Checkbox per RSVP: attended yes/no +- Track attendance rate per event +- Member attendance history viewable + +```sql +-- Add to RSVPs +attended BOOLEAN DEFAULT FALSE, +checked_in_at TIMESTAMPTZ, +checked_in_by UUID REFERENCES public.members(id) +``` + +### 3.9 Calendar Views + +**Available Views:** +1. **Month** - Traditional calendar grid +2. **Week** - Weekly schedule view +3. **Day** - Single day detailed view +4. **List** - Upcoming events list + +**Using FullCalendar (SvelteKit compatible):** +```typescript +import Calendar from '@event-calendar/core'; +import TimeGrid from '@event-calendar/time-grid'; +import DayGrid from '@event-calendar/day-grid'; +import List from '@event-calendar/list'; +``` + +### 3.10 Event Schema + +```sql +CREATE TABLE public.events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Basic Info + title TEXT NOT NULL, + description TEXT, + event_type_id UUID REFERENCES public.event_types(id), + + -- Date/Time + start_datetime TIMESTAMPTZ NOT NULL, + end_datetime TIMESTAMPTZ NOT NULL, + all_day BOOLEAN DEFAULT FALSE, + timezone TEXT DEFAULT 'Europe/Monaco', + + -- Location + location TEXT, + location_url TEXT, -- Google Maps link, etc. + + -- Capacity + max_attendees INTEGER, -- null = unlimited + max_guests_per_member INTEGER DEFAULT 1, + + -- Pricing + is_paid BOOLEAN DEFAULT FALSE, + member_price DECIMAL(10,2) DEFAULT 0, + non_member_price DECIMAL(10,2) DEFAULT 0, + pricing_notes TEXT, + + -- Visibility + visibility TEXT NOT NULL DEFAULT 'members' + CHECK (visibility IN ('public', 'members', 'board', 'admin')), + + -- Status + status TEXT NOT NULL DEFAULT 'published' + CHECK (status IN ('draft', 'published', 'cancelled', 'completed')), + + -- Media + cover_image_url TEXT, -- Event banner/cover image + + -- Meta + created_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Member RSVPs +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 (for paid events) + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + payment_reference TEXT, + payment_amount DECIMAL(10,2), + + -- Attendance + 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) +); + +-- View for event with 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; +``` + +### 3.11 Event Permissions + +| Action | Member | Board | Admin | +|--------|--------|-------|-------| +| View public events | - | - | - | +| View member events | ✓ | ✓ | ✓ | +| View board events | - | ✓ | ✓ | +| View admin events | - | - | ✓ | +| RSVP to events | ✓ | ✓ | ✓ | +| Create events | - | ✓ | ✓ | +| Edit own events | - | ✓ | ✓ | +| Edit any event | - | - | ✓ | +| Delete events | - | - | ✓ | +| Manage RSVPs | - | ✓ | ✓ | +| Track attendance | - | ✓ | ✓ | + +### 3.12 Event Email Notifications + +**Email Types:** +1. `event_created` - New event announcement (for public/member events) +2. `event_reminder` - Reminder before event (configurable: 1 day, 1 hour) +3. `event_updated` - Event details changed +4. `event_cancelled` - Event cancelled +5. `rsvp_confirmation` - RSVP received +6. `waitlist_promoted` - Promoted from waitlist +7. `event_payment_reminder` - Payment reminder for paid events + +**Template Variables:** +- `{{event_title}}`, `{{event_date}}`, `{{event_time}}` +- `{{event_location}}`, `{{event_description}}` +- `{{member_name}}`, `{{guest_count}}` +- `{{payment_amount}}`, `{{payment_iban}}` +- `{{rsvp_status}}`, `{{portal_link}}` + +--- + +## 4. AUTH & DASHBOARDS (Detailed) + +### 4.1 Authentication Method + +**Email/Password only** (no social login): +- Standard email + password signup/login +- Email verification required +- Password reset via email +- Remember me option (extended session) + +### 4.2 Login Page Design + +**Branded login with:** +- Monaco USA logo +- Association tagline +- Login form (email, password, remember me) +- Links: Forgot password, Sign up +- Glass-morphism styling +- Responsive (mobile-friendly) + +### 4.3 Auth Flow + +``` +┌──────────────────────────────────────────────────────────────┐ +│ SIGNUP FLOW │ +├──────────────────────────────────────────────────────────────┤ +│ /signup │ +│ ├── Full form (all required fields) │ +│ ├── Supabase Auth: signUp(email, password) │ +│ ├── Create member record (status: pending) │ +│ ├── Send verification email │ +│ └── Show "Check your email" message │ +│ │ +│ /auth/callback (email verification link) │ +│ ├── Verify email token │ +│ ├── Update email_verified = true │ +│ └── Redirect to /login with success message │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ LOGIN FLOW │ +├──────────────────────────────────────────────────────────────┤ +│ /login │ +│ ├── Email + Password form │ +│ ├── Supabase Auth: signInWithPassword() │ +│ ├── Set session cookie (via Supabase SSR) │ +│ ├── Fetch member record │ +│ └── Redirect to /dashboard │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ PASSWORD RESET │ +├──────────────────────────────────────────────────────────────┤ +│ /forgot-password │ +│ ├── Email input form │ +│ ├── Supabase Auth: resetPasswordForEmail() │ +│ └── Show "Check your email" message │ +│ │ +│ /auth/reset-password (from email link) │ +│ ├── New password form │ +│ ├── Supabase Auth: updateUser({ password }) │ +│ └── Redirect to /login with success │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 4.4 Session Management + +**Supabase SSR Configuration:** +```typescript +// src/hooks.server.ts +export const handle: Handle = async ({ event, resolve }) => { + event.locals.supabase = createServerClient( + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => event.cookies.getAll(), + setAll: (cookies) => cookies.forEach(({ name, value, options }) => + event.cookies.set(name, value, { ...options, path: '/' }) + ) + } + } + ); + + event.locals.safeGetSession = async () => { + const { data: { session } } = await event.locals.supabase.auth.getSession(); + if (!session) return { session: null, user: null, member: null }; + + const { data: { user } } = await event.locals.supabase.auth.getUser(); + if (!user) return { session: null, user: null, member: null }; + + // Fetch member record + const { data: member } = await event.locals.supabase + .from('members_with_dues') + .select('*') + .eq('id', user.id) + .single(); + + return { session, user, member }; + }; + + return resolve(event); +}; +``` + +### 4.5 Navigation Structure + +**Desktop: Collapsible Sidebar** +``` +┌─────────────────────────────────────────────────────┐ +│ ┌─────┐ │ +│ │ │ Dashboard │ +│ │LOGO │ ───────────────────────────────────── │ +│ │ │ │ +│ └─────┘ [Sidebar Navigation] [Content] │ +│ │ +│ 📊 Dashboard │ +│ 👤 My Profile │ +│ 📅 Events │ +│ 💳 Payments │ +│ │ +│ ── Board ──────── (if board/admin) │ +│ 👥 Members │ +│ 📋 Dues Management │ +│ 📅 Event Management │ +│ │ +│ ── Admin ──────── (if admin) │ +│ ⚙️ Settings │ +│ 👥 User Management │ +│ 📄 Documents │ +│ │ +│ ───────────────── │ +│ 🚪 Logout │ +└─────────────────────────────────────────────────────┘ +``` + +**Mobile: Bottom Navigation Bar** +``` +┌─────────────────────────────────────┐ +│ │ +│ [Main Content] │ +│ │ +│ │ +├─────────────────────────────────────┤ +│ 🏠 📅 👤 ⚙️ ☰ │ +│ Home Events Profile Settings More │ +└─────────────────────────────────────┘ +``` + +### 4.6 Unified Dashboard with Role Sections + +**Single `/dashboard` route with role-based sections:** + +```svelte + + + + + + + + + +{#if isBoard} + + + + +{/if} + + +{#if isAdmin} + + + + +{/if} +``` + +### 4.7 Member Dashboard Section + +**Components:** +1. **Welcome Card** - Greeting with name, membership status badge +2. **Dues Status Card** - Current status, next due date, quick pay info +3. **Upcoming Events Card** - Next 3-5 events with RSVP status +4. **Profile Quick View** - Photo, basic info, edit link + +**Data Loaded:** +```typescript +// routes/(app)/dashboard/+page.server.ts +export const load = async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + const upcomingEvents = await getUpcomingEventsForMember(member.id, 5); + + return { + member, + upcomingEvents + }; +}; +``` + +### 4.8 Board Dashboard Section + +**Additional Components (visible to board/admin):** +1. **Member Stats Card** - Total, active, pending, inactive counts +2. **Pending Members Card** - New signups awaiting approval/payment +3. **Dues Overview Card** - Current, due soon, overdue breakdown +4. **Recent RSVPs Card** - Latest event RSVPs + +**Board Stats:** +```typescript +interface BoardStats { + totalMembers: number; + activeMembers: number; + pendingMembers: number; + inactiveMembers: number; + duesSoon: number; // Due in next 30 days + duesOverdue: number; // Past due date + upcomingEvents: number; + pendingRsvps: number; +} +``` + +### 4.9 Admin Dashboard Section + +**Additional Components (admin only):** +1. **System Health Card** - Supabase status, email status +2. **Recent Activity Card** - Latest logins, signups, payments +3. **Quick Actions Card** - Add member, create event, send broadcast +4. **Alerts Card** - Issues requiring attention + +**Admin Stats:** +```typescript +interface AdminStats extends BoardStats { + totalUsers: number; // Auth users + recentLogins: number; // Last 24 hours + failedLogins: number; // Last 24 hours + emailsSent: number; // This month + storageUsed: number; // MB +} +``` + +### 4.10 Route Protection + +**Layout-level guards using SvelteKit:** + +```typescript +// routes/(app)/+layout.server.ts +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ locals }) => { + const { session, member } = await locals.safeGetSession(); + + if (!session) { + throw redirect(303, '/login'); + } + + return { member }; +}; + +// routes/(app)/board/+layout.server.ts +export const load = async ({ locals, parent }) => { + const { member } = await parent(); + + if (member.role !== 'board' && member.role !== 'admin') { + throw redirect(303, '/dashboard'); + } + + return {}; +}; + +// routes/(app)/admin/+layout.server.ts +export const load = async ({ locals, parent }) => { + const { member } = await parent(); + + if (member.role !== 'admin') { + throw redirect(303, '/dashboard'); + } + + return {}; +}; +``` + +### 4.11 Responsive Breakpoints + +| Breakpoint | Width | Layout | +|------------|-------|--------| +| Mobile | < 640px | Bottom nav, stacked cards | +| Tablet | 640-1024px | Collapsed sidebar rail, 2-column | +| Desktop | > 1024px | Full sidebar, 3-column grid | + +### 4.12 Dashboard Glass-Morphism Design + +**Glass Card Base Style:** +```css +.glass-card { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.glass-card-dark { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} +``` + +**Monaco Red Accent:** +```css +:root { + --monaco-red: #dc2626; + --monaco-red-light: #fee2e2; + --monaco-red-dark: #991b1b; +} +``` + +--- + +## 5. DOCUMENT STORAGE (Detailed) + +### 5.1 Document Categories (Admin-Configurable) + +```sql +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, -- Lucide icon name + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Default categories +INSERT INTO document_categories (name, display_name, icon) VALUES + ('meeting_minutes', 'Meeting Minutes', 'file-text'), + ('governance', 'Governance & Bylaws', 'scale'), + ('legal', 'Legal Documents', 'briefcase'), + ('financial', 'Financial Reports', 'dollar-sign'), + ('member_resources', 'Member Resources', 'book-open'), + ('forms', 'Forms & Templates', 'clipboard'), + ('other', 'Other Documents', 'file'); +``` + +### 5.2 Upload Permissions + +**Who can upload:** +- Board members +- Administrators + +**Members cannot upload** - they can only view documents shared with them. + +### 5.3 Document Visibility (Per-Document) + +**Visibility Options:** +| Level | Who Can View | +|-------|--------------| +| `public` | Anyone (no login required) | +| `members` | All logged-in members | +| `board` | Board + Admin only | +| `admin` | Admin only | + +**Custom permissions** can also specify specific member IDs for restricted access. + +### 5.4 Document Schema + +```sql +CREATE TABLE public.documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Basic Info + title TEXT NOT NULL, + description TEXT, + category_id UUID REFERENCES public.document_categories(id), + + -- File Info (Supabase Storage) + file_path TEXT NOT NULL, -- Storage path + file_name TEXT NOT NULL, -- Original filename + file_size INTEGER NOT NULL, -- Bytes + mime_type TEXT NOT NULL, -- 'application/pdf', etc. + + -- Visibility + visibility TEXT NOT NULL DEFAULT 'members' + CHECK (visibility IN ('public', 'members', 'board', 'admin')), + + -- Optional: Specific member access (for restricted docs) + allowed_member_ids UUID[], -- If set, only these members can view + + -- Version tracking + version INTEGER DEFAULT 1, + replaces_document_id UUID REFERENCES public.documents(id), + + -- Metadata + uploaded_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Document access log (for audit) +CREATE TABLE public.document_access_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE, + accessed_by UUID REFERENCES public.members(id), -- null if public access + access_type TEXT NOT NULL CHECK (access_type IN ('view', 'download')), + ip_address TEXT, + accessed_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 5.5 File Storage (Supabase Storage) + +**Bucket Configuration:** +```typescript +// Storage bucket: 'documents' +// Path structure: documents/{category}/{year}/{filename} + +// Example paths: +// documents/meeting_minutes/2026/board-meeting-2026-01-15.pdf +// documents/governance/bylaws-v2.pdf +// documents/financial/2025/annual-report-2025.pdf +``` + +**Upload Limits:** +- Max file size: 50MB +- Allowed types: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, PNG + +### 5.6 Document UI Features + +**Document Library View:** +- Filter by category +- Filter by visibility level +- Search by title/description +- Sort by date, name, category +- Grid or list view toggle + +**Document Card:** +``` +┌────────────────────────────────────────┐ +│ 📄 [Category Icon] │ +│ │ +│ Board Meeting Minutes - January 2026 │ +│ Meeting minutes from the monthly... │ +│ │ +│ 📅 Jan 15, 2026 | 📎 PDF | 1.2 MB │ +│ │ +│ [View] [Download] 👁️ Members │ +└────────────────────────────────────────┘ +``` + +**Upload Form (Board/Admin):** +- Title (required) +- Description (optional) +- Category (required, dropdown) +- Visibility (required) +- Custom access (optional, member multi-select) +- File upload (drag & drop) + +### 5.7 Document Permissions (RLS) + +```sql +-- RLS Policies for documents +ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; + +-- Public documents viewable by anyone +CREATE POLICY "Public documents are viewable" + ON public.documents FOR SELECT + USING (visibility = 'public'); + +-- Member documents viewable by authenticated users +CREATE POLICY "Member documents viewable by members" + 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)) + ); + +-- Board/Admin can manage documents +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 "Uploader or admin can update documents" + ON public.documents FOR UPDATE + TO authenticated + USING ( + uploaded_by = auth.uid() + OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); + +CREATE POLICY "Admin can delete documents" + ON public.documents FOR DELETE + TO authenticated + USING ( + EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin') + ); +``` + +### 5.8 Version History + +**Document versioning:** +- When replacing a document, create new record with `replaces_document_id` +- Previous versions remain accessible (archived) +- View version history for any document + +```sql +-- Get version history for a document +SELECT d.*, m.first_name, m.last_name +FROM public.documents d +JOIN public.members m ON d.uploaded_by = m.id +WHERE d.id = :document_id + OR d.replaces_document_id = :document_id + OR d.id IN ( + SELECT replaces_document_id FROM public.documents + WHERE id = :document_id + ) +ORDER BY d.version DESC; +``` + +### 5.9 Meeting Minutes Special Handling + +**For meeting minutes category:** +- Date field (meeting date) +- Attendees list (optional) +- Agenda reference (optional) +- Quick template for consistency + +```sql +-- Optional meeting minutes metadata +ALTER TABLE public.documents ADD COLUMN meeting_date DATE; +ALTER TABLE public.documents ADD COLUMN meeting_attendees UUID[]; +``` + +--- + +## 6. ADMIN SETTINGS SYSTEM (Detailed) + +### 6.1 Settings Architecture Overview + +**Centralized configuration** for all customizable aspects of the portal, accessible only to Admins via `/admin/settings`. + +**Settings Categories:** +1. **Organization** - Association branding and info +2. **Membership** - Statuses, types, and pricing +3. **Dues** - Payment settings and reminders +4. **Events** - Event types and defaults +5. **Documents** - Categories and storage +6. **Directory** - Visibility controls +7. **Email** - SMTP and template settings +8. **System** - Technical settings + +### 6.2 Settings Storage (Unified Table) + +```sql +-- Flexible key-value settings with JSON support +CREATE TABLE public.app_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category TEXT NOT NULL, -- 'organization', 'dues', 'email', etc. + setting_key TEXT NOT NULL, -- 'payment_iban', 'reminder_days', etc. + setting_value JSONB NOT NULL, -- Supports strings, numbers, arrays, objects + setting_type TEXT NOT NULL DEFAULT 'text' -- 'text', 'number', 'boolean', 'json', 'array' + CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'array')), + display_name TEXT NOT NULL, -- Human-readable label + description TEXT, -- Help text for admins + is_public BOOLEAN DEFAULT FALSE, -- If true, accessible without auth + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES public.members(id), + + UNIQUE(category, setting_key) +); + +-- Audit log for settings changes +CREATE TABLE public.settings_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + setting_id UUID NOT NULL REFERENCES public.app_settings(id), + old_value JSONB, + new_value JSONB NOT NULL, + changed_by UUID NOT NULL REFERENCES public.members(id), + changed_at TIMESTAMPTZ DEFAULT NOW(), + change_reason TEXT +); + +-- RLS: Only admins can read/write settings +ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Only admins 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') + OR is_public = TRUE + ); +``` + +### 6.3 Default Settings (Seeded on First Run) + +```sql +-- Organization Settings +INSERT INTO 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 address', true), + ('organization', 'address', '"Monaco"', 'text', 'Address', 'Association physical address', true), + ('organization', 'logo_url', '"/logo.png"', 'text', 'Logo URL', 'Path to association logo', true), + ('organization', 'primary_color', '"#dc2626"', 'text', 'Primary Color', 'Brand primary color (hex)', true), + +-- Dues Settings + ('dues', 'payment_iban', '"MC58 1756 9000 0104 0050 1001 860"', 'text', 'Payment IBAN', 'Bank IBAN for dues payment', 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', 'payment_instructions', '"Please include your Member ID (MUSA-XXXX) in the reference"', 'text', 'Payment Instructions', 'Instructions shown to members', false), + ('dues', 'reminder_days_before', '[30, 7, 1]', 'array', 'Reminder Days', 'Days before due date to send reminders', false), + ('dues', 'grace_period_days', '30', 'number', 'Grace Period', 'Days after due date before auto-inactive', false), + ('dues', 'overdue_reminder_interval', '14', 'number', 'Overdue Reminder Interval', 'Days between overdue reminder emails', false), + ('dues', 'auto_inactive_enabled', 'true', 'boolean', 'Auto Inactive', 'Automatically set members inactive after grace period', false), + +-- Event Settings + ('events', 'default_max_guests', '2', 'number', 'Default Max Guests', 'Default maximum guests per RSVP', false), + ('events', 'reminder_hours_before', '[24, 1]', 'array', 'Event Reminder Hours', 'Hours before event to send reminders', false), + ('events', 'allow_public_rsvp', 'true', 'boolean', 'Allow Public RSVP', 'Allow non-members to RSVP to public events', false), + ('events', 'auto_close_rsvp_hours', '0', 'number', 'Auto Close RSVP', 'Hours before event to close RSVP (0 = never)', false), + +-- Directory Settings + ('directory', 'member_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "member_since"]', 'array', 'Member Visible Fields', 'Fields visible to regular members', false), + ('directory', 'board_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "email", "phone", "address", "date_of_birth", "member_since", "membership_status"]', 'array', 'Board Visible Fields', 'Fields visible to board members', false), + ('directory', 'show_membership_status', 'false', 'boolean', 'Show Status to Members', 'Show membership status in directory for regular members', false), + +-- System Settings + ('system', 'maintenance_mode', 'false', 'boolean', 'Maintenance Mode', 'Put the portal in maintenance mode', false), + ('system', 'maintenance_message', '"The portal is currently undergoing maintenance. Please check back soon."', 'text', 'Maintenance Message', 'Message shown during maintenance', false), + ('system', 'session_timeout_hours', '168', 'number', 'Session Timeout', 'Hours until session expires (default: 7 days)', false), + ('system', 'max_upload_size_mb', '50', 'number', 'Max Upload Size', 'Maximum file upload size in MB', 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); +``` + +### 6.4 Settings UI Layout + +**Navigation Tabs:** +``` +┌──────────────────────────────────────────────────────────────────┐ +│ ⚙️ Settings │ +├──────────────────────────────────────────────────────────────────┤ +│ [Organization] [Membership] [Dues] [Events] [Documents] │ +│ [Directory] [Email] [System] │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Organization Settings │ │ +│ │ ───────────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ Association Name │ │ +│ │ ┌──────────────────────────────────────────────────────┐ │ │ +│ │ │ Monaco USA │ │ │ +│ │ └──────────────────────────────────────────────────────┘ │ │ +│ │ Official name of the association │ │ +│ │ │ │ +│ │ Tagline │ │ +│ │ ┌──────────────────────────────────────────────────────┐ │ │ +│ │ │ Americans in Monaco │ │ │ +│ │ └──────────────────────────────────────────────────────┘ │ │ +│ │ Association tagline shown on login │ │ +│ │ │ │ +│ │ Primary Color │ │ +│ │ ┌────────┐ ┌──────────────────────────────────────────┐ │ │ +│ │ │ 🎨 │ │ #dc2626 │ │ │ +│ │ └────────┘ └──────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [Save Changes] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 6.5 Membership Settings Tab + +**Manages configurable membership statuses and types:** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Membership Settings │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ MEMBERSHIP STATUSES │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ ┌───────────┬─────────────┬──────────┬────────────┬──────────┐ │ +│ │ Name │ Display │ Color │ Is Default │ Actions │ │ +│ ├───────────┼─────────────┼──────────┼────────────┼──────────┤ │ +│ │ pending │ Pending │ 🟡 Yellow│ ✓ │ ✏️ 🗑️ │ │ +│ │ active │ Active │ 🟢 Green │ │ ✏️ 🗑️ │ │ +│ │ inactive │ Inactive │ ⚪ Gray │ │ ✏️ 🗑️ │ │ +│ │ expired │ Expired │ 🔴 Red │ │ ✏️ 🗑️ │ │ +│ └───────────┴─────────────┴──────────┴────────────┴──────────┘ │ +│ │ +│ [+ Add Status] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ MEMBERSHIP TYPES │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ ┌───────────┬───────────────┬──────────┬────────────┬────────┐ │ +│ │ Name │ Display │ Annual € │ Is Default │Actions │ │ +│ ├───────────┼───────────────┼──────────┼────────────┼────────┤ │ +│ │ regular │ Regular │ €50.00 │ ✓ │ ✏️ 🗑️ │ │ +│ │ student │ Student │ €25.00 │ │ ✏️ 🗑️ │ │ +│ │ senior │ Senior (65+) │ €35.00 │ │ ✏️ 🗑️ │ │ +│ │ family │ Family │ €75.00 │ │ ✏️ 🗑️ │ │ +│ │ honorary │ Honorary │ €0.00 │ │ ✏️ 🗑️ │ │ +│ └───────────┴───────────────┴──────────┴────────────┴────────┘ │ +│ │ +│ [+ Add Membership Type] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 6.6 Event Types Settings + +**Admin can manage event types with colors and icons:** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Event Types │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┬───────────────┬────────────┬────────┬────────┐ │ +│ │ Name │ Display │ Color │ Icon │Actions │ │ +│ ├─────────────┼───────────────┼────────────┼────────┼────────┤ │ +│ │ social │ Social Event │ 🟢 #10b981 │ 🎉 │ ✏️ 🗑️ │ │ +│ │ meeting │ Meeting │ 🔵 #6366f1 │ 👥 │ ✏️ 🗑️ │ │ +│ │ fundraiser │ Fundraiser │ 🟠 #f59e0b │ 💝 │ ✏️ 🗑️ │ │ +│ │ workshop │ Workshop │ 🟣 #8b5cf6 │ 🎓 │ ✏️ 🗑️ │ │ +│ │ gala │ Gala/Formal │ 🌸 #ec4899 │ ✨ │ ✏️ 🗑️ │ │ +│ │ other │ Other │ ⚫ #6b7280 │ 📅 │ ✏️ 🗑️ │ │ +│ └─────────────┴───────────────┴────────────┴────────┴────────┘ │ +│ │ +│ [+ Add Event Type] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 6.7 Document Categories Settings + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Document Categories │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┬─────────────────────┬────────┬────────────┐ │ +│ │ Name │ Display │ Icon │ Actions │ │ +│ ├─────────────────┼─────────────────────┼────────┼────────────┤ │ +│ │ meeting_minutes │ Meeting Minutes │ 📄 │ ✏️ 🗑️ │ │ +│ │ governance │ Governance & Bylaws │ ⚖️ │ ✏️ 🗑️ │ │ +│ │ legal │ Legal Documents │ 💼 │ ✏️ 🗑️ │ │ +│ │ financial │ Financial Reports │ 💰 │ ✏️ 🗑️ │ │ +│ │ member_resources│ Member Resources │ 📚 │ ✏️ 🗑️ │ │ +│ │ forms │ Forms & Templates │ 📋 │ ✏️ 🗑️ │ │ +│ │ other │ Other Documents │ 📁 │ ✏️ 🗑️ │ │ +│ └─────────────────┴─────────────────────┴────────┴────────────┘ │ +│ │ +│ [+ Add Category] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 6.8 Directory Visibility Settings + +**Admin controls what fields are visible to different roles:** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Directory Visibility │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Configure which member fields are visible in the directory. │ +│ Admin always sees all fields. │ +│ │ +│ ┌─────────────────┬──────────────────┬──────────────────┐ │ +│ │ Field │ Visible to │ Visible to │ │ +│ │ │ Members │ Board │ │ +│ ├─────────────────┼──────────────────┼──────────────────┤ │ +│ │ First Name │ ☑️ Always shown │ ☑️ Always shown │ │ +│ │ Last Name │ ☑️ Always shown │ ☑️ Always shown │ │ +│ │ Profile Photo │ ☑️ │ ☑️ │ │ +│ │ Nationality │ ☑️ │ ☑️ │ │ +│ │ Email │ ☐ │ ☑️ │ │ +│ │ Phone │ ☐ │ ☑️ │ │ +│ │ Address │ ☐ │ ☑️ │ │ +│ │ Date of Birth │ ☐ │ ☑️ │ │ +│ │ Member Since │ ☑️ │ ☑️ │ │ +│ │ Status │ ☐ │ ☑️ │ │ +│ │ Membership Type │ ☐ │ ☑️ │ │ +│ └─────────────────┴──────────────────┴──────────────────┘ │ +│ │ +│ [Save Visibility Settings] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 6.9 System Settings Tab + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ System Settings │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ MAINTENANCE │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ ☐ Enable Maintenance Mode │ +│ │ +│ Maintenance Message: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ The portal is currently undergoing maintenance. │ │ +│ │ Please check back soon. │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ SECURITY │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Session Timeout (hours): │ +│ ┌────────────┐ │ +│ │ 168 │ (7 days) │ +│ └────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ FILE UPLOADS │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Max Upload Size (MB): │ +│ ┌────────────┐ │ +│ │ 50 │ │ +│ └────────────┘ │ +│ │ +│ Allowed File Types: │ +│ [PDF] [DOC] [DOCX] [XLS] [XLSX] [PPT] [PPTX] │ +│ [TXT] [JPG] [PNG] [WEBP] [+ Add Type] │ +│ │ +│ [Save System Settings] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 6.10 Settings Access Pattern + +```typescript +// src/lib/server/settings.ts + +// Get a single setting with type safety +export async function getSetting( + supabase: SupabaseClient, + category: string, + key: string, + defaultValue: T +): Promise { + const { data } = await supabase + .from('app_settings') + .select('setting_value') + .eq('category', category) + .eq('setting_key', key) + .single(); + + return data?.setting_value ?? defaultValue; +} + +// Get all settings for a category +export async function getCategorySettings( + supabase: SupabaseClient, + category: string +): Promise> { + const { data } = await supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', category); + + return Object.fromEntries( + (data ?? []).map(s => [s.setting_key, s.setting_value]) + ); +} + +// Update a setting (admin only) +export async function updateSetting( + supabase: SupabaseClient, + category: string, + key: string, + value: any, + userId: string +): Promise { + await supabase + .from('app_settings') + .update({ + setting_value: value, + updated_at: new Date().toISOString(), + updated_by: userId + }) + .eq('category', category) + .eq('setting_key', key); +} +``` + +### 6.11 Settings Permissions + +| Action | Member | Board | Admin | +|--------|--------|-------|-------| +| View public settings | ✓ | ✓ | ✓ | +| View all settings | - | - | ✓ | +| Edit settings | - | - | ✓ | +| Manage statuses | - | - | ✓ | +| Manage membership types | - | - | ✓ | +| Manage event types | - | - | ✓ | +| Manage document categories | - | - | ✓ | +| View settings audit log | - | - | ✓ | + +--- + +## 7. EMAIL SYSTEM (Detailed) + +### 7.1 Email Architecture + +**Provider**: Supabase Edge Functions + external SMTP (Resend, SendGrid, or Mailgun) + +**Why external SMTP:** +- Supabase built-in email is limited to auth emails only +- External SMTP provides better deliverability, tracking, and templates +- Resend recommended for simplicity and modern API + +### 7.2 Email Provider Configuration + +```sql +-- Email settings (stored in app_settings) +INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description) VALUES + ('email', 'provider', '"resend"', 'text', 'Email Provider', 'Email service provider (resend, sendgrid, mailgun)'), + ('email', 'api_key', '""', 'text', 'API Key', 'Email provider API key (stored securely)'), + ('email', 'from_address', '"noreply@monacousa.org"', 'text', 'From Address', 'Default sender email address'), + ('email', 'from_name', '"Monaco USA"', 'text', 'From Name', 'Default sender name'), + ('email', 'reply_to', '"contact@monacousa.org"', 'text', 'Reply-To Address', 'Reply-to email address'), + ('email', 'enable_tracking', 'true', 'boolean', 'Enable Tracking', 'Track email opens and clicks'), + ('email', 'batch_size', '50', 'number', 'Batch Size', 'Max emails per batch send'), + ('email', 'rate_limit_per_hour', '100', 'number', 'Rate Limit', 'Maximum emails per hour'); +``` + +### 7.3 Email Types & Triggers + +| Email Type | Trigger | Recipients | Automated | +|------------|---------|------------|-----------| +| `welcome` | New signup verified | New member | Yes | +| `email_verification` | Signup | New member | Yes (Supabase) | +| `password_reset` | Password reset request | Member | Yes (Supabase) | +| `dues_reminder` | X days before due | Member | Yes (cron) | +| `dues_due_today` | Due date | Member | Yes (cron) | +| `dues_overdue` | Every X days overdue | Member | Yes (cron) | +| `dues_lapsed` | Grace period ends | Member | Yes (cron) | +| `dues_received` | Payment recorded | Member | Yes | +| `event_created` | New event published | All/visibility | Optional | +| `event_reminder` | X hours before event | RSVP'd members | Yes (cron) | +| `event_updated` | Event details changed | RSVP'd members | Yes | +| `event_cancelled` | Event cancelled | RSVP'd members | Yes | +| `rsvp_confirmation` | RSVP submitted | Member | Yes | +| `waitlist_promoted` | Spot opens up | Waitlisted member | Yes | +| `member_invite` | Admin invites member | Invitee | Manual | +| `broadcast` | Admin sends message | Selected members | Manual | + +### 7.4 Email Templates Schema + +```sql +CREATE TABLE public.email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Template identification + template_key TEXT UNIQUE NOT NULL, -- 'dues_reminder', 'welcome', etc. + template_name TEXT NOT NULL, -- 'Dues Reminder Email' + category TEXT NOT NULL, -- 'dues', 'events', 'system' + + -- Template content + subject TEXT NOT NULL, -- Subject line with {{variables}} + body_html TEXT NOT NULL, -- HTML body with {{variables}} + body_text TEXT, -- Plain text fallback + + -- Settings + is_active BOOLEAN DEFAULT TRUE, + is_system BOOLEAN DEFAULT FALSE, -- System templates can't be deleted + + -- Metadata + variables_schema JSONB, -- Available variables documentation + preview_data JSONB, -- Sample data for preview + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES public.members(id) +); + +-- Default email templates +INSERT INTO email_templates (template_key, template_name, category, subject, body_html, is_system, variables_schema) VALUES + +-- Welcome Email +('welcome', 'Welcome Email', 'system', + 'Welcome to Monaco USA, {{member_name}}!', + ' + + + +
+ Monaco USA +

Welcome to Monaco USA!

+

Dear {{member_name}},

+

Thank you for joining Monaco USA! Your Member ID is {{member_id}}.

+

To complete your membership, please pay your annual dues of €{{dues_amount}}.

+

Payment Details:

+
    +
  • Bank: {{bank_name}}
  • +
  • IBAN: {{iban}}
  • +
  • Account Holder: {{account_holder}}
  • +
  • Reference: {{member_id}}
  • +
+

Access Your Portal

+

Best regards,
Monaco USA Team

+
+ +', + TRUE, + '{"member_name":"string","member_id":"string","dues_amount":"number","bank_name":"string","iban":"string","account_holder":"string","portal_link":"string","logo_url":"string"}' +), + +-- Dues Reminder +('dues_reminder', 'Dues Reminder', 'dues', + 'Your Monaco USA dues are due in {{days_until_due}} days', + ' + + +
+

Dues Reminder

+

Dear {{member_name}},

+

This is a friendly reminder that your Monaco USA membership dues of €{{dues_amount}} are due on {{due_date}} ({{days_until_due}} days from now).

+

Payment Details:

+
    +
  • IBAN: {{iban}}
  • +
  • Account Holder: {{account_holder}}
  • +
  • Reference: {{member_id}}
  • +
+

View Payment Details

+

Thank you for being a valued member!

+
+ +', + TRUE, + '{"member_name":"string","member_id":"string","dues_amount":"number","due_date":"date","days_until_due":"number","iban":"string","account_holder":"string","portal_link":"string"}' +), + +-- Dues Overdue +('dues_overdue', 'Dues Overdue Notice', 'dues', + 'OVERDUE: Your Monaco USA dues are {{days_overdue}} days past due', + ' + + +
+

Payment Overdue

+

Dear {{member_name}},

+

Your Monaco USA membership dues of €{{dues_amount}} are now {{days_overdue}} days overdue.

+

Please make your payment as soon as possible to maintain your membership benefits.

+ {{#if grace_period_remaining}} +

Note: You have {{grace_period_remaining}} days remaining in your grace period before your membership is set to inactive.

+ {{/if}} +

Payment Details:

+
    +
  • IBAN: {{iban}}
  • +
  • Account Holder: {{account_holder}}
  • +
  • Reference: {{member_id}}
  • +
+

Pay Now

+
+ +', + TRUE, + '{"member_name":"string","member_id":"string","dues_amount":"number","days_overdue":"number","grace_period_remaining":"number","iban":"string","account_holder":"string","portal_link":"string"}' +), + +-- Dues Received +('dues_received', 'Payment Confirmation', 'dues', + 'Thank you! Your Monaco USA dues payment has been received', + ' + + +
+

Payment Received!

+

Dear {{member_name}},

+

Thank you! We have received your membership dues payment.

+

Payment Details:

+
    +
  • Amount: €{{amount_paid}}
  • +
  • Payment Date: {{payment_date}}
  • +
  • Next Due Date: {{next_due_date}}
  • +
  • Reference: {{payment_reference}}
  • +
+

Your membership is now active until {{next_due_date}}.

+

View Payment History

+
+ +', + TRUE, + '{"member_name":"string","amount_paid":"number","payment_date":"date","next_due_date":"date","payment_reference":"string","portal_link":"string"}' +), + +-- Event RSVP Confirmation +('rsvp_confirmation', 'RSVP Confirmation', 'events', + 'You''re registered: {{event_title}}', + ' + + +
+

You''re Registered!

+

Dear {{member_name}},

+

Your RSVP for {{event_title}} has been confirmed.

+

Event Details:

+
    +
  • Date: {{event_date}}
  • +
  • Time: {{event_time}}
  • +
  • Location: {{event_location}}
  • + {{#if guest_count}}
  • Additional Guests: {{guest_count}}
  • {{/if}} +
+ {{#if is_paid}} +

Payment Required:

+

Total: €{{total_amount}}

+
    +
  • IBAN: {{iban}}
  • +
  • Reference: {{payment_reference}}
  • +
+ {{/if}} +

View Event

+
+ +', + TRUE, + '{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","guest_count":"number","is_paid":"boolean","total_amount":"number","iban":"string","payment_reference":"string","event_id":"string","portal_link":"string"}' +), + +-- Event Reminder +('event_reminder', 'Event Reminder', 'events', + 'Reminder: {{event_title}} is {{time_until_event}}', + ' + + +
+

Event Reminder

+

Dear {{member_name}},

+

This is a reminder that {{event_title}} is {{time_until_event}}.

+

Event Details:

+
    +
  • Date: {{event_date}}
  • +
  • Time: {{event_time}}
  • +
  • Location: {{event_location}}
  • +
+ {{#if event_description}} +

{{event_description}}

+ {{/if}} +

We look forward to seeing you there!

+
+ +', + TRUE, + '{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","event_description":"string","time_until_event":"string"}' +); +``` + +### 7.5 Email Logging Schema + +```sql +-- Enhanced email logs with tracking +CREATE TABLE public.email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Recipients + recipient_id UUID REFERENCES public.members(id), + recipient_email TEXT NOT NULL, + recipient_name TEXT, + + -- Email details + template_key TEXT REFERENCES public.email_templates(template_key), + subject TEXT NOT NULL, + email_type TEXT NOT NULL, + + -- Status tracking + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed')), + + -- Provider data + provider TEXT, -- 'resend', 'sendgrid', etc. + provider_message_id TEXT, -- External message ID for tracking + + -- Engagement tracking + opened_at TIMESTAMPTZ, + clicked_at TIMESTAMPTZ, + + -- Error handling + error_message TEXT, + retry_count INTEGER DEFAULT 0, + + -- Metadata + template_variables JSONB, -- Variables used in template + sent_by UUID REFERENCES public.members(id), -- For manual sends + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ +); + +-- Index for common queries +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_type ON public.email_logs(email_type); +CREATE INDEX idx_email_logs_created ON public.email_logs(created_at DESC); +``` + +### 7.6 Automated Email Scheduler (Supabase Edge Function) + +```typescript +// supabase/functions/email-scheduler/index.ts + +import { createClient } from '@supabase/supabase-js'; +import { Resend } from 'resend'; + +// Runs daily via pg_cron +Deno.serve(async (req) => { + const supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 1. Get settings + const settings = await getSettings(supabase); + const reminderDays = settings.reminder_days_before as number[]; + const gracePeriod = settings.grace_period_days as number; + + // 2. Find members needing reminders + const { data: membersWithDues } = await supabase + .from('members_with_dues') + .select('*') + .in('dues_status', ['current', 'due_soon', 'overdue']); + + for (const member of membersWithDues || []) { + const dueDate = new Date(member.current_due_date); + const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + const daysOverdue = member.days_overdue || 0; + + // Check if we need to send a reminder + if (daysUntil > 0 && reminderDays.includes(daysUntil)) { + // Send upcoming reminder + await sendEmail(supabase, 'dues_reminder', member, { + days_until_due: daysUntil + }); + } else if (daysUntil === 0) { + // Due today + await sendEmail(supabase, 'dues_due_today', member, {}); + } else if (daysOverdue > 0 && daysOverdue <= gracePeriod) { + // Overdue but in grace period + if (daysOverdue % settings.overdue_reminder_interval === 0) { + await sendEmail(supabase, 'dues_overdue', member, { + days_overdue: daysOverdue, + grace_period_remaining: gracePeriod - daysOverdue + }); + } + } else if (daysOverdue === gracePeriod + 1) { + // Grace period just ended + await sendEmail(supabase, 'dues_lapsed', member, {}); + } + } + + // 3. Send event reminders + await sendEventReminders(supabase, settings); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' } + }); +}); + +async function sendEmail( + supabase: any, + templateKey: string, + member: any, + extraVariables: Record +) { + // Get template + const { data: template } = await supabase + .from('email_templates') + .select('*') + .eq('template_key', templateKey) + .eq('is_active', true) + .single(); + + if (!template) return; + + // Build variables + const variables = { + member_name: `${member.first_name} ${member.last_name}`, + member_id: member.member_id, + dues_amount: member.annual_dues || 50, + due_date: member.current_due_date, + portal_link: Deno.env.get('PORTAL_URL'), + ...extraVariables + }; + + // Add payment settings + const settings = await getSettings(supabase); + variables.iban = settings.payment_iban; + variables.account_holder = settings.payment_account_holder; + variables.bank_name = settings.payment_bank_name; + + // Render template + const subject = renderTemplate(template.subject, variables); + const html = renderTemplate(template.body_html, variables); + + // Send via provider + const resend = new Resend(Deno.env.get('RESEND_API_KEY')); + const result = await resend.emails.send({ + from: `${settings.from_name} <${settings.from_address}>`, + to: member.email, + subject, + html + }); + + // Log email + await supabase.from('email_logs').insert({ + recipient_id: member.id, + recipient_email: member.email, + recipient_name: variables.member_name, + template_key: templateKey, + subject, + email_type: templateKey, + status: result.error ? 'failed' : 'sent', + provider: 'resend', + provider_message_id: result.data?.id, + error_message: result.error?.message, + template_variables: variables, + sent_at: new Date().toISOString() + }); +} +``` + +### 7.7 Email Settings UI (Admin) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Email Settings │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ PROVIDER CONFIGURATION │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Email Provider: │ +│ ┌────────────────────────────────────────┐ │ +│ │ Resend ▼ │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ API Key: │ +│ ┌────────────────────────────────────────┐ │ +│ │ re_••••••••••••••• │ [Test Connection] │ +│ └────────────────────────────────────────┘ │ +│ │ +│ From Address: │ +│ ┌────────────────────────────────────────┐ │ +│ │ noreply@monacousa.org │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ From Name: │ +│ ┌────────────────────────────────────────┐ │ +│ │ Monaco USA │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ Reply-To: │ +│ ┌────────────────────────────────────────┐ │ +│ │ contact@monacousa.org │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ TRACKING │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ ☑️ Enable open tracking │ +│ ☑️ Enable click tracking │ +│ │ +│ [Save Email Settings] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 7.8 Email Templates Editor (Admin) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Email Templates │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────┬────────────────┬─────────┬──────────────┐ │ +│ │ Template │ Category │ Active │ Actions │ │ +│ ├───────────────────┼────────────────┼─────────┼──────────────┤ │ +│ │ Welcome Email │ System │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Dues Reminder │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Dues Due Today │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Dues Overdue │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Dues Lapsed │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Dues Received │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ RSVP Confirmation │ Events │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Event Reminder │ Events │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Event Cancelled │ Events │ ✓ │ ✏️ 👁️ 📧 │ │ +│ │ Waitlist Promoted │ Events │ ✓ │ ✏️ 👁️ 📧 │ │ +│ └───────────────────┴────────────────┴─────────┴──────────────┘ │ +│ │ +│ Legend: ✏️ Edit | 👁️ Preview | 📧 Send Test │ +│ │ +│ [+ Create Custom Template] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 7.9 Template Editor View + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Edit Template: Dues Reminder │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Template Name: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Dues Reminder │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ Subject Line: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Your Monaco USA dues are due in {{days_until_due}} days │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────┬───────────────────────────────────┐ │ +│ │ HTML Editor │ Preview │ │ +│ ├──────────────────────┼───────────────────────────────────┤ │ +│ │

Dues ReminderDear {{member_nam │ ┌─────────────────────────────┐ │ │ +│ │ e}},

│ │ Dues Reminder │ │ │ +│ │

This is a friendl │ │ │ │ │ +│ │ y reminder that your │ │ Dear John Doe, │ │ │ +│ │ Monaco USA membershi │ │ │ │ │ +│ │ p dues...

│ │ This is a friendly │ │ │ +│ │ ... │ │ reminder that your Monaco │ │ │ +│ │ │ │ USA membership dues of │ │ │ +│ │ │ │ €50.00 are due on... │ │ │ +│ │ │ └─────────────────────────────┘ │ │ +│ └──────────────────────┴───────────────────────────────────┘ │ +│ │ +│ AVAILABLE VARIABLES: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ {{member_name}} {{member_id}} {{dues_amount}} {{due_date}}│ │ +│ │ {{days_until_due}} {{iban}} {{account_holder}} │ │ +│ │ {{portal_link}} │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ [Cancel] [Send Test Email] [Save Changes] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 7.10 Email Logs View (Admin) + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Email Logs │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Filter: [All Types ▼] [All Status ▼] [Last 30 days ▼] [Search] │ +│ │ +│ ┌────────┬────────────────┬──────────────┬────────┬──────────┐ │ +│ │ Date │ Recipient │ Subject │ Type │ Status │ │ +│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │ +│ │ Jan 9 │ john@email.com │ Your Monaco │ dues_ │ 📬 Opened │ │ +│ │ 14:30 │ John Doe │ USA dues... │ remind │ │ │ +│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │ +│ │ Jan 9 │ jane@email.com │ You're regis │ rsvp_ │ ✅ Sent │ │ +│ │ 10:15 │ Jane Smith │ tered: Gala │ conf │ │ │ +│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │ +│ │ Jan 8 │ bob@email.com │ OVERDUE: You │ dues_ │ 🔴 Bounce │ │ +│ │ 09:00 │ Bob Wilson │ r Monaco... │ overdu │ │ │ +│ └────────┴────────────────┴──────────────┴────────┴──────────┘ │ +│ │ +│ Status Legend: │ +│ ✅ Sent | 📬 Opened | 🔗 Clicked | 🔴 Bounced | ❌ Failed │ +│ │ +│ Stats: 156 sent this month | 78% open rate | 2 bounces │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 7.11 Manual Broadcast Feature (Admin) + +**Admin can send broadcast emails to selected members:** + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Send Broadcast Email │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Recipients: │ +│ ○ All active members (45) │ +│ ○ All members (52) │ +│ ○ Board members only (5) │ +│ ● Select specific members │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 🔍 Search members... │ │ +│ ├──────────────────────────────────────────────────────────┤ │ +│ │ ☑️ John Doe (john@email.com) │ │ +│ │ ☑️ Jane Smith (jane@email.com) │ │ +│ │ ☐ Bob Wilson (bob@email.com) │ │ +│ │ ... │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ Selected: 2 members │ +│ │ +│ Subject: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Important Update from Monaco USA │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ Message: │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ [Rich text editor with formatting options] │ │ +│ │ │ │ +│ │ Dear {{member_name}}, │ │ +│ │ │ │ +│ │ We wanted to inform you about... │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ [Preview] [Send Test to Myself] [Send to 2 Recipients] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 7.12 Email Cron Jobs (pg_cron in Supabase) + +```sql +-- Enable pg_cron extension +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- Schedule daily email checks (runs at 9 AM Monaco time) +SELECT cron.schedule( + 'daily-email-scheduler', + '0 9 * * *', -- Every day at 9:00 AM + $$ + SELECT net.http_post( + url := 'https://your-project.supabase.co/functions/v1/email-scheduler', + headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb, + body := '{}'::jsonb + ); + $$ +); + +-- Schedule event reminders (runs every hour) +SELECT cron.schedule( + 'hourly-event-reminders', + '0 * * * *', -- Every hour + $$ + SELECT net.http_post( + url := 'https://your-project.supabase.co/functions/v1/event-reminders', + headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb, + body := '{}'::jsonb + ); + $$ +); +``` + +### 7.13 Email Permissions + +| Action | Member | Board | Admin | +|--------|--------|-------|-------| +| Receive automated emails | ✓ | ✓ | ✓ | +| View own email history | ✓ | ✓ | ✓ | +| View all email logs | - | - | ✓ | +| Edit email templates | - | - | ✓ | +| Send broadcast emails | - | - | ✓ | +| Send manual reminders | - | ✓ | ✓ | +| Configure email settings | - | - | ✓ | +| Test email connection | - | - | ✓ | + +--- + +## Phase 1: Current System Analysis (COMPLETED) + +### Current Tech Stack +| Layer | Technology | +|-------|------------| +| Framework | Nuxt 3 (Vue 3) - SSR disabled, CSR-only | +| UI Components | Vuetify 3 + Tailwind CSS + Custom SCSS | +| Database | NocoDB (REST API over PostgreSQL) | +| Authentication | Keycloak (OAuth2/OIDC) | +| File Storage | MinIO (S3-compatible) | +| Email | Nodemailer + Handlebars templates | +| State | Vue Composition API (no Pinia) | + +### Current Features Inventory + +#### 1. Member Management +- **Data Model**: Member with 20+ fields (name, email, phone, DOB, nationality, address, etc.) +- **Member ID Format**: `MUSA-YYYY-XXXX` (auto-generated) +- **Status States**: Active, Inactive, Pending, Expired +- **Portal Tiers**: admin, board, user +- **Profile Images**: Stored in MinIO + +#### 2. Dues/Subscription System +- **Calculation**: Due 1 year after last payment +- **Fields Tracked**: `membership_date_paid`, `payment_due_date`, `current_year_dues_paid` +- **Reminders**: 30-day advance warning, overdue notifications +- **Auto-Status**: Members 1+ year overdue marked Inactive +- **Rates**: €50 regular, €25 student, €35 senior, €75 family, €200 corporate + +#### 3. Events System +- **Event Types**: meeting, social, fundraiser, workshop, board-only +- **RSVP System**: confirmed, declined, pending, waitlist +- **Guest Management**: Extra guests per RSVP +- **Pricing**: Member vs non-member pricing +- **Visibility**: public, board-only, admin-only +- **Calendar**: FullCalendar integration with iCal feed + +#### 4. Authentication & Authorization +- **Login Methods**: OAuth (Keycloak) + Direct login (ROPC) +- **Role System**: Keycloak realm roles (monaco-admin, monaco-board, monaco-user) +- **Session**: Server-side with HTTP-only cookies (7-30 days) +- **Rate Limiting**: 5 attempts/15min, 1-hour IP block +- **Signup Flow**: Form → reCAPTCHA → Keycloak user → NocoDB member → Verification email + +#### 5. Three Dashboard Types +- **Admin**: Full system control, user management, settings, all features +- **Board**: Member directory, dues management, events, meetings, governance +- **Member**: Personal profile, events, payments, resources + +### Current Pain Points (to address in rebuild) +1. CSR-only limits SEO and initial load performance +2. NocoDB adds complexity vs direct database access +3. String booleans ("true"/"false") cause type issues +4. No payment history table (only last payment tracked) +5. Vuetify + Tailwind overlap creates CSS conflicts +6. Large monolithic layout files (700-800+ lines each) + +--- + +## Phase 2: New System Requirements + +### Core Requirements (confirmed) +- [x] Beautiful, modern, responsive frontend (not generic Vue look) +- [x] Member tracking with subscription/dues management +- [x] Dues due 1 year after last payment +- [x] Event calendar with RSVP system +- [x] Board members can create/manage events +- [x] Event features: capacity, +1 guests, member/non-member pricing +- [x] Three dashboard types: Admin, Board, Member +- [x] Signup system similar to current +- [x] **Manual payment tracking only** (no Stripe integration) +- [x] **Email notifications** (dues reminders, event updates) +- [x] **Document storage** (meeting minutes, governance docs) + +### Deployment Strategy +- **Replace entirely** - Switch over when ready (no parallel systems) +- **Manual data entry** - Members will be entered manually (no migration scripts) + +--- + +## FRONTEND FRAMEWORK OPTIONS + +### Tier 1: Modern & Distinctive (Recommended) + +#### 1. **Qwik + QwikCity** ⭐ MOST INNOVATIVE +| Aspect | Details | +|--------|---------| +| **What it is** | Resumable framework - HTML loads instantly, JS loads on-demand | +| **Unique Feature** | Zero hydration - fastest possible load times | +| **Learning Curve** | Medium (JSX-like but different mental model) | +| **Ecosystem** | Growing - Auth.js, Drizzle, Modular Forms integrations | +| **Benchmark Score** | 93.8 (highest among all frameworks) | + +**Pros:** +- Blazing fast initial load (no hydration delay) +- Feels like React/JSX but with better performance model +- Built-in form handling with Zod validation +- Server functions with `"use server"` directive +- Excellent TypeScript support +- Unique - won't look like every other site + +**Cons:** +- Smaller community than React/Vue +- Fewer pre-built component libraries +- Newer framework (less battle-tested in production) +- Some patterns feel unfamiliar at first + +**Best For:** Performance-focused apps where first impression matters + +--- + +#### 2. **SolidStart (Solid.js)** ⭐ MOST PERFORMANT REACTIVITY +| Aspect | Details | +|--------|---------| +| **What it is** | Fine-grained reactive framework with meta-framework | +| **Unique Feature** | No Virtual DOM - direct DOM updates via signals | +| **Learning Curve** | Medium (React-like JSX, different reactivity) | +| **Ecosystem** | Good - Ark UI, Kobalte for components | +| **Benchmark Score** | 92.2 | + +**Pros:** +- Smallest bundle sizes in the industry +- React-like syntax (easy transition) +- True reactivity (no re-renders, just updates) +- Server functions and data loading built-in +- Growing rapidly in popularity +- Unique performance characteristics + +**Cons:** +- Smaller ecosystem than React +- Fewer tutorials and resources +- Some React patterns don't translate directly +- Component libraries less mature + +**Best For:** Highly interactive dashboards with lots of real-time updates + +--- + +#### 3. **SvelteKit** ⭐ BEST DEVELOPER EXPERIENCE +| Aspect | Details | +|--------|---------| +| **What it is** | Compiler-based framework with full-stack capabilities | +| **Unique Feature** | No virtual DOM, compiles to vanilla JS | +| **Learning Curve** | Low (closest to vanilla HTML/CSS/JS) | +| **Ecosystem** | Strong - Skeleton UI, Melt UI, Shadcn-Svelte | +| **Benchmark Score** | 91.0 | + +**Pros:** +- Simplest syntax - looks like enhanced HTML +- Smallest learning curve +- Excellent built-in animations/transitions +- Strong TypeScript integration +- Great form handling +- Active, helpful community +- Svelte 5 runes make state even simpler + +**Cons:** +- Different mental model from React/Vue +- Smaller job market (if that matters) +- Some advanced patterns less documented +- Breaking changes between Svelte 4 and 5 + +**Best For:** Clean, maintainable code with minimal boilerplate + +--- + +#### 4. **Astro + React/Vue/Svelte Islands** ⭐ MOST FLEXIBLE +| Aspect | Details | +|--------|---------| +| **What it is** | Content-focused framework with "islands" of interactivity | +| **Unique Feature** | Mix multiple frameworks, zero JS by default | +| **Learning Curve** | Low-Medium | +| **Ecosystem** | Excellent - use ANY UI library | +| **Benchmark Score** | 90.2 | + +**Pros:** +- Use React, Vue, Svelte, or Solid components together +- Zero JavaScript shipped by default +- Excellent for content + interactive sections +- Built-in image optimization +- Great Supabase integration documented +- View Transitions API support + +**Cons:** +- Not ideal for highly interactive SPAs +- Island architecture adds complexity +- More configuration for full interactivity +- Less unified than single-framework approach + +**Best For:** Marketing site + member portal hybrid + +--- + +### Tier 2: Battle-Tested Mainstream + +#### 5. **Next.js 15 (React)** +| Aspect | Details | +|--------|---------| +| **What it is** | Most popular React meta-framework | +| **Unique Feature** | App Router, Server Components, huge ecosystem | +| **Learning Curve** | Medium-High (lots of concepts) | +| **Ecosystem** | Largest - shadcn/ui, Radix, everything | +| **Benchmark Score** | N/A (didn't query) | + +**Pros:** +- Largest ecosystem and community +- Most job opportunities +- shadcn/ui provides beautiful, customizable components +- Excellent documentation +- Vercel hosting optimized + +**Cons:** +- Can feel "generic" - many sites use it +- Complex mental model (Server vs Client components) +- Heavier than alternatives +- Vercel-centric development + +--- + +#### 6. **Remix** +| Aspect | Details | +|--------|---------| +| **What it is** | Full-stack React framework focused on web standards | +| **Unique Feature** | Nested routing, progressive enhancement | +| **Learning Curve** | Medium | +| **Ecosystem** | Good - React ecosystem compatible | +| **Benchmark Score** | 89.4 | + +**Pros:** +- Web standards focused (works without JS) +- Excellent data loading patterns +- Great error handling +- Form handling is first-class +- Can deploy anywhere (not Vercel-locked) + +**Cons:** +- Smaller community than Next.js +- Less "magic" means more manual work +- Merged with React Router (transition period) + +--- + +#### 7. **TanStack Start (React)** +| Aspect | Details | +|--------|---------| +| **What it is** | New full-stack framework from TanStack team | +| **Unique Feature** | Type-safe from database to UI | +| **Learning Curve** | Medium | +| **Ecosystem** | TanStack Query, Form, Router built-in | +| **Benchmark Score** | 80.7 | + +**Pros:** +- Built by TanStack (Query, Router, Form authors) +- End-to-end type safety +- Modern patterns throughout +- Excellent data fetching built-in + +**Cons:** +- Very new (beta/early stage) +- Smaller community +- Less documentation +- Rapidly evolving API + +--- + +#### 8. **Nuxt 4 (Vue 3)** +| Aspect | Details | +|--------|---------| +| **What it is** | Latest Vue meta-framework | +| **Unique Feature** | Familiar from current system | +| **Learning Curve** | Low (you know it) | +| **Ecosystem** | Good - Nuxt UI, PrimeVue | + +**Pros:** +- Familiar - no learning curve +- Can reuse some current code/patterns +- Strong conventions +- Good TypeScript support now + +**Cons:** +- User specifically wants to avoid "generic Vue look" +- Similar limitations to current system +- Less innovative than alternatives + +--- + +#### 9. **Angular 19** +| Aspect | Details | +|--------|---------| +| **What it is** | Google's enterprise framework | +| **Unique Feature** | Signals, standalone components, full framework | +| **Learning Curve** | High | +| **Ecosystem** | Enterprise-grade | +| **Benchmark Score** | 90.3 | + +**Pros:** +- Complete framework (no decisions to make) +- Excellent for large applications +- Strong typing throughout +- Signals in Angular 19 are modern + +**Cons:** +- Steeper learning curve +- More verbose +- "Enterprise" feel may not fit small org +- Overkill for this scale + +--- + +### Tier 3: Experimental/Niche + +#### 10. **Leptos (Rust)** +| Aspect | Details | +|--------|---------| +| **What it is** | Full-stack Rust framework | +| **Unique Feature** | WASM-based, extremely fast | +| **Benchmark Score** | 89.7 | + +**Pros:** +- Blazing fast (Rust + WASM) +- Type safety at compile time +- Innovative approach + +**Cons:** +- Requires learning Rust +- Small ecosystem +- Harder to find developers +- Overkill for this use case + +--- + +#### 11. **Hono + HTMX** +| Aspect | Details | +|--------|---------| +| **What it is** | Lightweight backend + hypermedia frontend | +| **Unique Feature** | Server-rendered, minimal JS | +| **Benchmark Score** | 92.8 | + +**Pros:** +- Extremely lightweight +- Simple mental model +- Works on edge (Cloudflare Workers) +- Fast development + +**Cons:** +- Less rich interactivity +- Different paradigm (hypermedia) +- Limited complex UI patterns +- Manual work for dashboards + +--- + +## UI COMPONENT LIBRARY OPTIONS + +### For React-based Frameworks (Next.js, Remix, TanStack) + +| Library | Style | Customizable | Notes | +|---------|-------|--------------|-------| +| **shadcn/ui** | Modern, clean | Fully (copy/paste) | Most popular, highly customizable | +| **Radix Themes** | Polished | Theme-based | Beautiful defaults, less work | +| **Radix Primitives** | Unstyled | Fully | Build completely custom | +| **Ark UI** | Unstyled | Fully | Works with multiple frameworks | +| **Park UI** | Pre-styled Ark | Moderate | Ark + beautiful defaults | + +### For Solid.js + +| Library | Style | Notes | +|---------|-------|-------| +| **Kobalte** | Unstyled | Radix-like primitives for Solid | +| **Ark UI Solid** | Unstyled | Same Ark, Solid version | +| **Solid UI** | Various | Community components | + +### For Svelte + +| Library | Style | Notes | +|---------|-------|-------| +| **shadcn-svelte** | Modern | Port of shadcn for Svelte | +| **Skeleton UI** | Tailwind | Full design system | +| **Melt UI** | Unstyled | Primitives for Svelte | +| **Bits UI** | Unstyled | Headless components | + +### For Qwik + +| Library | Style | Notes | +|---------|-------|-------| +| **Qwik UI** | Official | Growing component library | +| **Custom + Tailwind** | Any | Build from scratch | + +### For Vue/Nuxt + +| Library | Style | Notes | +|---------|-------|-------| +| **shadcn-vue** | Modern | Port of shadcn for Vue | +| **Radix Vue** | Unstyled | Radix primitives for Vue | +| **Nuxt UI** | Tailwind | Official Nuxt components | +| **PrimeVue** | Various | Comprehensive but generic | + +--- + +## DATABASE OPTIONS - DETAILED COMPARISON + +### Option 1: **Supabase** ⭐ RECOMMENDED +| Aspect | Details | +|--------|---------| +| **Type** | PostgreSQL + Auth + Storage + Realtime | +| **Hosting** | Managed cloud or self-hosted | +| **Pricing** | Free tier, then $25/mo | + +**Pros:** +- All-in-one: Database + Auth + File Storage + Realtime +- PostgreSQL (industry standard, powerful) +- Row-level security built-in +- Excellent TypeScript support +- Auto-generated APIs +- Real-time subscriptions +- Built-in auth (replaces Keycloak) +- Dashboard for data management +- Can self-host if needed + +**Cons:** +- Vendor lock-in (mitigated by self-host option) +- Learning curve for RLS policies +- Free tier has limits +- Less control than raw PostgreSQL + +**Best For:** Rapid development with full-stack features + +--- + +### Option 2: **PostgreSQL + Prisma** +| Aspect | Details | +|--------|---------| +| **Type** | Direct database + Type-safe ORM | +| **Hosting** | Any PostgreSQL host (Neon, Railway, etc.) | +| **Pricing** | Database hosting costs only | + +**Pros:** +- Full control over database +- Prisma schema is very readable +- Excellent TypeScript types +- Migrations handled automatically +- Works with any PostgreSQL +- Large community + +**Cons:** +- Need separate auth solution +- Need separate file storage +- More setup work +- Prisma can be slow for complex queries + +**Best For:** Maximum control and flexibility + +--- + +### Option 3: **PostgreSQL + Drizzle ORM** +| Aspect | Details | +|--------|---------| +| **Type** | Direct database + Lightweight ORM | +| **Hosting** | Any PostgreSQL host | +| **Pricing** | Database hosting costs only | + +**Pros:** +- Closer to SQL (less abstraction) +- Faster than Prisma +- Smaller bundle size +- TypeScript-first +- Better for complex queries +- Growing rapidly + +**Cons:** +- Newer, smaller community +- Less documentation +- Need separate auth/storage +- More manual migration work + +**Best For:** Performance-critical apps, SQL-comfortable teams + +--- + +### Option 4: **PlanetScale + Drizzle** +| Aspect | Details | +|--------|---------| +| **Type** | Serverless MySQL | +| **Hosting** | Managed cloud only | +| **Pricing** | Free tier, then usage-based | + +**Pros:** +- Serverless scaling +- Branching (like git for databases) +- No connection limits +- Fast globally + +**Cons:** +- MySQL not PostgreSQL +- No foreign keys (by design) +- Vendor lock-in +- Can get expensive at scale + +**Best For:** Serverless deployments, edge functions + +--- + +### Option 5: **Keep NocoDB** +| Aspect | Details | +|--------|---------| +| **Type** | Spreadsheet-like interface over database | +| **Hosting** | Self-hosted or cloud | + +**Pros:** +- Already configured +- Non-technical users can edit data +- Flexible schema changes +- API already exists + +**Cons:** +- Adds complexity layer +- String booleans issue +- Less type safety +- Performance overhead +- Limited query capabilities + +**Best For:** Non-technical admin users need direct access + +--- + +## AUTHENTICATION OPTIONS - DETAILED COMPARISON + +### Option 1: **Supabase Auth** ⭐ IF USING SUPABASE +| Aspect | Details | +|--------|---------| +| **Type** | Built into Supabase | +| **Providers** | Email, OAuth (Google, GitHub, etc.), Magic Link | + +**Pros:** +- Integrated with Supabase (one platform) +- Row-level security integration +- Simple setup +- Built-in user management +- Social logins included +- Magic link support + +**Cons:** +- Tied to Supabase +- Less customizable than Keycloak +- No SAML/enterprise SSO on free tier + +--- + +### Option 2: **Keep Keycloak** +| Aspect | Details | +|--------|---------| +| **Type** | Self-hosted identity provider | +| **Providers** | Everything (OIDC, SAML, social, etc.) | + +**Pros:** +- Already configured and working +- Enterprise-grade features +- Full control +- SAML support +- Custom themes +- User federation + +**Cons:** +- Complex to maintain +- Heavy resource usage +- Overkill for small org +- Requires Java expertise +- Self-hosted burden + +--- + +### Option 3: **Better Auth** ⭐ MODERN CHOICE +| Aspect | Details | +|--------|---------| +| **Type** | Framework-agnostic TypeScript auth | +| **Providers** | Email, OAuth, Magic Link, Passkeys | + +**Pros:** +- Modern, TypeScript-first +- Works with any framework +- Plugin system for features +- Session management built-in +- Two-factor auth support +- Lightweight + +**Cons:** +- Newer (less battle-tested) +- Self-implemented +- Need own user storage + +--- + +### Option 4: **Auth.js (NextAuth)** +| Aspect | Details | +|--------|---------| +| **Type** | Framework-agnostic auth library | +| **Providers** | 50+ OAuth providers | + +**Pros:** +- Massive provider support +- Well documented +- Active development +- Works with Qwik, SvelteKit, etc. + +**Cons:** +- Complex configuration +- Database adapter setup +- v5 migration issues +- Can be finicky + +--- + +### Option 5: **Clerk** +| Aspect | Details | +|--------|---------| +| **Type** | Auth-as-a-service | +| **Providers** | Everything + beautiful UI | + +**Pros:** +- Beautiful pre-built components +- Zero config setup +- Great DX +- Organizations/teams built-in + +**Cons:** +- Expensive at scale +- Vendor lock-in +- Less control +- Monthly costs + +--- + +### Option 6: **Lucia Auth** +| Aspect | Details | +|--------|---------| +| **Type** | Low-level auth library | +| **Note** | Being deprecated in favor of guides | + +**Pros:** +- Full control +- Lightweight +- Educational + +**Cons:** +- Being sunset +- More DIY work + +--- + +## CHOSEN STACK (FINAL) + +| Layer | Technology | Rationale | +|-------|------------|-----------| +| **Framework** | SvelteKit 2 | Best DX, simple syntax, excellent performance | +| **UI Components** | shadcn-svelte + Bits UI | Beautiful, customizable, accessible | +| **Styling** | Tailwind CSS 4 | Utility-first, works great with shadcn | +| **Database** | Supabase (PostgreSQL) | All-in-one, managed, real-time capable | +| **Auth** | Supabase Auth | Integrated with database, simple setup | +| **File Storage** | Supabase Storage | Profile images, documents | +| **Design** | Glass-morphism (evolved) | Modern, distinctive, refined | +| **Language** | TypeScript | Type safety throughout | + +--- + +## Phase 3: Architecture Design + +### Project Structure + +``` +monacousa-portal-2026/ +├── src/ +│ ├── lib/ +│ │ ├── components/ # Reusable UI components +│ │ │ ├── ui/ # shadcn-svelte base components +│ │ │ ├── dashboard/ # Dashboard widgets +│ │ │ ├── members/ # Member-related components +│ │ │ ├── events/ # Event components +│ │ │ └── layout/ # Layout components (sidebar, header) +│ │ ├── server/ # Server-only utilities +│ │ │ ├── supabase.ts # Supabase server client +│ │ │ └── auth.ts # Auth helpers +│ │ ├── stores/ # Svelte stores for state +│ │ ├── utils/ # Shared utilities +│ │ │ ├── types.ts # TypeScript types +│ │ │ ├── constants.ts # App constants +│ │ │ └── helpers.ts # Helper functions +│ │ └── supabase.ts # Supabase client (browser) +│ │ +│ ├── routes/ +│ │ ├── +layout.svelte # Root layout +│ │ ├── +layout.server.ts # Root server load (auth) +│ │ ├── +page.svelte # Landing page +│ │ │ +│ │ ├── (auth)/ # Auth group (guest only) +│ │ │ ├── login/ +│ │ │ ├── signup/ +│ │ │ ├── forgot-password/ +│ │ │ └── callback/ # OAuth callback +│ │ │ +│ │ ├── (app)/ # Protected app group +│ │ │ ├── +layout.svelte # App layout with sidebar +│ │ │ ├── +layout.server.ts # Auth guard +│ │ │ │ +│ │ │ ├── dashboard/ # User dashboard +│ │ │ ├── profile/ # User profile +│ │ │ ├── events/ # Events calendar/list +│ │ │ ├── payments/ # Dues/payments view +│ │ │ │ +│ │ │ ├── board/ # Board-only routes +│ │ │ │ ├── +layout.server.ts # Board guard +│ │ │ │ ├── dashboard/ +│ │ │ │ ├── members/ +│ │ │ │ ├── events/ # Event management +│ │ │ │ └── meetings/ +│ │ │ │ +│ │ │ └── admin/ # Admin-only routes +│ │ │ ├── +layout.server.ts # Admin guard +│ │ │ ├── dashboard/ +│ │ │ ├── members/ +│ │ │ ├── users/ +│ │ │ ├── events/ +│ │ │ └── settings/ +│ │ │ +│ │ └── api/ # API routes (if needed) +│ │ +│ ├── hooks.server.ts # Server hooks (Supabase SSR) +│ └── app.d.ts # TypeScript declarations +│ +├── static/ # Static assets +├── supabase/ # Supabase local dev +│ └── migrations/ # Database migrations +├── tests/ # Test files +├── svelte.config.js +├── tailwind.config.ts +├── vite.config.ts +└── package.json +``` + +--- + +### Database Schema (Supabase/PostgreSQL) + +```sql +-- USERS (managed by Supabase Auth) +-- auth.users table is automatic + +-- MEMBERS (extends auth.users) +CREATE TABLE public.members ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + member_id TEXT UNIQUE NOT NULL, -- MUSA-2026-0001 format + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + phone TEXT, + date_of_birth DATE, + address TEXT, + nationality TEXT[], -- Array of country codes ['FR', 'US'] + + -- Membership + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'board', 'admin')), + membership_status TEXT NOT NULL DEFAULT 'pending' + CHECK (membership_status IN ('active', 'inactive', 'pending', 'expired')), + member_since DATE DEFAULT CURRENT_DATE, + + -- Profile + avatar_url TEXT, + bio TEXT, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- DUES/PAYMENTS (tracks payment history) +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, -- When this payment period ends + payment_method TEXT, -- 'bank_transfer', 'cash', etc. + reference TEXT, -- Transaction reference + notes TEXT, + + recorded_by UUID REFERENCES public.members(id), -- Who recorded this payment + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- EVENTS +CREATE TABLE public.events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + title TEXT NOT NULL, + description TEXT, + event_type TEXT NOT NULL CHECK (event_type IN ('social', 'meeting', 'fundraiser', 'workshop', 'other')), + + start_datetime TIMESTAMPTZ NOT NULL, + end_datetime TIMESTAMPTZ NOT NULL, + location TEXT, + + -- Capacity & Pricing + max_attendees INTEGER, + max_guests_per_member INTEGER DEFAULT 1, + member_price DECIMAL(10,2) DEFAULT 0, + non_member_price DECIMAL(10,2) DEFAULT 0, + + -- Visibility + 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')), + + -- Metadata + created_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- EVENT RSVPs +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', 'waitlist', 'cancelled')), + guest_count INTEGER DEFAULT 0, + guest_names TEXT[], + + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + + attended BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(event_id, member_id) +); + +-- DOCUMENTS (meeting minutes, governance, etc.) +CREATE TABLE public.documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + title TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL CHECK (category IN ('meeting_minutes', 'governance', 'financial', 'other')), + + file_path TEXT NOT NULL, -- Supabase Storage path + file_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + + visibility TEXT NOT NULL DEFAULT 'board' + CHECK (visibility IN ('members', 'board', 'admin')), + + uploaded_by UUID NOT NULL REFERENCES public.members(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- EMAIL NOTIFICATIONS LOG +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, + email_type TEXT NOT NULL, -- 'dues_reminder', 'event_invite', etc. + subject TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'sent' + CHECK (status IN ('sent', 'failed', 'bounced')), + + sent_at TIMESTAMPTZ DEFAULT NOW(), + error_message TEXT +); + +-- COMPUTED VIEW: Member with dues status +CREATE VIEW public.members_with_dues AS +SELECT + m.*, + dp.payment_date as last_payment_date, + dp.due_date as current_due_date, + CASE + WHEN dp.due_date IS NULL THEN 'never_paid' + WHEN dp.due_date < CURRENT_DATE THEN 'overdue' + WHEN dp.due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon' + ELSE 'current' + END as dues_status, + CASE + WHEN dp.due_date < CURRENT_DATE + THEN CURRENT_DATE - dp.due_date + ELSE NULL + END as days_overdue +FROM public.members m +LEFT JOIN LATERAL ( + SELECT payment_date, due_date + FROM public.dues_payments + WHERE member_id = m.id + ORDER BY due_date DESC + LIMIT 1 +) dp ON true; + +-- ROW LEVEL SECURITY +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; + +-- Members: Users can read all, update own, admins can do anything +CREATE POLICY "Members are 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 -- Self-registration + ); + +-- Events: Based on visibility +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' + )) + ); + +-- Board/Admin can manage events +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')) + ); +``` + +--- + +### Authentication Flow + +``` +1. SIGNUP FLOW + ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ + │ /signup │────▶│ Supabase Auth│────▶│ Email Verify│ + │ Form │ │ signUp() │ │ Link Sent │ + └─────────────┘ └──────────────┘ └─────────────┘ + │ + ▼ + ┌──────────────┐ + │ Create Member│ + │ Record (RLS) │ + └──────────────┘ + +2. LOGIN FLOW + ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ + │ /login │────▶│ Supabase Auth│────▶│ Set Session │ + │ Form │ │ signIn() │ │ Cookie │ + └─────────────┘ └──────────────┘ └─────────────┘ + │ + ▼ + ┌──────────────┐ + │ Redirect to │ + │ Dashboard │ + └──────────────┘ + +3. PROTECTED ROUTES + ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ + │ Request │────▶│ hooks.server │────▶│ Check Role │ + │ /admin/* │ │ getSession() │ │ in members │ + └─────────────┘ └──────────────┘ └─────────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌─────────────┐ + │ Valid? │ │ Redirect or │ + │ Yes → Render │ │ 403 Error │ + └──────────────┘ └─────────────┘ +``` + +--- + +### UI Component Library + +Using **shadcn-svelte** with custom glass-morphism theme: + +```typescript +// tailwind.config.ts - Glass theme extensions +export default { + theme: { + extend: { + colors: { + monaco: { + 50: '#fef2f2', + 100: '#fee2e2', + 500: '#ef4444', + 600: '#dc2626', // Primary + 700: '#b91c1c', + 900: '#7f1d1d', + } + }, + backdropBlur: { + xs: '2px', + }, + boxShadow: { + 'glass': '0 8px 32px rgba(0, 0, 0, 0.1)', + 'glass-lg': '0 25px 50px rgba(0, 0, 0, 0.15)', + } + } + } +} +``` + +**Custom Glass Components:** +- `GlassCard` - Frosted glass container +- `GlassSidebar` - Navigation sidebar +- `GlassButton` - Glass-effect buttons +- `GlassInput` - Form inputs with glass styling +- `StatCard` - Dashboard stat display +- `EventCard` - Event display card +- `MemberCard` - Member profile card +- `DuesStatusBadge` - Dues status indicator + +--- + +### Key Features Implementation + +#### 1. Member Management +- View all members (admin/board) +- Edit member details +- Upload profile photos (Supabase Storage) +- Track membership status +- Filter by status, nationality, dues + +#### 2. Dues Tracking +- Payment history table +- Auto-calculate due dates (1 year from payment) +- Visual status indicators +- Overdue notifications +- Manual payment recording + +#### 3. Event System +- Calendar view (FullCalendar or custom) +- List view with filters +- RSVP with guest management +- Attendance tracking +- Event creation (board/admin) + +#### 4. Three Dashboards +| Dashboard | Features | +|-----------|----------| +| **Member** | Profile, upcoming events, dues status, quick actions | +| **Board** | Member stats, pending applications, dues overview, event management | +| **Admin** | System stats, user management, all member data, settings | + +#### 5. Email Notifications +- Dues reminder emails (30 days before, on due date, overdue) +- Event invitation/updates +- Welcome email on signup +- Password reset emails (Supabase built-in) + +#### 6. Document Storage +- Upload meeting minutes, governance docs +- Organize by category +- Visibility controls (members/board/admin) +- Download/preview functionality + +--- + +## Phase 4: Implementation Roadmap + +### Stage 1: Foundation (Week 1-2) +1. Initialize SvelteKit project with TypeScript +2. Set up Tailwind CSS 4 + shadcn-svelte +3. Configure Supabase project +4. Create database schema + migrations +5. Implement Supabase SSR hooks +6. Build base layout components + +### Stage 2: Authentication (Week 2-3) +1. Login/Signup pages +2. Email verification flow +3. Password reset +4. Protected route guards +5. Role-based access control + +### Stage 3: Core Features (Week 3-5) +1. Member dashboard +2. Profile management +3. Member directory (board/admin) +4. Dues tracking system +5. Payment recording + +### Stage 4: Events (Week 5-6) +1. Event listing/calendar +2. Event detail view +3. RSVP system +4. Event creation (board) +5. Attendance tracking + +### Stage 5: Admin Features (Week 6-7) +1. Admin dashboard +2. User management +3. System settings +4. Data export + +### Stage 6: Polish (Week 7-8) +1. Glass-morphism styling refinement +2. Responsive design +3. Performance optimization +4. Testing +5. Documentation + +--- + +## Verification Plan + +### Development Testing +```bash +# Start Supabase local +npx supabase start + +# Run dev server +npm run dev + +# Type checking +npm run check +``` + +### Manual Testing Checklist +- [ ] User can sign up and receive verification email +- [ ] User can log in and see dashboard +- [ ] Member can view/edit profile +- [ ] Member can view events and RSVP +- [ ] Board member can access board dashboard +- [ ] Board member can create/manage events +- [ ] Board member can view member directory +- [ ] Board member can record dues payments +- [ ] Admin can access all features +- [ ] Admin can manage user roles +- [ ] Role-based routing works correctly +- [ ] Responsive on mobile/tablet/desktop + +### Browser Testing +- Chrome, Firefox, Safari, Edge +- iOS Safari, Android Chrome + +--- + +## Data Entry Strategy + +Since members will be entered manually (no automated migration): + +### Admin Setup +1. Create first admin account via Supabase dashboard +2. Manually set `role = 'admin'` in members table +3. Admin can then add other members through the portal + +### Member Entry Options +1. **Admin adds members** - Admin creates accounts for existing members +2. **Self-registration** - Members sign up themselves +3. **Invite system** - Admin sends email invites with signup links + +### Initial Launch Checklist +- [ ] Admin account created and verified +- [ ] Board member accounts created +- [ ] Test member account for verification +- [ ] Email templates configured (Supabase) + +--- + +## Files to Create + +| Path | Purpose | +|------|---------| +| `monacousa-portal-2026/` | New project root | +| `src/hooks.server.ts` | Supabase SSR setup | +| `src/lib/supabase.ts` | Client initialization | +| `src/lib/server/supabase.ts` | Server client | +| `src/routes/+layout.svelte` | Root layout | +| `src/routes/(auth)/login/+page.svelte` | Login page | +| `src/routes/(auth)/signup/+page.svelte` | Signup page | +| `src/routes/(app)/+layout.svelte` | App layout | +| `src/routes/(app)/dashboard/+page.svelte` | Member dashboard | +| `supabase/migrations/001_schema.sql` | Database schema | diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..43f85a0 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,230 @@ +# Monaco USA Portal - Production Deployment Guide + +## Prerequisites + +- Debian/Ubuntu server with root access +- Domain DNS configured (portal.monacousa.org, api.monacousa.org, studio.monacousa.org) +- Ports 80 and 443 open in firewall + +## Quick Start + +### 1. First-Time Server Setup + +```bash +# Clone the repository +git clone https://code.letsbe.solutions/matt/monacousa-portal.git +cd monacousa-portal + +# Make deploy script executable +chmod +x deploy.sh + +# Run first-time setup (installs Docker, configures firewall) +sudo ./deploy.sh setup +``` + +### 2. Configure Environment + +```bash +# Copy environment template +cp .env.production.example .env + +# Generate secrets +./deploy.sh generate-secrets + +# Edit environment file with your values +nano .env +``` + +**Important environment variables to configure:** +- `DOMAIN` - Your domain (e.g., portal.monacousa.org) +- `POSTGRES_PASSWORD` - Strong database password +- `JWT_SECRET` - 32+ character random string +- `ANON_KEY` / `SERVICE_ROLE_KEY` - Generate at supabase.com/docs/guides/self-hosting#api-keys +- `SMTP_*` - Email server settings + +### 3. Install and Configure Nginx + +```bash +# Install nginx +sudo apt install nginx certbot python3-certbot-nginx -y + +# Copy nginx config +sudo cp nginx/portal.monacousa.org.initial.conf /etc/nginx/sites-available/portal.monacousa.org + +# Enable the site +sudo ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/ + +# Remove default site if exists +sudo rm -f /etc/nginx/sites-enabled/default + +# Test config +sudo nginx -t + +# Reload nginx +sudo systemctl reload nginx +``` + +### 4. Deploy Docker Services + +```bash +# Deploy all services +./deploy.sh deploy + +# Wait for services to be healthy (check status) +./deploy.sh status +``` + +### 5. Get SSL Certificate + +```bash +# Get SSL certificate (after Docker services are running) +sudo certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org + +# Test auto-renewal +sudo certbot renew --dry-run +``` + +## Common Commands + +```bash +# View logs +./deploy.sh logs # All services +./deploy.sh logs portal # Portal only +./deploy.sh logs db # Database only + +# Service management +./deploy.sh status # Check status +./deploy.sh restart # Restart all services +./deploy.sh stop # Stop all services + +# Database +./deploy.sh backup # Backup database +./deploy.sh restore backup.sql.gz # Restore from backup + +# Updates +./deploy.sh update # Pull latest code and rebuild portal + +# Cleanup +./deploy.sh cleanup # Remove unused Docker resources +``` + +## Architecture + +``` + ┌─────────────────┐ + │ Internet │ + └────────┬────────┘ + │ + ┌────────┴────────┐ + │ Nginx (Host) │ + │ :80 / :443 │ + │ SSL Termination│ + └────────┬────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ Portal │ │ API │ │ Studio │ + │ :7453 │ │ :7455 │ │ :7454 │ + └────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ ┌────┴────┐ │ + │ │ Kong │ │ + │ │ Gateway │ │ + │ └────┬────┘ │ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────────┐ + │ Docker Network │ + │ ┌──────┐ ┌──────┐ ┌─────────┐ ┌──────────┐ │ + │ │ DB │ │ Auth │ │ Storage │ │ Realtime │ │ + │ └──────┘ └──────┘ └─────────┘ └──────────┘ │ + └─────────────────────────────────────────────────┘ +``` + +## Ports + +| Service | Internal Port | External (localhost) | +|---------|---------------|---------------------| +| Portal | 3000 | 7453 | +| Studio | 3000 | 7454 | +| Kong | 8000 | 7455 | + +## Troubleshooting + +### Services not starting + +```bash +# Check Docker logs +docker logs monacousa-portal +docker logs monacousa-db +docker logs monacousa-kong + +# Check if ports are in use +sudo netstat -tlnp | grep -E '7453|7454|7455' +``` + +### Database connection issues + +```bash +# Check database health +docker exec monacousa-db pg_isready -U postgres + +# View database logs +docker logs monacousa-db --tail=50 +``` + +### Nginx issues + +```bash +# Test config +sudo nginx -t + +# Check error log +sudo tail -f /var/log/nginx/error.log + +# Check portal access log +sudo tail -f /var/log/nginx/portal.monacousa.org.error.log +``` + +### SSL certificate issues + +```bash +# Renew certificates manually +sudo certbot renew + +# Check certificate status +sudo certbot certificates +``` + +## Backup Strategy + +### Automated Daily Backups + +Add to crontab (`crontab -e`): + +```bash +# Daily database backup at 3 AM +0 3 * * * /path/to/monacousa-portal/deploy.sh backup 2>&1 | logger -t monacousa-backup +``` + +### Backup Storage + +Backups are saved to the project directory as `backup_YYYYMMDD_HHMMSS.sql.gz`. + +Consider copying to remote storage: +```bash +# Copy to remote server +scp backup_*.sql.gz user@backup-server:/backups/monacousa/ +``` + +## Security Checklist + +- [ ] Strong passwords in .env file +- [ ] Firewall enabled (only 80, 443, 22 open) +- [ ] SSL certificate installed +- [ ] Studio protected with basic auth +- [ ] Regular backups configured +- [ ] Log rotation configured +- [ ] Fail2ban installed (optional) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a6b0b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# Monaco USA Portal - SvelteKit Application +# Multi-stage build for optimized production image + +# ============================================ +# Stage 1: Dependencies +# ============================================ +FROM node:20-alpine AS deps +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm ci + +# ============================================ +# Stage 2: Builder +# ============================================ +FROM node:20-alpine AS builder +WORKDIR /app + +# Copy package files first +COPY package.json package-lock.json* ./ + +# Install dependencies - use npm install instead of npm ci to properly +# resolve platform-specific optional dependencies (rollup binaries) +RUN rm -rf node_modules && npm install --legacy-peer-deps + +# Copy source files +COPY . . + +# Build arguments for environment variables +ARG PUBLIC_SUPABASE_URL +ARG PUBLIC_SUPABASE_ANON_KEY +ARG SUPABASE_SERVICE_ROLE_KEY + +# Set environment variables for build +ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL +ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY +ENV SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY + +# Build the application +RUN npm run build + +# Prune dev dependencies +RUN npm prune --production + +# ============================================ +# Stage 3: Runner (Production) +# ============================================ +FROM node:20-alpine AS runner +WORKDIR /app + +# Set production environment +ENV NODE_ENV=production + +# Create non-root user for security +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 sveltekit + +# Copy built application +COPY --from=builder --chown=sveltekit:nodejs /app/build ./build +COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=sveltekit:nodejs /app/package.json ./package.json + +# Switch to non-root user +USER sveltekit + +# Expose port +EXPOSE 3000 + +# Set runtime environment variables +ENV HOST=0.0.0.0 +ENV PORT=3000 + +# Start the application +CMD ["node", "build"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..75842c4 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/build.log b/build.log new file mode 100644 index 0000000..e69de29 diff --git a/components.json b/components.json new file mode 100644 index 0000000..05e10cb --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "new-york", + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks" + }, + "typescript": true, + "registry": "https://next.shadcn-svelte.com/registry" +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..1201afe --- /dev/null +++ b/deploy.sh @@ -0,0 +1,339 @@ +#!/bin/bash +# Monaco USA Portal - Production Deployment Script +# For Debian/Ubuntu Linux servers +# +# Usage: ./deploy.sh [command] +# Commands: +# setup - First-time setup (install Docker, configure firewall) +# deploy - Build and start all services +# update - Pull latest changes and rebuild portal +# logs - View logs +# status - Check service status +# backup - Backup database +# restore - Restore database from backup + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +COMPOSE_FILE="docker-compose.nginx.yml" +PROJECT_NAME="monacousa" + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if running as root +check_root() { + if [ "$EUID" -ne 0 ]; then + log_error "Please run as root (sudo ./deploy.sh)" + exit 1 + fi +} + +# Install Docker and Docker Compose on Debian +install_docker() { + log_info "Installing Docker..." + + # Remove old versions + apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true + + # Install dependencies + apt-get update + apt-get install -y \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg \ + lsb-release + + # Add Docker's official GPG key + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + # Add repository + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Install Docker + apt-get update + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # Start and enable Docker + systemctl start docker + systemctl enable docker + + log_info "Docker installed successfully" +} + +# Configure firewall +configure_firewall() { + log_info "Configuring firewall..." + + # Install ufw if not present + apt-get install -y ufw + + # Allow SSH, HTTP, HTTPS + ufw allow ssh + ufw allow http + ufw allow https + + # Enable firewall + ufw --force enable + + log_info "Firewall configured (SSH, HTTP, HTTPS allowed)" +} + +# First-time setup +setup() { + check_root + log_info "Starting first-time setup..." + + # Update system + apt-get update && apt-get upgrade -y + + # Install Docker + install_docker + + # Configure firewall + configure_firewall + + # Install useful tools + apt-get install -y htop nano git apache2-utils + + # Check for .env file + if [ ! -f .env ]; then + log_warn ".env file not found!" + log_info "Copy .env.production.example to .env and configure it:" + echo " cp .env.production.example .env" + echo " nano .env" + fi + + log_info "Setup complete! Next steps:" + echo " 1. Configure .env file: nano .env" + echo " 2. Deploy: ./deploy.sh deploy" +} + +# Generate secrets helper +generate_secrets() { + log_info "Generating secrets..." + echo "" + echo "JWT_SECRET=$(openssl rand -base64 32)" + echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)" + echo "SECRET_KEY_BASE=$(openssl rand -base64 64)" + echo "" + log_info "Copy these values to your .env file" +} + +# Deploy/start services +deploy() { + log_info "Deploying Monaco USA Portal..." + + # Check for .env file + if [ ! -f .env ]; then + log_error ".env file not found! Copy .env.production.example to .env first." + exit 1 + fi + + # Build and start + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME build --no-cache portal + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME up -d + + log_info "Deployment complete!" + log_info "Waiting for services to be healthy..." + sleep 10 + + # Show status + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps + + log_info "Portal should be available at https://\$(grep DOMAIN .env | cut -d '=' -f2)" +} + +# Update and rebuild +update() { + log_info "Updating Monaco USA Portal..." + + # Pull latest code (if git repo) + if [ -d .git ]; then + git pull origin main + fi + + # Rebuild only the portal service + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME build --no-cache portal + + # Restart portal with zero downtime + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME up -d --no-deps portal + + log_info "Update complete!" +} + +# View logs +logs() { + local service=${1:-""} + if [ -z "$service" ]; then + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME logs -f --tail=100 + else + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME logs -f --tail=100 $service + fi +} + +# Check status +status() { + log_info "Service Status:" + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps + echo "" + log_info "Resource Usage:" + docker stats --no-stream $(docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps -q) +} + +# Backup database +backup() { + local backup_file="backup_$(date +%Y%m%d_%H%M%S).sql" + log_info "Backing up database to $backup_file..." + + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \ + pg_dump -U postgres postgres > "$backup_file" + + # Compress + gzip "$backup_file" + + log_info "Backup complete: ${backup_file}.gz" +} + +# Restore database +restore() { + local backup_file=$1 + if [ -z "$backup_file" ]; then + log_error "Usage: ./deploy.sh restore " + exit 1 + fi + + log_warn "This will overwrite the current database!" + read -p "Are you sure? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + + log_info "Restoring database from $backup_file..." + + # Decompress if needed + if [[ "$backup_file" == *.gz ]]; then + gunzip -c "$backup_file" | docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \ + psql -U postgres postgres + else + cat "$backup_file" | docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \ + psql -U postgres postgres + fi + + log_info "Restore complete!" +} + +# Stop all services +stop() { + log_info "Stopping all services..." + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME down + log_info "All services stopped" +} + +# Restart all services +restart() { + log_info "Restarting all services..." + docker compose -f $COMPOSE_FILE -p $PROJECT_NAME restart + log_info "All services restarted" +} + +# Clean up unused Docker resources +cleanup() { + log_info "Cleaning up unused Docker resources..." + docker system prune -af --volumes + log_info "Cleanup complete" +} + +# Show help +help() { + echo "Monaco USA Portal - Deployment Script" + echo "" + echo "Usage: ./deploy.sh [command]" + echo "" + echo "Commands:" + echo " setup First-time server setup (install Docker, firewall)" + echo " generate-secrets Generate random secrets for .env" + echo " deploy Build and start all services" + echo " update Pull latest code and rebuild portal" + echo " stop Stop all services" + echo " restart Restart all services" + echo " status Show service status and resource usage" + echo " logs [service] View logs (optionally for specific service)" + echo " backup Backup database to file" + echo " restore Restore database from backup" + echo " cleanup Remove unused Docker resources" + echo " help Show this help message" + echo "" + echo "Examples:" + echo " sudo ./deploy.sh setup # First-time setup" + echo " ./deploy.sh deploy # Deploy the portal" + echo " ./deploy.sh logs portal # View portal logs" + echo " ./deploy.sh backup # Backup database" +} + +# Main command handler +case "${1:-help}" in + setup) + setup + ;; + generate-secrets) + generate_secrets + ;; + deploy) + deploy + ;; + update) + update + ;; + stop) + stop + ;; + restart) + restart + ;; + status) + status + ;; + logs) + logs $2 + ;; + backup) + backup + ;; + restore) + restore $2 + ;; + cleanup) + cleanup + ;; + help|--help|-h) + help + ;; + *) + log_error "Unknown command: $1" + help + exit 1 + ;; +esac diff --git a/dev-output.txt b/dev-output.txt new file mode 100644 index 0000000..e69de29 diff --git a/dev.log b/dev.log new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml new file mode 100644 index 0000000..7864136 --- /dev/null +++ b/docker-compose.nginx.yml @@ -0,0 +1,386 @@ +# Monaco USA Portal - Production Docker Compose (with Nginx on host) +# For deployment on Debian/Linux servers using Nginx as reverse proxy +# +# Usage: +# 1. Copy .env.production.example to .env +# 2. Configure all environment variables +# 3. Run: docker compose -f docker-compose.nginx.yml up -d +# +# Ports exposed to localhost (nginx proxies to these): +# - 7453: Portal (SvelteKit) +# - 7454: Studio (Supabase Dashboard) +# - 7455: Kong (API Gateway) + +services: + # ============================================ + # 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} + volumes: + - db-data:/var/lib/postgresql/data + - ./supabase/migrations:/docker-entrypoint-initdb.d + 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) + # ============================================ + studio: + image: supabase/studio:20241202-71e5240 + container_name: monacousa-studio + restart: unless-stopped + ports: + - "127.0.0.1:7454:3000" + 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 + 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 + ports: + - "127.0.0.1:7455:8000" + 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: + - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro + depends_on: + auth: + condition: service_healthy + networks: + - monacousa-network + 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} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: true + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + 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} + 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} + 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} + 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: + build: + context: . + dockerfile: Dockerfile + args: + PUBLIC_SUPABASE_URL: https://api.${DOMAIN} + PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + container_name: monacousa-portal + restart: unless-stopped + ports: + - "127.0.0.1:7453:3000" + 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} + depends_on: + kong: + condition: service_started + db: + condition: service_healthy + networks: + - monacousa-network + 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..aa8219b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,440 @@ +# Monaco USA Portal - Production Docker Compose +# For deployment on Debian/Linux servers with Traefik reverse proxy +# +# Usage: +# 1. Copy .env.production.example to .env +# 2. Configure all environment variables +# 3. Run: docker compose -f docker-compose.prod.yml 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} + volumes: + - db-data:/var/lib/postgresql/data + - ./supabase/migrations:/docker-entrypoint-initdb.d + 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: + - ./supabase/docker/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} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: true + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + 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} + 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} + 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} + 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: + build: + context: . + dockerfile: Dockerfile + args: + PUBLIC_SUPABASE_URL: https://api.${DOMAIN} + PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + 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} + 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd12f0f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,318 @@ +# Monaco USA Portal - Full Stack Docker Compose +# Includes: PostgreSQL, Supabase Services, and SvelteKit App + +services: + # ============================================ + # PostgreSQL Database + # ============================================ + db: + image: supabase/postgres:15.8.1.060 + container_name: monacousa-db + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5435}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-postgres} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY:-3600} + volumes: + - db-data:/var/lib/postgresql/data + - ./supabase/migrations:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - monacousa-network + + # ============================================ + # Supabase Studio (Dashboard) + # ============================================ + studio: + image: supabase/studio:20241202-71e5240 + container_name: monacousa-studio + restart: unless-stopped + ports: + - "${STUDIO_PORT:-7454}:3000" + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + DEFAULT_ORGANIZATION_NAME: Monaco USA + DEFAULT_PROJECT_NAME: Monaco USA Portal + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL:-http://localhost:7455} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + depends_on: + meta: + condition: service_healthy + networks: + - monacousa-network + + # ============================================ + # Kong API Gateway + # ============================================ + kong: + image: kong:2.8.1 + container_name: monacousa-kong + restart: unless-stopped + ports: + - "${KONG_HTTP_PORT:-7455}:8000" + - "${KONG_HTTPS_PORT:-7456}:8443" + 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: + - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro + depends_on: + auth: + condition: service_healthy + networks: + - monacousa-network + + # ============================================ + # 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: ${API_EXTERNAL_URL:-http://localhost:7455} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}?search_path=auth + + GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000} + 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:-noreply@monacousa.org} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Monaco USA} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE:-/auth/verify} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION:-/auth/verify} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY:-/auth/verify} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${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 + + # ============================================ + # 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}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres} + 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 + + # ============================================ + # 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:-postgres} + DB_NAME: ${POSTGRES_DB:-postgres} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq} + 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 + + # ============================================ + # 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}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres} + 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 + + # ============================================ + # 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 + + # ============================================ + # 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:-postgres} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exit 0"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - monacousa-network + + # ============================================ + # Monaco USA Portal (SvelteKit App) + # ============================================ + portal: + build: + context: . + dockerfile: Dockerfile + args: + PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455} + PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + container_name: monacousa-portal + restart: unless-stopped + ports: + - "${PORTAL_PORT:-7453}:3000" + environment: + PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455} + PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_INTERNAL_URL: http://kong:8000 + NODE_ENV: production + ORIGIN: http://localhost:7453 + # Body size limit for file uploads (50MB) + BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-52428800} + depends_on: + kong: + condition: service_started + db: + condition: service_healthy + networks: + - monacousa-network + +# ============================================ +# Networks +# ============================================ +networks: + monacousa-network: + driver: bridge + +# ============================================ +# Volumes +# ============================================ +volumes: + db-data: + driver: local + storage-data: + driver: local diff --git a/nginx/portal.monacousa.org.conf b/nginx/portal.monacousa.org.conf new file mode 100644 index 0000000..503710f --- /dev/null +++ b/nginx/portal.monacousa.org.conf @@ -0,0 +1,244 @@ +# Monaco USA Portal - Nginx Configuration +# Location: /etc/nginx/sites-available/portal.monacousa.org +# +# Installation: +# 1. Copy to /etc/nginx/sites-available/ +# 2. Create symlink: ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/ +# 3. Test config: nginx -t +# 4. Get SSL cert: certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org +# 5. Reload: systemctl reload nginx + +# Rate limiting zone +limit_req_zone $binary_remote_addr zone=portal_limit:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s; + +# Upstream definitions +upstream portal_backend { + server 127.0.0.1:7453; + keepalive 32; +} + +upstream api_backend { + server 127.0.0.1:7455; + keepalive 32; +} + +upstream studio_backend { + server 127.0.0.1:7454; + keepalive 16; +} + +# Main Portal - portal.monacousa.org +server { + listen 80; + listen [::]:80; + server_name portal.monacousa.org; + + # Redirect all HTTP to HTTPS + location / { + return 301 https://$host$request_uri; + } + + # Let's Encrypt challenge + location /.well-known/acme-challenge/ { + root /var/www/html; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name portal.monacousa.org; + + # SSL certificates (managed by certbot) + # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem; + # include /etc/letsencrypt/options-ssl-nginx.conf; + # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Temporary self-signed for testing (remove after certbot) + ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Logging + access_log /var/log/nginx/portal.monacousa.org.access.log; + error_log /var/log/nginx/portal.monacousa.org.error.log; + + # Client body size (for file uploads) + client_max_body_size 50M; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml; + + # Rate limiting + limit_req zone=portal_limit burst=20 nodelay; + + # Main application + location / { + proxy_pass http://portal_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # Static assets with caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://portal_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache static assets + expires 1y; + add_header Cache-Control "public, immutable"; + } +} + +# Supabase API - api.monacousa.org +server { + listen 80; + listen [::]:80; + server_name api.monacousa.org; + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/html; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.monacousa.org; + + # SSL certificates (managed by certbot) + # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem; + # include /etc/letsencrypt/options-ssl-nginx.conf; + # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Temporary self-signed for testing + ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + + # Logging + access_log /var/log/nginx/api.monacousa.org.access.log; + error_log /var/log/nginx/api.monacousa.org.error.log; + + # Client body size + client_max_body_size 50M; + + # Rate limiting (higher for API) + limit_req zone=api_limit burst=50 nodelay; + + # CORS preflight + location / { + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, apikey, x-client-info'; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } + + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Longer timeout for realtime connections + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } +} + +# Supabase Studio - studio.monacousa.org (optional, for admin access) +server { + listen 80; + listen [::]:80; + server_name studio.monacousa.org; + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + root /var/www/html; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name studio.monacousa.org; + + # SSL certificates (managed by certbot) + # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem; + # include /etc/letsencrypt/options-ssl-nginx.conf; + # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Temporary self-signed for testing + ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + # Basic auth protection for studio + auth_basic "Monaco USA Admin"; + auth_basic_user_file /etc/nginx/.htpasswd; + + # Logging + access_log /var/log/nginx/studio.monacousa.org.access.log; + error_log /var/log/nginx/studio.monacousa.org.error.log; + + location / { + proxy_pass http://studio_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/nginx/portal.monacousa.org.initial.conf b/nginx/portal.monacousa.org.initial.conf new file mode 100644 index 0000000..5e49ff1 --- /dev/null +++ b/nginx/portal.monacousa.org.initial.conf @@ -0,0 +1,161 @@ +# Monaco USA Portal - Initial Nginx Configuration (HTTP only) +# Location: /etc/nginx/sites-available/portal.monacousa.org +# +# This is the initial config before running certbot. +# +# Installation: +# 1. sudo cp portal.monacousa.org.initial.conf /etc/nginx/sites-available/portal.monacousa.org +# 2. sudo ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/ +# 3. sudo nginx -t +# 4. sudo systemctl reload nginx +# 5. sudo certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org +# +# After certbot succeeds, it will automatically update this config with SSL settings. + +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=portal_limit:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s; + +# Upstream definitions +upstream portal_backend { + server 127.0.0.1:7453; + keepalive 32; +} + +upstream api_backend { + server 127.0.0.1:7455; + keepalive 32; +} + +upstream studio_backend { + server 127.0.0.1:7454; + keepalive 16; +} + +# Main Portal - portal.monacousa.org +server { + listen 80; + listen [::]:80; + server_name portal.monacousa.org; + + # Let's Encrypt challenge + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # Logging + access_log /var/log/nginx/portal.monacousa.org.access.log; + error_log /var/log/nginx/portal.monacousa.org.error.log; + + # Client body size (for file uploads) + client_max_body_size 50M; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml; + + # Rate limiting + limit_req zone=portal_limit burst=20 nodelay; + + # Main application + location / { + proxy_pass http://portal_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # Static assets with caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://portal_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache static assets + expires 1y; + add_header Cache-Control "public, immutable"; + } +} + +# Supabase API - api.monacousa.org +server { + listen 80; + listen [::]:80; + server_name api.monacousa.org; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # Logging + access_log /var/log/nginx/api.monacousa.org.access.log; + error_log /var/log/nginx/api.monacousa.org.error.log; + + # Client body size + client_max_body_size 50M; + + # Rate limiting + limit_req zone=api_limit burst=50 nodelay; + + location / { + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Longer timeout for realtime connections + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } +} + +# Supabase Studio - studio.monacousa.org (optional) +server { + listen 80; + listen [::]:80; + server_name studio.monacousa.org; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # Logging + access_log /var/log/nginx/studio.monacousa.org.access.log; + error_log /var/log/nginx/studio.monacousa.org.error.log; + + location / { + proxy_pass http://studio_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/npm.log b/npm.log new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4146dc5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4514 @@ +{ + "name": "monacousa-portal-2026", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monacousa-portal-2026", + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", + "@aws-sdk/s3-request-presigner": "^3.700.0", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.90.1", + "@sveltejs/adapter-node": "^5.5.1", + "flag-icons": "^7.4.0", + "nodemailer": "^6.9.0" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.49.1", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/vite": "^4.1.18", + "bits-ui": "^2.15.4", + "clsx": "^2.1.1", + "lucide-svelte": "^0.562.0", + "svelte": "^5.45.6", + "svelte-check": "^4.3.4", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.2.6" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.974.0.tgz", + "integrity": "sha512-X+vpXNJ8cU8Iw1FtDgDHxo9z6RxlXfcTtpdGnKws4rk+tCYKSAor/DG6BRMzbh4E5xAA7DiU1Ny3BTrRRSt/Yg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/credential-provider-node": "^3.972.1", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.1", + "@aws-sdk/middleware-expect-continue": "^3.972.1", + "@aws-sdk/middleware-flexible-checksums": "^3.972.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-location-constraint": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-sdk-s3": "^3.972.1", + "@aws-sdk/middleware-ssec": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/signature-v4-multi-region": "3.972.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.0", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.10", + "@smithy/middleware-retry": "^4.4.26", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.25", + "@smithy/util-defaults-mode-node": "^4.2.28", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.974.0.tgz", + "integrity": "sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.10", + "@smithy/middleware-retry": "^4.4.26", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.25", + "@smithy/util-defaults-mode-node": "^4.2.28", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.0.tgz", + "integrity": "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/xml-builder": "^3.972.1", + "@smithy/core": "^3.21.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", + "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.1.tgz", + "integrity": "sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.1.tgz", + "integrity": "sha512-AeopObGW5lpWbDRZ+t4EAtS7wdfSrHPLeFts7jaBzgIaCCD7TL7jAyAB9Y5bCLOPF+17+GL54djCCsjePljUAw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.1.tgz", + "integrity": "sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/credential-provider-env": "^3.972.1", + "@aws-sdk/credential-provider-http": "^3.972.1", + "@aws-sdk/credential-provider-login": "^3.972.1", + "@aws-sdk/credential-provider-process": "^3.972.1", + "@aws-sdk/credential-provider-sso": "^3.972.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.1", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.1.tgz", + "integrity": "sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.1.tgz", + "integrity": "sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.1", + "@aws-sdk/credential-provider-http": "^3.972.1", + "@aws-sdk/credential-provider-ini": "^3.972.1", + "@aws-sdk/credential-provider-process": "^3.972.1", + "@aws-sdk/credential-provider-sso": "^3.972.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.1.tgz", + "integrity": "sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.1.tgz", + "integrity": "sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.974.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/token-providers": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.1.tgz", + "integrity": "sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.1.tgz", + "integrity": "sha512-YVvoitBdE8WOpHqIXvv49efT73F4bJ99XH2bi3Dn3mx7WngI4RwHwn/zF5i0q1Wdi5frGSCNF3vuh+pY817//w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-arn-parser": "^3.972.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.1.tgz", + "integrity": "sha512-6lfl2/J/kutzw/RLu1kjbahsz4vrGPysrdxWaw8fkjLYG+6M6AswocIAZFS/LgAVi/IWRwPTx9YC0/NH2wDrSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.1.tgz", + "integrity": "sha512-kjVVREpqeUkYQsXr78AcsJbEUlxGH7+H6yS7zkjrnu6HyEVxbdSndkKX6VpKneFOihjCAhIXlk4wf3butDHkNQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/crc64-nvme": "3.972.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.1.tgz", + "integrity": "sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.1.tgz", + "integrity": "sha512-YisPaCbvBk9gY5aUI8jDMDKXsLZ9Fet0WYj1MviK8tZYMgxBIYHM6l3O/OHaAIujojZvamd9F3haYYYWp5/V3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.1.tgz", + "integrity": "sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.1.tgz", + "integrity": "sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.1.tgz", + "integrity": "sha512-q/hK0ZNf/aafFRv2wIlDM3p+izi5cXwktVNvRvW646A0MvVZmT4/vwadv/jPA9AORFbnpyf/0luxiMz181f9yg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-arn-parser": "^3.972.1", + "@smithy/core": "^3.21.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.1.tgz", + "integrity": "sha512-fLtRTPd/MxJT2drJKft2GVGKm35PiNEeQ1Dvz1vc/WhhgAteYrp4f1SfSgjgLaYWGMExESJL4bt8Dxqp6tVsog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.1.tgz", + "integrity": "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@smithy/core": "^3.21.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.974.0.tgz", + "integrity": "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.10", + "@smithy/middleware-retry": "^4.4.26", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.25", + "@smithy/util-defaults-mode-node": "^4.2.28", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.1.tgz", + "integrity": "sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.974.0.tgz", + "integrity": "sha512-tApmJb4XXBdNQzxTYIBq9aYj8vjJqiMPyeUF25wzvGjLQfXgvcv5sTR4yyzXBxRc8+O7quWDBgMJGtcNerapRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.972.0", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-format-url": "^3.972.1", + "@smithy/middleware-endpoint": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.11", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.972.0.tgz", + "integrity": "sha512-2udiRijmjpN81Pvajje4TsjbXDZNP6K9bYUanBYH8hXa/tZG5qfGCySD+TyX0sgDxCQmEDMg3LaQdfjNHBDEgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.972.0", + "@aws-sdk/types": "3.972.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/core": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.972.0.tgz", + "integrity": "sha512-nEeUW2M9F+xdIaD98F5MBcQ4ITtykj3yKbgFZ6J0JtL3bq+Z90szQ6Yy8H/BLPYXTs3V4n9ifnBo8cprRDiE6A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@aws-sdk/xml-builder": "3.972.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.0.tgz", + "integrity": "sha512-0bcKFXWx+NZ7tIlOo7KjQ+O2rydiHdIQahrq+fN6k9Osky29v17guy68urUKfhTobR6iY6KvxkroFWaFtTgS5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.972.0", + "@aws-sdk/types": "3.972.0", + "@aws-sdk/util-arn-parser": "3.972.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.0.tgz", + "integrity": "sha512-RM5Mmo/KJ593iMSrALlHEOcc9YOIyOsDmS5x2NLOMdEmzv1o00fcpAkCQ02IGu1eFneBFT7uX0Mpag0HI+Cz2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.0.tgz", + "integrity": "sha512-POaGMcXnozzqBUyJM3HLUZ9GR6OKJWPGJEmhtTnxZXt8B6JcJ/6K3xRJ5H/j8oovVLz8Wg6vFxAHv8lvuASxMg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.974.0.tgz", + "integrity": "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.0", + "@aws-sdk/nested-clients": "3.974.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.0.tgz", + "integrity": "sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.1.tgz", + "integrity": "sha512-XnNit6H9PPHhqUXW/usjX6JeJ6Pm8ZNqivTjmNjgWHeOfVpblUc/MTic02UmCNR0jJLPjQ3mBKiMen0tnkNQjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz", + "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.1.tgz", + "integrity": "sha512-8wJ4/XOLU/RIYBHsXsIOTR04bNmalC8F2YPMyf3oL8YC750M3Rv5WGywW0Fo07HCv770KXJOzVq03Gyl68moFg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.3.tgz", + "integrity": "sha512-FNUqAjlKAGA7GM05kywE99q8wiPHPZqrzhq3wXRga6PRD6A0kzT85Pb0AzYBVTBRpSrKyyr6M92Y6bnSBVp2BA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.1.tgz", + "integrity": "sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.0", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.1.tgz", + "integrity": "sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.1.tgz", + "integrity": "sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.21.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.1.tgz", + "integrity": "sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.11.tgz", + "integrity": "sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.21.1", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.27", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.27.tgz", + "integrity": "sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz", + "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.12", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.12.tgz", + "integrity": "sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.21.1", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.26", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.26.tgz", + "integrity": "sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.29", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.29.tgz", + "integrity": "sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.10", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz", + "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.91.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.91.0.tgz", + "integrity": "sha512-9ywvsKLsxTwv7fvN5fXzP3UfRreqrX2waylTBDu0lkmeHXa8WtSQS9e0WV9FBduiazYqQbgfBQXBNPRPsRgWOQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.91.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.91.0.tgz", + "integrity": "sha512-WaakXOqLK1mLtBNFXp5o5T+LlI6KZuADSeXz+9ofPRG5OpVSvW148LVJB1DRZ16Phck1a0YqIUswOUgxCz6vMw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.91.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.91.0.tgz", + "integrity": "sha512-5S41zv2euNpGucvtM4Wy+xOmLznqt/XO+Lh823LOFEQ00ov7QJfvqb6VzIxufvzhooZpmGR0BxvMcJtWxCIFdQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.91.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.91.0.tgz", + "integrity": "sha512-u2YuJFG35umw8DO9beC27L/jYXm3KhF+73WQwbynMpV0tXsFIA0DOGRM0NgRyy03hJIdO6mxTTwe8efW3yx3Tg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz", + "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.76.1" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.91.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.91.0.tgz", + "integrity": "sha512-CI7fsVIBQHfNObqU9kmyQ1GWr+Ug44y4rSpvxT4LdQB9tlhg1NTBov6z7Dlmt8d6lGi/8a9lf/epCDxyWI792g==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.91.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.91.0.tgz", + "integrity": "sha512-Rjb0QqkKrmXMVwUOdEqysPBZ0ZDZakeptTkUa6k2d8r3strBdbWVDqjOdkCjAmvvZMtXecBeyTyMEXD1Zzjfvg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.91.0", + "@supabase/functions-js": "2.91.0", + "@supabase/postgrest-js": "2.91.0", + "@supabase/realtime-js": "2.91.0", + "@supabase/storage-js": "2.91.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz", + "integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.2.tgz", + "integrity": "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.9.5" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.50.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz", + "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/kit/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.4.tgz", + "integrity": "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/lucide-svelte": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.562.0.tgz", + "integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz", + "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e4d1dd8 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "monacousa-portal-2026", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.0", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/vite": "^4.1.18", + "bits-ui": "^2.15.4", + "clsx": "^2.1.1", + "lucide-svelte": "^0.562.0", + "svelte": "^5.47.0", + "svelte-check": "^4.3.4", + "tailwind-merge": "^3.4.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.971.0", + "@aws-sdk/s3-request-presigner": "^3.971.0", + "@internationalized/date": "^3.7.0", + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.90.1", + "@sveltejs/adapter-node": "^5.5.1", + "flag-icons": "^7.4.0", + "libphonenumber-js": "^1.12.8", + "nodemailer": "^6.10.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5f1fea1 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,7 @@ +// PostCSS config for monacousa-portal-2026 +// Tailwind CSS v4 is handled by @tailwindcss/vite plugin in vite.config.ts +// This file exists to prevent Vite from picking up parent directory configs + +export default { + plugins: {} +}; diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..ec4b959 --- /dev/null +++ b/src/app.css @@ -0,0 +1,263 @@ +@import "tailwindcss"; + +/* Monaco USA Custom Theme */ +@theme { + /* Monaco Red - Primary Brand Color */ + --color-monaco-50: #fef2f2; + --color-monaco-100: #fee2e2; + --color-monaco-200: #fecaca; + --color-monaco-300: #fca5a5; + --color-monaco-400: #f87171; + --color-monaco-500: #ef4444; + --color-monaco-600: #dc2626; + --color-monaco-700: #b91c1c; + --color-monaco-800: #991b1b; + --color-monaco-900: #7f1d1d; + --color-monaco-950: #450a0a; + + /* Glass effect shadows */ + --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.1); + --shadow-glass-lg: 0 25px 50px rgba(0, 0, 0, 0.15); + --shadow-glass-xl: 0 35px 60px rgba(0, 0, 0, 0.2); + + /* Backdrop blur values */ + --blur-glass: 10px; + --blur-glass-lg: 20px; + + /* Border radius */ + --radius-glass: 16px; + --radius-glass-lg: 24px; + + /* Animation durations */ + --duration-fast: 150ms; + --duration-normal: 300ms; + --duration-slow: 500ms; + + /* Font family */ + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* Base styles */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 0 84% 50%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 0 84% 50%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 0 84% 50%; + --primary-foreground: 210 40% 98%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 0 84% 50%; + } +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: var(--font-sans); + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +/* Glass-morphism utilities - in @layer components for proper ordering */ +@layer components { + .glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(var(--blur-glass)); + -webkit-backdrop-filter: blur(var(--blur-glass)); + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: var(--shadow-glass); + } + + .glass-dark { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(var(--blur-glass)); + -webkit-backdrop-filter: blur(var(--blur-glass)); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: var(--shadow-glass); + } + + .glass-card { + background: rgba(255, 255, 255, 0.97); + backdrop-filter: blur(var(--blur-glass)); + -webkit-backdrop-filter: blur(var(--blur-glass)); + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: var(--shadow-glass); + border-radius: var(--radius-glass); + } + + .glass-card-dark { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(var(--blur-glass)); + -webkit-backdrop-filter: blur(var(--blur-glass)); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: var(--shadow-glass); + border-radius: var(--radius-glass); + } + + .gradient-monaco { + background: linear-gradient(135deg, var(--color-monaco-600) 0%, var(--color-monaco-700) 100%); + } + + .gradient-monaco-light { + background: linear-gradient(135deg, var(--color-monaco-50) 0%, var(--color-monaco-100) 100%); + } + + .text-gradient-monaco { + background: linear-gradient(135deg, var(--color-monaco-600) 0%, var(--color-monaco-800) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } +} + +/* Custom utilities for shadcn-svelte compatibility */ +@layer utilities { + /* Border color utility using CSS variable */ + .border-border { + border-color: hsl(var(--border)); + } + + /* Background utilities using CSS variables */ + .bg-background { + background-color: hsl(var(--background)); + } + + .bg-foreground { + background-color: hsl(var(--foreground)); + } + + .bg-card { + background-color: hsl(var(--card)); + } + + .bg-popover { + background-color: hsl(var(--popover)); + } + + .bg-primary { + background-color: hsl(var(--primary)); + } + + .bg-secondary { + background-color: hsl(var(--secondary)); + } + + .bg-muted { + background-color: hsl(var(--muted)); + } + + .bg-accent { + background-color: hsl(var(--accent)); + } + + .bg-destructive { + background-color: hsl(var(--destructive)); + } + + /* Text color utilities using CSS variables */ + .text-foreground { + color: hsl(var(--foreground)); + } + + .text-card-foreground { + color: hsl(var(--card-foreground)); + } + + .text-popover-foreground { + color: hsl(var(--popover-foreground)); + } + + .text-primary-foreground { + color: hsl(var(--primary-foreground)); + } + + .text-secondary-foreground { + color: hsl(var(--secondary-foreground)); + } + + .text-muted-foreground { + color: hsl(var(--muted-foreground)); + } + + .text-accent-foreground { + color: hsl(var(--accent-foreground)); + } + + .text-destructive-foreground { + color: hsl(var(--destructive-foreground)); + } + + /* Ring utility */ + .ring-ring { + --tw-ring-color: hsl(var(--ring)); + } + + /* Input border utility */ + .border-input { + border-color: hsl(var(--input)); + } + + /* Scrollbar styling */ + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted-foreground) / 0.3); + border-radius: 3px; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--muted-foreground) / 0.5); + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..df65d88 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,27 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +import type { SupabaseClient, Session, User } from '@supabase/supabase-js'; +import type { Database, MemberWithDues } from '$lib/types/database'; + +declare global { + namespace App { + // interface Error {} + interface Locals { + supabase: SupabaseClient; + safeGetSession: () => Promise<{ + session: Session | null; + user: User | null; + member: MemberWithDues | null; + }>; + } + interface PageData { + session: Session | null; + user: User | null; + member: MemberWithDues | null; + } + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..eb422c7 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,134 @@ +import pkg from '@supabase/ssr'; +const { createServerClient } = pkg; +import { type Handle, redirect } from '@sveltejs/kit'; +import { sequence } from '@sveltejs/kit/hooks'; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; +import { env } from '$env/dynamic/private'; +import type { Database } from '$lib/types/database'; + +// Use internal URL for server-side operations (Docker network), fallback to public URL +const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL; + +/** + * Supabase authentication hook + * Sets up the Supabase client with cookie handling for SSR + */ +const supabaseHandle: Handle = async ({ event, resolve }) => { + event.locals.supabase = createServerClient( + SERVER_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => event.cookies.getAll(), + setAll: (cookiesToSet) => { + cookiesToSet.forEach(({ name, value, options }) => { + event.cookies.set(name, value, { ...options, path: '/' }); + }); + } + } + } + ); + + /** + * Safe session getter that validates the JWT + * Returns session, user, and member data + */ + event.locals.safeGetSession = async () => { + const { + data: { session } + } = await event.locals.supabase.auth.getSession(); + + if (!session) { + return { session: null, user: null, member: null }; + } + + // Validate the session by getting the user + const { + data: { user }, + error: userError + } = await event.locals.supabase.auth.getUser(); + + if (userError || !user) { + return { session: null, user: null, member: null }; + } + + // Fetch member profile with dues status + const { data: member } = await event.locals.supabase + .from('members_with_dues') + .select('*') + .eq('id', user.id) + .single(); + + return { session, user, member }; + }; + + return resolve(event, { + filterSerializedResponseHeaders(name) { + return name === 'content-range' || name === 'x-supabase-api-version'; + } + }); +}; + +/** + * Authorization hook + * Protects routes based on authentication and role requirements + */ +const authorizationHandle: Handle = async ({ event, resolve }) => { + const { session, member } = await event.locals.safeGetSession(); + const path = event.url.pathname; + + // API routes handle their own authentication + if (path.startsWith('/api/')) { + return resolve(event); + } + + // Auth callback routes should always be accessible + if (path.startsWith('/auth/')) { + return resolve(event); + } + + // Logout route should always be accessible + if (path === '/logout') { + return resolve(event); + } + + // Protected routes - require authentication + const protectedPrefixes = ['/dashboard', '/profile', '/payments', '/documents', '/board', '/admin']; + const isProtectedRoute = protectedPrefixes.some((prefix) => path.startsWith(prefix)); + + if (isProtectedRoute && !session) { + throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`); + } + + // Handle authenticated users without a member profile + // This can happen if member record creation failed or was deleted + if (isProtectedRoute && session && !member) { + console.error('Authenticated user has no member profile:', session.user?.id); + // Sign them out and redirect to login with an error + await event.locals.supabase.auth.signOut(); + throw redirect(303, '/login?error=no_profile'); + } + + // Board routes - require board or admin role + if (path.startsWith('/board') && member) { + if (member.role !== 'board' && member.role !== 'admin') { + throw redirect(303, '/dashboard'); + } + } + + // Admin routes - require admin role + if (path.startsWith('/admin') && member) { + if (member.role !== 'admin') { + throw redirect(303, '/dashboard'); + } + } + + // Redirect authenticated users away from auth pages + if (session && (path === '/login' || path === '/signup')) { + throw redirect(303, '/dashboard'); + } + + return resolve(event); +}; + +export const handle: Handle = sequence(supabaseHandle, authorizationHandle); diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/components/EmailVerificationBanner.svelte b/src/lib/components/EmailVerificationBanner.svelte new file mode 100644 index 0000000..5175a26 --- /dev/null +++ b/src/lib/components/EmailVerificationBanner.svelte @@ -0,0 +1,92 @@ + + +{#if !dismissed} +
+
+
+
+
+ +
+
+

+ Please verify your email address +

+

+ Check {email} for verification link +

+
+
+ +
+ {#if resendSuccess} + + + Email sent! + + {:else} + + {/if} + +
+
+
+
+{/if} diff --git a/src/lib/components/auth/FormField.svelte b/src/lib/components/auth/FormField.svelte new file mode 100644 index 0000000..7e95d1a --- /dev/null +++ b/src/lib/components/auth/FormField.svelte @@ -0,0 +1,51 @@ + + +
+ + + {#if error} +

{error}

+ {/if} +
diff --git a/src/lib/components/auth/FormMessage.svelte b/src/lib/components/auth/FormMessage.svelte new file mode 100644 index 0000000..a5a8390 --- /dev/null +++ b/src/lib/components/auth/FormMessage.svelte @@ -0,0 +1,29 @@ + + +{#if message} +
+ +

{message}

+
+{/if} diff --git a/src/lib/components/auth/LoadingSpinner.svelte b/src/lib/components/auth/LoadingSpinner.svelte new file mode 100644 index 0000000..d9ca774 --- /dev/null +++ b/src/lib/components/auth/LoadingSpinner.svelte @@ -0,0 +1,28 @@ + + + + + + diff --git a/src/lib/components/auth/index.ts b/src/lib/components/auth/index.ts new file mode 100644 index 0000000..1593171 --- /dev/null +++ b/src/lib/components/auth/index.ts @@ -0,0 +1,3 @@ +export { default as FormField } from './FormField.svelte'; +export { default as FormMessage } from './FormMessage.svelte'; +export { default as LoadingSpinner } from './LoadingSpinner.svelte'; diff --git a/src/lib/components/dashboard/DuesStatusCard.svelte b/src/lib/components/dashboard/DuesStatusCard.svelte new file mode 100644 index 0000000..63dbc08 --- /dev/null +++ b/src/lib/components/dashboard/DuesStatusCard.svelte @@ -0,0 +1,113 @@ + + +
+
+

+ + Dues Status +

+
+ +
+
+ +
+

{duesInfo.label}

+

{duesInfo.description}

+
+
+ +
+
+

Annual Dues

+

+ {member.annual_dues ? `€${member.annual_dues.toFixed(2)}` : '€50.00'} +

+
+
+

Membership Type

+

{member.membership_type_name || 'Regular'}

+
+
+

Last Payment

+

{formatDate(member.last_payment_date)}

+
+
+

Next Due Date

+

{formatDate(member.current_due_date)}

+
+
+ +
+ +
+
+
diff --git a/src/lib/components/dashboard/QuickActionsCard.svelte b/src/lib/components/dashboard/QuickActionsCard.svelte new file mode 100644 index 0000000..93a394f --- /dev/null +++ b/src/lib/components/dashboard/QuickActionsCard.svelte @@ -0,0 +1,97 @@ + + +
+

Quick Actions

+ +
+ {#each actions.slice(0, 4) as action} + +
+ +
+ {action.label} + {action.description} +
+ {/each} +
+
diff --git a/src/lib/components/dashboard/StatsCard.svelte b/src/lib/components/dashboard/StatsCard.svelte new file mode 100644 index 0000000..26e0892 --- /dev/null +++ b/src/lib/components/dashboard/StatsCard.svelte @@ -0,0 +1,48 @@ + + +
+
+
+

{title}

+

{value}

+ {#if description} +

{description}

+ {/if} + {#if trend} +

+ = 0 ? 'text-green-600' : 'text-red-600'}> + {trend.value >= 0 ? '+' : ''}{trend.value}% + + {trend.label} +

+ {/if} +
+
+ +
+
+
diff --git a/src/lib/components/dashboard/UpcomingEventsCard.svelte b/src/lib/components/dashboard/UpcomingEventsCard.svelte new file mode 100644 index 0000000..b5446c2 --- /dev/null +++ b/src/lib/components/dashboard/UpcomingEventsCard.svelte @@ -0,0 +1,107 @@ + + +
+
+

+ + Upcoming Events +

+ + View all + +
+ +
+ {#if events.length === 0} +
+ +

No upcoming events

+

Check back later for new events.

+
+ {:else} + {#each events as event} + {@const { date, time } = formatDateTime(event.start_datetime)} + +
+ +
+ + {date.split(' ')[1]} + + + {date.split(' ')[2]} + +
+ + +
+
+ + + {event.event_type_name || 'Event'} + +
+

{event.title}

+
+ + + {time} + + {#if event.location} + + + {event.location} + + {/if} + + + {event.total_attendees} + {event.max_attendees ? ` / ${event.max_attendees}` : ''} attending + +
+
+ + +
+
+ {/each} + {/if} +
+
diff --git a/src/lib/components/dashboard/WelcomeCard.svelte b/src/lib/components/dashboard/WelcomeCard.svelte new file mode 100644 index 0000000..36f7d53 --- /dev/null +++ b/src/lib/components/dashboard/WelcomeCard.svelte @@ -0,0 +1,78 @@ + + +
+
+
+ + {#if member.avatar_url} + {`${member.first_name} + {:else} +
+ {member.first_name[0]}{member.last_name[0]} +
+ {/if} + +
+

{getGreeting()},

+

+ {member.first_name} + {member.last_name} +

+
+ {member.member_id} + | + + {member.status_display_name || 'Pending'} + +
+
+
+ + + {#if member.nationality && member.nationality.length > 0} +
+ {#each member.nationality as code} + + {/each} +
+ {/if} +
+
diff --git a/src/lib/components/dashboard/index.ts b/src/lib/components/dashboard/index.ts new file mode 100644 index 0000000..49c1a5d --- /dev/null +++ b/src/lib/components/dashboard/index.ts @@ -0,0 +1,5 @@ +export { default as WelcomeCard } from './WelcomeCard.svelte'; +export { default as DuesStatusCard } from './DuesStatusCard.svelte'; +export { default as UpcomingEventsCard } from './UpcomingEventsCard.svelte'; +export { default as QuickActionsCard } from './QuickActionsCard.svelte'; +export { default as StatsCard } from './StatsCard.svelte'; diff --git a/src/lib/components/documents/CreateFolderModal.svelte b/src/lib/components/documents/CreateFolderModal.svelte new file mode 100644 index 0000000..5a3658c --- /dev/null +++ b/src/lib/components/documents/CreateFolderModal.svelte @@ -0,0 +1,142 @@ + + + + + + diff --git a/src/lib/components/documents/DocumentPreviewModal.svelte b/src/lib/components/documents/DocumentPreviewModal.svelte new file mode 100644 index 0000000..d2ffbcb --- /dev/null +++ b/src/lib/components/documents/DocumentPreviewModal.svelte @@ -0,0 +1,246 @@ + + + + + + diff --git a/src/lib/components/documents/FolderBreadcrumbs.svelte b/src/lib/components/documents/FolderBreadcrumbs.svelte new file mode 100644 index 0000000..0320925 --- /dev/null +++ b/src/lib/components/documents/FolderBreadcrumbs.svelte @@ -0,0 +1,49 @@ + + + diff --git a/src/lib/components/documents/FolderItem.svelte b/src/lib/components/documents/FolderItem.svelte new file mode 100644 index 0000000..8cbd7ae --- /dev/null +++ b/src/lib/components/documents/FolderItem.svelte @@ -0,0 +1,120 @@ + + +
+ +
+ +
+ + +
+

{folder.name}

+
+ + {folder.visibility} + + {#if folder.creator} + · + {folder.creator.first_name} {folder.creator.last_name} + {/if} +
+
+ + + {#if canEdit || canDelete} + + {/if} +
diff --git a/src/lib/components/documents/index.ts b/src/lib/components/documents/index.ts new file mode 100644 index 0000000..7da6c72 --- /dev/null +++ b/src/lib/components/documents/index.ts @@ -0,0 +1,4 @@ +export { default as DocumentPreviewModal } from './DocumentPreviewModal.svelte'; +export { default as FolderItem } from './FolderItem.svelte'; +export { default as FolderBreadcrumbs } from './FolderBreadcrumbs.svelte'; +export { default as CreateFolderModal } from './CreateFolderModal.svelte'; diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte new file mode 100644 index 0000000..3b01046 --- /dev/null +++ b/src/lib/components/layout/Header.svelte @@ -0,0 +1,152 @@ + + + + +
+ + + + +

{title}

+ + +
+ + + + + + + + {#if member} + + {/if} +
+
diff --git a/src/lib/components/layout/MobileMenu.svelte b/src/lib/components/layout/MobileMenu.svelte new file mode 100644 index 0000000..9cf0a41 --- /dev/null +++ b/src/lib/components/layout/MobileMenu.svelte @@ -0,0 +1,203 @@ + + +{#if open} + +
+ + + +{/if} diff --git a/src/lib/components/layout/MobileNav.svelte b/src/lib/components/layout/MobileNav.svelte new file mode 100644 index 0000000..a8fbf07 --- /dev/null +++ b/src/lib/components/layout/MobileNav.svelte @@ -0,0 +1,53 @@ + + + diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte new file mode 100644 index 0000000..3189fcf --- /dev/null +++ b/src/lib/components/layout/Sidebar.svelte @@ -0,0 +1,245 @@ + + + diff --git a/src/lib/components/layout/index.ts b/src/lib/components/layout/index.ts new file mode 100644 index 0000000..1bdcf7c --- /dev/null +++ b/src/lib/components/layout/index.ts @@ -0,0 +1,4 @@ +export { default as Sidebar } from './Sidebar.svelte'; +export { default as Header } from './Header.svelte'; +export { default as MobileNav } from './MobileNav.svelte'; +export { default as MobileMenu } from './MobileMenu.svelte'; diff --git a/src/lib/components/ui/AddToCalendarButton.svelte b/src/lib/components/ui/AddToCalendarButton.svelte new file mode 100644 index 0000000..f625291 --- /dev/null +++ b/src/lib/components/ui/AddToCalendarButton.svelte @@ -0,0 +1,171 @@ + + +
+ + + {#if isOpen} + + {/if} +
diff --git a/src/lib/components/ui/CountryFlag.svelte b/src/lib/components/ui/CountryFlag.svelte new file mode 100644 index 0000000..42d12cf --- /dev/null +++ b/src/lib/components/ui/CountryFlag.svelte @@ -0,0 +1,40 @@ + + +{code} flag diff --git a/src/lib/components/ui/CountrySelect.svelte b/src/lib/components/ui/CountrySelect.svelte new file mode 100644 index 0000000..704a06f --- /dev/null +++ b/src/lib/components/ui/CountrySelect.svelte @@ -0,0 +1,173 @@ + + +
+ ({ value: c.code, label: c.name }))} + bind:value + onValueChange={(v) => v && handleSelect(v)} + > +
+ + {#if selectedCountry} + + + {selectedCountry.name} + + {:else} + {placeholder} + {/if} + + + +
+ + + +
+
+ + { + searchValue = e.currentTarget.value; + }} + /> +
+
+ + + {#if filteredCountries.length === 0} +
+ No countries found +
+ {:else} + {#each filteredCountries as country (country.code)} + {@const isSelected = value === country.code} + + {#snippet children({ selected })} + + {country.name} + {#if selected} + + {/if} + {/snippet} + + {/each} + {/if} +
+
+
+
+ + + {#if name} + + {/if} +
diff --git a/src/lib/components/ui/DatePicker.svelte b/src/lib/components/ui/DatePicker.svelte new file mode 100644 index 0000000..91dcc9d --- /dev/null +++ b/src/lib/components/ui/DatePicker.svelte @@ -0,0 +1,190 @@ + + + + + {#snippet children({ segments })} +
+ {#each segments as { part, value: segValue }} + {#if part === 'literal'} + {segValue} + {:else} + + {segValue} + + {/if} + {/each} +
+ + + + {/snippet} +
+ + + + {#snippet children({ months, weekdays })} + + + + + + + + + + + {#each months as month} + + + + {#each weekdays as day} + + {day.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates} + + {#each weekDates as date} + + + + {/each} + + {/each} + + + {/each} + {/snippet} + + +
+ + +{#if name} + +{/if} diff --git a/src/lib/components/ui/NationalitySelect.svelte b/src/lib/components/ui/NationalitySelect.svelte new file mode 100644 index 0000000..c8f542c --- /dev/null +++ b/src/lib/components/ui/NationalitySelect.svelte @@ -0,0 +1,198 @@ + + +
+ + {#if value.length > 0} +
+ {#each value as code} + {@const country = getCountry(code)} + {#if country} + + + {country.name} + + + {/if} + {/each} +
+ {/if} + + + onValueChange?.(v)} + > +
+ + + 0 ? 'Add more...' : placeholder} + {disabled} + oninput={(e) => { + searchValue = e.currentTarget.value; + if (!open) open = true; + }} + /> + + + +
+ + + + {#if filteredCountries.length === 0} +
+ No countries found +
+ {:else} + {#each filteredCountries as country (country.code)} + {@const isSelected = value.includes(country.code)} + {@const isDisabled = !isSelected && maxSelections > 0 && value.length >= maxSelections} + + {#snippet children({ selected })} + + {country.name} + {country.code} + {#if selected} + + {/if} + {/snippet} + + {/each} + {/if} +
+
+
+
+ + + {#if name} + + {/if} +
diff --git a/src/lib/components/ui/PhoneInput.svelte b/src/lib/components/ui/PhoneInput.svelte new file mode 100644 index 0000000..3e7e9a0 --- /dev/null +++ b/src/lib/components/ui/PhoneInput.svelte @@ -0,0 +1,260 @@ + + +
+ + ({ value: c.code, label: c.name }))} + bind:value={countryCode} + onValueChange={(v) => v && handleCountryChange(v)} + > +
+ + + {selectedCountry.dialCode} + + + +
+ + + +
+
+ + { + searchValue = e.currentTarget.value; + }} + /> +
+
+ + + {#if filteredCountries.length === 0} +
+ No countries found +
+ {:else} + {#each filteredCountries as country (country.code)} + {@const isSelected = countryCode === country.code} + + {#snippet children({ selected })} + + {country.name} + {country.dialCode} + {/snippet} + + {/each} + {/if} +
+
+
+
+ + + + + + {#if name} + + {/if} +
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..a6711e0 --- /dev/null +++ b/src/lib/components/ui/button/button.svelte @@ -0,0 +1,65 @@ + + + + + diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..6086c50 --- /dev/null +++ b/src/lib/components/ui/button/index.ts @@ -0,0 +1,15 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants +} from './button.svelte'; + +export { + Root, + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, + Root as Button +}; diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..fba5c6b --- /dev/null +++ b/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..2da58ab --- /dev/null +++ b/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,13 @@ + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..739f801 --- /dev/null +++ b/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..80801c6 --- /dev/null +++ b/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..9b4e997 --- /dev/null +++ b/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,13 @@ + + +

+ {@render children?.()} +

diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..29e1c6f --- /dev/null +++ b/src/lib/components/ui/card/card.svelte @@ -0,0 +1,16 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..2f3ee7a --- /dev/null +++ b/src/lib/components/ui/card/index.ts @@ -0,0 +1,21 @@ +import Root from './card.svelte'; +import Content from './card-content.svelte'; +import Description from './card-description.svelte'; +import Footer from './card-footer.svelte'; +import Header from './card-header.svelte'; +import Title from './card-title.svelte'; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle +}; diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts new file mode 100644 index 0000000..41c3320 --- /dev/null +++ b/src/lib/components/ui/index.ts @@ -0,0 +1,19 @@ +// UI Components - shadcn-svelte style +export { Button, buttonVariants, type ButtonProps, type ButtonSize, type ButtonVariant } from './button'; +export { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from './card'; +export { Input } from './input'; +export { Label } from './label'; + +// Custom components +export { default as DatePicker } from './DatePicker.svelte'; +export { default as NationalitySelect } from './NationalitySelect.svelte'; +export { default as CountryFlag } from './CountryFlag.svelte'; +export { default as PhoneInput } from './PhoneInput.svelte'; +export { default as CountrySelect } from './CountrySelect.svelte'; diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..ba5ce62 --- /dev/null +++ b/src/lib/components/ui/input/index.ts @@ -0,0 +1,3 @@ +import Root from './input.svelte'; + +export { Root, Root as Input }; diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..640c347 --- /dev/null +++ b/src/lib/components/ui/input/input.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..af72692 --- /dev/null +++ b/src/lib/components/ui/label/index.ts @@ -0,0 +1,3 @@ +import Root from './label.svelte'; + +export { Root, Root as Label }; diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..9442ac9 --- /dev/null +++ b/src/lib/components/ui/label/label.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..68d31ef --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,3 @@ +// Monaco USA Portal 2026 - Library exports +export * from './utils'; + diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts new file mode 100644 index 0000000..9ebea9a --- /dev/null +++ b/src/lib/server/audit.ts @@ -0,0 +1,233 @@ +import { supabaseAdmin } from './supabase'; + +export type AuditAction = + | 'member.create' + | 'member.update' + | 'member.delete' + | 'member.role_change' + | 'member.status_change' + | 'member.invite' + | 'event.create' + | 'event.update' + | 'event.delete' + | 'event.cancel' + | 'rsvp.create' + | 'rsvp.update' + | 'rsvp.cancel' + | 'rsvp.waitlist_promote' + | 'payment.record' + | 'payment.delete' + | 'document.upload' + | 'document.delete' + | 'document.visibility_change' + | 'settings.update' + | 'email.send' + | 'auth.login' + | 'auth.logout' + | 'auth.password_reset'; + +export interface AuditLogEntry { + userId?: string; + userEmail?: string; + action: AuditAction; + resourceType?: string; + resourceId?: string; + details?: Record; + ipAddress?: string; + userAgent?: string; +} + +/** + * Log an audit event to the database + */ +export async function logAudit(entry: AuditLogEntry): Promise<{ success: boolean; error?: string }> { + try { + const { error } = await supabaseAdmin.from('audit_logs').insert({ + user_id: entry.userId || null, + user_email: entry.userEmail || null, + action: entry.action, + resource_type: entry.resourceType || null, + resource_id: entry.resourceId || null, + details: entry.details || {}, + ip_address: entry.ipAddress || null, + user_agent: entry.userAgent || null + }); + + if (error) { + console.error('Audit log error:', error); + return { success: false, error: error.message }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Audit log exception:', error); + return { success: false, error: errorMessage }; + } +} + +/** + * Log a member-related action + */ +export async function logMemberAction( + action: 'create' | 'update' | 'delete' | 'role_change' | 'status_change' | 'invite', + performedBy: { id: string; email: string }, + targetMember: { id: string; email?: string }, + details?: Record, + requestInfo?: { ip?: string; userAgent?: string } +): Promise { + await logAudit({ + userId: performedBy.id, + userEmail: performedBy.email, + action: `member.${action}` as AuditAction, + resourceType: 'member', + resourceId: targetMember.id, + details: { + target_email: targetMember.email, + ...details + }, + ipAddress: requestInfo?.ip, + userAgent: requestInfo?.userAgent + }); +} + +/** + * Log an event-related action + */ +export async function logEventAction( + action: 'create' | 'update' | 'delete' | 'cancel', + performedBy: { id: string; email: string }, + event: { id: string; title?: string }, + details?: Record, + requestInfo?: { ip?: string; userAgent?: string } +): Promise { + await logAudit({ + userId: performedBy.id, + userEmail: performedBy.email, + action: `event.${action}` as AuditAction, + resourceType: 'event', + resourceId: event.id, + details: { + event_title: event.title, + ...details + }, + ipAddress: requestInfo?.ip, + userAgent: requestInfo?.userAgent + }); +} + +/** + * Log a payment-related action + */ +export async function logPaymentAction( + action: 'record' | 'delete', + performedBy: { id: string; email: string }, + payment: { id?: string; memberId: string; amount?: number }, + details?: Record, + requestInfo?: { ip?: string; userAgent?: string } +): Promise { + await logAudit({ + userId: performedBy.id, + userEmail: performedBy.email, + action: `payment.${action}` as AuditAction, + resourceType: 'payment', + resourceId: payment.id, + details: { + member_id: payment.memberId, + amount: payment.amount, + ...details + }, + ipAddress: requestInfo?.ip, + userAgent: requestInfo?.userAgent + }); +} + +/** + * Log a document-related action + */ +export async function logDocumentAction( + action: 'upload' | 'delete' | 'visibility_change', + performedBy: { id: string; email: string }, + document: { id: string; title?: string }, + details?: Record, + requestInfo?: { ip?: string; userAgent?: string } +): Promise { + await logAudit({ + userId: performedBy.id, + userEmail: performedBy.email, + action: `document.${action}` as AuditAction, + resourceType: 'document', + resourceId: document.id, + details: { + document_title: document.title, + ...details + }, + ipAddress: requestInfo?.ip, + userAgent: requestInfo?.userAgent + }); +} + +/** + * Log settings update + */ +export async function logSettingsUpdate( + performedBy: { id: string; email: string }, + category: string, + details?: Record, + requestInfo?: { ip?: string; userAgent?: string } +): Promise { + await logAudit({ + userId: performedBy.id, + userEmail: performedBy.email, + action: 'settings.update', + resourceType: 'settings', + resourceId: category, + details, + ipAddress: requestInfo?.ip, + userAgent: requestInfo?.userAgent + }); +} + +/** + * Get recent audit logs + */ +export async function getRecentAuditLogs( + limit: number = 50, + filters?: { + action?: string; + resourceType?: string; + userId?: string; + startDate?: string; + endDate?: string; + } +): Promise<{ logs: any[]; error: string | null }> { + let query = supabaseAdmin + .from('audit_logs') + .select('*') + .order('created_at', { ascending: false }) + .limit(limit); + + if (filters?.action) { + query = query.eq('action', filters.action); + } + if (filters?.resourceType) { + query = query.eq('resource_type', filters.resourceType); + } + if (filters?.userId) { + query = query.eq('user_id', filters.userId); + } + if (filters?.startDate) { + query = query.gte('created_at', filters.startDate); + } + if (filters?.endDate) { + query = query.lte('created_at', filters.endDate); + } + + const { data, error } = await query; + + if (error) { + return { logs: [], error: error.message }; + } + + return { logs: data || [], error: null }; +} diff --git a/src/lib/server/dues.ts b/src/lib/server/dues.ts new file mode 100644 index 0000000..1c0ba73 --- /dev/null +++ b/src/lib/server/dues.ts @@ -0,0 +1,881 @@ +/** + * Dues Management Service + * Handles dues reminders, bulk operations, and analytics + */ + +import { supabaseAdmin } from './supabase'; +import { sendTemplatedEmail } from './email'; +import type { MemberWithDues } from '$lib/types/database'; + +// ============================================ +// TYPES +// ============================================ + +export type ReminderType = 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice'; + +// Onboarding reminder types (for new members with payment_deadline) +export type OnboardingReminderType = 'onboarding_reminder_7' | 'onboarding_reminder_1' | 'onboarding_expired'; + +export interface DuesSettings { + reminder_days_before: number[]; + grace_period_days: number; + auto_inactive_enabled: boolean; + payment_iban: string; + payment_account_holder: string; + payment_bank_name: string; +} + +export interface DuesReminderResult { + sent: number; + skipped: number; + errors: string[]; + members: Array<{ id: string; name: string; email: string; status: 'sent' | 'skipped' | 'error'; error?: string }>; +} + +export interface DuesAnalytics { + totalMembers: number; + current: number; + dueSoon: number; + overdue: number; + neverPaid: number; + totalCollectedThisMonth: number; + totalCollectedThisYear: number; + totalOutstanding: number; + paymentsByMonth: Array<{ month: string; amount: number; count: number }>; + remindersSentThisMonth: number; + statusBreakdown: Array<{ status: string; count: number; percentage: number }>; +} + +// ============================================ +// SETTINGS +// ============================================ + +/** + * Get dues-related settings from the database + */ +export async function getDuesSettings(): Promise { + const { data: settings } = await supabaseAdmin + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'dues'); + + const config: Record = {}; + for (const s of settings || []) { + config[s.setting_key] = s.setting_value; + } + + return { + reminder_days_before: Array.isArray(config.reminder_days_before) + ? config.reminder_days_before + : [30, 7, 1], + grace_period_days: typeof config.grace_period_days === 'number' ? config.grace_period_days : 30, + auto_inactive_enabled: + typeof config.auto_inactive_enabled === 'boolean' ? config.auto_inactive_enabled : true, + payment_iban: config.payment_iban || '', + payment_account_holder: config.payment_account_holder || '', + payment_bank_name: config.payment_bank_name || '' + }; +} + +// ============================================ +// MEMBER QUERIES +// ============================================ + +/** + * Get members who need a specific type of reminder + * Excludes members who have already received this reminder for their current due date + */ +export async function getMembersNeedingReminder(reminderType: ReminderType): Promise { + const settings = await getDuesSettings(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Get all members with dues info + const { data: members, error } = await supabaseAdmin + .from('members_with_dues') + .select('*') + .not('email', 'is', null); + + if (error || !members) { + console.error('Error fetching members:', error); + return []; + } + + // Filter based on reminder type + let filteredMembers: MemberWithDues[] = []; + + if (reminderType.startsWith('due_soon_')) { + const daysMatch = reminderType.match(/due_soon_(\d+)/); + if (!daysMatch) return []; + const daysBefore = parseInt(daysMatch[1]); + + filteredMembers = members.filter((m) => { + if (!m.current_due_date || m.dues_status === 'never_paid') return false; + const dueDate = new Date(m.current_due_date); + const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + // Member is due within the specified window (e.g., 30 days means dues_until <= 30) + return daysUntil > 0 && daysUntil <= daysBefore; + }); + } else if (reminderType === 'overdue') { + filteredMembers = members.filter((m) => { + if (!m.current_due_date) return false; + const daysOverdue = m.days_overdue || 0; + // Overdue but still within grace period + return m.dues_status === 'overdue' && daysOverdue <= settings.grace_period_days; + }); + } else if (reminderType === 'grace_period') { + filteredMembers = members.filter((m) => { + if (!m.current_due_date) return false; + const daysOverdue = m.days_overdue || 0; + // In final week of grace period + const graceDaysRemaining = settings.grace_period_days - daysOverdue; + return m.dues_status === 'overdue' && graceDaysRemaining > 0 && graceDaysRemaining <= 7; + }); + } + + // Exclude members who already received this reminder for their current due period + if (filteredMembers.length > 0) { + const memberIds = filteredMembers.map((m) => m.id); + + // Get reminders already sent + const { data: existingReminders } = await supabaseAdmin + .from('dues_reminder_logs') + .select('member_id, due_date') + .eq('reminder_type', reminderType) + .in('member_id', memberIds); + + if (existingReminders && existingReminders.length > 0) { + const sentSet = new Set( + existingReminders.map((r) => `${r.member_id}-${r.due_date}`) + ); + + filteredMembers = filteredMembers.filter((m) => { + const key = `${m.id}-${m.current_due_date}`; + return !sentSet.has(key); + }); + } + } + + return filteredMembers; +} + +/** + * Get overdue members who have exceeded the grace period and should be marked inactive + */ +export async function getMembersForInactivation(): Promise { + const settings = await getDuesSettings(); + + if (!settings.auto_inactive_enabled) { + return []; + } + + const { data: members } = await supabaseAdmin + .from('members_with_dues') + .select('*') + .eq('dues_status', 'overdue') + .not('status_name', 'eq', 'inactive'); + + if (!members) return []; + + // Filter to those past grace period + return members.filter((m) => { + const daysOverdue = m.days_overdue || 0; + return daysOverdue > settings.grace_period_days; + }); +} + +// ============================================ +// REMINDER SENDING +// ============================================ + +/** + * Send a dues reminder to a specific member + */ +export async function sendDuesReminder( + member: MemberWithDues, + reminderType: ReminderType, + baseUrl: string = 'https://monacousa.org' +): Promise<{ success: boolean; error?: string; emailLogId?: string }> { + const settings = await getDuesSettings(); + + // Determine template key based on reminder type + const templateKey = + reminderType === 'overdue' + ? 'dues_overdue' + : reminderType === 'grace_period' + ? 'dues_grace_warning' + : reminderType === 'inactive_notice' + ? 'dues_inactive_notice' + : `dues_reminder_${reminderType.replace('due_soon_', '')}`; + + // Calculate variables + const dueDate = member.current_due_date + ? new Date(member.current_due_date).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + : 'N/A'; + + const daysOverdue = member.days_overdue || 0; + const graceDaysRemaining = Math.max(0, settings.grace_period_days - daysOverdue); + const graceEndDate = member.current_due_date + ? new Date( + new Date(member.current_due_date).getTime() + + settings.grace_period_days * 24 * 60 * 60 * 1000 + ).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + : 'N/A'; + + const variables: Record = { + first_name: member.first_name, + last_name: member.last_name, + member_id: member.member_id, + due_date: dueDate, + amount: `€${(member.annual_dues || 50).toFixed(2)}`, + days_overdue: daysOverdue.toString(), + grace_days_remaining: graceDaysRemaining.toString(), + grace_end_date: graceEndDate, + account_holder: settings.payment_account_holder, + bank_name: settings.payment_bank_name, + iban: settings.payment_iban, + portal_url: `${baseUrl}/payments` + }; + + // Send email + const result = await sendTemplatedEmail(templateKey, member.email, variables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + baseUrl + }); + + if (!result.success) { + return { success: false, error: result.error }; + } + + // Log the reminder + const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({ + member_id: member.id, + reminder_type: reminderType, + due_date: member.current_due_date || new Date().toISOString().split('T')[0] + }); + + if (logError) { + console.error('Error logging reminder:', logError); + } + + return { success: true }; +} + +/** + * Send bulk reminders of a specific type + */ +export async function sendBulkReminders( + reminderType: ReminderType, + baseUrl: string = 'https://monacousa.org' +): Promise { + const members = await getMembersNeedingReminder(reminderType); + + const result: DuesReminderResult = { + sent: 0, + skipped: 0, + errors: [], + members: [] + }; + + for (const member of members) { + try { + const sendResult = await sendDuesReminder(member, reminderType, baseUrl); + + if (sendResult.success) { + result.sent++; + result.members.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email, + status: 'sent' + }); + } else { + result.errors.push(`${member.email}: ${sendResult.error}`); + result.members.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email, + status: 'error', + error: sendResult.error + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`${member.email}: ${errorMessage}`); + result.members.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email, + status: 'error', + error: errorMessage + }); + } + } + + return result; +} + +// ============================================ +// GRACE PERIOD & INACTIVATION +// ============================================ + +/** + * Process members who have exceeded grace period and mark them inactive + */ +export async function processGracePeriodExpirations( + baseUrl: string = 'https://monacousa.org' +): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> { + const settings = await getDuesSettings(); + + if (!settings.auto_inactive_enabled) { + return { processed: 0, members: [] }; + } + + const members = await getMembersForInactivation(); + const processed: Array<{ id: string; name: string; email: string }> = []; + + // Get inactive status ID + const { data: inactiveStatus } = await supabaseAdmin + .from('membership_statuses') + .select('id') + .eq('name', 'inactive') + .single(); + + if (!inactiveStatus) { + console.error('Inactive status not found'); + return { processed: 0, members: [] }; + } + + for (const member of members) { + // Update member status to inactive + const { error: updateError } = await supabaseAdmin + .from('members') + .update({ membership_status_id: inactiveStatus.id }) + .eq('id', member.id); + + if (updateError) { + console.error(`Error updating member ${member.id}:`, updateError); + continue; + } + + // Send inactive notice + await sendDuesReminder(member, 'inactive_notice', baseUrl); + + // Log the reminder + await supabaseAdmin.from('dues_reminder_logs').insert({ + member_id: member.id, + reminder_type: 'inactive_notice', + due_date: member.current_due_date || new Date().toISOString().split('T')[0] + }); + + processed.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email + }); + } + + return { processed: processed.length, members: processed }; +} + +// ============================================ +// ANALYTICS +// ============================================ + +/** + * Get comprehensive dues analytics + */ +export async function getDuesAnalytics(): Promise { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfYear = new Date(now.getFullYear(), 0, 1); + + // Get members with dues + const { data: members } = await supabaseAdmin.from('members_with_dues').select('*'); + + const allMembers = members || []; + const totalMembers = allMembers.length; + const current = allMembers.filter((m) => m.dues_status === 'current').length; + const dueSoon = allMembers.filter((m) => m.dues_status === 'due_soon').length; + const overdue = allMembers.filter((m) => m.dues_status === 'overdue').length; + const neverPaid = allMembers.filter((m) => m.dues_status === 'never_paid').length; + + // Calculate total outstanding (overdue + due_soon + never_paid) + const totalOutstanding = allMembers + .filter((m) => m.dues_status !== 'current') + .reduce((sum, m) => sum + (m.annual_dues || 0), 0); + + // Get payments this month + const { data: monthPayments } = await supabaseAdmin + .from('dues_payments') + .select('amount') + .gte('payment_date', startOfMonth.toISOString().split('T')[0]); + + const totalCollectedThisMonth = (monthPayments || []).reduce((sum, p) => sum + p.amount, 0); + + // Get payments this year + const { data: yearPayments } = await supabaseAdmin + .from('dues_payments') + .select('amount') + .gte('payment_date', startOfYear.toISOString().split('T')[0]); + + const totalCollectedThisYear = (yearPayments || []).reduce((sum, p) => sum + p.amount, 0); + + // Get payments by month (last 12 months) + const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1); + const { data: allPayments } = await supabaseAdmin + .from('dues_payments') + .select('amount, payment_date') + .gte('payment_date', twelveMonthsAgo.toISOString().split('T')[0]) + .order('payment_date', { ascending: true }); + + const paymentsByMonth: Array<{ month: string; amount: number; count: number }> = []; + const monthMap = new Map(); + + for (const payment of allPayments || []) { + const date = new Date(payment.payment_date); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + + const existing = monthMap.get(monthKey) || { amount: 0, count: 0 }; + existing.amount += payment.amount; + existing.count += 1; + monthMap.set(monthKey, existing); + } + + // Fill in missing months + for (let i = 0; i < 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() - 11 + i, 1); + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + const data = monthMap.get(monthKey) || { amount: 0, count: 0 }; + paymentsByMonth.push({ + month: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + amount: data.amount, + count: data.count + }); + } + + // Get reminders sent this month + const { count: remindersSentThisMonth } = await supabaseAdmin + .from('dues_reminder_logs') + .select('*', { count: 'exact', head: true }) + .gte('sent_at', startOfMonth.toISOString()); + + // Status breakdown with percentages + const statusBreakdown = [ + { status: 'current', count: current, percentage: totalMembers > 0 ? (current / totalMembers) * 100 : 0 }, + { status: 'due_soon', count: dueSoon, percentage: totalMembers > 0 ? (dueSoon / totalMembers) * 100 : 0 }, + { status: 'overdue', count: overdue, percentage: totalMembers > 0 ? (overdue / totalMembers) * 100 : 0 }, + { status: 'never_paid', count: neverPaid, percentage: totalMembers > 0 ? (neverPaid / totalMembers) * 100 : 0 } + ]; + + return { + totalMembers, + current, + dueSoon, + overdue, + neverPaid, + totalCollectedThisMonth, + totalCollectedThisYear, + totalOutstanding, + paymentsByMonth, + remindersSentThisMonth: remindersSentThisMonth || 0, + statusBreakdown + }; +} + +/** + * Get detailed dues report data for CSV export + */ +export async function getDuesReportData(): Promise<{ + members: Array<{ + member_id: string; + name: string; + email: string; + membership_type: string; + status: string; + dues_status: string; + annual_dues: number; + last_payment_date: string | null; + current_due_date: string | null; + days_overdue: number | null; + }>; + payments: Array<{ + member_id: string; + member_name: string; + amount: number; + payment_date: string; + payment_method: string; + reference: string | null; + recorded_by: string | null; + }>; +}> { + // Get members with dues + const { data: members } = await supabaseAdmin.from('members_with_dues').select('*'); + + const memberReport = (members || []).map((m) => ({ + member_id: m.member_id, + name: `${m.first_name} ${m.last_name}`, + email: m.email, + membership_type: m.membership_type_name || 'Regular', + status: m.status_display_name || 'Unknown', + dues_status: m.dues_status, + annual_dues: m.annual_dues || 0, + last_payment_date: m.last_payment_date, + current_due_date: m.current_due_date, + days_overdue: m.days_overdue + })); + + // Get all payments with member info + const { data: payments } = await supabaseAdmin + .from('dues_payments') + .select( + ` + *, + member:members(member_id, first_name, last_name), + recorder:members!dues_payments_recorded_by_fkey(first_name, last_name) + ` + ) + .order('payment_date', { ascending: false }); + + const paymentReport = (payments || []).map((p: any) => ({ + member_id: p.member?.member_id || 'Unknown', + member_name: p.member ? `${p.member.first_name} ${p.member.last_name}` : 'Unknown', + amount: p.amount, + payment_date: p.payment_date, + payment_method: p.payment_method, + reference: p.reference, + recorded_by: p.recorder ? `${p.recorder.first_name} ${p.recorder.last_name}` : null + })); + + return { + members: memberReport, + payments: paymentReport + }; +} + +/** + * Get reminder effectiveness stats + */ +export async function getReminderEffectiveness(): Promise<{ + totalRemindersSent: number; + paidWithin7Days: number; + paidWithin30Days: number; + effectivenessRate: number; +}> { + // Get all reminder logs with payment data + const { data: reminders } = await supabaseAdmin + .from('dues_reminder_logs') + .select('member_id, sent_at, due_date'); + + if (!reminders || reminders.length === 0) { + return { + totalRemindersSent: 0, + paidWithin7Days: 0, + paidWithin30Days: 0, + effectivenessRate: 0 + }; + } + + let paidWithin7Days = 0; + let paidWithin30Days = 0; + + for (const reminder of reminders) { + const sentDate = new Date(reminder.sent_at); + const sevenDaysLater = new Date(sentDate.getTime() + 7 * 24 * 60 * 60 * 1000); + const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000); + + // Check if member paid within windows + const { data: payments } = await supabaseAdmin + .from('dues_payments') + .select('payment_date') + .eq('member_id', reminder.member_id) + .gte('payment_date', sentDate.toISOString().split('T')[0]) + .lte('payment_date', thirtyDaysLater.toISOString().split('T')[0]) + .limit(1); + + if (payments && payments.length > 0) { + const paymentDate = new Date(payments[0].payment_date); + if (paymentDate <= sevenDaysLater) { + paidWithin7Days++; + } + paidWithin30Days++; + } + } + + return { + totalRemindersSent: reminders.length, + paidWithin7Days, + paidWithin30Days, + effectivenessRate: reminders.length > 0 ? (paidWithin30Days / reminders.length) * 100 : 0 + }; +} + +// ============================================ +// ONBOARDING REMINDERS +// ============================================ + +interface OnboardingMember { + id: string; + first_name: string; + last_name: string; + email: string; + member_id: string; + payment_deadline: string; +} + +/** + * Get new members who need onboarding payment reminders + * These are members with a payment_deadline set from onboarding + */ +export async function getMembersNeedingOnboardingReminder( + reminderType: OnboardingReminderType +): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Get pending status ID + const { data: pendingStatus } = await supabaseAdmin + .from('membership_statuses') + .select('id') + .eq('name', 'pending') + .single(); + + if (!pendingStatus) { + console.error('Pending status not found'); + return []; + } + + // Get members with payment_deadline set (from onboarding) + const { data: members, error } = await supabaseAdmin + .from('members') + .select('id, first_name, last_name, email, member_id, payment_deadline') + .eq('membership_status_id', pendingStatus.id) + .not('payment_deadline', 'is', null) + .not('email', 'is', null); + + if (error || !members) { + console.error('Error fetching onboarding members:', error); + return []; + } + + // Filter based on reminder type + let filteredMembers: OnboardingMember[] = []; + + if (reminderType === 'onboarding_reminder_7') { + // 7 days or less until deadline + filteredMembers = members.filter((m) => { + if (!m.payment_deadline) return false; + const deadline = new Date(m.payment_deadline); + const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + return daysUntil > 0 && daysUntil <= 7; + }) as OnboardingMember[]; + } else if (reminderType === 'onboarding_reminder_1') { + // 1 day or less until deadline (final reminder) + filteredMembers = members.filter((m) => { + if (!m.payment_deadline) return false; + const deadline = new Date(m.payment_deadline); + const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + return daysUntil === 1; + }) as OnboardingMember[]; + } else if (reminderType === 'onboarding_expired') { + // Deadline has passed + filteredMembers = members.filter((m) => { + if (!m.payment_deadline) return false; + const deadline = new Date(m.payment_deadline); + return deadline < today; + }) as OnboardingMember[]; + } + + // Exclude members who already received this reminder + if (filteredMembers.length > 0) { + const memberIds = filteredMembers.map((m) => m.id); + + const { data: existingReminders } = await supabaseAdmin + .from('dues_reminder_logs') + .select('member_id') + .eq('reminder_type', reminderType) + .in('member_id', memberIds); + + if (existingReminders && existingReminders.length > 0) { + const sentSet = new Set(existingReminders.map((r) => r.member_id)); + filteredMembers = filteredMembers.filter((m) => !sentSet.has(m.id)); + } + } + + return filteredMembers; +} + +/** + * Send an onboarding reminder to a specific member + */ +export async function sendOnboardingReminder( + member: OnboardingMember, + reminderType: OnboardingReminderType, + baseUrl: string = 'https://monacousa.org' +): Promise<{ success: boolean; error?: string }> { + const settings = await getDuesSettings(); + + // Calculate days until deadline + const deadline = new Date(member.payment_deadline); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const daysLeft = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + + // Get default membership dues amount + const { data: defaultType } = await supabaseAdmin + .from('membership_types') + .select('annual_dues') + .eq('is_default', true) + .single(); + + const variables: Record = { + first_name: member.first_name, + last_name: member.last_name, + member_id: member.member_id || 'N/A', + payment_deadline: deadline.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }), + days_remaining: Math.max(0, daysLeft).toString(), + amount: `€${defaultType?.annual_dues || 150}`, + account_holder: settings.payment_account_holder || 'Monaco USA', + bank_name: settings.payment_bank_name || 'Credit Foncier de Monaco', + iban: settings.payment_iban || 'Contact for details', + portal_url: `${baseUrl}/payments` + }; + + // Send email + const result = await sendTemplatedEmail(reminderType, member.email, variables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + baseUrl + }); + + if (!result.success) { + return { success: false, error: result.error }; + } + + // Log the reminder + const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({ + member_id: member.id, + reminder_type: reminderType, + due_date: member.payment_deadline + }); + + if (logError) { + console.error('Error logging onboarding reminder:', logError); + } + + return { success: true }; +} + +/** + * Send bulk onboarding reminders of a specific type + */ +export async function sendOnboardingReminders( + reminderType: OnboardingReminderType, + baseUrl: string = 'https://monacousa.org' +): Promise { + const members = await getMembersNeedingOnboardingReminder(reminderType); + + const result: DuesReminderResult = { + sent: 0, + skipped: 0, + errors: [], + members: [] + }; + + for (const member of members) { + try { + const sendResult = await sendOnboardingReminder(member, reminderType, baseUrl); + + if (sendResult.success) { + result.sent++; + result.members.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email, + status: 'sent' + }); + } else { + result.errors.push(`${member.email}: ${sendResult.error}`); + result.members.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email, + status: 'error', + error: sendResult.error + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`${member.email}: ${errorMessage}`); + result.members.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email, + status: 'error', + error: errorMessage + }); + } + } + + return result; +} + +/** + * Process expired onboarding payment deadlines and mark members as inactive + */ +export async function processOnboardingExpirations( + baseUrl: string = 'https://monacousa.org' +): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> { + const members = await getMembersNeedingOnboardingReminder('onboarding_expired'); + const processed: Array<{ id: string; name: string; email: string }> = []; + + // Get inactive status ID + const { data: inactiveStatus } = await supabaseAdmin + .from('membership_statuses') + .select('id') + .eq('name', 'inactive') + .single(); + + if (!inactiveStatus) { + console.error('Inactive status not found'); + return { processed: 0, members: [] }; + } + + for (const member of members) { + // Update member status to inactive + const { error: updateError } = await supabaseAdmin + .from('members') + .update({ membership_status_id: inactiveStatus.id }) + .eq('id', member.id); + + if (updateError) { + console.error(`Error updating member ${member.id}:`, updateError); + continue; + } + + // Send expired notice + await sendOnboardingReminder(member, 'onboarding_expired', baseUrl); + + processed.push({ + id: member.id, + name: `${member.first_name} ${member.last_name}`, + email: member.email + }); + } + + return { processed: processed.length, members: processed }; +} diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..6b5d884 --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,394 @@ +import nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; +import { supabaseAdmin } from './supabase'; + +export interface SmtpConfig { + host: string; + port: number; + secure: boolean; + username: string; + password: string; + from_address: string; + from_name: string; +} + +export interface SendEmailOptions { + to: string; + subject: string; + html: string; + text?: string; + recipientId?: string; + recipientName?: string; + templateKey?: string; + emailType?: string; + sentBy?: string; +} + +/** + * Get SMTP configuration from app_settings table + */ +export async function getSmtpConfig(): Promise { + const { data: settings } = await supabaseAdmin + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'email'); + + if (!settings || settings.length === 0) { + return null; + } + + const config: Record = {}; + for (const s of settings) { + // Parse the value - it might be JSON stringified or plain + let value = s.setting_value; + if (typeof value === 'string') { + // Remove surrounding quotes if present + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value as string; + } + + // Validate required fields + if (!config.smtp_host || !config.smtp_username || !config.smtp_password) { + return null; + } + + return { + host: config.smtp_host, + port: parseInt(config.smtp_port || '587'), + secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465, + username: config.smtp_username, + password: config.smtp_password, + from_address: config.smtp_from_address || 'noreply@monacousa.org', + from_name: config.smtp_from_name || 'Monaco USA' + }; +} + +/** + * Create a nodemailer transporter with the configured SMTP settings + */ +export async function createTransporter(): Promise { + const config = await getSmtpConfig(); + if (!config) { + console.error('SMTP configuration not found or incomplete'); + return null; + } + + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: { + user: config.username, + pass: config.password + } + }); +} + +/** + * Send an email using the configured SMTP settings + */ +export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; error?: string; messageId?: string }> { + const config = await getSmtpConfig(); + if (!config) { + return { success: false, error: 'SMTP not configured. Please configure email settings first.' }; + } + + const transporter = await createTransporter(); + if (!transporter) { + return { success: false, error: 'Failed to create email transporter' }; + } + + try { + const result = await transporter.sendMail({ + from: `"${config.from_name}" <${config.from_address}>`, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text || stripHtml(options.html) + }); + + // Log to email_logs table + await supabaseAdmin.from('email_logs').insert({ + recipient_id: options.recipientId || null, + recipient_email: options.to, + recipient_name: options.recipientName || null, + template_key: options.templateKey || null, + subject: options.subject, + email_type: options.emailType || 'manual', + status: 'sent', + provider: 'smtp', + provider_message_id: result.messageId, + sent_by: options.sentBy || null, + sent_at: new Date().toISOString() + }); + + return { success: true, messageId: result.messageId }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Email send error:', error); + + // Log failed attempt + await supabaseAdmin.from('email_logs').insert({ + recipient_id: options.recipientId || null, + recipient_email: options.to, + recipient_name: options.recipientName || null, + template_key: options.templateKey || null, + subject: options.subject, + email_type: options.emailType || 'manual', + status: 'failed', + provider: 'smtp', + error_message: errorMessage, + sent_by: options.sentBy || null + }); + + return { success: false, error: errorMessage }; + } +} + +/** + * Send a templated email with variable substitution + * Templates should contain content only (no full HTML wrapper) - will be wrapped automatically + */ +export async function sendTemplatedEmail( + templateKey: string, + to: string, + variables: Record, + options?: { + recipientId?: string; + recipientName?: string; + sentBy?: string; + baseUrl?: string; + } +): Promise<{ success: boolean; error?: string; messageId?: string }> { + // Fetch template from database + const { data: template, error: templateError } = await supabaseAdmin + .from('email_templates') + .select('*') + .eq('template_key', templateKey) + .eq('is_active', true) + .single(); + + if (templateError || !template) { + return { success: false, error: `Email template "${templateKey}" not found or inactive` }; + } + + // Get site URL for logo + const baseUrl = options?.baseUrl || process.env.SITE_URL || 'https://monacousa.org'; + const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`; + + // Add default variables + const allVariables: Record = { + logo_url: logoUrl, + site_url: baseUrl, + ...variables + }; + + // Replace variables in subject and body + let subject = template.subject; + let bodyContent = template.body_html; + let text = template.body_text || ''; + + for (const [key, value] of Object.entries(allVariables)) { + const regex = new RegExp(`{{${key}}}`, 'g'); + subject = subject.replace(regex, value); + bodyContent = bodyContent.replace(regex, value); + text = text.replace(regex, value); + } + + // Extract title from template or use subject + // Look for title in template metadata or first h2 tag + let emailTitle = template.email_title || subject; + // Try to extract from first h2 in content + const h2Match = bodyContent.match(/]*>([^<]+)<\/h2>/i); + if (h2Match) { + emailTitle = h2Match[1].replace(/{{[^}]+}}/g, '').trim(); + } + + // Check if template already has full HTML wrapper (legacy templates) + const hasFullWrapper = bodyContent.includes('/g, + `` + ); + } else { + // Content-only template - wrap with Monaco template + html = wrapInMonacoTemplate({ + title: emailTitle, + content: bodyContent, + logoUrl + }); + } + + return sendEmail({ + to, + subject, + html, + text: text || undefined, + recipientId: options?.recipientId, + recipientName: options?.recipientName, + templateKey, + emailType: template.category, + sentBy: options?.sentBy + }); +} + +/** + * Test SMTP connection and optionally send a test email + */ +export async function testSmtpConnection( + sendTo?: string, + sentBy?: string +): Promise<{ success: boolean; error?: string }> { + const config = await getSmtpConfig(); + if (!config) { + return { success: false, error: 'SMTP not configured. Please configure and save email settings first.' }; + } + + const transporter = await createTransporter(); + if (!transporter) { + return { success: false, error: 'Failed to create email transporter' }; + } + + try { + // Verify connection + await transporter.verify(); + + // If a recipient is provided, send a test email + if (sendTo) { + const testContent = ` +

This is a test email from your Monaco USA Portal.

+
+

✓ Configuration Verified

+

Your SMTP settings are working correctly!

+
+

Sent at ${new Date().toLocaleString()}

`; + + const result = await sendEmail({ + to: sendTo, + subject: 'Monaco USA Portal - SMTP Test Email', + html: wrapInMonacoTemplate({ + title: 'SMTP Test Successful!', + content: testContent + }), + emailType: 'test', + sentBy + }); + + if (!result.success) { + return { success: false, error: result.error }; + } + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('SMTP test error:', error); + return { success: false, error: `SMTP connection failed: ${errorMessage}` }; + } +} + +// S3-hosted background image URL matching login screen +const EMAIL_BACKGROUND_IMAGE_URL = 'https://s3.monacousa.org/public/monaco_high_res.jpg'; + +/** + * Wrap email content in Monaco-branded template + * This creates a consistent look matching the login page styling with background image + */ +export function wrapInMonacoTemplate(options: { + title: string; + content: string; + logoUrl?: string; + backgroundImageUrl?: string; +}): string { + const baseUrl = process.env.SITE_URL || 'http://localhost:7453'; + const logoUrl = options.logoUrl || `${baseUrl}/MONACOUSA-Flags_376x376.png`; + const bgImageUrl = options.backgroundImageUrl || EMAIL_BACKGROUND_IMAGE_URL; + + return ` + + + + + + + + +
+ + + +
+ +
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

${options.title}

+
${options.content}
+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +`; +} + +/** + * Strip HTML tags from a string to create plain text version + */ +function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/lib/server/event-reminders.ts b/src/lib/server/event-reminders.ts new file mode 100644 index 0000000..33fe494 --- /dev/null +++ b/src/lib/server/event-reminders.ts @@ -0,0 +1,339 @@ +/** + * Event Reminder Service + * Handles sending automated reminder emails 24 hours before events + */ + +import { supabaseAdmin } from './supabase'; +import { sendTemplatedEmail } from './email'; + +// ============================================ +// TYPES +// ============================================ + +export interface EventReminderSettings { + event_reminders_enabled: boolean; + event_reminder_hours_before: number; +} + +export interface EventReminderResult { + sent: number; + skipped: number; + errors: string[]; + reminders: Array<{ + eventId: string; + eventTitle: string; + memberId: string; + memberName: string; + email: string; + status: 'sent' | 'skipped' | 'error'; + error?: string; + }>; +} + +export interface EventNeedingReminder { + event_id: string; + event_title: string; + start_datetime: string; + end_datetime: string; + location: string | null; + timezone: string; + rsvp_id: string; + member_id: string; + guest_count: number; + rsvp_status: string; + first_name: string; + last_name: string; + email: string; +} + +// ============================================ +// SETTINGS +// ============================================ + +/** + * Get event reminder settings from the database + */ +export async function getEventReminderSettings(): Promise { + const { data: settings } = await supabaseAdmin + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'events') + .in('setting_key', ['event_reminders_enabled', 'event_reminder_hours_before']); + + const config: Record = {}; + for (const s of settings || []) { + config[s.setting_key] = s.setting_value as string; + } + + return { + event_reminders_enabled: config.event_reminders_enabled !== 'false', + event_reminder_hours_before: parseInt(config.event_reminder_hours_before || '24') + }; +} + +// ============================================ +// QUERIES +// ============================================ + +/** + * Get events with confirmed RSVPs that need reminders sent + * Uses the events_needing_reminders view for efficient querying + */ +export async function getEventsNeedingReminders(): Promise { + const settings = await getEventReminderSettings(); + + if (!settings.event_reminders_enabled) { + return []; + } + + // Calculate the time window based on settings + const hoursBeforeEvent = settings.event_reminder_hours_before; + const now = new Date(); + const windowStart = new Date(now.getTime() + (hoursBeforeEvent - 1) * 60 * 60 * 1000); + const windowEnd = new Date(now.getTime() + (hoursBeforeEvent + 1) * 60 * 60 * 1000); + + // Query events starting within the reminder window + const { data: events, error } = await supabaseAdmin + .from('events') + .select(` + id, + title, + start_datetime, + end_datetime, + location, + timezone + `) + .eq('status', 'published') + .gt('start_datetime', windowStart.toISOString()) + .lte('start_datetime', windowEnd.toISOString()); + + if (error || !events || events.length === 0) { + return []; + } + + // Get RSVPs for these events + const eventIds = events.map(e => e.id); + const { data: rsvps, error: rsvpError } = await supabaseAdmin + .from('event_rsvps') + .select(` + id, + event_id, + member_id, + guest_count, + status, + member:members(first_name, last_name, email) + `) + .in('event_id', eventIds) + .eq('status', 'confirmed'); + + if (rsvpError || !rsvps) { + return []; + } + + // Get already sent reminders + const { data: sentReminders } = await supabaseAdmin + .from('event_reminder_logs') + .select('event_id, member_id') + .in('event_id', eventIds) + .eq('reminder_type', '24hr'); + + const sentSet = new Set( + (sentReminders || []).map(r => `${r.event_id}-${r.member_id}`) + ); + + // Build the result array + const result: EventNeedingReminder[] = []; + + for (const event of events) { + const eventRsvps = rsvps.filter(r => r.event_id === event.id); + + for (const rsvp of eventRsvps) { + const member = rsvp.member as { first_name: string; last_name: string; email: string } | null; + if (!member?.email) continue; + + // Skip if reminder already sent + const key = `${event.id}-${rsvp.member_id}`; + if (sentSet.has(key)) continue; + + result.push({ + event_id: event.id, + event_title: event.title, + start_datetime: event.start_datetime, + end_datetime: event.end_datetime, + location: event.location, + timezone: event.timezone || 'Europe/Monaco', + rsvp_id: rsvp.id, + member_id: rsvp.member_id, + guest_count: rsvp.guest_count || 0, + rsvp_status: rsvp.status, + first_name: member.first_name, + last_name: member.last_name, + email: member.email + }); + } + } + + return result; +} + +// ============================================ +// REMINDER SENDING +// ============================================ + +/** + * Send a single event reminder email + */ +export async function sendEventReminder( + reminder: EventNeedingReminder, + baseUrl: string = 'https://monacousa.org' +): Promise<{ success: boolean; error?: string }> { + // Format date and time + const eventDate = new Date(reminder.start_datetime); + const formattedDate = eventDate.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }); + const formattedTime = eventDate.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZone: reminder.timezone + }); + + const variables: Record = { + first_name: reminder.first_name, + event_title: reminder.event_title, + event_date: formattedDate, + event_time: formattedTime, + event_location: reminder.location || 'TBD', + guest_count: reminder.guest_count > 0 ? reminder.guest_count.toString() : '', + portal_url: `${baseUrl}/events/${reminder.event_id}` + }; + + // Send email + const result = await sendTemplatedEmail('event_reminder_24hr', reminder.email, variables, { + recipientId: reminder.member_id, + recipientName: `${reminder.first_name} ${reminder.last_name}`, + baseUrl + }); + + if (!result.success) { + return { success: false, error: result.error }; + } + + // Log the reminder + const { error: logError } = await supabaseAdmin.from('event_reminder_logs').insert({ + event_id: reminder.event_id, + rsvp_id: reminder.rsvp_id, + member_id: reminder.member_id, + reminder_type: '24hr' + }); + + if (logError) { + console.error('Error logging event reminder:', logError); + } + + return { success: true }; +} + +/** + * Send all pending event reminders + */ +export async function sendEventReminders( + baseUrl: string = 'https://monacousa.org' +): Promise { + const remindersNeeded = await getEventsNeedingReminders(); + + const result: EventReminderResult = { + sent: 0, + skipped: 0, + errors: [], + reminders: [] + }; + + for (const reminder of remindersNeeded) { + try { + const sendResult = await sendEventReminder(reminder, baseUrl); + + if (sendResult.success) { + result.sent++; + result.reminders.push({ + eventId: reminder.event_id, + eventTitle: reminder.event_title, + memberId: reminder.member_id, + memberName: `${reminder.first_name} ${reminder.last_name}`, + email: reminder.email, + status: 'sent' + }); + } else { + result.errors.push(`${reminder.email}: ${sendResult.error}`); + result.reminders.push({ + eventId: reminder.event_id, + eventTitle: reminder.event_title, + memberId: reminder.member_id, + memberName: `${reminder.first_name} ${reminder.last_name}`, + email: reminder.email, + status: 'error', + error: sendResult.error + }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`${reminder.email}: ${errorMessage}`); + result.reminders.push({ + eventId: reminder.event_id, + eventTitle: reminder.event_title, + memberId: reminder.member_id, + memberName: `${reminder.first_name} ${reminder.last_name}`, + email: reminder.email, + status: 'error', + error: errorMessage + }); + } + } + + return result; +} + +// ============================================ +// ANALYTICS +// ============================================ + +/** + * Get statistics about event reminders + */ +export async function getEventReminderStats(): Promise<{ + totalRemindersSent: number; + remindersSentThisMonth: number; + eventsWithReminders: number; +}> { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + // Get total reminders sent + const { count: totalRemindersSent } = await supabaseAdmin + .from('event_reminder_logs') + .select('*', { count: 'exact', head: true }); + + // Get reminders sent this month + const { count: remindersSentThisMonth } = await supabaseAdmin + .from('event_reminder_logs') + .select('*', { count: 'exact', head: true }) + .gte('sent_at', startOfMonth.toISOString()); + + // Get unique events with reminders + const { data: uniqueEvents } = await supabaseAdmin + .from('event_reminder_logs') + .select('event_id') + .limit(10000); + + const uniqueEventIds = new Set((uniqueEvents || []).map(e => e.event_id)); + + return { + totalRemindersSent: totalRemindersSent || 0, + remindersSentThisMonth: remindersSentThisMonth || 0, + eventsWithReminders: uniqueEventIds.size + }; +} diff --git a/src/lib/server/ical.ts b/src/lib/server/ical.ts new file mode 100644 index 0000000..44a4c7a --- /dev/null +++ b/src/lib/server/ical.ts @@ -0,0 +1,299 @@ +/** + * iCal Calendar Generation Utilities + * Generate .ics files for events and calendar feeds + */ + +export interface ICalEvent { + id: string; + title: string; + description?: string; + start_datetime: string; + end_datetime: string; + location?: string | null; + location_url?: string | null; + timezone?: string; + status?: 'published' | 'cancelled' | 'draft'; + event_type_name?: string | null; + organizer_name?: string; + organizer_email?: string; + url?: string; + all_day?: boolean; +} + +/** + * Escape special characters for iCal format + */ +function escapeICalText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') + .replace(/\r/g, ''); +} + +/** + * Format a date for iCal (YYYYMMDDTHHMMSSZ format for UTC) + */ +function formatICalDate(dateStr: string, timezone?: string): string { + const date = new Date(dateStr); + // Format as UTC + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + return `${year}${month}${day}T${hours}${minutes}${seconds}Z`; +} + +/** + * Format a date for all-day events (YYYYMMDD format) + */ +function formatICalDateOnly(dateStr: string): string { + const date = new Date(dateStr); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +/** + * Map event status to iCal status + */ +function getICalStatus(status?: string): string { + switch (status) { + case 'cancelled': + return 'CANCELLED'; + case 'draft': + return 'TENTATIVE'; + default: + return 'CONFIRMED'; + } +} + +/** + * Fold long lines according to iCal spec (max 75 chars per line) + */ +function foldLine(line: string): string { + const maxLength = 75; + if (line.length <= maxLength) { + return line; + } + + const result: string[] = []; + let remaining = line; + + // First line can be full length + result.push(remaining.substring(0, maxLength)); + remaining = remaining.substring(maxLength); + + // Continuation lines start with a space and have maxLength-1 content + while (remaining.length > 0) { + result.push(' ' + remaining.substring(0, maxLength - 1)); + remaining = remaining.substring(maxLength - 1); + } + + return result.join('\r\n'); +} + +/** + * Generate a single iCal event entry + */ +export function generateICalEvent(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string { + const uid = `${event.id}@monacousa.org`; + const dtstamp = formatICalDate(new Date().toISOString()); + const created = dtstamp; + const lastModified = dtstamp; + + const lines: string[] = [ + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${dtstamp}`, + `CREATED:${created}`, + `LAST-MODIFIED:${lastModified}` + ]; + + // Date/time + if (event.all_day) { + lines.push(`DTSTART;VALUE=DATE:${formatICalDateOnly(event.start_datetime)}`); + // For all-day events, end date is exclusive, so add one day + const endDate = new Date(event.end_datetime); + endDate.setDate(endDate.getDate() + 1); + lines.push(`DTEND;VALUE=DATE:${formatICalDateOnly(endDate.toISOString())}`); + } else { + lines.push(`DTSTART:${formatICalDate(event.start_datetime, event.timezone)}`); + lines.push(`DTEND:${formatICalDate(event.end_datetime, event.timezone)}`); + } + + // Summary (title) + lines.push(foldLine(`SUMMARY:${escapeICalText(event.title)}`)); + + // Description + if (event.description) { + lines.push(foldLine(`DESCRIPTION:${escapeICalText(event.description)}`)); + } + + // Location + if (event.location) { + lines.push(foldLine(`LOCATION:${escapeICalText(event.location)}`)); + } + + // URL + const eventUrl = event.url || `${baseUrl}/events/${event.id}`; + lines.push(`URL:${eventUrl}`); + + // Status + lines.push(`STATUS:${getICalStatus(event.status)}`); + + // Categories + if (event.event_type_name) { + lines.push(`CATEGORIES:${escapeICalText(event.event_type_name)}`); + } + + // Organizer + if (event.organizer_email) { + const organizerName = event.organizer_name || 'Monaco USA'; + lines.push(`ORGANIZER;CN=${escapeICalText(organizerName)}:mailto:${event.organizer_email}`); + } + + // Sequence (for updates) + lines.push('SEQUENCE:0'); + + lines.push('END:VEVENT'); + + return lines.join('\r\n'); +} + +/** + * Generate a complete iCal calendar file for a single event + */ +export function generateSingleEventIcal(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Monaco USA//Event Calendar//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + `X-WR-CALNAME:${escapeICalText(event.title)}`, + 'X-WR-TIMEZONE:Europe/Monaco', + generateICalEvent(event, baseUrl), + 'END:VCALENDAR' + ]; + + return lines.join('\r\n') + '\r\n'; +} + +/** + * Generate a complete iCal calendar feed for multiple events + */ +export function generateCalendarFeed( + events: ICalEvent[], + calendarName: string = 'Monaco USA Events', + baseUrl: string = 'https://monacousa.org' +): string { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Monaco USA//Event Calendar//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + `X-WR-CALNAME:${escapeICalText(calendarName)}`, + 'X-WR-TIMEZONE:Europe/Monaco', + // Refresh interval hint (1 hour) + 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', + 'X-PUBLISHED-TTL:PT1H' + ]; + + // Add each event + for (const event of events) { + lines.push(generateICalEvent(event, baseUrl)); + } + + lines.push('END:VCALENDAR'); + + return lines.join('\r\n') + '\r\n'; +} + +/** + * Generate a Google Calendar URL for an event + */ +export function generateGoogleCalendarUrl(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string { + const start = new Date(event.start_datetime); + const end = new Date(event.end_datetime); + + // Format: YYYYMMDDTHHMMSSZ + const formatGoogleDate = (date: Date) => { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + }; + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: event.title, + dates: `${formatGoogleDate(start)}/${formatGoogleDate(end)}`, + details: event.description || '', + location: event.location || '', + sprop: `website:${baseUrl}`, + sf: 'true', + output: 'xml' + }); + + return `https://www.google.com/calendar/render?${params.toString()}`; +} + +/** + * Generate an Outlook.com calendar URL for an event + */ +export function generateOutlookCalendarUrl(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string { + const start = new Date(event.start_datetime); + const end = new Date(event.end_datetime); + + // Outlook uses ISO format with URL encoding + const params = new URLSearchParams({ + rru: 'addevent', + startdt: start.toISOString(), + enddt: end.toISOString(), + subject: event.title, + body: event.description || '', + location: event.location || '', + path: '/calendar/action/compose' + }); + + return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`; +} + +/** + * Generate a Yahoo Calendar URL for an event + */ +export function generateYahooCalendarUrl(event: ICalEvent): string { + const start = new Date(event.start_datetime); + const end = new Date(event.end_datetime); + + // Duration in hours and minutes + const durationMs = end.getTime() - start.getTime(); + const hours = Math.floor(durationMs / (1000 * 60 * 60)); + const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); + const duration = `${String(hours).padStart(2, '0')}${String(minutes).padStart(2, '0')}`; + + // Format: YYYYMMDDTHHMMSS + const formatYahooDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const m = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${year}${month}${day}T${h}${m}${s}`; + }; + + const params = new URLSearchParams({ + v: '60', + title: event.title, + st: formatYahooDate(start), + dur: duration, + desc: event.description || '', + in_loc: event.location || '' + }); + + return `https://calendar.yahoo.com/?${params.toString()}`; +} diff --git a/src/lib/server/poste.ts b/src/lib/server/poste.ts new file mode 100644 index 0000000..509c135 --- /dev/null +++ b/src/lib/server/poste.ts @@ -0,0 +1,303 @@ +/** + * Poste.io Mail Server API Client + * Documentation: https://mail.monacousa.org/admin/api/doc + */ + +export interface PosteConfig { + host: string; + adminEmail: string; + adminPassword: string; +} + +export interface Mailbox { + address: string; + name: string; + disabled: boolean; + super_admin: boolean; + created?: string; + storage_limit?: number; + storage_usage?: number; +} + +export interface MailboxQuota { + storageLimit: number; + storageUsed: number; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +/** + * Make an authenticated request to the Poste API + */ +async function makeRequest( + config: PosteConfig, + method: string, + endpoint: string, + body?: Record +): Promise> { + const baseUrl = `https://${config.host}/admin/api/v1`; + const auth = Buffer.from(`${config.adminEmail}:${config.adminPassword}`).toString('base64'); + + try { + const response = await fetch(`${baseUrl}${endpoint}`, { + method, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.message || errorJson.error || errorMessage; + } catch { + errorMessage = errorText || errorMessage; + } + return { success: false, error: errorMessage }; + } + + // Handle empty responses (e.g., DELETE) + const text = await response.text(); + if (!text) { + return { success: true }; + } + + const data = JSON.parse(text) as T; + return { success: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: message }; + } +} + +/** + * Test connection to the Poste API + */ +export async function testConnection(config: PosteConfig): Promise<{ success: boolean; error?: string }> { + const result = await makeRequest<{ results: unknown[] }>(config, 'GET', '/domains'); + if (!result.success) { + return { success: false, error: result.error }; + } + return { success: true }; +} + +/** + * List all mailboxes + */ +export async function listMailboxes( + config: PosteConfig, + options?: { query?: string; page?: number; limit?: number } +): Promise<{ success: boolean; mailboxes?: Mailbox[]; total?: number; error?: string }> { + let endpoint = '/boxes'; + const params = new URLSearchParams(); + + if (options?.query) params.set('query', options.query); + if (options?.page) params.set('page', options.page.toString()); + if (options?.limit) params.set('paging', options.limit.toString()); + + const queryString = params.toString(); + if (queryString) endpoint += `?${queryString}`; + + const result = await makeRequest<{ results: Mailbox[]; results_count: number }>( + config, + 'GET', + endpoint + ); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { + success: true, + mailboxes: result.data?.results || [], + total: result.data?.results_count || 0 + }; +} + +/** + * Get a single mailbox + */ +export async function getMailbox( + config: PosteConfig, + email: string +): Promise<{ success: boolean; mailbox?: Mailbox; error?: string }> { + const result = await makeRequest(config, 'GET', `/boxes/${encodeURIComponent(email)}`); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true, mailbox: result.data }; +} + +/** + * Create a new mailbox + */ +export async function createMailbox( + config: PosteConfig, + options: { + email: string; + name: string; + password: string; + disabled?: boolean; + superAdmin?: boolean; + } +): Promise<{ success: boolean; error?: string }> { + const result = await makeRequest(config, 'POST', '/boxes', { + email: options.email, + name: options.name, + passwordPlaintext: options.password, + disabled: options.disabled ?? false, + superAdmin: options.superAdmin ?? false + }); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true }; +} + +/** + * Update a mailbox + */ +export async function updateMailbox( + config: PosteConfig, + email: string, + updates: { + name?: string; + password?: string; + disabled?: boolean; + superAdmin?: boolean; + } +): Promise<{ success: boolean; error?: string }> { + const body: Record = {}; + + if (updates.name !== undefined) body.name = updates.name; + if (updates.password !== undefined) body.passwordPlaintext = updates.password; + if (updates.disabled !== undefined) body.disabled = updates.disabled; + if (updates.superAdmin !== undefined) body.superAdmin = updates.superAdmin; + + const result = await makeRequest( + config, + 'PATCH', + `/boxes/${encodeURIComponent(email)}`, + body + ); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true }; +} + +/** + * Delete a mailbox + */ +export async function deleteMailbox( + config: PosteConfig, + email: string +): Promise<{ success: boolean; error?: string }> { + const result = await makeRequest( + config, + 'DELETE', + `/boxes/${encodeURIComponent(email)}` + ); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true }; +} + +/** + * Get mailbox storage quota + */ +export async function getMailboxQuota( + config: PosteConfig, + email: string +): Promise<{ success: boolean; quota?: MailboxQuota; error?: string }> { + const result = await makeRequest<{ storageLimit: number; storageUsed: number }>( + config, + 'GET', + `/boxes/${encodeURIComponent(email)}/quota` + ); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { + success: true, + quota: { + storageLimit: result.data?.storageLimit || 0, + storageUsed: result.data?.storageUsed || 0 + } + }; +} + +/** + * Set mailbox storage quota + */ +export async function setMailboxQuota( + config: PosteConfig, + email: string, + storageLimitMB: number +): Promise<{ success: boolean; error?: string }> { + const result = await makeRequest( + config, + 'PATCH', + `/boxes/${encodeURIComponent(email)}/quota`, + { storageLimit: storageLimitMB } + ); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true }; +} + +/** + * List all domains + */ +export async function listDomains( + config: PosteConfig +): Promise<{ success: boolean; domains?: string[]; error?: string }> { + const result = await makeRequest<{ results: { name: string }[] }>(config, 'GET', '/domains'); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { + success: true, + domains: result.data?.results?.map(d => d.name) || [] + }; +} + +/** + * Generate a random password + */ +export function generatePassword(length = 16): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; + let password = ''; + const randomValues = new Uint32Array(length); + crypto.getRandomValues(randomValues); + for (let i = 0; i < length; i++) { + password += chars[randomValues[i] % chars.length]; + } + return password; +} diff --git a/src/lib/server/storage.ts b/src/lib/server/storage.ts new file mode 100644 index 0000000..ce7d7d1 --- /dev/null +++ b/src/lib/server/storage.ts @@ -0,0 +1,901 @@ +import { supabaseAdmin } from './supabase'; +import { PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + DeleteObjectsCommand, + ListObjectsV2Command, + HeadBucketCommand +} from '@aws-sdk/client-s3'; +import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner'; + +export type StorageBucket = 'documents' | 'avatars' | 'event-images'; + +/** + * Generate a browser-accessible public URL for Supabase Storage + * This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL + */ +function getBrowserAccessibleUrl(bucket: StorageBucket, path: string): string { + return `${PUBLIC_SUPABASE_URL}/storage/v1/object/public/${bucket}/${path}`; +} + +export interface UploadResult { + success: boolean; + path?: string; + publicUrl?: string; + localUrl?: string; + s3Url?: string; + error?: string; +} + +export interface S3Config { + endpoint: string; + bucket: string; + accessKey: string; + secretKey: string; + region: string; + useSSL: boolean; + forcePathStyle: boolean; + enabled: boolean; +} + +let s3ClientCache: S3Client | null = null; +let s3ConfigCache: S3Config | null = null; +let s3ConfigCacheTime: number = 0; +const S3_CONFIG_CACHE_TTL = 60000; // 1 minute cache + +/** + * Get S3 configuration from app_settings table + */ +export async function getS3Config(): Promise { + // Check cache + if (s3ConfigCache && Date.now() - s3ConfigCacheTime < S3_CONFIG_CACHE_TTL) { + return s3ConfigCache; + } + + const { data: settings } = await supabaseAdmin + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'storage'); + + if (!settings || settings.length === 0) { + return null; + } + + const config: Record = {}; + for (const s of settings) { + let value = s.setting_value; + if (typeof value === 'string') { + // Remove surrounding quotes if present (from JSON stringified values) + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value; + } + + // Check if S3 is enabled - handle both boolean true and string 'true' + const isEnabled = config.s3_enabled === true || config.s3_enabled === 'true'; + + // Check if S3 is enabled and configured + if (!isEnabled || !config.s3_endpoint || !config.s3_access_key || !config.s3_secret_key) { + console.log('S3 config check failed:', { + isEnabled, + hasEndpoint: !!config.s3_endpoint, + hasAccessKey: !!config.s3_access_key, + hasSecretKey: !!config.s3_secret_key + }); + return null; + } + + s3ConfigCache = { + endpoint: config.s3_endpoint, + bucket: config.s3_bucket || 'monacousa-documents', + accessKey: config.s3_access_key, + secretKey: config.s3_secret_key, + region: config.s3_region || 'us-east-1', + useSSL: config.s3_use_ssl === true || config.s3_use_ssl === 'true', + forcePathStyle: config.s3_force_path_style === true || config.s3_force_path_style === 'true' || config.s3_force_path_style === undefined, + enabled: true + }; + s3ConfigCacheTime = Date.now(); + + return s3ConfigCache; +} + +/** + * Get or create S3 client + */ +export async function getS3Client(): Promise { + const config = await getS3Config(); + if (!config) { + return null; + } + + // Return cached client if config hasn't changed + if (s3ClientCache && s3ConfigCache) { + return s3ClientCache; + } + + s3ClientCache = new S3Client({ + endpoint: config.endpoint, + region: config.region, + credentials: { + accessKeyId: config.accessKey, + secretAccessKey: config.secretKey + }, + forcePathStyle: config.forcePathStyle + }); + + return s3ClientCache; +} + +/** + * Clear S3 client cache (call when settings change) + */ +export function clearS3ClientCache(): void { + s3ClientCache = null; + s3ConfigCache = null; + s3ConfigCacheTime = 0; +} + +/** + * Test S3 connection + */ +export async function testS3Connection(): Promise<{ success: boolean; error?: string }> { + const config = await getS3Config(); + if (!config) { + return { success: false, error: 'S3 not configured. Please configure and enable S3 storage settings first.' }; + } + + const client = await getS3Client(); + if (!client) { + return { success: false, error: 'Failed to create S3 client' }; + } + + try { + await client.send(new HeadBucketCommand({ Bucket: config.bucket })); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('S3 connection test error:', error); + return { success: false, error: `S3 connection failed: ${errorMessage}` }; + } +} + +/** + * Check if S3 storage is enabled + */ +export async function isS3Enabled(): Promise { + const config = await getS3Config(); + return config !== null && config.enabled; +} + +/** + * Get the S3 key with bucket prefix for organization + */ +function getS3Key(bucket: StorageBucket, path: string): string { + return `${bucket}/${path}`; +} + +/** + * Upload a file to S3 + */ +async function uploadToS3( + bucket: StorageBucket, + path: string, + file: File | ArrayBuffer | Buffer, + options?: { + contentType?: string; + } +): Promise { + const config = await getS3Config(); + const client = await getS3Client(); + + if (!config || !client) { + return { success: false, error: 'S3 not configured' }; + } + + try { + const key = getS3Key(bucket, path); + let body: Buffer; + + if (file instanceof ArrayBuffer) { + body = Buffer.from(file); + } else if (Buffer.isBuffer(file)) { + body = file; + } else { + // It's a File object + body = Buffer.from(await file.arrayBuffer()); + } + + await client.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: body, + ContentType: options?.contentType + }) + ); + + // Construct public URL + const protocol = config.useSSL ? 'https' : 'http'; + let publicUrl: string; + if (config.forcePathStyle) { + publicUrl = `${config.endpoint}/${config.bucket}/${key}`; + } else { + publicUrl = `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`; + } + + return { + success: true, + path: key, + publicUrl + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('S3 upload error:', error); + return { success: false, error: errorMessage }; + } +} + +/** + * Get a signed URL from S3 + */ +async function getS3PresignedUrl( + bucket: StorageBucket, + path: string, + expiresIn: number = 3600 +): Promise<{ url: string | null; error: string | null }> { + const config = await getS3Config(); + const client = await getS3Client(); + + if (!config || !client) { + return { url: null, error: 'S3 not configured' }; + } + + try { + const key = getS3Key(bucket, path); + const command = new GetObjectCommand({ + Bucket: config.bucket, + Key: key + }); + + const url = await getS3SignedUrl(client, command, { expiresIn }); + return { url, error: null }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('S3 signed URL error:', error); + return { url: null, error: errorMessage }; + } +} + +/** + * Delete a file from S3 + */ +async function deleteFromS3( + bucket: StorageBucket, + path: string +): Promise<{ success: boolean; error?: string }> { + const config = await getS3Config(); + const client = await getS3Client(); + + if (!config || !client) { + return { success: false, error: 'S3 not configured' }; + } + + try { + const key = getS3Key(bucket, path); + await client.send( + new DeleteObjectCommand({ + Bucket: config.bucket, + Key: key + }) + ); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('S3 delete error:', error); + return { success: false, error: errorMessage }; + } +} + +/** + * Delete multiple files from S3 + */ +async function deleteMultipleFromS3( + bucket: StorageBucket, + paths: string[] +): Promise<{ success: boolean; error?: string }> { + const config = await getS3Config(); + const client = await getS3Client(); + + if (!config || !client) { + return { success: false, error: 'S3 not configured' }; + } + + try { + const objects = paths.map((p) => ({ Key: getS3Key(bucket, p) })); + await client.send( + new DeleteObjectsCommand({ + Bucket: config.bucket, + Delete: { Objects: objects } + }) + ); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('S3 delete multiple error:', error); + return { success: false, error: errorMessage }; + } +} + +/** + * List files from S3 + */ +async function listFilesFromS3( + bucket: StorageBucket, + folder?: string, + options?: { + limit?: number; + } +): Promise<{ files: any[]; error: string | null }> { + const config = await getS3Config(); + const client = await getS3Client(); + + if (!config || !client) { + return { files: [], error: 'S3 not configured' }; + } + + try { + const prefix = folder ? `${bucket}/${folder}/` : `${bucket}/`; + const response = await client.send( + new ListObjectsV2Command({ + Bucket: config.bucket, + Prefix: prefix, + MaxKeys: options?.limit || 100 + }) + ); + + const files = (response.Contents || []).map((obj) => ({ + name: obj.Key?.replace(prefix, '') || '', + size: obj.Size, + updated_at: obj.LastModified?.toISOString(), + created_at: obj.LastModified?.toISOString() + })); + + return { files, error: null }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('S3 list error:', error); + return { files: [], error: errorMessage }; + } +} + +// =========================================== +// PUBLIC API - Uses S3 or Supabase based on settings +// =========================================== + +/** + * Upload a file to storage (S3 or Supabase) + */ +export async function uploadFile( + bucket: StorageBucket, + path: string, + file: File | ArrayBuffer, + options?: { + contentType?: string; + cacheControl?: string; + upsert?: boolean; + } +): Promise { + // Check if S3 is enabled + if (await isS3Enabled()) { + return uploadToS3(bucket, path, file, options); + } + + // Fall back to Supabase Storage + try { + const { data, error } = await supabaseAdmin.storage.from(bucket).upload(path, file, { + contentType: options?.contentType, + cacheControl: options?.cacheControl || '3600', + upsert: options?.upsert || false + }); + + if (error) { + console.error('Storage upload error:', error); + return { success: false, error: error.message }; + } + + // Generate browser-accessible public URL (not the internal Docker URL) + const publicUrl = getBrowserAccessibleUrl(bucket, path); + + return { + success: true, + path: data.path, + publicUrl + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Storage upload exception:', error); + return { success: false, error: errorMessage }; + } +} + +/** + * Get the public URL for a file in storage + */ +export async function getPublicUrl(bucket: StorageBucket, path: string): Promise { + // Check if S3 is enabled + if (await isS3Enabled()) { + const config = await getS3Config(); + if (config) { + const key = getS3Key(bucket, path); + if (config.forcePathStyle) { + return `${config.endpoint}/${config.bucket}/${key}`; + } + const protocol = config.useSSL ? 'https' : 'http'; + return `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`; + } + } + + // Fall back to Supabase Storage - use browser-accessible URL + return getBrowserAccessibleUrl(bucket, path); +} + +/** + * Get a signed URL for private file access + */ +export async function getSignedUrl( + bucket: StorageBucket, + path: string, + expiresIn: number = 3600 +): Promise<{ url: string | null; error: string | null }> { + // Check if S3 is enabled + if (await isS3Enabled()) { + return getS3PresignedUrl(bucket, path, expiresIn); + } + + // Fall back to Supabase Storage + const { data, error } = await supabaseAdmin.storage + .from(bucket) + .createSignedUrl(path, expiresIn); + + if (error) { + return { url: null, error: error.message }; + } + + return { url: data.signedUrl, error: null }; +} + +/** + * Delete a file from storage + */ +export async function deleteFile( + bucket: StorageBucket, + path: string +): Promise<{ success: boolean; error?: string }> { + // Check if S3 is enabled + if (await isS3Enabled()) { + return deleteFromS3(bucket, path); + } + + // Fall back to Supabase Storage + const { error } = await supabaseAdmin.storage.from(bucket).remove([path]); + + if (error) { + console.error('Storage delete error:', error); + return { success: false, error: error.message }; + } + + return { success: true }; +} + +/** + * Delete multiple files from storage + */ +export async function deleteFiles( + bucket: StorageBucket, + paths: string[] +): Promise<{ success: boolean; error?: string }> { + // Check if S3 is enabled + if (await isS3Enabled()) { + return deleteMultipleFromS3(bucket, paths); + } + + // Fall back to Supabase Storage + const { error } = await supabaseAdmin.storage.from(bucket).remove(paths); + + if (error) { + console.error('Storage delete error:', error); + return { success: false, error: error.message }; + } + + return { success: true }; +} + +/** + * List files in a bucket/folder + */ +export async function listFiles( + bucket: StorageBucket, + folder?: string, + options?: { + limit?: number; + offset?: number; + sortBy?: { column: string; order: 'asc' | 'desc' }; + } +): Promise<{ files: any[]; error: string | null }> { + // Check if S3 is enabled + if (await isS3Enabled()) { + return listFilesFromS3(bucket, folder, options); + } + + // Fall back to Supabase Storage + const { data, error } = await supabaseAdmin.storage.from(bucket).list(folder || '', { + limit: options?.limit || 100, + offset: options?.offset || 0, + sortBy: options?.sortBy || { column: 'created_at', order: 'desc' } + }); + + if (error) { + return { files: [], error: error.message }; + } + + return { files: data || [], error: null }; +} + +/** + * Generate a unique filename with timestamp + */ +export function generateUniqueFilename(originalName: string): string { + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).substring(2, 8); + const safeName = originalName.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50); + const ext = safeName.split('.').pop() || ''; + const nameWithoutExt = safeName.replace(`.${ext}`, ''); + return `${timestamp}-${randomStr}-${nameWithoutExt}.${ext}`; +} + +/** + * Upload an avatar image for a member + * Returns both S3 and local URLs for storage flexibility + */ +export async function uploadAvatar( + memberId: string, + file: File, + userSupabase?: ReturnType +): Promise { + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP, GIF' }; + } + + // Validate file size (max 5MB) + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + return { success: false, error: 'Image size must be less than 5MB' }; + } + + // Generate path - memberId must match auth.uid() for RLS + const ext = file.name.split('.').pop() || 'jpg'; + const path = `${memberId}/avatar.${ext}`; + + // Convert to ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Check if S3 is enabled + const s3Enabled = await isS3Enabled(); + + // Result object + const result: UploadResult = { + success: false, + path + }; + + // Upload to S3 if enabled + if (s3Enabled) { + const s3Result = await uploadToS3('avatars', path, arrayBuffer, { + contentType: file.type + }); + + if (!s3Result.success) { + return s3Result; + } + + result.s3Url = s3Result.publicUrl; + result.publicUrl = s3Result.publicUrl; + result.success = true; + } + + // Always upload to Supabase Storage as well (for fallback) + try { + // First try to delete existing avatar (ignore errors) + await supabaseAdmin.storage.from('avatars').remove([path]); + + const { data, error } = await supabaseAdmin.storage.from('avatars').upload(path, arrayBuffer, { + contentType: file.type, + cacheControl: '3600', + upsert: true + }); + + if (error) { + // If S3 succeeded, this is okay - just log + if (result.success) { + console.warn('Local storage upload failed (S3 succeeded):', error); + } else { + console.error('Avatar upload error:', error); + return { success: false, error: error.message }; + } + } else { + // Generate browser-accessible public URL (not the internal Docker URL) + result.localUrl = getBrowserAccessibleUrl('avatars', path); + + // If S3 is not enabled, use local URL as the public URL + if (!s3Enabled) { + result.publicUrl = result.localUrl; + result.success = true; + } + } + } catch (error) { + // If S3 succeeded, this is okay + if (!result.success) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Avatar upload exception:', error); + return { success: false, error: errorMessage }; + } + } + + return result; +} + +/** + * Delete a member's avatar from ALL storage backends + * Always attempts to delete from both S3 and Supabase Storage + */ +export async function deleteAvatar( + memberId: string, + avatarPath?: string +): Promise<{ success: boolean; error?: string }> { + // If we have a specific path, use it; otherwise try common extensions + let paths: string[]; + if (avatarPath) { + paths = [avatarPath]; + } else { + const extensions = ['jpg', 'jpeg', 'png', 'webp', 'gif']; + paths = extensions.map((ext) => `${memberId}/avatar.${ext}`); + } + + const errors: string[] = []; + + // Always try to delete from S3 (in case it was uploaded when S3 was enabled) + try { + const s3Config = await getS3Config(); + if (s3Config) { + const result = await deleteMultipleFromS3('avatars', paths); + if (!result.success && result.error) { + console.warn('S3 avatar delete warning:', result.error); + } + } + } catch (error) { + console.warn('S3 avatar delete error (non-critical):', error); + } + + // Always try to delete from Supabase Storage + try { + await supabaseAdmin.storage.from('avatars').remove(paths); + } catch (error) { + console.warn('Local storage avatar delete error (non-critical):', error); + } + + return { success: true }; +} + +/** + * Get the appropriate avatar URL based on current storage settings + * Useful for getting the right URL when storage setting is toggled + */ +export async function getActiveAvatarUrl(member: { + avatar_url_s3?: string | null; + avatar_url_local?: string | null; + avatar_url?: string | null; +}): Promise { + // Check if S3 is enabled + if (await isS3Enabled()) { + return member.avatar_url_s3 || member.avatar_url || null; + } + return member.avatar_url_local || member.avatar_url || null; +} + +/** + * Upload a document to storage + * Returns both S3 and local URLs for storage flexibility (same pattern as avatars) + */ +export async function uploadDocument( + file: File, + options?: { + folder?: string; + } +): Promise { + // Validate file type + const allowedTypes = [ + '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' + ]; + + if (!allowedTypes.includes(file.type)) { + return { + success: false, + error: + 'File type not allowed. Supported: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, WebP, GIF' + }; + } + + // Validate file size (max 50MB) + const maxSize = 50 * 1024 * 1024; + if (file.size > maxSize) { + return { success: false, error: 'File size must be less than 50MB' }; + } + + // Generate unique storage path + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).substring(2, 8); + const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50); + const path = options?.folder ? `${options.folder}/${timestamp}-${randomStr}-${safeName}` : `${timestamp}-${randomStr}-${safeName}`; + + // Convert to ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Check if S3 is enabled + const s3Enabled = await isS3Enabled(); + + // Result object + const result: UploadResult = { + success: false, + path + }; + + // Upload to S3 if enabled + if (s3Enabled) { + const s3Result = await uploadToS3('documents', path, arrayBuffer, { + contentType: file.type + }); + + if (!s3Result.success) { + return s3Result; + } + + result.s3Url = s3Result.publicUrl; + result.publicUrl = s3Result.publicUrl; + result.success = true; + } + + // Always upload to Supabase Storage as well (for fallback) + try { + const { data, error } = await supabaseAdmin.storage.from('documents').upload(path, arrayBuffer, { + contentType: file.type, + cacheControl: '3600', + upsert: false + }); + + if (error) { + // If S3 succeeded, this is okay - just log + if (result.success) { + console.warn('Local storage upload failed (S3 succeeded):', error); + } else { + console.error('Document upload error:', error); + return { success: false, error: error.message }; + } + } else { + // Generate browser-accessible public URL (not the internal Docker URL) + result.localUrl = getBrowserAccessibleUrl('documents', path); + + // If S3 is not enabled, use local URL as the public URL + if (!s3Enabled) { + result.publicUrl = result.localUrl; + result.success = true; + } + } + } catch (error) { + // If S3 succeeded, this is okay + if (!result.success) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Document upload exception:', error); + return { success: false, error: errorMessage }; + } + } + + return result; +} + +/** + * Delete a document from ALL storage backends + * Always attempts to delete from both S3 and Supabase Storage + */ +export async function deleteDocument( + storagePath: string +): Promise<{ success: boolean; error?: string }> { + const errors: string[] = []; + + // Always try to delete from S3 (in case it was uploaded when S3 was enabled) + try { + const s3Config = await getS3Config(); + if (s3Config) { + const result = await deleteFromS3('documents', storagePath); + if (!result.success && result.error) { + console.warn('S3 document delete warning:', result.error); + } + } + } catch (error) { + console.warn('S3 document delete error (non-critical):', error); + } + + // Always try to delete from Supabase Storage + try { + await supabaseAdmin.storage.from('documents').remove([storagePath]); + } catch (error) { + console.warn('Local storage document delete error (non-critical):', error); + } + + return { success: true }; +} + +/** + * Get the appropriate document URL based on current storage settings + * Useful for getting the right URL when storage setting is toggled + */ +export async function getActiveDocumentUrl(document: { + file_url_s3?: string | null; + file_url_local?: string | null; + file_path?: string | null; +}): Promise { + // Check if S3 is enabled + if (await isS3Enabled()) { + return document.file_url_s3 || document.file_path || null; + } + return document.file_url_local || document.file_path || null; +} + +/** + * Upload an event cover image + */ +export async function uploadEventImage(eventId: string, file: File): Promise { + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP' }; + } + + // Validate file size (max 10MB) + const maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + return { success: false, error: 'Image size must be less than 10MB' }; + } + + // Generate path + const ext = file.name.split('.').pop() || 'jpg'; + const path = `${eventId}/cover.${ext}`; + + // Convert to ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Upload with upsert to replace existing cover + return uploadFile('event-images', path, arrayBuffer, { + contentType: file.type, + cacheControl: '3600', + upsert: true + }); +} diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts new file mode 100644 index 0000000..72136e1 --- /dev/null +++ b/src/lib/server/supabase.ts @@ -0,0 +1,42 @@ +import pkg from '@supabase/ssr'; +const { createServerClient } = pkg; +import { createClient as createSupabaseClient } from '@supabase/supabase-js'; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; +import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private'; +import { env } from '$env/dynamic/private'; +import type { Cookies } from '@sveltejs/kit'; +import type { Database } from '$lib/types/database'; + +// Use internal URL for server-side operations (Docker network), fallback to public URL +const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL; + +/** + * Create a Supabase client for server-side operations with cookie handling + */ +export function createSupabaseServerClient(cookies: Cookies) { + return createServerClient(SERVER_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + cookies: { + getAll: () => cookies.getAll(), + setAll: (cookiesToSet) => { + cookiesToSet.forEach(({ name, value, options }) => { + cookies.set(name, value, { ...options, path: '/' }); + }); + } + } + }); +} + +/** + * Supabase Admin client with service role key + * Use this for administrative operations that bypass RLS + */ +export const supabaseAdmin = createSupabaseClient( + SERVER_SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false + } + } +); diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..0d101a0 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,11 @@ +import pkg from '@supabase/ssr'; +const { createBrowserClient } = pkg; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; +import type { Database } from './types/database'; + +/** + * Create a Supabase client for browser-side operations + */ +export function createClient() { + return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY); +} diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts new file mode 100644 index 0000000..6535339 --- /dev/null +++ b/src/lib/types/database.ts @@ -0,0 +1,806 @@ +/** + * Database Types for Monaco USA Portal 2026 + * Generated based on the architecture plan schema + */ + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export type Database = { + public: { + Tables: { + members: { + Row: { + id: string; + member_id: string; + first_name: string; + last_name: string; + email: string; + phone: string; + date_of_birth: string; + address: string; + nationality: string[]; + role: 'member' | 'board' | 'admin'; + membership_status_id: string | null; + membership_type_id: string | null; + member_since: string; + avatar_url: string | null; + notes: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id: string; + member_id?: string; + first_name: string; + last_name: string; + email: string; + phone: string; + date_of_birth: string; + address: string; + nationality: string[]; + role?: 'member' | 'board' | 'admin'; + membership_status_id?: string | null; + membership_type_id?: string | null; + member_since?: string; + avatar_url?: string | null; + notes?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + member_id?: string; + first_name?: string; + last_name?: string; + email?: string; + phone?: string; + date_of_birth?: string; + address?: string; + nationality?: string[]; + role?: 'member' | 'board' | 'admin'; + membership_status_id?: string | null; + membership_type_id?: string | null; + member_since?: string; + avatar_url?: string | null; + notes?: string | null; + created_at?: string; + updated_at?: string; + }; + }; + membership_statuses: { + Row: { + id: string; + name: string; + display_name: string; + color: string; + description: string | null; + is_default: boolean; + sort_order: number; + created_at: string; + }; + Insert: { + id?: string; + name: string; + display_name: string; + color?: string; + description?: string | null; + is_default?: boolean; + sort_order?: number; + created_at?: string; + }; + Update: { + id?: string; + name?: string; + display_name?: string; + color?: string; + description?: string | null; + is_default?: boolean; + sort_order?: number; + created_at?: string; + }; + }; + membership_types: { + Row: { + id: string; + name: string; + display_name: string; + annual_dues: number; + description: string | null; + is_default: boolean; + is_active: boolean; + sort_order: number; + created_at: string; + }; + Insert: { + id?: string; + name: string; + display_name: string; + annual_dues: number; + description?: string | null; + is_default?: boolean; + is_active?: boolean; + sort_order?: number; + created_at?: string; + }; + Update: { + id?: string; + name?: string; + display_name?: string; + annual_dues?: number; + description?: string | null; + is_default?: boolean; + is_active?: boolean; + sort_order?: number; + created_at?: string; + }; + }; + dues_payments: { + Row: { + id: string; + member_id: string; + amount: number; + currency: string; + payment_date: string; + due_date: string; + payment_method: string; + reference: string | null; + notes: string | null; + recorded_by: string; + created_at: string; + }; + Insert: { + id?: string; + member_id: string; + amount: number; + currency?: string; + payment_date: string; + due_date?: string; + payment_method?: string; + reference?: string | null; + notes?: string | null; + recorded_by: string; + created_at?: string; + }; + Update: { + id?: string; + member_id?: string; + amount?: number; + currency?: string; + payment_date?: string; + due_date?: string; + payment_method?: string; + reference?: string | null; + notes?: string | null; + recorded_by?: string; + created_at?: string; + }; + }; + events: { + Row: { + id: string; + title: string; + description: string | null; + event_type_id: string | null; + start_datetime: string; + end_datetime: string; + all_day: boolean; + timezone: string; + location: string | null; + location_url: string | null; + max_attendees: number | null; + max_guests_per_member: number; + is_paid: boolean; + member_price: number; + non_member_price: number; + pricing_notes: string | null; + visibility: 'public' | 'members' | 'board' | 'admin'; + status: 'draft' | 'published' | 'cancelled' | 'completed'; + cover_image_url: string | null; + created_by: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + title: string; + description?: string | null; + event_type_id?: string | null; + start_datetime: string; + end_datetime: string; + all_day?: boolean; + timezone?: string; + location?: string | null; + location_url?: string | null; + max_attendees?: number | null; + max_guests_per_member?: number; + is_paid?: boolean; + member_price?: number; + non_member_price?: number; + pricing_notes?: string | null; + visibility?: 'public' | 'members' | 'board' | 'admin'; + status?: 'draft' | 'published' | 'cancelled' | 'completed'; + cover_image_url?: string | null; + created_by: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + title?: string; + description?: string | null; + event_type_id?: string | null; + start_datetime?: string; + end_datetime?: string; + all_day?: boolean; + timezone?: string; + location?: string | null; + location_url?: string | null; + max_attendees?: number | null; + max_guests_per_member?: number; + is_paid?: boolean; + member_price?: number; + non_member_price?: number; + pricing_notes?: string | null; + visibility?: 'public' | 'members' | 'board' | 'admin'; + status?: 'draft' | 'published' | 'cancelled' | 'completed'; + cover_image_url?: string | null; + created_by?: string; + created_at?: string; + updated_at?: string; + }; + }; + event_types: { + Row: { + id: string; + name: string; + display_name: string; + color: string; + icon: string | null; + description: string | null; + is_active: boolean; + sort_order: number; + created_at: string; + }; + Insert: { + id?: string; + name: string; + display_name: string; + color?: string; + icon?: string | null; + description?: string | null; + is_active?: boolean; + sort_order?: number; + created_at?: string; + }; + Update: { + id?: string; + name?: string; + display_name?: string; + color?: string; + icon?: string | null; + description?: string | null; + is_active?: boolean; + sort_order?: number; + created_at?: string; + }; + }; + event_rsvps: { + Row: { + id: string; + event_id: string; + member_id: string; + status: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled'; + guest_count: number; + guest_names: string[] | null; + notes: string | null; + payment_status: 'not_required' | 'pending' | 'paid'; + payment_reference: string | null; + payment_amount: number | null; + attended: boolean; + checked_in_at: string | null; + checked_in_by: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + event_id: string; + member_id: string; + status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled'; + guest_count?: number; + guest_names?: string[] | null; + notes?: string | null; + payment_status?: 'not_required' | 'pending' | 'paid'; + payment_reference?: string | null; + payment_amount?: number | null; + attended?: boolean; + checked_in_at?: string | null; + checked_in_by?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + event_id?: string; + member_id?: string; + status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled'; + guest_count?: number; + guest_names?: string[] | null; + notes?: string | null; + payment_status?: 'not_required' | 'pending' | 'paid'; + payment_reference?: string | null; + payment_amount?: number | null; + attended?: boolean; + checked_in_at?: string | null; + checked_in_by?: string | null; + created_at?: string; + updated_at?: string; + }; + }; + event_rsvps_public: { + Row: { + id: string; + event_id: string; + full_name: string; + email: string; + phone: string | null; + status: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled'; + guest_count: number; + guest_names: string[] | null; + payment_status: 'not_required' | 'pending' | 'paid'; + payment_reference: string | null; + payment_amount: number | null; + attended: boolean; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + event_id: string; + full_name: string; + email: string; + phone?: string | null; + status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled'; + guest_count?: number; + guest_names?: string[] | null; + payment_status?: 'not_required' | 'pending' | 'paid'; + payment_reference?: string | null; + payment_amount?: number | null; + attended?: boolean; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + event_id?: string; + full_name?: string; + email?: string; + phone?: string | null; + status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled'; + guest_count?: number; + guest_names?: string[] | null; + payment_status?: 'not_required' | 'pending' | 'paid'; + payment_reference?: string | null; + payment_amount?: number | null; + attended?: boolean; + created_at?: string; + updated_at?: string; + }; + }; + documents: { + Row: { + id: string; + title: string; + description: string | null; + category_id: string | null; + file_path: string; + file_name: string; + file_size: number; + mime_type: string; + visibility: 'public' | 'members' | 'board' | 'admin'; + allowed_member_ids: string[] | null; + version: number; + replaces_document_id: string | null; + meeting_date: string | null; + meeting_attendees: string[] | null; + uploaded_by: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + title: string; + description?: string | null; + category_id?: string | null; + file_path: string; + file_name: string; + file_size: number; + mime_type: string; + visibility?: 'public' | 'members' | 'board' | 'admin'; + allowed_member_ids?: string[] | null; + version?: number; + replaces_document_id?: string | null; + meeting_date?: string | null; + meeting_attendees?: string[] | null; + uploaded_by: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + title?: string; + description?: string | null; + category_id?: string | null; + file_path?: string; + file_name?: string; + file_size?: number; + mime_type?: string; + visibility?: 'public' | 'members' | 'board' | 'admin'; + allowed_member_ids?: string[] | null; + version?: number; + replaces_document_id?: string | null; + meeting_date?: string | null; + meeting_attendees?: string[] | null; + uploaded_by?: string; + created_at?: string; + updated_at?: string; + }; + }; + document_categories: { + Row: { + id: string; + name: string; + display_name: string; + description: string | null; + icon: string | null; + sort_order: number; + is_active: boolean; + created_at: string; + }; + Insert: { + id?: string; + name: string; + display_name: string; + description?: string | null; + icon?: string | null; + sort_order?: number; + is_active?: boolean; + created_at?: string; + }; + Update: { + id?: string; + name?: string; + display_name?: string; + description?: string | null; + icon?: string | null; + sort_order?: number; + is_active?: boolean; + created_at?: string; + }; + }; + app_settings: { + Row: { + id: string; + category: string; + setting_key: string; + setting_value: Json; + setting_type: 'text' | 'number' | 'boolean' | 'json' | 'array'; + display_name: string; + description: string | null; + is_public: boolean; + updated_at: string; + updated_by: string | null; + }; + Insert: { + id?: string; + category: string; + setting_key: string; + setting_value: Json; + setting_type?: 'text' | 'number' | 'boolean' | 'json' | 'array'; + display_name: string; + description?: string | null; + is_public?: boolean; + updated_at?: string; + updated_by?: string | null; + }; + Update: { + id?: string; + category?: string; + setting_key?: string; + setting_value?: Json; + setting_type?: 'text' | 'number' | 'boolean' | 'json' | 'array'; + display_name?: string; + description?: string | null; + is_public?: boolean; + updated_at?: string; + updated_by?: string | null; + }; + }; + email_templates: { + Row: { + id: string; + template_key: string; + template_name: string; + category: string; + subject: string; + body_html: string; + body_text: string | null; + is_active: boolean; + is_system: boolean; + variables_schema: Json | null; + preview_data: Json | null; + created_at: string; + updated_at: string; + updated_by: string | null; + }; + Insert: { + id?: string; + template_key: string; + template_name: string; + category: string; + subject: string; + body_html: string; + body_text?: string | null; + is_active?: boolean; + is_system?: boolean; + variables_schema?: Json | null; + preview_data?: Json | null; + created_at?: string; + updated_at?: string; + updated_by?: string | null; + }; + Update: { + id?: string; + template_key?: string; + template_name?: string; + category?: string; + subject?: string; + body_html?: string; + body_text?: string | null; + is_active?: boolean; + is_system?: boolean; + variables_schema?: Json | null; + preview_data?: Json | null; + created_at?: string; + updated_at?: string; + updated_by?: string | null; + }; + }; + email_logs: { + Row: { + id: string; + recipient_id: string | null; + recipient_email: string; + recipient_name: string | null; + template_key: string | null; + subject: string; + email_type: string; + status: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed'; + provider: string | null; + provider_message_id: string | null; + opened_at: string | null; + clicked_at: string | null; + error_message: string | null; + retry_count: number; + template_variables: Json | null; + sent_by: string | null; + created_at: string; + sent_at: string | null; + delivered_at: string | null; + }; + Insert: { + id?: string; + recipient_id?: string | null; + recipient_email: string; + recipient_name?: string | null; + template_key?: string | null; + subject: string; + email_type: string; + status?: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed'; + provider?: string | null; + provider_message_id?: string | null; + opened_at?: string | null; + clicked_at?: string | null; + error_message?: string | null; + retry_count?: number; + template_variables?: Json | null; + sent_by?: string | null; + created_at?: string; + sent_at?: string | null; + delivered_at?: string | null; + }; + Update: { + id?: string; + recipient_id?: string | null; + recipient_email?: string; + recipient_name?: string | null; + template_key?: string | null; + subject?: string; + email_type?: string; + status?: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed'; + provider?: string | null; + provider_message_id?: string | null; + opened_at?: string | null; + clicked_at?: string | null; + error_message?: string | null; + retry_count?: number; + template_variables?: Json | null; + sent_by?: string | null; + created_at?: string; + sent_at?: string | null; + delivered_at?: string | null; + }; + }; + audit_logs: { + Row: { + id: string; + user_id: string | null; + user_email: string | null; + action: string; + resource_type: string | null; + resource_id: string | null; + details: Json; + ip_address: string | null; + user_agent: string | null; + created_at: string; + }; + Insert: { + id?: string; + user_id?: string | null; + user_email?: string | null; + action: string; + resource_type?: string | null; + resource_id?: string | null; + details?: Json; + ip_address?: string | null; + user_agent?: string | null; + created_at?: string; + }; + Update: { + id?: string; + user_id?: string | null; + user_email?: string | null; + action?: string; + resource_type?: string | null; + resource_id?: string | null; + details?: Json; + ip_address?: string | null; + user_agent?: string | null; + created_at?: string; + }; + }; + dues_reminder_logs: { + Row: { + id: string; + member_id: string; + reminder_type: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice'; + due_date: string; + sent_at: string; + email_log_id: string | null; + }; + Insert: { + id?: string; + member_id: string; + reminder_type: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice'; + due_date: string; + sent_at?: string; + email_log_id?: string | null; + }; + Update: { + id?: string; + member_id?: string; + reminder_type?: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice'; + due_date?: string; + sent_at?: string; + email_log_id?: string | null; + }; + }; + }; + Views: { + members_with_dues: { + Row: { + id: string; + member_id: string; + first_name: string; + last_name: string; + email: string; + phone: string; + date_of_birth: string; + address: string; + nationality: string[]; + role: 'member' | 'board' | 'admin'; + membership_status_id: string | null; + membership_type_id: string | null; + member_since: string; + avatar_url: string | null; + notes: string | null; + created_at: string; + updated_at: string; + status_name: string | null; + status_display_name: string | null; + status_color: string | null; + membership_type_name: string | null; + annual_dues: number | null; + last_payment_date: string | null; + current_due_date: string | null; + dues_status: 'never_paid' | 'overdue' | 'due_soon' | 'current'; + days_overdue: number | null; + days_until_due: number | null; + }; + }; + events_with_counts: { + Row: { + id: string; + title: string; + description: string | null; + event_type_id: string | null; + start_datetime: string; + end_datetime: string; + all_day: boolean; + timezone: string; + location: string | null; + location_url: string | null; + max_attendees: number | null; + max_guests_per_member: number; + is_paid: boolean; + member_price: number; + non_member_price: number; + pricing_notes: string | null; + visibility: 'public' | 'members' | 'board' | 'admin'; + status: 'draft' | 'published' | 'cancelled' | 'completed'; + cover_image_url: string | null; + created_by: string; + created_at: string; + updated_at: string; + event_type_name: string | null; + event_type_color: string | null; + event_type_icon: string | null; + total_attendees: number; + member_count: number; + non_member_count: number; + waitlist_count: number; + is_full: boolean; + }; + }; + }; + Functions: Record; + Enums: Record; + }; +}; + +// Convenience types +export type Member = Database['public']['Tables']['members']['Row']; +export type MemberInsert = Database['public']['Tables']['members']['Insert']; +export type MemberUpdate = Database['public']['Tables']['members']['Update']; + +export type MemberWithDues = Database['public']['Views']['members_with_dues']['Row']; + +export type Event = Database['public']['Tables']['events']['Row']; +export type EventInsert = Database['public']['Tables']['events']['Insert']; +export type EventUpdate = Database['public']['Tables']['events']['Update']; + +export type EventWithCounts = Database['public']['Views']['events_with_counts']['Row']; + +export type EventRSVP = Database['public']['Tables']['event_rsvps']['Row']; +export type EventRSVPInsert = Database['public']['Tables']['event_rsvps']['Insert']; + +export type DuesPayment = Database['public']['Tables']['dues_payments']['Row']; +export type DuesPaymentInsert = Database['public']['Tables']['dues_payments']['Insert']; + +export type Document = Database['public']['Tables']['documents']['Row']; +export type DocumentInsert = Database['public']['Tables']['documents']['Insert']; + +export type AppSetting = Database['public']['Tables']['app_settings']['Row']; +export type EmailTemplate = Database['public']['Tables']['email_templates']['Row']; +export type EmailLog = Database['public']['Tables']['email_logs']['Row']; +export type AuditLog = Database['public']['Tables']['audit_logs']['Row']; +export type DuesReminderLog = Database['public']['Tables']['dues_reminder_logs']['Row']; + +// Role type +export type MemberRole = 'member' | 'board' | 'admin'; + +// Event visibility +export type EventVisibility = 'public' | 'members' | 'board' | 'admin'; + +// Dues status +export type DuesStatus = 'never_paid' | 'overdue' | 'due_soon' | 'current'; diff --git a/src/lib/utils/countries.ts b/src/lib/utils/countries.ts new file mode 100644 index 0000000..0db4d26 --- /dev/null +++ b/src/lib/utils/countries.ts @@ -0,0 +1,262 @@ +// Complete ISO 3166-1 alpha-2 country codes with names and flags +export const countries = [ + { code: 'AF', name: 'Afghanistan', flag: '🇦🇫' }, + { code: 'AL', name: 'Albania', flag: '🇦🇱' }, + { code: 'DZ', name: 'Algeria', flag: '🇩🇿' }, + { code: 'AS', name: 'American Samoa', flag: '🇦🇸' }, + { code: 'AD', name: 'Andorra', flag: '🇦🇩' }, + { code: 'AO', name: 'Angola', flag: '🇦🇴' }, + { code: 'AI', name: 'Anguilla', flag: '🇦🇮' }, + { code: 'AQ', name: 'Antarctica', flag: '🇦🇶' }, + { code: 'AG', name: 'Antigua and Barbuda', flag: '🇦🇬' }, + { code: 'AR', name: 'Argentina', flag: '🇦🇷' }, + { code: 'AM', name: 'Armenia', flag: '🇦🇲' }, + { code: 'AW', name: 'Aruba', flag: '🇦🇼' }, + { code: 'AU', name: 'Australia', flag: '🇦🇺' }, + { code: 'AT', name: 'Austria', flag: '🇦🇹' }, + { code: 'AZ', name: 'Azerbaijan', flag: '🇦🇿' }, + { code: 'BS', name: 'Bahamas', flag: '🇧🇸' }, + { code: 'BH', name: 'Bahrain', flag: '🇧🇭' }, + { code: 'BD', name: 'Bangladesh', flag: '🇧🇩' }, + { code: 'BB', name: 'Barbados', flag: '🇧🇧' }, + { code: 'BY', name: 'Belarus', flag: '🇧🇾' }, + { code: 'BE', name: 'Belgium', flag: '🇧🇪' }, + { code: 'BZ', name: 'Belize', flag: '🇧🇿' }, + { code: 'BJ', name: 'Benin', flag: '🇧🇯' }, + { code: 'BM', name: 'Bermuda', flag: '🇧🇲' }, + { code: 'BT', name: 'Bhutan', flag: '🇧🇹' }, + { code: 'BO', name: 'Bolivia', flag: '🇧🇴' }, + { code: 'BA', name: 'Bosnia and Herzegovina', flag: '🇧🇦' }, + { code: 'BW', name: 'Botswana', flag: '🇧🇼' }, + { code: 'BR', name: 'Brazil', flag: '🇧🇷' }, + { code: 'IO', name: 'British Indian Ocean Territory', flag: '🇮🇴' }, + { code: 'VG', name: 'British Virgin Islands', flag: '🇻🇬' }, + { code: 'BN', name: 'Brunei', flag: '🇧🇳' }, + { code: 'BG', name: 'Bulgaria', flag: '🇧🇬' }, + { code: 'BF', name: 'Burkina Faso', flag: '🇧🇫' }, + { code: 'BI', name: 'Burundi', flag: '🇧🇮' }, + { code: 'CV', name: 'Cabo Verde', flag: '🇨🇻' }, + { code: 'KH', name: 'Cambodia', flag: '🇰🇭' }, + { code: 'CM', name: 'Cameroon', flag: '🇨🇲' }, + { code: 'CA', name: 'Canada', flag: '🇨🇦' }, + { code: 'KY', name: 'Cayman Islands', flag: '🇰🇾' }, + { code: 'CF', name: 'Central African Republic', flag: '🇨🇫' }, + { code: 'TD', name: 'Chad', flag: '🇹🇩' }, + { code: 'CL', name: 'Chile', flag: '🇨🇱' }, + { code: 'CN', name: 'China', flag: '🇨🇳' }, + { code: 'CX', name: 'Christmas Island', flag: '🇨🇽' }, + { code: 'CC', name: 'Cocos (Keeling) Islands', flag: '🇨🇨' }, + { code: 'CO', name: 'Colombia', flag: '🇨🇴' }, + { code: 'KM', name: 'Comoros', flag: '🇰🇲' }, + { code: 'CG', name: 'Congo', flag: '🇨🇬' }, + { code: 'CD', name: 'Congo (DRC)', flag: '🇨🇩' }, + { code: 'CK', name: 'Cook Islands', flag: '🇨🇰' }, + { code: 'CR', name: 'Costa Rica', flag: '🇨🇷' }, + { code: 'CI', name: "Côte d'Ivoire", flag: '🇨🇮' }, + { code: 'HR', name: 'Croatia', flag: '🇭🇷' }, + { code: 'CU', name: 'Cuba', flag: '🇨🇺' }, + { code: 'CW', name: 'Curaçao', flag: '🇨🇼' }, + { code: 'CY', name: 'Cyprus', flag: '🇨🇾' }, + { code: 'CZ', name: 'Czech Republic', flag: '🇨🇿' }, + { code: 'DK', name: 'Denmark', flag: '🇩🇰' }, + { code: 'DJ', name: 'Djibouti', flag: '🇩🇯' }, + { code: 'DM', name: 'Dominica', flag: '🇩🇲' }, + { code: 'DO', name: 'Dominican Republic', flag: '🇩🇴' }, + { code: 'EC', name: 'Ecuador', flag: '🇪🇨' }, + { code: 'EG', name: 'Egypt', flag: '🇪🇬' }, + { code: 'SV', name: 'El Salvador', flag: '🇸🇻' }, + { code: 'GQ', name: 'Equatorial Guinea', flag: '🇬🇶' }, + { code: 'ER', name: 'Eritrea', flag: '🇪🇷' }, + { code: 'EE', name: 'Estonia', flag: '🇪🇪' }, + { code: 'SZ', name: 'Eswatini', flag: '🇸🇿' }, + { code: 'ET', name: 'Ethiopia', flag: '🇪🇹' }, + { code: 'FK', name: 'Falkland Islands', flag: '🇫🇰' }, + { code: 'FO', name: 'Faroe Islands', flag: '🇫🇴' }, + { code: 'FJ', name: 'Fiji', flag: '🇫🇯' }, + { code: 'FI', name: 'Finland', flag: '🇫🇮' }, + { code: 'FR', name: 'France', flag: '🇫🇷' }, + { code: 'GF', name: 'French Guiana', flag: '🇬🇫' }, + { code: 'PF', name: 'French Polynesia', flag: '🇵🇫' }, + { code: 'TF', name: 'French Southern Territories', flag: '🇹🇫' }, + { code: 'GA', name: 'Gabon', flag: '🇬🇦' }, + { code: 'GM', name: 'Gambia', flag: '🇬🇲' }, + { code: 'GE', name: 'Georgia', flag: '🇬🇪' }, + { code: 'DE', name: 'Germany', flag: '🇩🇪' }, + { code: 'GH', name: 'Ghana', flag: '🇬🇭' }, + { code: 'GI', name: 'Gibraltar', flag: '🇬🇮' }, + { code: 'GR', name: 'Greece', flag: '🇬🇷' }, + { code: 'GL', name: 'Greenland', flag: '🇬🇱' }, + { code: 'GD', name: 'Grenada', flag: '🇬🇩' }, + { code: 'GP', name: 'Guadeloupe', flag: '🇬🇵' }, + { code: 'GU', name: 'Guam', flag: '🇬🇺' }, + { code: 'GT', name: 'Guatemala', flag: '🇬🇹' }, + { code: 'GG', name: 'Guernsey', flag: '🇬🇬' }, + { code: 'GN', name: 'Guinea', flag: '🇬🇳' }, + { code: 'GW', name: 'Guinea-Bissau', flag: '🇬🇼' }, + { code: 'GY', name: 'Guyana', flag: '🇬🇾' }, + { code: 'HT', name: 'Haiti', flag: '🇭🇹' }, + { code: 'HN', name: 'Honduras', flag: '🇭🇳' }, + { code: 'HK', name: 'Hong Kong', flag: '🇭🇰' }, + { code: 'HU', name: 'Hungary', flag: '🇭🇺' }, + { code: 'IS', name: 'Iceland', flag: '🇮🇸' }, + { code: 'IN', name: 'India', flag: '🇮🇳' }, + { code: 'ID', name: 'Indonesia', flag: '🇮🇩' }, + { code: 'IR', name: 'Iran', flag: '🇮🇷' }, + { code: 'IQ', name: 'Iraq', flag: '🇮🇶' }, + { code: 'IE', name: 'Ireland', flag: '🇮🇪' }, + { code: 'IM', name: 'Isle of Man', flag: '🇮🇲' }, + { code: 'IL', name: 'Israel', flag: '🇮🇱' }, + { code: 'IT', name: 'Italy', flag: '🇮🇹' }, + { code: 'JM', name: 'Jamaica', flag: '🇯🇲' }, + { code: 'JP', name: 'Japan', flag: '🇯🇵' }, + { code: 'JE', name: 'Jersey', flag: '🇯🇪' }, + { code: 'JO', name: 'Jordan', flag: '🇯🇴' }, + { code: 'KZ', name: 'Kazakhstan', flag: '🇰🇿' }, + { code: 'KE', name: 'Kenya', flag: '🇰🇪' }, + { code: 'KI', name: 'Kiribati', flag: '🇰🇮' }, + { code: 'KW', name: 'Kuwait', flag: '🇰🇼' }, + { code: 'KG', name: 'Kyrgyzstan', flag: '🇰🇬' }, + { code: 'LA', name: 'Laos', flag: '🇱🇦' }, + { code: 'LV', name: 'Latvia', flag: '🇱🇻' }, + { code: 'LB', name: 'Lebanon', flag: '🇱🇧' }, + { code: 'LS', name: 'Lesotho', flag: '🇱🇸' }, + { code: 'LR', name: 'Liberia', flag: '🇱🇷' }, + { code: 'LY', name: 'Libya', flag: '🇱🇾' }, + { code: 'LI', name: 'Liechtenstein', flag: '🇱🇮' }, + { code: 'LT', name: 'Lithuania', flag: '🇱🇹' }, + { code: 'LU', name: 'Luxembourg', flag: '🇱🇺' }, + { code: 'MO', name: 'Macao', flag: '🇲🇴' }, + { code: 'MG', name: 'Madagascar', flag: '🇲🇬' }, + { code: 'MW', name: 'Malawi', flag: '🇲🇼' }, + { code: 'MY', name: 'Malaysia', flag: '🇲🇾' }, + { code: 'MV', name: 'Maldives', flag: '🇲🇻' }, + { code: 'ML', name: 'Mali', flag: '🇲🇱' }, + { code: 'MT', name: 'Malta', flag: '🇲🇹' }, + { code: 'MH', name: 'Marshall Islands', flag: '🇲🇭' }, + { code: 'MQ', name: 'Martinique', flag: '🇲🇶' }, + { code: 'MR', name: 'Mauritania', flag: '🇲🇷' }, + { code: 'MU', name: 'Mauritius', flag: '🇲🇺' }, + { code: 'YT', name: 'Mayotte', flag: '🇾🇹' }, + { code: 'MX', name: 'Mexico', flag: '🇲🇽' }, + { code: 'FM', name: 'Micronesia', flag: '🇫🇲' }, + { code: 'MD', name: 'Moldova', flag: '🇲🇩' }, + { code: 'MC', name: 'Monaco', flag: '🇲🇨' }, + { code: 'MN', name: 'Mongolia', flag: '🇲🇳' }, + { code: 'ME', name: 'Montenegro', flag: '🇲🇪' }, + { code: 'MS', name: 'Montserrat', flag: '🇲🇸' }, + { code: 'MA', name: 'Morocco', flag: '🇲🇦' }, + { code: 'MZ', name: 'Mozambique', flag: '🇲🇿' }, + { code: 'MM', name: 'Myanmar', flag: '🇲🇲' }, + { code: 'NA', name: 'Namibia', flag: '🇳🇦' }, + { code: 'NR', name: 'Nauru', flag: '🇳🇷' }, + { code: 'NP', name: 'Nepal', flag: '🇳🇵' }, + { code: 'NL', name: 'Netherlands', flag: '🇳🇱' }, + { code: 'NC', name: 'New Caledonia', flag: '🇳🇨' }, + { code: 'NZ', name: 'New Zealand', flag: '🇳🇿' }, + { code: 'NI', name: 'Nicaragua', flag: '🇳🇮' }, + { code: 'NE', name: 'Niger', flag: '🇳🇪' }, + { code: 'NG', name: 'Nigeria', flag: '🇳🇬' }, + { code: 'NU', name: 'Niue', flag: '🇳🇺' }, + { code: 'NF', name: 'Norfolk Island', flag: '🇳🇫' }, + { code: 'KP', name: 'North Korea', flag: '🇰🇵' }, + { code: 'MK', name: 'North Macedonia', flag: '🇲🇰' }, + { code: 'MP', name: 'Northern Mariana Islands', flag: '🇲🇵' }, + { code: 'NO', name: 'Norway', flag: '🇳🇴' }, + { code: 'OM', name: 'Oman', flag: '🇴🇲' }, + { code: 'PK', name: 'Pakistan', flag: '🇵🇰' }, + { code: 'PW', name: 'Palau', flag: '🇵🇼' }, + { code: 'PS', name: 'Palestine', flag: '🇵🇸' }, + { code: 'PA', name: 'Panama', flag: '🇵🇦' }, + { code: 'PG', name: 'Papua New Guinea', flag: '🇵🇬' }, + { code: 'PY', name: 'Paraguay', flag: '🇵🇾' }, + { code: 'PE', name: 'Peru', flag: '🇵🇪' }, + { code: 'PH', name: 'Philippines', flag: '🇵🇭' }, + { code: 'PN', name: 'Pitcairn Islands', flag: '🇵🇳' }, + { code: 'PL', name: 'Poland', flag: '🇵🇱' }, + { code: 'PT', name: 'Portugal', flag: '🇵🇹' }, + { code: 'PR', name: 'Puerto Rico', flag: '🇵🇷' }, + { code: 'QA', name: 'Qatar', flag: '🇶🇦' }, + { code: 'RE', name: 'Réunion', flag: '🇷🇪' }, + { code: 'RO', name: 'Romania', flag: '🇷🇴' }, + { code: 'RU', name: 'Russia', flag: '🇷🇺' }, + { code: 'RW', name: 'Rwanda', flag: '🇷🇼' }, + { code: 'BL', name: 'Saint Barthélemy', flag: '🇧🇱' }, + { code: 'SH', name: 'Saint Helena', flag: '🇸🇭' }, + { code: 'KN', name: 'Saint Kitts and Nevis', flag: '🇰🇳' }, + { code: 'LC', name: 'Saint Lucia', flag: '🇱🇨' }, + { code: 'MF', name: 'Saint Martin', flag: '🇲🇫' }, + { code: 'PM', name: 'Saint Pierre and Miquelon', flag: '🇵🇲' }, + { code: 'VC', name: 'Saint Vincent and the Grenadines', flag: '🇻🇨' }, + { code: 'WS', name: 'Samoa', flag: '🇼🇸' }, + { code: 'SM', name: 'San Marino', flag: '🇸🇲' }, + { code: 'ST', name: 'São Tomé and Príncipe', flag: '🇸🇹' }, + { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' }, + { code: 'SN', name: 'Senegal', flag: '🇸🇳' }, + { code: 'RS', name: 'Serbia', flag: '🇷🇸' }, + { code: 'SC', name: 'Seychelles', flag: '🇸🇨' }, + { code: 'SL', name: 'Sierra Leone', flag: '🇸🇱' }, + { code: 'SG', name: 'Singapore', flag: '🇸🇬' }, + { code: 'SX', name: 'Sint Maarten', flag: '🇸🇽' }, + { code: 'SK', name: 'Slovakia', flag: '🇸🇰' }, + { code: 'SI', name: 'Slovenia', flag: '🇸🇮' }, + { code: 'SB', name: 'Solomon Islands', flag: '🇸🇧' }, + { code: 'SO', name: 'Somalia', flag: '🇸🇴' }, + { code: 'ZA', name: 'South Africa', flag: '🇿🇦' }, + { code: 'GS', name: 'South Georgia and the South Sandwich Islands', flag: '🇬🇸' }, + { code: 'KR', name: 'South Korea', flag: '🇰🇷' }, + { code: 'SS', name: 'South Sudan', flag: '🇸🇸' }, + { code: 'ES', name: 'Spain', flag: '🇪🇸' }, + { code: 'LK', name: 'Sri Lanka', flag: '🇱🇰' }, + { code: 'SD', name: 'Sudan', flag: '🇸🇩' }, + { code: 'SR', name: 'Suriname', flag: '🇸🇷' }, + { code: 'SJ', name: 'Svalbard and Jan Mayen', flag: '🇸🇯' }, + { code: 'SE', name: 'Sweden', flag: '🇸🇪' }, + { code: 'CH', name: 'Switzerland', flag: '🇨🇭' }, + { code: 'SY', name: 'Syria', flag: '🇸🇾' }, + { code: 'TW', name: 'Taiwan', flag: '🇹🇼' }, + { code: 'TJ', name: 'Tajikistan', flag: '🇹🇯' }, + { code: 'TZ', name: 'Tanzania', flag: '🇹🇿' }, + { code: 'TH', name: 'Thailand', flag: '🇹🇭' }, + { code: 'TL', name: 'Timor-Leste', flag: '🇹🇱' }, + { code: 'TG', name: 'Togo', flag: '🇹🇬' }, + { code: 'TK', name: 'Tokelau', flag: '🇹🇰' }, + { code: 'TO', name: 'Tonga', flag: '🇹🇴' }, + { code: 'TT', name: 'Trinidad and Tobago', flag: '🇹🇹' }, + { code: 'TN', name: 'Tunisia', flag: '🇹🇳' }, + { code: 'TR', name: 'Turkey', flag: '🇹🇷' }, + { code: 'TM', name: 'Turkmenistan', flag: '🇹🇲' }, + { code: 'TC', name: 'Turks and Caicos Islands', flag: '🇹🇨' }, + { code: 'TV', name: 'Tuvalu', flag: '🇹🇻' }, + { code: 'UG', name: 'Uganda', flag: '🇺🇬' }, + { code: 'UA', name: 'Ukraine', flag: '🇺🇦' }, + { code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪' }, + { code: 'GB', name: 'United Kingdom', flag: '🇬🇧' }, + { code: 'US', name: 'United States', flag: '🇺🇸' }, + { code: 'UM', name: 'United States Minor Outlying Islands', flag: '🇺🇲' }, + { code: 'VI', name: 'United States Virgin Islands', flag: '🇻🇮' }, + { code: 'UY', name: 'Uruguay', flag: '🇺🇾' }, + { code: 'UZ', name: 'Uzbekistan', flag: '🇺🇿' }, + { code: 'VU', name: 'Vanuatu', flag: '🇻🇺' }, + { code: 'VA', name: 'Vatican City', flag: '🇻🇦' }, + { code: 'VE', name: 'Venezuela', flag: '🇻🇪' }, + { code: 'VN', name: 'Vietnam', flag: '🇻🇳' }, + { code: 'WF', name: 'Wallis and Futuna', flag: '🇼🇫' }, + { code: 'EH', name: 'Western Sahara', flag: '🇪🇭' }, + { code: 'YE', name: 'Yemen', flag: '🇾🇪' }, + { code: 'ZM', name: 'Zambia', flag: '🇿🇲' }, + { code: 'ZW', name: 'Zimbabwe', flag: '🇿🇼' } +] as const; + +export type CountryCode = (typeof countries)[number]['code']; + +export function getCountryByCode(code: string) { + return countries.find((c) => c.code === code); +} + +export function getCountryName(code: string) { + return getCountryByCode(code)?.name || code; +} + +export function getCountryFlag(code: string) { + return getCountryByCode(code)?.flag || ''; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 0000000..5532ada --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,59 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * Merge Tailwind CSS classes with proper precedence + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +/** + * Format a date to a human-readable string + */ +export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string { + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + ...options + }); +} + +/** + * Format currency amount + */ +export function formatCurrency(amount: number, currency = 'EUR'): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(amount); +} + +/** + * Generate a random ID + */ +export function generateId(prefix = ''): string { + const id = Math.random().toString(36).substring(2, 9); + return prefix ? `${prefix}_${id}` : id; +} + +/** + * Debounce a function + */ +export function debounce unknown>( + fn: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; +} + +/** + * Check if running in browser + */ +export const isBrowser = typeof window !== 'undefined'; diff --git a/src/lib/utils/phoneCountries.ts b/src/lib/utils/phoneCountries.ts new file mode 100644 index 0000000..a260690 --- /dev/null +++ b/src/lib/utils/phoneCountries.ts @@ -0,0 +1,255 @@ +// Complete phone country codes with dial codes +// Prioritized: US, Monaco, France first, then common countries, then alphabetical +export const phoneCountries = [ + // Priority countries for Monaco USA + { code: 'US', name: 'United States', dialCode: '+1' }, + { code: 'MC', name: 'Monaco', dialCode: '+377' }, + { code: 'FR', name: 'France', dialCode: '+33' }, + { code: 'GB', name: 'United Kingdom', dialCode: '+44' }, + { code: 'IT', name: 'Italy', dialCode: '+39' }, + { code: 'CH', name: 'Switzerland', dialCode: '+41' }, + { code: 'DE', name: 'Germany', dialCode: '+49' }, + { code: 'ES', name: 'Spain', dialCode: '+34' }, + // All other countries alphabetically + { code: 'AF', name: 'Afghanistan', dialCode: '+93' }, + { code: 'AL', name: 'Albania', dialCode: '+355' }, + { code: 'DZ', name: 'Algeria', dialCode: '+213' }, + { code: 'AS', name: 'American Samoa', dialCode: '+1684' }, + { code: 'AD', name: 'Andorra', dialCode: '+376' }, + { code: 'AO', name: 'Angola', dialCode: '+244' }, + { code: 'AI', name: 'Anguilla', dialCode: '+1264' }, + { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268' }, + { code: 'AR', name: 'Argentina', dialCode: '+54' }, + { code: 'AM', name: 'Armenia', dialCode: '+374' }, + { code: 'AW', name: 'Aruba', dialCode: '+297' }, + { code: 'AU', name: 'Australia', dialCode: '+61' }, + { code: 'AT', name: 'Austria', dialCode: '+43' }, + { code: 'AZ', name: 'Azerbaijan', dialCode: '+994' }, + { code: 'BS', name: 'Bahamas', dialCode: '+1242' }, + { code: 'BH', name: 'Bahrain', dialCode: '+973' }, + { code: 'BD', name: 'Bangladesh', dialCode: '+880' }, + { code: 'BB', name: 'Barbados', dialCode: '+1246' }, + { code: 'BY', name: 'Belarus', dialCode: '+375' }, + { code: 'BE', name: 'Belgium', dialCode: '+32' }, + { code: 'BZ', name: 'Belize', dialCode: '+501' }, + { code: 'BJ', name: 'Benin', dialCode: '+229' }, + { code: 'BM', name: 'Bermuda', dialCode: '+1441' }, + { code: 'BT', name: 'Bhutan', dialCode: '+975' }, + { code: 'BO', name: 'Bolivia', dialCode: '+591' }, + { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387' }, + { code: 'BW', name: 'Botswana', dialCode: '+267' }, + { code: 'BR', name: 'Brazil', dialCode: '+55' }, + { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246' }, + { code: 'VG', name: 'British Virgin Islands', dialCode: '+1284' }, + { code: 'BN', name: 'Brunei', dialCode: '+673' }, + { code: 'BG', name: 'Bulgaria', dialCode: '+359' }, + { code: 'BF', name: 'Burkina Faso', dialCode: '+226' }, + { code: 'BI', name: 'Burundi', dialCode: '+257' }, + { code: 'CV', name: 'Cabo Verde', dialCode: '+238' }, + { code: 'KH', name: 'Cambodia', dialCode: '+855' }, + { code: 'CM', name: 'Cameroon', dialCode: '+237' }, + { code: 'CA', name: 'Canada', dialCode: '+1' }, + { code: 'KY', name: 'Cayman Islands', dialCode: '+1345' }, + { code: 'CF', name: 'Central African Republic', dialCode: '+236' }, + { code: 'TD', name: 'Chad', dialCode: '+235' }, + { code: 'CL', name: 'Chile', dialCode: '+56' }, + { code: 'CN', name: 'China', dialCode: '+86' }, + { code: 'CX', name: 'Christmas Island', dialCode: '+61' }, + { code: 'CC', name: 'Cocos (Keeling) Islands', dialCode: '+61' }, + { code: 'CO', name: 'Colombia', dialCode: '+57' }, + { code: 'KM', name: 'Comoros', dialCode: '+269' }, + { code: 'CG', name: 'Congo', dialCode: '+242' }, + { code: 'CD', name: 'Congo (DRC)', dialCode: '+243' }, + { code: 'CK', name: 'Cook Islands', dialCode: '+682' }, + { code: 'CR', name: 'Costa Rica', dialCode: '+506' }, + { code: 'CI', name: "Côte d'Ivoire", dialCode: '+225' }, + { code: 'HR', name: 'Croatia', dialCode: '+385' }, + { code: 'CU', name: 'Cuba', dialCode: '+53' }, + { code: 'CW', name: 'Curaçao', dialCode: '+599' }, + { code: 'CY', name: 'Cyprus', dialCode: '+357' }, + { code: 'CZ', name: 'Czech Republic', dialCode: '+420' }, + { code: 'DK', name: 'Denmark', dialCode: '+45' }, + { code: 'DJ', name: 'Djibouti', dialCode: '+253' }, + { code: 'DM', name: 'Dominica', dialCode: '+1767' }, + { code: 'DO', name: 'Dominican Republic', dialCode: '+1' }, + { code: 'EC', name: 'Ecuador', dialCode: '+593' }, + { code: 'EG', name: 'Egypt', dialCode: '+20' }, + { code: 'SV', name: 'El Salvador', dialCode: '+503' }, + { code: 'GQ', name: 'Equatorial Guinea', dialCode: '+240' }, + { code: 'ER', name: 'Eritrea', dialCode: '+291' }, + { code: 'EE', name: 'Estonia', dialCode: '+372' }, + { code: 'SZ', name: 'Eswatini', dialCode: '+268' }, + { code: 'ET', name: 'Ethiopia', dialCode: '+251' }, + { code: 'FK', name: 'Falkland Islands', dialCode: '+500' }, + { code: 'FO', name: 'Faroe Islands', dialCode: '+298' }, + { code: 'FJ', name: 'Fiji', dialCode: '+679' }, + { code: 'FI', name: 'Finland', dialCode: '+358' }, + { code: 'GF', name: 'French Guiana', dialCode: '+594' }, + { code: 'PF', name: 'French Polynesia', dialCode: '+689' }, + { code: 'GA', name: 'Gabon', dialCode: '+241' }, + { code: 'GM', name: 'Gambia', dialCode: '+220' }, + { code: 'GE', name: 'Georgia', dialCode: '+995' }, + { code: 'GH', name: 'Ghana', dialCode: '+233' }, + { code: 'GI', name: 'Gibraltar', dialCode: '+350' }, + { code: 'GR', name: 'Greece', dialCode: '+30' }, + { code: 'GL', name: 'Greenland', dialCode: '+299' }, + { code: 'GD', name: 'Grenada', dialCode: '+1473' }, + { code: 'GP', name: 'Guadeloupe', dialCode: '+590' }, + { code: 'GU', name: 'Guam', dialCode: '+1671' }, + { code: 'GT', name: 'Guatemala', dialCode: '+502' }, + { code: 'GG', name: 'Guernsey', dialCode: '+44' }, + { code: 'GN', name: 'Guinea', dialCode: '+224' }, + { code: 'GW', name: 'Guinea-Bissau', dialCode: '+245' }, + { code: 'GY', name: 'Guyana', dialCode: '+592' }, + { code: 'HT', name: 'Haiti', dialCode: '+509' }, + { code: 'HN', name: 'Honduras', dialCode: '+504' }, + { code: 'HK', name: 'Hong Kong', dialCode: '+852' }, + { code: 'HU', name: 'Hungary', dialCode: '+36' }, + { code: 'IS', name: 'Iceland', dialCode: '+354' }, + { code: 'IN', name: 'India', dialCode: '+91' }, + { code: 'ID', name: 'Indonesia', dialCode: '+62' }, + { code: 'IR', name: 'Iran', dialCode: '+98' }, + { code: 'IQ', name: 'Iraq', dialCode: '+964' }, + { code: 'IE', name: 'Ireland', dialCode: '+353' }, + { code: 'IM', name: 'Isle of Man', dialCode: '+44' }, + { code: 'IL', name: 'Israel', dialCode: '+972' }, + { code: 'JM', name: 'Jamaica', dialCode: '+1' }, + { code: 'JP', name: 'Japan', dialCode: '+81' }, + { code: 'JE', name: 'Jersey', dialCode: '+44' }, + { code: 'JO', name: 'Jordan', dialCode: '+962' }, + { code: 'KZ', name: 'Kazakhstan', dialCode: '+7' }, + { code: 'KE', name: 'Kenya', dialCode: '+254' }, + { code: 'KI', name: 'Kiribati', dialCode: '+686' }, + { code: 'KW', name: 'Kuwait', dialCode: '+965' }, + { code: 'KG', name: 'Kyrgyzstan', dialCode: '+996' }, + { code: 'LA', name: 'Laos', dialCode: '+856' }, + { code: 'LV', name: 'Latvia', dialCode: '+371' }, + { code: 'LB', name: 'Lebanon', dialCode: '+961' }, + { code: 'LS', name: 'Lesotho', dialCode: '+266' }, + { code: 'LR', name: 'Liberia', dialCode: '+231' }, + { code: 'LY', name: 'Libya', dialCode: '+218' }, + { code: 'LI', name: 'Liechtenstein', dialCode: '+423' }, + { code: 'LT', name: 'Lithuania', dialCode: '+370' }, + { code: 'LU', name: 'Luxembourg', dialCode: '+352' }, + { code: 'MO', name: 'Macao', dialCode: '+853' }, + { code: 'MG', name: 'Madagascar', dialCode: '+261' }, + { code: 'MW', name: 'Malawi', dialCode: '+265' }, + { code: 'MY', name: 'Malaysia', dialCode: '+60' }, + { code: 'MV', name: 'Maldives', dialCode: '+960' }, + { code: 'ML', name: 'Mali', dialCode: '+223' }, + { code: 'MT', name: 'Malta', dialCode: '+356' }, + { code: 'MH', name: 'Marshall Islands', dialCode: '+692' }, + { code: 'MQ', name: 'Martinique', dialCode: '+596' }, + { code: 'MR', name: 'Mauritania', dialCode: '+222' }, + { code: 'MU', name: 'Mauritius', dialCode: '+230' }, + { code: 'YT', name: 'Mayotte', dialCode: '+262' }, + { code: 'MX', name: 'Mexico', dialCode: '+52' }, + { code: 'FM', name: 'Micronesia', dialCode: '+691' }, + { code: 'MD', name: 'Moldova', dialCode: '+373' }, + { code: 'MN', name: 'Mongolia', dialCode: '+976' }, + { code: 'ME', name: 'Montenegro', dialCode: '+382' }, + { code: 'MS', name: 'Montserrat', dialCode: '+1664' }, + { code: 'MA', name: 'Morocco', dialCode: '+212' }, + { code: 'MZ', name: 'Mozambique', dialCode: '+258' }, + { code: 'MM', name: 'Myanmar', dialCode: '+95' }, + { code: 'NA', name: 'Namibia', dialCode: '+264' }, + { code: 'NR', name: 'Nauru', dialCode: '+674' }, + { code: 'NP', name: 'Nepal', dialCode: '+977' }, + { code: 'NL', name: 'Netherlands', dialCode: '+31' }, + { code: 'NC', name: 'New Caledonia', dialCode: '+687' }, + { code: 'NZ', name: 'New Zealand', dialCode: '+64' }, + { code: 'NI', name: 'Nicaragua', dialCode: '+505' }, + { code: 'NE', name: 'Niger', dialCode: '+227' }, + { code: 'NG', name: 'Nigeria', dialCode: '+234' }, + { code: 'NU', name: 'Niue', dialCode: '+683' }, + { code: 'NF', name: 'Norfolk Island', dialCode: '+672' }, + { code: 'KP', name: 'North Korea', dialCode: '+850' }, + { code: 'MK', name: 'North Macedonia', dialCode: '+389' }, + { code: 'MP', name: 'Northern Mariana Islands', dialCode: '+1670' }, + { code: 'NO', name: 'Norway', dialCode: '+47' }, + { code: 'OM', name: 'Oman', dialCode: '+968' }, + { code: 'PK', name: 'Pakistan', dialCode: '+92' }, + { code: 'PW', name: 'Palau', dialCode: '+680' }, + { code: 'PS', name: 'Palestine', dialCode: '+970' }, + { code: 'PA', name: 'Panama', dialCode: '+507' }, + { code: 'PG', name: 'Papua New Guinea', dialCode: '+675' }, + { code: 'PY', name: 'Paraguay', dialCode: '+595' }, + { code: 'PE', name: 'Peru', dialCode: '+51' }, + { code: 'PH', name: 'Philippines', dialCode: '+63' }, + { code: 'PL', name: 'Poland', dialCode: '+48' }, + { code: 'PT', name: 'Portugal', dialCode: '+351' }, + { code: 'PR', name: 'Puerto Rico', dialCode: '+1' }, + { code: 'QA', name: 'Qatar', dialCode: '+974' }, + { code: 'RE', name: 'Réunion', dialCode: '+262' }, + { code: 'RO', name: 'Romania', dialCode: '+40' }, + { code: 'RU', name: 'Russia', dialCode: '+7' }, + { code: 'RW', name: 'Rwanda', dialCode: '+250' }, + { code: 'BL', name: 'Saint Barthélemy', dialCode: '+590' }, + { code: 'SH', name: 'Saint Helena', dialCode: '+290' }, + { code: 'KN', name: 'Saint Kitts and Nevis', dialCode: '+1869' }, + { code: 'LC', name: 'Saint Lucia', dialCode: '+1758' }, + { code: 'MF', name: 'Saint Martin', dialCode: '+590' }, + { code: 'PM', name: 'Saint Pierre and Miquelon', dialCode: '+508' }, + { code: 'VC', name: 'Saint Vincent and the Grenadines', dialCode: '+1784' }, + { code: 'WS', name: 'Samoa', dialCode: '+685' }, + { code: 'SM', name: 'San Marino', dialCode: '+378' }, + { code: 'ST', name: 'São Tomé and Príncipe', dialCode: '+239' }, + { code: 'SA', name: 'Saudi Arabia', dialCode: '+966' }, + { code: 'SN', name: 'Senegal', dialCode: '+221' }, + { code: 'RS', name: 'Serbia', dialCode: '+381' }, + { code: 'SC', name: 'Seychelles', dialCode: '+248' }, + { code: 'SL', name: 'Sierra Leone', dialCode: '+232' }, + { code: 'SG', name: 'Singapore', dialCode: '+65' }, + { code: 'SX', name: 'Sint Maarten', dialCode: '+1721' }, + { code: 'SK', name: 'Slovakia', dialCode: '+421' }, + { code: 'SI', name: 'Slovenia', dialCode: '+386' }, + { code: 'SB', name: 'Solomon Islands', dialCode: '+677' }, + { code: 'SO', name: 'Somalia', dialCode: '+252' }, + { code: 'ZA', name: 'South Africa', dialCode: '+27' }, + { code: 'KR', name: 'South Korea', dialCode: '+82' }, + { code: 'SS', name: 'South Sudan', dialCode: '+211' }, + { code: 'LK', name: 'Sri Lanka', dialCode: '+94' }, + { code: 'SD', name: 'Sudan', dialCode: '+249' }, + { code: 'SR', name: 'Suriname', dialCode: '+597' }, + { code: 'SJ', name: 'Svalbard and Jan Mayen', dialCode: '+47' }, + { code: 'SE', name: 'Sweden', dialCode: '+46' }, + { code: 'SY', name: 'Syria', dialCode: '+963' }, + { code: 'TW', name: 'Taiwan', dialCode: '+886' }, + { code: 'TJ', name: 'Tajikistan', dialCode: '+992' }, + { code: 'TZ', name: 'Tanzania', dialCode: '+255' }, + { code: 'TH', name: 'Thailand', dialCode: '+66' }, + { code: 'TL', name: 'Timor-Leste', dialCode: '+670' }, + { code: 'TG', name: 'Togo', dialCode: '+228' }, + { code: 'TK', name: 'Tokelau', dialCode: '+690' }, + { code: 'TO', name: 'Tonga', dialCode: '+676' }, + { code: 'TT', name: 'Trinidad and Tobago', dialCode: '+1' }, + { code: 'TN', name: 'Tunisia', dialCode: '+216' }, + { code: 'TR', name: 'Turkey', dialCode: '+90' }, + { code: 'TM', name: 'Turkmenistan', dialCode: '+993' }, + { code: 'TC', name: 'Turks and Caicos Islands', dialCode: '+1649' }, + { code: 'TV', name: 'Tuvalu', dialCode: '+688' }, + { code: 'UG', name: 'Uganda', dialCode: '+256' }, + { code: 'UA', name: 'Ukraine', dialCode: '+380' }, + { code: 'AE', name: 'United Arab Emirates', dialCode: '+971' }, + { code: 'UY', name: 'Uruguay', dialCode: '+598' }, + { code: 'VI', name: 'United States Virgin Islands', dialCode: '+1340' }, + { code: 'UZ', name: 'Uzbekistan', dialCode: '+998' }, + { code: 'VU', name: 'Vanuatu', dialCode: '+678' }, + { code: 'VA', name: 'Vatican City', dialCode: '+39' }, + { code: 'VE', name: 'Venezuela', dialCode: '+58' }, + { code: 'VN', name: 'Vietnam', dialCode: '+84' }, + { code: 'WF', name: 'Wallis and Futuna', dialCode: '+681' }, + { code: 'YE', name: 'Yemen', dialCode: '+967' }, + { code: 'ZM', name: 'Zambia', dialCode: '+260' }, + { code: 'ZW', name: 'Zimbabwe', dialCode: '+263' } +] as const; + +export type PhoneCountryCode = (typeof phoneCountries)[number]['code']; + +export function getPhoneCountry(code: string) { + return phoneCountries.find((c) => c.code === code); +} + +export function getDialCode(code: string) { + return getPhoneCountry(code)?.dialCode || ''; +} diff --git a/src/routes/(app)/+error.svelte b/src/routes/(app)/+error.svelte new file mode 100644 index 0000000..d5fc1a5 --- /dev/null +++ b/src/routes/(app)/+error.svelte @@ -0,0 +1,179 @@ + + + + {status} - {errorInfo.title} | Monaco USA + + +
+ +
+ + Monaco USA + +
+ + +
+ +
+ + + +
+ + +
+ Error {status} +
+ + +

{errorInfo.title}

+ + +

{errorInfo.message}

+ + +
+ + + + + {#if status === 401} + + {/if} +
+
+ + +

+ Need assistance? + + Contact support + +

+ + + {#if $page.error?.message && import.meta.env.DEV} +
+ + Technical details + +
{$page.error.message}
+
+ {/if} +
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..2e97741 --- /dev/null +++ b/src/routes/(app)/+layout.server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals, url }) => { + const { session, user, member } = await locals.safeGetSession(); + + // Require authentication for all app routes + if (!session) { + throw redirect(303, `/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + // Require member profile to exist + if (!member) { + // User is authenticated but has no member profile - unusual situation + await locals.supabase.auth.signOut(); + throw redirect(303, '/login?error=no_profile'); + } + + // Check if user's email is verified + const emailVerified = user?.email_confirmed_at !== null && user?.email_confirmed_at !== undefined; + + return { + session, + member, + emailVerified + }; +}; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..c453b4f --- /dev/null +++ b/src/routes/(app)/+layout.svelte @@ -0,0 +1,87 @@ + + +
+ + + + +
+ +
+ + + {#if !data.emailVerified} + + {/if} + + +
+
+ {@render children()} +
+
+
+ + + + + + +
diff --git a/src/routes/(app)/admin/+layout.server.ts b/src/routes/(app)/admin/+layout.server.ts new file mode 100644 index 0000000..0f15a57 --- /dev/null +++ b/src/routes/(app)/admin/+layout.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ parent }) => { + const { member } = await parent(); + + // Only admins can access admin pages + if (member?.role !== 'admin') { + throw redirect(303, '/dashboard'); + } + + return {}; +}; diff --git a/src/routes/(app)/admin/dashboard/+page.server.ts b/src/routes/(app)/admin/dashboard/+page.server.ts new file mode 100644 index 0000000..1e16518 --- /dev/null +++ b/src/routes/(app)/admin/dashboard/+page.server.ts @@ -0,0 +1,91 @@ +import type { PageServerLoad } from './$types'; +import { getRecentAuditLogs } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + // Get all members with dues info for stats + const { data: members } = await locals.supabase + .from('members_with_dues') + .select('*'); + + // Get recent payments (this month and last month) + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + + const { data: recentPayments } = await locals.supabase + .from('dues_payments') + .select(` + *, + member:members(first_name, last_name, email) + `) + .gte('payment_date', startOfLastMonth.toISOString()) + .order('payment_date', { ascending: false }); + + // Get upcoming events (next 30 days) + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + const { data: upcomingEvents } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('status', 'published') + .gte('start_datetime', now.toISOString()) + .lte('start_datetime', thirtyDaysFromNow.toISOString()) + .order('start_datetime', { ascending: true }) + .limit(5); + + // Get recent audit logs + const { logs: auditLogs } = await getRecentAuditLogs(20); + + // Calculate member stats + const memberStats = { + total: members?.length || 0, + byRole: { + admin: members?.filter(m => m.role === 'admin').length || 0, + board: members?.filter(m => m.role === 'board').length || 0, + member: members?.filter(m => m.role === 'member').length || 0 + }, + byDuesStatus: { + current: members?.filter(m => m.dues_status === 'current').length || 0, + due_soon: members?.filter(m => m.dues_status === 'due_soon').length || 0, + overdue: members?.filter(m => m.dues_status === 'overdue').length || 0, + never_paid: members?.filter(m => m.dues_status === 'never_paid').length || 0 + } + }; + + // Calculate revenue stats + const thisMonthPayments = recentPayments?.filter(p => + new Date(p.payment_date) >= startOfMonth + ) || []; + const lastMonthPayments = recentPayments?.filter(p => + new Date(p.payment_date) >= startOfLastMonth && + new Date(p.payment_date) < startOfMonth + ) || []; + + const revenueStats = { + thisMonth: thisMonthPayments.reduce((sum, p) => sum + (p.amount || 0), 0), + lastMonth: lastMonthPayments.reduce((sum, p) => sum + (p.amount || 0), 0), + thisMonthCount: thisMonthPayments.length, + lastMonthCount: lastMonthPayments.length + }; + + // Get recent payments for display + const { data: latestPayments } = await locals.supabase + .from('dues_payments') + .select(` + *, + member:members(first_name, last_name, email), + recorder:members!dues_payments_recorded_by_fkey(first_name, last_name) + `) + .order('created_at', { ascending: false }) + .limit(5); + + return { + memberStats, + revenueStats, + upcomingEvents: upcomingEvents || [], + recentPayments: latestPayments || [], + auditLogs: auditLogs || [], + overdueMembers: members?.filter(m => m.dues_status === 'overdue').slice(0, 5) || [] + }; +}; diff --git a/src/routes/(app)/admin/dashboard/+page.svelte b/src/routes/(app)/admin/dashboard/+page.svelte new file mode 100644 index 0000000..3e1634e --- /dev/null +++ b/src/routes/(app)/admin/dashboard/+page.svelte @@ -0,0 +1,295 @@ + + + + Admin Dashboard | Monaco USA + + +
+ +
+

Admin Dashboard

+

Overview of Monaco USA portal activity and metrics

+
+ + +
+ +
+
+
+

Total Members

+

{memberStats.total}

+
+
+ +
+
+
+ {memberStats.byRole.admin} Admin + {memberStats.byRole.board} Board + {memberStats.byRole.member} Members +
+
+ + +
+
+
+

Revenue This Month

+

{formatCurrency(revenueStats.thisMonth)}

+
+
+ +
+
+
+ {#if revenueUp} + + +{revenueTrend}% + {:else} + + {revenueTrend}% + {/if} + vs last month +
+
+ + +
+
+
+

Dues Status

+

{memberStats.byDuesStatus.current}

+
+
+ +
+
+
+ Current + {memberStats.byDuesStatus.due_soon} Due Soon + {memberStats.byDuesStatus.overdue} Overdue +
+
+ + +
+
+
+

Upcoming Events

+

{upcomingEvents.length}

+
+
+ +
+
+

Next 30 days

+
+
+ + +
+ +
+
+

Recent Payments

+ View all +
+ + {#if recentPayments.length > 0} +
+ {#each recentPayments as payment} +
+
+

+ {payment.member?.first_name} {payment.member?.last_name} +

+

{formatDate(payment.payment_date)}

+
+ {formatCurrency(payment.amount)} +
+ {/each} +
+ {:else} +

No recent payments

+ {/if} +
+ + +
+
+

Upcoming Events

+ View all +
+ + {#if upcomingEvents.length > 0} +
+ {#each upcomingEvents as event} +
+
+

{event.title}

+

+ {formatDateTime(event.start_datetime)} + {#if event.location} + | {event.location} + {/if} +

+
+
+ {event.total_attendees} + {#if event.max_attendees} + /{event.max_attendees} + {/if} +

attendees

+
+
+ {/each} +
+ {:else} +

No upcoming events

+ {/if} +
+ + +
+
+

Overdue Dues

+ View all +
+ + {#if overdueMembers.length > 0} +
+ {#each overdueMembers as member} +
+
+

+ {member.first_name} {member.last_name} +

+

{member.email}

+
+
+ + {member.days_overdue} days + +

overdue

+
+
+ {/each} +
+ {:else} +
+ +

All members are current!

+
+ {/if} +
+ + +
+
+

Recent Activity

+ +
+ + {#if auditLogs.length > 0} +
+ {#each auditLogs.slice(0, 8) as log} +
+
+ {#if log.action.startsWith('member')} + + {:else if log.action.startsWith('event')} + + {:else if log.action.startsWith('payment')} + + {:else if log.action.startsWith('document')} + + {:else} + + {/if} +
+
+

+ {formatAuditAction(log.action)} + {#if log.details?.target_email} + ({log.details.target_email}) + {/if} +

+

+ {log.user_email || 'System'} · {formatDateTime(log.created_at)} +

+
+
+ {/each} +
+ {:else} +

No recent activity

+ {/if} +
+
+
diff --git a/src/routes/(app)/admin/email-templates/+page.server.ts b/src/routes/(app)/admin/email-templates/+page.server.ts new file mode 100644 index 0000000..5535763 --- /dev/null +++ b/src/routes/(app)/admin/email-templates/+page.server.ts @@ -0,0 +1,83 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + // Check admin access + const { data: { user } } = await locals.supabase.auth.getUser(); + if (!user) throw redirect(303, '/login'); + + const { data: member } = await locals.supabase + .from('members') + .select('role') + .eq('id', user.id) + .single(); + + if (!member || !['admin', 'board'].includes(member.role)) { + throw redirect(303, '/dashboard'); + } + + // Load all email templates + const { data: templates, error } = await locals.supabase + .from('email_templates') + .select('*') + .order('category', { ascending: true }) + .order('template_name', { ascending: true }); + + if (error) { + console.error('Error loading email templates:', error); + return { templates: [] }; + } + + return { templates: templates || [] }; +}; + +export const actions: Actions = { + updateTemplate: async ({ request, locals }) => { + const { data: { user } } = await locals.supabase.auth.getUser(); + if (!user) { + return fail(401, { error: 'Unauthorized' }); + } + + // Check admin access + const { data: member } = await locals.supabase + .from('members') + .select('role') + .eq('id', user.id) + .single(); + + if (!member || !['admin', 'board'].includes(member.role)) { + return fail(403, { error: 'Access denied' }); + } + + const formData = await request.formData(); + const template_key = formData.get('template_key') as string; + const subject = formData.get('subject') as string; + const body_html = formData.get('body_html') as string; + const body_text = formData.get('body_text') as string; + const is_active = formData.get('is_active') === 'true'; + + if (!template_key) { + return fail(400, { error: 'Template key is required' }); + } + + // Update the template + const { error } = await locals.supabase + .from('email_templates') + .update({ + subject, + body_html, + body_text, + is_active, + updated_at: new Date().toISOString(), + updated_by: user.id + }) + .eq('template_key', template_key); + + if (error) { + console.error('Error updating template:', error); + return fail(500, { error: 'Failed to update template' }); + } + + return { success: true, message: 'Template updated successfully' }; + } +}; diff --git a/src/routes/(app)/admin/email-templates/+page.svelte b/src/routes/(app)/admin/email-templates/+page.svelte new file mode 100644 index 0000000..e0af0d4 --- /dev/null +++ b/src/routes/(app)/admin/email-templates/+page.svelte @@ -0,0 +1,566 @@ + + + + Email Templates | Monaco USA Admin + + +
+ + + +
+

Email Templates

+

Edit the text content of email notifications sent by the system

+
+ + + {#if form?.success} +
+ + {form.message || 'Template updated successfully'} +
+ {/if} + + {#if form?.error} +
+ + {form.error} +
+ {/if} + + +
+
+ +
+ + +
+ + +
+ + +
+
+
+ + +
+ {#each filteredTemplates as template} +
+
+
+
+ + {formatCategory(template.category)} + + {#if !template.is_active} + + Inactive + + {/if} +
+

{template.template_name}

+

{template.subject}

+
+ +
+
+ {template.template_key} + +
+
+ {:else} +
+ +

No templates found

+
+ {/each} +
+
+ + +{#if editingTemplate} +
{ if (e.target === e.currentTarget) closeEditor(); }} + onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }} + role="dialog" + aria-modal="true" + tabindex="-1" + > +
+ +
+
+

Edit: {editingTemplate.template_name}

+

{editingTemplate.template_key}

+
+ +
+ + +
{ + isSubmitting = true; + return async ({ update, result }) => { + isSubmitting = false; + if (result.type === 'success') { + await invalidateAll(); + closeEditor(); + } + await update(); + }; + }} + class="p-6 space-y-6" + > + + + +
+ + activeField = 'subject'} + class="h-11 font-mono text-sm" + /> +
+ + + {#if templateVariables.length > 0} +
+
+ + Available Variables + (click to insert at cursor) +
+
+ {#each templateVariables as [varName, description]} + + {/each} +
+
+ {/if} + + +
+
+ + +
+ {#if showPreview} +
+
+

Subject: {renderPreview(editSubject)}

+
+ +
+ {:else} + + {/if} +
+ + +
+ + +
+ + +
+ + Template Active +
+ + +
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/admin/email-testing/+page.server.ts b/src/routes/(app)/admin/email-testing/+page.server.ts new file mode 100644 index 0000000..46d760d --- /dev/null +++ b/src/routes/(app)/admin/email-testing/+page.server.ts @@ -0,0 +1,425 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { sendEmail, sendTemplatedEmail, getSmtpConfig, wrapInMonacoTemplate } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return { + templates: [], + recentLogs: [], + smtpConfigured: false + }; + } + + // Check if SMTP is configured + const smtpConfig = await getSmtpConfig(); + const smtpConfigured = !!smtpConfig; + + // Fetch all email templates + const { data: templates } = await supabaseAdmin + .from('email_templates') + .select('*') + .order('category', { ascending: true }) + .order('name', { ascending: true }); + + // Fetch recent email logs + const { data: recentLogs } = await supabaseAdmin + .from('email_logs') + .select(` + *, + sender:members!email_logs_sent_by_fkey(first_name, last_name) + `) + .order('created_at', { ascending: false }) + .limit(20); + + // Group templates by category + const templatesByCategory: Record = {}; + for (const template of templates || []) { + const category = template.category || 'other'; + if (!templatesByCategory[category]) { + templatesByCategory[category] = []; + } + templatesByCategory[category].push(template); + } + + return { + templates: templates || [], + templatesByCategory, + recentLogs: recentLogs || [], + smtpConfigured, + adminEmail: member.email + }; +}; + +export const actions: Actions = { + /** + * Send a test email using a template + */ + sendTestTemplate: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can send test emails' }); + } + + const formData = await request.formData(); + const templateKey = formData.get('template_key') as string; + const recipientEmail = formData.get('recipient_email') as string; + + if (!templateKey || !recipientEmail) { + return fail(400, { error: 'Template and recipient email are required' }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(recipientEmail)) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + // Build test variables based on template type + const testVariables = getTestVariables(templateKey, member, url.origin); + + const result = await sendTemplatedEmail(templateKey, recipientEmail, testVariables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + sentBy: member.id, + baseUrl: url.origin + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to send test email' }); + } + + return { success: `Test email sent successfully to ${recipientEmail}!` }; + }, + + /** + * Send a custom test email + */ + sendCustomEmail: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can send test emails' }); + } + + const formData = await request.formData(); + const recipientEmail = formData.get('recipient_email') as string; + const subject = formData.get('subject') as string; + const messageContent = formData.get('message') as string; + + if (!recipientEmail || !subject || !messageContent) { + return fail(400, { error: 'Recipient, subject, and message are required' }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(recipientEmail)) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + // Wrap the message in the Monaco template + const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`; + const html = wrapInMonacoTemplate({ + title: subject, + content: `

${messageContent.replace(/\n/g, '
')}

`, + logoUrl + }); + + const result = await sendEmail({ + to: recipientEmail, + subject, + html, + emailType: 'test_custom', + sentBy: member.id + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to send email' }); + } + + return { success: `Custom email sent successfully to ${recipientEmail}!` }; + }, + + /** + * Test all notification types to a single recipient + */ + sendAllTests: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can send test emails' }); + } + + const formData = await request.formData(); + const recipientEmail = formData.get('recipient_email') as string; + + if (!recipientEmail) { + return fail(400, { error: 'Recipient email is required' }); + } + + // Get all active templates + const { data: templates } = await supabaseAdmin + .from('email_templates') + .select('template_key') + .eq('is_active', true); + + if (!templates || templates.length === 0) { + return fail(400, { error: 'No active email templates found' }); + } + + let successCount = 0; + let failCount = 0; + const errors: string[] = []; + + for (const template of templates) { + const testVariables = getTestVariables(template.template_key, member, url.origin); + + const result = await sendTemplatedEmail(template.template_key, recipientEmail, testVariables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + sentBy: member.id, + baseUrl: url.origin + }); + + if (result.success) { + successCount++; + } else { + failCount++; + errors.push(`${template.template_key}: ${result.error}`); + } + + // Small delay between emails to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + if (failCount === 0) { + return { success: `All ${successCount} test emails sent successfully!` }; + } else if (successCount === 0) { + return fail(500, { error: `All emails failed to send. Errors: ${errors.join('; ')}` }); + } else { + return { + success: `Sent ${successCount} emails, ${failCount} failed.`, + errors + }; + } + }, + + /** + * Preview a template (returns HTML) + */ + previewTemplate: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Unauthorized' }); + } + + const formData = await request.formData(); + const templateKey = formData.get('template_key') as string; + + if (!templateKey) { + return fail(400, { error: 'Template key is required' }); + } + + // Fetch the template + const { data: template } = await supabaseAdmin + .from('email_templates') + .select('*') + .eq('template_key', templateKey) + .single(); + + if (!template) { + return fail(404, { error: 'Template not found' }); + } + + // Get test variables + const testVariables = getTestVariables(templateKey, member, url.origin); + const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`; + + // Replace variables in the template + let html = template.body_html; + let subject = template.subject; + + const allVariables: Record = { + logo_url: logoUrl, + site_url: url.origin, + ...testVariables + }; + + for (const [key, value] of Object.entries(allVariables)) { + const regex = new RegExp(`{{${key}}}`, 'g'); + html = html.replace(regex, value); + subject = subject.replace(regex, value); + } + + return { + preview: { + subject, + html, + templateName: template.name + } + }; + } +}; + +/** + * Get test variables for different template types + */ +function getTestVariables(templateKey: string, member: any, baseUrl: string): Record { + const commonVars = { + first_name: member.first_name || 'Test', + last_name: member.last_name || 'User', + member_name: `${member.first_name || 'Test'} ${member.last_name || 'User'}`, + member_id: member.member_id || 'TEST-001', + email: member.email || 'test@example.com', + site_url: baseUrl, + portal_url: baseUrl, + logo_url: `${baseUrl}/MONACOUSA-Flags_376x376.png` + }; + + // Template-specific variables + switch (templateKey) { + case 'welcome': + return { + ...commonVars, + login_url: `${baseUrl}/login` + }; + + case 'payment_received': + return { + ...commonVars, + amount: '100.00', + payment_date: new Date().toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }), + reference: 'TEST-REF-123', + due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + }; + + case 'dues_reminder_30': + case 'dues_reminder_7': + case 'dues_reminder_1': + const daysMap: Record = { + dues_reminder_30: '30', + dues_reminder_7: '7', + dues_reminder_1: '1' + }; + return { + ...commonVars, + days_until_due: daysMap[templateKey] || '30', + due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }), + amount_due: '100.00', + payment_url: `${baseUrl}/payments`, + bank_name: 'Monaco Bank', + iban: 'MC00 0000 0000 0000 0000 0000 000', + bic: 'MONACOXX', + payment_reference: `DUES-${member.member_id || 'TEST001'}` + }; + + case 'dues_overdue': + return { + ...commonVars, + days_overdue: '15', + due_date: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }), + amount_due: '100.00', + payment_url: `${baseUrl}/payments`, + bank_name: 'Monaco Bank', + iban: 'MC00 0000 0000 0000 0000 0000 000', + bic: 'MONACOXX', + payment_reference: `DUES-${member.member_id || 'TEST001'}` + }; + + case 'dues_grace_warning': + return { + ...commonVars, + grace_days_remaining: '7', + grace_end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }), + amount_due: '100.00', + payment_url: `${baseUrl}/payments` + }; + + case 'dues_inactive_notice': + return { + ...commonVars, + inactive_date: new Date().toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }), + reactivation_url: `${baseUrl}/payments` + }; + + case 'event_invitation': + return { + ...commonVars, + event_title: 'Monaco USA Annual Gala', + event_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }), + event_time: '7:00 PM', + event_location: 'Hotel de Paris, Monaco', + event_description: 'Join us for our annual celebration bringing together Americans living in Monaco.', + event_url: `${baseUrl}/events/test-event`, + rsvp_url: `${baseUrl}/events/test-event` + }; + + case 'event_reminder': + return { + ...commonVars, + event_title: 'Monaco USA Monthly Meetup', + event_date: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }), + event_time: '6:00 PM', + event_location: 'Stars n Bars, Monaco', + event_url: `${baseUrl}/events/test-event` + }; + + case 'waitlist_promotion': + return { + ...commonVars, + event_title: 'Exclusive Wine Tasting Event', + event_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }), + event_time: '8:00 PM', + event_location: 'Cave Princesse, Monaco', + event_url: `${baseUrl}/events/test-event`, + confirm_url: `${baseUrl}/events/test-event` + }; + + default: + return commonVars; + } +} diff --git a/src/routes/(app)/admin/email-testing/+page.svelte b/src/routes/(app)/admin/email-testing/+page.svelte new file mode 100644 index 0000000..3001952 --- /dev/null +++ b/src/routes/(app)/admin/email-testing/+page.svelte @@ -0,0 +1,561 @@ + + + + Email Testing | Monaco USA Admin + + +
+ +
+
+

Email Testing

+

Test email notifications across the platform

+
+ + + Email Settings + +
+ + + {#if !smtpConfigured} +
+
+ +
+

SMTP Not Configured

+

+ Email sending is not configured. Please configure SMTP settings in the + Admin Settings + before testing emails. +

+
+
+
+ {/if} + + + {#if form?.success} +
+
+ + {form.success} +
+
+ {/if} + + {#if form?.error} +
+
+ + {form.error} +
+
+ {/if} + + +
+ + + +
+ + + {#if activeTab === 'templates'} +
+ +
+ {#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]} +
+
+

{getCategoryName(category)}

+
+
+ {#each categoryTemplates as template} +
(selectedTemplate = template.template_key)} + onkeydown={(e) => e.key === 'Enter' && (selectedTemplate = template.template_key)} + role="button" + tabindex="0" + > +
+

{template.name}

+

{template.subject}

+
+
+ {#if template.is_active} + + Active + + {:else} + + Inactive + + {/if} +
+
+ {/each} +
+
+ {/each} + + {#if !templates || templates.length === 0} +
+ +

No Email Templates

+

+ No email templates have been configured yet. +

+
+ {/if} +
+ + +
+
+

Send Test Email

+ +
{ + isLoading = true; + return async ({ update }) => { + isLoading = false; + await update(); + await invalidateAll(); + }; + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ +
+
+ + + {#if selectedTemplate} +
{ + isLoadingPreview = true; + return async ({ result, update }) => { + isLoadingPreview = false; + if (result.type === 'success' && result.data?.preview) { + previewHtml = result.data.preview.html; + previewSubject = result.data.preview.subject; + showPreview = true; + } + await update(); + }; + }} + class="mt-4" + > + + +
+ {/if} + + +
+

Bulk Testing

+
{ + if (!confirm(`This will send ALL active email templates to ${recipientEmail}. Continue?`)) { + return async () => {}; + } + isLoading = true; + return async ({ update }) => { + isLoading = false; + await update(); + await invalidateAll(); + }; + }} + > + + +
+

+ Sends all active templates to test the complete notification system. +

+
+
+
+
+ {/if} + + + {#if activeTab === 'custom'} +
+
+

Send Custom Test Email

+

+ Send a custom email using the Monaco USA branding template. +

+ +
{ + isLoading = true; + return async ({ update }) => { + isLoading = false; + await update(); + await invalidateAll(); + }; + }} + class="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + +

+ The message will be wrapped in the Monaco USA email template. +

+
+ + +
+
+
+ {/if} + + + {#if activeTab === 'logs'} +
+
+

Recent Email Logs

+ +
+ + {#if recentLogs.length === 0} +
+ +

No Email Logs

+

No emails have been sent yet.

+
+ {:else} +
+ + + + + + + + + + + + + {#each recentLogs as log} + {@const statusBadge = getStatusBadge(log.status)} + + + + + + + + + {#if log.status === 'failed' && log.error_message} + + + + {/if} + {/each} + +
StatusRecipientSubjectTypeSentBy
+ + + {log.status} + + +
+

{log.recipient_email}

+ {#if log.recipient_name} +

{log.recipient_name}

+ {/if} +
+
+

+ {log.subject} +

+
+ + {log.email_type || 'manual'} + + + {log.sent_at ? formatDate(log.sent_at) : formatDate(log.created_at)} + + {#if log.sender} + {log.sender.first_name} {log.sender.last_name} + {:else} + System + {/if} +
+

+ Error: {log.error_message} +

+
+
+ {/if} +
+ {/if} +
+ + +{#if showPreview} +
+
+
+
+

Email Preview

+

{previewSubject}

+
+ +
+
+
+ {@html previewHtml} +
+
+
+ +
+
+
+{/if} diff --git a/src/routes/(app)/admin/members/+page.server.ts b/src/routes/(app)/admin/members/+page.server.ts new file mode 100644 index 0000000..a56b1ad --- /dev/null +++ b/src/routes/(app)/admin/members/+page.server.ts @@ -0,0 +1,430 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { sendEmail } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const searchQuery = url.searchParams.get('search') || ''; + const roleFilter = url.searchParams.get('role') || 'all'; + const statusFilter = url.searchParams.get('status') || 'all'; + + // Load all members with dues info using admin client (bypasses RLS for admin page) + const { data: members } = await supabaseAdmin + .from('members_with_dues') + .select('*') + .order('created_at', { ascending: false }); + + // Filter members + let filteredMembers = members || []; + + if (searchQuery) { + const lowerSearch = searchQuery.toLowerCase(); + filteredMembers = filteredMembers.filter( + (m: any) => + m.first_name?.toLowerCase().includes(lowerSearch) || + m.last_name?.toLowerCase().includes(lowerSearch) || + m.email?.toLowerCase().includes(lowerSearch) || + m.member_id?.toLowerCase().includes(lowerSearch) + ); + } + + if (roleFilter !== 'all') { + filteredMembers = filteredMembers.filter((m: any) => m.role === roleFilter); + } + + if (statusFilter !== 'all') { + filteredMembers = filteredMembers.filter((m: any) => m.status_name === statusFilter); + } + + // Load membership statuses for dropdown + const { data: statuses } = await supabaseAdmin + .from('membership_statuses') + .select('*') + .order('sort_order', { ascending: true }); + + // Calculate stats + const stats = { + total: members?.length || 0, + admins: members?.filter((m: any) => m.role === 'admin').length || 0, + board: members?.filter((m: any) => m.role === 'board').length || 0, + members: members?.filter((m: any) => m.role === 'member').length || 0 + }; + + return { + members: filteredMembers, + statuses: statuses || [], + stats, + filters: { + search: searchQuery, + role: roleFilter, + status: statusFilter + } + }; +}; + +export const actions: Actions = { + updateRole: async ({ request, locals }) => { + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + const newRole = formData.get('role') as string; + + if (!memberId || !newRole) { + return fail(400, { error: 'Member ID and role are required' }); + } + + if (!['member', 'board', 'admin'].includes(newRole)) { + return fail(400, { error: 'Invalid role' }); + } + + const { error } = await locals.supabase + .from('members') + .update({ role: newRole, updated_at: new Date().toISOString() }) + .eq('id', memberId); + + if (error) { + console.error('Update role error:', error); + return fail(500, { error: 'Failed to update role' }); + } + + return { success: 'Role updated successfully!' }; + }, + + updateStatus: async ({ request, locals }) => { + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + const statusId = formData.get('status_id') as string; + + if (!memberId || !statusId) { + return fail(400, { error: 'Member ID and status are required' }); + } + + const { error } = await locals.supabase + .from('members') + .update({ + membership_status_id: statusId, + updated_at: new Date().toISOString() + }) + .eq('id', memberId); + + if (error) { + console.error('Update status error:', error); + return fail(500, { error: 'Failed to update status' }); + } + + return { success: 'Status updated successfully!' }; + }, + + deleteMember: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can delete members' }); + } + + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + + if (!memberId) { + return fail(400, { error: 'Member ID is required' }); + } + + // Prevent admin from deleting themselves + if (memberId === member.id) { + return fail(400, { error: 'You cannot delete your own account' }); + } + + // First handle related records that have foreign keys to members + + // Reassign events created by this member to the current admin + await supabaseAdmin + .from('events') + .update({ created_by: member.id }) + .eq('created_by', memberId); + + // Reassign app_settings updated by this member to the current admin + await supabaseAdmin + .from('app_settings') + .update({ updated_by: member.id }) + .eq('updated_by', memberId); + + // Delete dues payments + await supabaseAdmin.from('dues_payments').delete().eq('member_id', memberId); + + // Delete event RSVPs + await supabaseAdmin.from('event_rsvps').delete().eq('member_id', memberId); + + // Delete email logs + await supabaseAdmin.from('email_logs').delete().eq('recipient_id', memberId); + await supabaseAdmin.from('email_logs').delete().eq('sent_by', memberId); + + // Now delete from members table using admin client (bypasses RLS) + const { error } = await supabaseAdmin.from('members').delete().eq('id', memberId); + + if (error) { + console.error('Delete member error:', error); + console.error('Error details:', JSON.stringify(error, null, 2)); + return fail(500, { error: `Failed to delete member: ${error.message}` }); + } + + // Also delete the auth user using admin client + const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(memberId); + + if (authError) { + console.error('Delete auth user error:', authError); + // Member is already deleted, just log this + } + + return { success: 'Member deleted successfully!' }; + }, + + inviteMember: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can invite members' }); + } + + const formData = await request.formData(); + const email = (formData.get('email') as string)?.trim().toLowerCase(); + const firstName = (formData.get('first_name') as string)?.trim() || ''; + const lastName = (formData.get('last_name') as string)?.trim() || ''; + const role = (formData.get('role') as string) || 'member'; + const duesPaidDate = formData.get('dues_paid_date') as string; + + if (!email) { + return fail(400, { error: 'Email is required' }); + } + + // Validate role + if (!['member', 'board', 'admin'].includes(role)) { + return fail(400, { error: 'Invalid role' }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + // Check if email already exists + const { data: existingMember } = await locals.supabase + .from('members') + .select('id') + .eq('email', email) + .single(); + + if (existingMember) { + return fail(400, { error: 'A member with this email already exists' }); + } + + // Get default status (pending) + const { data: defaultStatus } = await locals.supabase + .from('membership_statuses') + .select('id') + .eq('is_default', true) + .single(); + + // Get default membership type + const { data: defaultType } = await locals.supabase + .from('membership_types') + .select('id, annual_dues') + .eq('is_default', true) + .single(); + + // Create auth user with a temporary password using admin client (requires service_role) + // The user will reset their password when they first log in + const tempPassword = crypto.randomUUID(); + + const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({ + email, + password: tempPassword, + email_confirm: true, // Auto-confirm the email since admin is inviting + user_metadata: { + first_name: firstName || 'New', + last_name: lastName || 'Member', + invited_by: member.id + } + }); + + if (authError) { + console.error('Auth user creation error:', authError); + if (authError.message.includes('already registered')) { + return fail(400, { error: 'This email is already registered' }); + } + return fail(500, { error: 'Failed to create user account. Please try again.' }); + } + + if (!authData.user) { + return fail(500, { error: 'Failed to create user account' }); + } + + // Get active status if dues are paid + let statusId = defaultStatus?.id; + if (duesPaidDate) { + const { data: activeStatus } = await locals.supabase + .from('membership_statuses') + .select('id') + .eq('name', 'active') + .single(); + if (activeStatus) { + statusId = activeStatus.id; + } + } + + // Create member record + const { error: memberError } = await locals.supabase.from('members').insert({ + id: authData.user.id, + first_name: firstName || 'New', + last_name: lastName || 'Member', + email: email, + phone: '', + date_of_birth: '1990-01-01', // Placeholder - member will update + address: 'TBD', // Placeholder + nationality: [], + role: role, + membership_status_id: statusId, + membership_type_id: defaultType?.id + }); + + if (memberError) { + console.error('Member creation error:', memberError); + // Clean up auth user using admin client + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { error: 'Failed to create member record. Please try again.' }); + } + + // Create dues payment record if dues paid date is provided + // The dues_paid_date is the date when dues are NEXT due (not when paid) + // So we calculate payment_date as 1 year before the due date + if (duesPaidDate) { + const dueDate = new Date(duesPaidDate); + const paymentDate = new Date(dueDate); + paymentDate.setFullYear(paymentDate.getFullYear() - 1); + + const { error: duesError } = await locals.supabase.from('dues_payments').insert({ + member_id: authData.user.id, + amount: defaultType?.annual_dues || 0, + payment_date: paymentDate.toISOString().split('T')[0], + due_date: duesPaidDate, + payment_method: 'other', + notes: 'Initial dues set by admin during member invitation', + recorded_by: member.id + }); + + if (duesError) { + console.error('Dues payment creation error:', duesError); + // Non-critical - member still created + } + } + + // Send welcome email with Monaco branding + const baseUrl = url.origin; + const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`; + const memberFirstName = firstName || 'New Member'; + + // Format dues paid date for display + const formattedDuesPaidDate = duesPaidDate + ? new Date(duesPaidDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : null; + + // Dues section for the email + const duesSection = duesPaidDate + ? `
+

Membership Status

+

Status: Active Member

+

Dues Paid Through: ${formattedDuesPaidDate}

+
` + : ''; + + const welcomeEmailResult = await sendEmail({ + to: email, + subject: `Welcome to Monaco USA, ${memberFirstName}!`, + html: ` + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Welcome to Monaco USA!

+

Dear ${memberFirstName},

+

We are thrilled to welcome you to the Monaco USA community! Your membership account has been created and you are now part of our growing network of Americans in Monaco.

+ ${duesSection} +
+

To get started:

+
    +
  1. You will receive a separate email shortly to set up your password
  2. +
  3. Log in to your member portal at ${baseUrl}
  4. +
  5. Complete your profile with your details
  6. +
  7. Explore upcoming events and connect with fellow members
  8. +
+
+

If you have any questions, please don't hesitate to reach out to our board members.

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +`, + recipientId: authData.user.id, + recipientName: `${firstName} ${lastName}`.trim() || 'New Member', + emailType: 'welcome', + sentBy: member.id + }); + + if (!welcomeEmailResult.success) { + console.error('Welcome email error:', welcomeEmailResult.error); + // Non-critical - member still created + } + + // Send password reset email so user can set their own password + const { error: resetError } = await locals.supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${url.origin}/auth/reset-password` + }); + + if (resetError) { + console.error('Password reset email error:', resetError); + // Member created but email failed - not critical + } + + return { + success: `Invitation sent to ${email}! They will receive a welcome email and instructions to set up their password.` + }; + } +}; diff --git a/src/routes/(app)/admin/members/+page.svelte b/src/routes/(app)/admin/members/+page.svelte new file mode 100644 index 0000000..74ff84d --- /dev/null +++ b/src/routes/(app)/admin/members/+page.svelte @@ -0,0 +1,596 @@ + + + + User Management | Monaco USA Admin + + +
+
+
+

User Management

+

Manage member accounts, roles, and statuses

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + + +
+
+
+
+ +
+
+

{stats.total}

+

Total Users

+
+
+
+
+
+
+ +
+
+

{stats.admins}

+

Admins

+
+
+
+
+
+
+ +
+
+

{stats.board}

+

Board Members

+
+
+
+
+
+
+ +
+
+

{stats.members}

+

Members

+
+
+
+
+ + +
+
+
+ + { + searchQuery = e.currentTarget.value; + handleSearch(e.currentTarget.value); + }} + class="h-10 pl-9" + /> +
+ + + + +
+
+ + +
+ {#if members.length === 0} +
+ +

No users found

+

Try adjusting your search or filters.

+
+ {:else} +
+ + + + + + + + + + + + + {#each members as member} + {@const roleInfo = getRoleInfo(member.role)} + {@const statusInfo = getStatusInfo(member.status_name)} + + + + + + + + + {/each} + +
UserContactRoleStatusJoinedActions
+
+ {#if member.avatar_url} + + {:else} +
+ {member.first_name?.[0]}{member.last_name?.[0]} +
+ {/if} +
+
+

+ {member.first_name} {member.last_name} +

+ {#if member.nationality && member.nationality.length > 0} +
+ {#each member.nationality as code} + + {/each} +
+ {/if} +
+

{member.member_id}

+
+
+
+
+
+ + {member.email} +
+ {#if member.phone} +
+ + {member.phone} +
+ {/if} +
+
+
{ + return async ({ update }) => { + await invalidateAll(); + await update(); + }; + }} + class="inline" + > + + +
+
+
{ + return async ({ update }) => { + await invalidateAll(); + await update(); + }; + }} + class="inline" + > + + +
+
+ {formatDate(member.member_since)} + + +
+
+ {/if} +
+
+ + +{#if showDeleteConfirm && memberToDelete} +
+
+
+ +

Delete Member

+
+ +

+ Are you sure you want to delete {memberToDelete.first_name} {memberToDelete.last_name} + ({memberToDelete.member_id})? This action cannot be undone. +

+ +

+ This will permanently delete their account, payment history, and all associated data. +

+ +
+ +
{ + return async ({ update, result }) => { + if (result.type === 'success') { + showDeleteConfirm = false; + memberToDelete = null; + await invalidateAll(); + } + await update(); + }; + }} + class="flex-1" + > + + +
+
+
+
+{/if} + + +{#if showInviteModal} +
+
+
+ +

Invite New Member

+
+ +

+ Send an invitation email to a new member. They will receive instructions to set up their account. +

+ +
{ + inviteLoading = true; + return async ({ update, result }) => { + inviteLoading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + class="space-y-4" + > +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + inviteDuesPaidDate = e.currentTarget.value} + disabled={inviteLoading} + class="h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500" + /> +
+
+ +

+ * Required field. Other fields are optional - the member can update their profile after joining. +

+ +
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/admin/settings/+page.server.ts b/src/routes/(app)/admin/settings/+page.server.ts new file mode 100644 index 0000000..47105ab --- /dev/null +++ b/src/routes/(app)/admin/settings/+page.server.ts @@ -0,0 +1,709 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email'; +import { testS3Connection, clearS3ClientCache } from '$lib/server/storage'; +import * as poste from '$lib/server/poste'; + +export const load: PageServerLoad = async ({ locals }) => { + // Load all configurable data + const [ + { data: membershipStatuses }, + { data: membershipTypes }, + { data: eventTypes }, + { data: documentCategories }, + { data: appSettings }, + { data: emailTemplates } + ] = await Promise.all([ + locals.supabase.from('membership_statuses').select('*').order('sort_order', { ascending: true }), + locals.supabase.from('membership_types').select('*').order('sort_order', { ascending: true }), + locals.supabase.from('event_types').select('*').order('sort_order', { ascending: true }), + locals.supabase.from('document_categories').select('*').order('sort_order', { ascending: true }), + locals.supabase.from('app_settings').select('*'), + locals.supabase.from('email_templates').select('template_key, template_name, category').eq('is_active', true).order('category').order('template_name') + ]); + + // Convert settings to object by category + const settings: Record> = {}; + for (const setting of appSettings || []) { + if (!settings[setting.category]) { + settings[setting.category] = {}; + } + settings[setting.category][setting.setting_key] = setting.setting_value; + } + + return { + membershipStatuses: membershipStatuses || [], + membershipTypes: membershipTypes || [], + eventTypes: eventTypes || [], + documentCategories: documentCategories || [], + settings, + emailTemplates: emailTemplates || [] + }; +}; + +export const actions: Actions = { + // Membership Status actions + createStatus: async ({ request, locals }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const displayName = formData.get('display_name') as string; + const color = formData.get('color') as string; + const description = formData.get('description') as string; + + if (!name || !displayName) { + return fail(400, { error: 'Name and display name are required' }); + } + + const { error } = await locals.supabase.from('membership_statuses').insert({ + name: name.toLowerCase().replace(/\s+/g, '_'), + display_name: displayName, + color: color || '#6b7280', + description: description || null + }); + + if (error) { + console.error('Create status error:', error); + return fail(500, { error: 'Failed to create status' }); + } + + return { success: 'Status created successfully!' }; + }, + + deleteStatus: async ({ request, locals }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + const { error } = await locals.supabase.from('membership_statuses').delete().eq('id', id); + + if (error) { + console.error('Delete status error:', error); + return fail(500, { error: 'Failed to delete status' }); + } + + return { success: 'Status deleted!' }; + }, + + // Membership Type actions + createType: async ({ request, locals }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const displayName = formData.get('display_name') as string; + const annualDues = formData.get('annual_dues') as string; + const description = formData.get('description') as string; + + if (!name || !displayName || !annualDues) { + return fail(400, { error: 'Name, display name, and annual dues are required' }); + } + + const { error } = await locals.supabase.from('membership_types').insert({ + name: name.toLowerCase().replace(/\s+/g, '_'), + display_name: displayName, + annual_dues: parseFloat(annualDues), + description: description || null + }); + + if (error) { + console.error('Create type error:', error); + return fail(500, { error: 'Failed to create membership type' }); + } + + return { success: 'Membership type created successfully!' }; + }, + + deleteType: async ({ request, locals }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + const { error } = await locals.supabase.from('membership_types').delete().eq('id', id); + + if (error) { + console.error('Delete type error:', error); + return fail(500, { error: 'Failed to delete membership type' }); + } + + return { success: 'Membership type deleted!' }; + }, + + // Event Type actions + createEventType: async ({ request, locals }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const displayName = formData.get('display_name') as string; + const color = formData.get('color') as string; + + if (!name || !displayName) { + return fail(400, { error: 'Name and display name are required' }); + } + + const { error } = await locals.supabase.from('event_types').insert({ + name: name.toLowerCase().replace(/\s+/g, '_'), + display_name: displayName, + color: color || '#3b82f6' + }); + + if (error) { + console.error('Create event type error:', error); + return fail(500, { error: 'Failed to create event type' }); + } + + return { success: 'Event type created successfully!' }; + }, + + deleteEventType: async ({ request, locals }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + const { error } = await locals.supabase.from('event_types').delete().eq('id', id); + + if (error) { + console.error('Delete event type error:', error); + return fail(500, { error: 'Failed to delete event type' }); + } + + return { success: 'Event type deleted!' }; + }, + + // Document Category actions + createCategory: async ({ request, locals }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + const displayName = formData.get('display_name') as string; + const description = formData.get('description') as string; + + if (!name || !displayName) { + return fail(400, { error: 'Name and display name are required' }); + } + + const { error } = await locals.supabase.from('document_categories').insert({ + name: name.toLowerCase().replace(/\s+/g, '_'), + display_name: displayName, + description: description || null + }); + + if (error) { + console.error('Create category error:', error); + return fail(500, { error: 'Failed to create document category' }); + } + + return { success: 'Document category created successfully!' }; + }, + + deleteCategory: async ({ request, locals }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + + const { error } = await locals.supabase.from('document_categories').delete().eq('id', id); + + if (error) { + console.error('Delete category error:', error); + return fail(500, { error: 'Failed to delete document category' }); + } + + return { success: 'Document category deleted!' }; + }, + + // Update app settings + updateSettings: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + const formData = await request.formData(); + const category = formData.get('category') as string; + + // Get existing boolean settings for this category to handle unchecked checkboxes + const { data: existingSettings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_type') + .eq('category', category); + + const existingBooleanKeys = new Set( + (existingSettings || []) + .filter(s => s.setting_type === 'boolean') + .map(s => s.setting_key) + ); + + // Get all settings from form data + const settingsToUpdate: Array<{ key: string; value: any; type: string }> = []; + const processedKeys = new Set(); + + for (const [key, value] of formData.entries()) { + if (key !== 'category' && key.startsWith('setting_')) { + const settingKey = key.replace('setting_', ''); + processedKeys.add(settingKey); + // Handle checkbox values - they come as 'on' when checked + const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey); + settingsToUpdate.push({ + key: settingKey, + value: isCheckbox ? (value === 'on' || value === 'true') : value, + type: isCheckbox ? 'boolean' : 'text' + }); + } + } + + // Handle unchecked checkboxes - they don't send any value + // For any existing boolean setting NOT in the form data, set to false + for (const booleanKey of existingBooleanKeys) { + if (!processedKeys.has(booleanKey)) { + settingsToUpdate.push({ + key: booleanKey, + value: false, + type: 'boolean' + }); + } + } + + // Update or insert each setting + for (const setting of settingsToUpdate) { + // Try to update first + const { data: existing } = await locals.supabase + .from('app_settings') + .select('id') + .eq('category', category) + .eq('setting_key', setting.key) + .single(); + + if (existing) { + // Update existing + await locals.supabase + .from('app_settings') + .update({ + setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value), + updated_at: new Date().toISOString(), + updated_by: member?.id + }) + .eq('category', category) + .eq('setting_key', setting.key); + } else { + // Insert new setting + await locals.supabase + .from('app_settings') + .insert({ + category, + setting_key: setting.key, + setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value), + setting_type: setting.type, + display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), + updated_at: new Date().toISOString(), + updated_by: member?.id + }); + } + } + + // Clear caches if storage settings were updated + if (category === 'storage') { + clearS3ClientCache(); + } + + return { success: 'Settings updated successfully!' }; + }, + + // Test SMTP connection + testSmtp: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + const formData = await request.formData(); + const testEmail = formData.get('test_email') as string; + + // Use the member's email if no test email is provided + const recipientEmail = testEmail || member?.email; + + if (!recipientEmail) { + return fail(400, { error: 'No email address provided for test' }); + } + + // Test SMTP connection and send a test email + const result = await testSmtpConnection(recipientEmail, member?.id); + + if (!result.success) { + return fail(400, { error: result.error || 'SMTP test failed' }); + } + + return { + success: `Test email sent successfully to ${recipientEmail}! Check your inbox.` + }; + }, + + // Test S3/MinIO connection + testS3: async () => { + // Clear cache to ensure fresh settings are used + clearS3ClientCache(); + + // Test S3 connection using the actual client + const result = await testS3Connection(); + + if (!result.success) { + return fail(400, { error: result.error || 'S3 connection test failed' }); + } + + return { + success: 'S3/MinIO connection successful! Bucket is accessible.' + }; + }, + + // Test email template + testEmailTemplate: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member?.email) { + return fail(400, { error: 'No email address found for your account' }); + } + + const formData = await request.formData(); + const templateKey = formData.get('template_key') as string; + + if (!templateKey) { + return fail(400, { error: 'Template key is required' }); + } + + // Get full member details including member_id + const { data: fullMember } = await locals.supabase + .from('members') + .select('member_id') + .eq('id', member.id) + .single(); + + const memberId = fullMember?.member_id || 'MUSA-0001'; + + // Create sample variables for each template type + const sampleVariables: Record> = { + // Welcome/Auth templates + welcome: { + first_name: member.first_name || 'Test', + last_name: member.last_name || 'User', + member_id: memberId, + portal_url: url.origin + }, + password_reset: { + first_name: member.first_name || 'Test', + reset_link: `${url.origin}/reset-password?token=sample-token` + }, + email_verification: { + first_name: member.first_name || 'Test', + verification_link: `${url.origin}/verify?token=sample-token` + }, + // Event templates + rsvp_confirmation: { + first_name: member.first_name || 'Test', + event_title: 'Monaco USA Annual Gala', + event_date: 'Saturday, March 15, 2026', + event_time: '7:00 PM', + event_location: 'Hotel Hermitage, Monaco', + guest_count: '2', + portal_url: `${url.origin}/events` + }, + waitlist_promotion: { + first_name: member.first_name || 'Test', + event_title: 'Monaco USA Annual Gala', + event_date: 'Saturday, March 15, 2026', + event_location: 'Hotel Hermitage, Monaco', + portal_url: `${url.origin}/events` + }, + event_reminder_24hr: { + first_name: member.first_name || 'Test', + event_title: 'Monaco USA Monthly Meetup', + event_date: 'Tomorrow, January 25, 2026', + event_time: '6:30 PM', + event_location: 'Stars\'n\'Bars, Monaco', + guest_count: '1', + portal_url: `${url.origin}/events/sample-event-id` + }, + // Payment/Dues templates + payment_received: { + first_name: member.first_name || 'Test', + amount: '€50.00', + payment_date: 'January 24, 2026', + payment_method: 'Bank Transfer', + new_due_date: 'January 24, 2027', + member_id: memberId + }, + dues_reminder_30: { + first_name: member.first_name || 'Test', + due_date: 'February 24, 2026', + amount: '€50.00', + member_id: memberId, + account_holder: 'Monaco USA Association', + bank_name: 'CMB Monaco', + iban: 'MC00 0000 0000 0000 0000 0000 000', + portal_url: `${url.origin}/payments` + }, + dues_reminder_7: { + first_name: member.first_name || 'Test', + due_date: 'January 31, 2026', + amount: '€50.00', + member_id: memberId, + iban: 'MC00 0000 0000 0000 0000 0000 000', + portal_url: `${url.origin}/payments` + }, + dues_reminder_1: { + first_name: member.first_name || 'Test', + due_date: 'January 25, 2026', + amount: '€50.00', + member_id: memberId, + iban: 'MC00 0000 0000 0000 0000 0000 000', + portal_url: `${url.origin}/payments` + }, + dues_overdue: { + first_name: member.first_name || 'Test', + due_date: 'January 15, 2026', + amount: '€50.00', + days_overdue: '9', + grace_days_remaining: '21', + member_id: memberId, + account_holder: 'Monaco USA Association', + iban: 'MC00 0000 0000 0000 0000 0000 000', + portal_url: `${url.origin}/payments` + }, + dues_grace_warning: { + first_name: member.first_name || 'Test', + due_date: 'December 24, 2025', + amount: '€50.00', + days_overdue: '31', + grace_days_remaining: '7', + grace_end_date: 'February 1, 2026', + member_id: memberId, + iban: 'MC00 0000 0000 0000 0000 0000 000', + portal_url: `${url.origin}/payments` + }, + dues_inactive_notice: { + first_name: member.first_name || 'Test', + amount: '€50.00', + member_id: memberId, + account_holder: 'Monaco USA Association', + iban: 'MC00 0000 0000 0000 0000 0000 000', + portal_url: `${url.origin}/payments` + } + }; + + // Get variables for this template, or use defaults + const variables = sampleVariables[templateKey] || { + first_name: member.first_name || 'Test', + last_name: member.last_name || 'User', + portal_url: url.origin + }; + + // Send test email + const result = await sendTemplatedEmail(templateKey, member.email, variables, { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + baseUrl: url.origin + }); + + if (!result.success) { + return fail(400, { error: result.error || 'Failed to send test email' }); + } + + return { + success: `Test email "${templateKey}" sent to ${member.email}` + }; + }, + + // ============================================ + // Poste Mail Server Actions + // ============================================ + + testPoste: async ({ locals }) => { + // Get Poste settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'poste'); + + if (!settings || settings.length === 0) { + return fail(400, { error: 'Poste mail server not configured. Please save settings first.' }); + } + + const config: Record = {}; + for (const s of settings) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value as string; + } + + if (!config.poste_api_host || !config.poste_admin_email || !config.poste_admin_password) { + return fail(400, { error: 'Poste configuration incomplete. Host, admin email, and password are required.' }); + } + + const result = await poste.testConnection({ + host: config.poste_api_host, + adminEmail: config.poste_admin_email, + adminPassword: config.poste_admin_password + }); + + if (!result.success) { + return fail(400, { error: result.error || 'Connection test failed' }); + } + + return { success: 'Connection to Poste mail server successful!' }; + }, + + listMailboxes: async ({ locals }) => { + // Get Poste settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'poste'); + + if (!settings || settings.length === 0) { + return fail(400, { error: 'Poste not configured' }); + } + + const config: Record = {}; + for (const s of settings) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value as string; + } + + const result = await poste.listMailboxes({ + host: config.poste_api_host, + adminEmail: config.poste_admin_email, + adminPassword: config.poste_admin_password + }); + + if (!result.success) { + return fail(400, { error: result.error }); + } + + return { mailboxes: result.mailboxes }; + }, + + createMailbox: async ({ request, locals }) => { + const formData = await request.formData(); + const emailPrefix = formData.get('email_prefix') as string; + const displayName = formData.get('display_name') as string; + const password = formData.get('password') as string; + + if (!emailPrefix || !displayName) { + return fail(400, { error: 'Email prefix and display name are required' }); + } + + // Get Poste settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'poste'); + + const config: Record = {}; + for (const s of settings || []) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value as string; + } + + const domain = config.poste_domain || 'monacousa.org'; + const fullEmail = `${emailPrefix}@${domain}`; + const actualPassword = password || poste.generatePassword(); + + const result = await poste.createMailbox( + { + host: config.poste_api_host, + adminEmail: config.poste_admin_email, + adminPassword: config.poste_admin_password + }, + { + email: fullEmail, + name: displayName, + password: actualPassword + } + ); + + if (!result.success) { + return fail(400, { error: result.error }); + } + + return { + success: `Mailbox ${fullEmail} created successfully!`, + generatedPassword: password ? undefined : actualPassword + }; + }, + + updateMailbox: async ({ request, locals }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + const displayName = formData.get('display_name') as string; + const newPassword = formData.get('new_password') as string; + const disabled = formData.get('disabled') === 'true'; + + if (!email) { + return fail(400, { error: 'Email is required' }); + } + + // Get Poste settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'poste'); + + const config: Record = {}; + for (const s of settings || []) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value as string; + } + + const updates: { name?: string; password?: string; disabled?: boolean } = {}; + if (displayName) updates.name = displayName; + if (newPassword) updates.password = newPassword; + updates.disabled = disabled; + + const result = await poste.updateMailbox( + { + host: config.poste_api_host, + adminEmail: config.poste_admin_email, + adminPassword: config.poste_admin_password + }, + email, + updates + ); + + if (!result.success) { + return fail(400, { error: result.error }); + } + + return { success: `Mailbox ${email} updated successfully!` }; + }, + + deleteMailbox: async ({ request, locals }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + + if (!email) { + return fail(400, { error: 'Email is required' }); + } + + // Get Poste settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'poste'); + + const config: Record = {}; + for (const s of settings || []) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + config[s.setting_key] = value as string; + } + + const result = await poste.deleteMailbox( + { + host: config.poste_api_host, + adminEmail: config.poste_admin_email, + adminPassword: config.poste_admin_password + }, + email + ); + + if (!result.success) { + return fail(400, { error: result.error }); + } + + return { success: `Mailbox ${email} deleted successfully!` }; + } +}; diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte new file mode 100644 index 0000000..de8a85e --- /dev/null +++ b/src/routes/(app)/admin/settings/+page.svelte @@ -0,0 +1,1703 @@ + + + + Settings | Monaco USA Admin + + +
+
+

Settings

+

Configure membership types, dues, events, and more

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + + +
+ +
+ + + {#if activeTab === 'membership'} +
+ +
+
+
+

Membership Statuses

+

Define the different states a membership can be in

+
+ +
+ +
+ + + + + + + + + + + + {#each membershipStatuses as status} + + + + + + + + {/each} + +
NameDisplayColorDefaultActions
{status.name}{status.display_name} + + + {status.is_default ? 'Yes' : ''} + +
+ + +
+
+
+
+ + +
+
+
+

Membership Types

+

Define different membership tiers with pricing

+
+ +
+ +
+ + + + + + + + + + + + {#each membershipTypes as type} + + + + + + + + {/each} + +
NameDisplayAnnual DuesDefaultActions
{type.name}{type.display_name}€{type.annual_dues.toFixed(2)} + {type.is_default ? 'Yes' : ''} + +
+ + +
+
+
+
+
+ {/if} + + + {#if activeTab === 'dues'} +
+

Payment Settings

+

Configure bank details and payment instructions shown to members

+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ {/if} + + + {#if activeTab === 'events'} +
+
+
+

Event Types

+

Define the different types of events

+
+ +
+ +
+ + + + + + + + + + + {#each eventTypes as eventType} + + + + + + + {/each} + +
NameDisplayColorActions
{eventType.name}{eventType.display_name} + + {eventType.display_name} + + +
+ + +
+
+
+
+ {/if} + + + {#if activeTab === 'documents'} +
+
+
+

Document Categories

+

Define categories for organizing documents

+
+ +
+ +
+ + + + + + + + + + + {#each documentCategories as category} + + + + + + + {/each} + +
NameDisplayDescriptionActions
{category.name}{category.display_name}{category.description || '-'} +
+ + +
+
+
+
+ {/if} + + + {#if activeTab === 'email'} +
+
+

SMTP Email Configuration

+

Configure outgoing email settings for notifications and reminders

+
+ + {#if smtpTestResult} +
+ {#if smtpTestResult.success} + + {:else} + + {/if} + {smtpTestResult.message} +
+ {/if} + +
+ + + +
+ +
+ +

When enabled, the system will send email notifications

+
+
+ + +
+

Server Settings

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

Authentication

+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+

Sender Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ +
{ + testingSmtp = true; + smtpTestResult = null; + return async ({ result }) => { + testingSmtp = false; + if (result.type === 'success' && result.data?.success) { + smtpTestResult = { success: true, message: result.data.success }; + } else if (result.type === 'failure' && result.data?.error) { + smtpTestResult = { success: false, message: result.data.error }; + } else { + smtpTestResult = { success: false, message: 'Test failed. Please check your settings.' }; + } + }; + }} + > + +
+
+ + +
+
+
+

Test Email Templates

+

Send test versions of each email template to yourself

+
+ + + Edit Templates + +
+ + {#if templateTestResult} +
+ {#if templateTestResult.success} + + {:else} + + {/if} + {templateTestResult.message} +
+ {/if} + +
+ {#each Object.entries(templatesByCategory) as [category, templates]} +
+

+ {category === 'events' ? 'Events' : category === 'payment' ? 'Payments & Dues' : category === 'auth' ? 'Authentication' : category === 'member' ? 'Membership' : 'Other'} +

+
+ {#each templates as template} +
{ + testingTemplate = template.template_key; + templateTestResult = null; + return async ({ result }) => { + testingTemplate = null; + if (result.type === 'success' && result.data?.success) { + templateTestResult = { success: true, message: result.data.success }; + } else if (result.type === 'failure' && result.data?.error) { + templateTestResult = { success: false, message: result.data.error }; + } else { + templateTestResult = { success: false, message: 'Failed to send test email' }; + } + }; + }} + > + + +
+ {/each} +
+
+ {/each} + + {#if !emailTemplates || emailTemplates.length === 0} +

No email templates found in the database.

+ {/if} +
+
+ {/if} + + + {#if activeTab === 'poste'} +
+ +
+
+

Poste Mail Server Configuration

+

Configure connection to your Poste.io mail server to manage @monacousa.org email accounts

+
+ + {#if posteTestResult} +
+ {#if posteTestResult.success} + + {:else} + + {/if} + {posteTestResult.message} +
+ {/if} + +
+ + +
+
+ + +

The hostname of your Poste mail server

+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+ +
{ + testingPoste = true; + posteTestResult = null; + return async ({ result }) => { + testingPoste = false; + if (result.type === 'success' && result.data?.success) { + posteTestResult = { success: true, message: result.data.success }; + await loadMailboxes(); + } else if (result.type === 'failure' && result.data?.error) { + posteTestResult = { success: false, message: result.data.error }; + } else { + posteTestResult = { success: false, message: 'Connection test failed.' }; + } + }; + }} + > + +
+
+ + +
+
+
+

Email Accounts

+

Manage @monacousa.org email accounts for board members

+
+
+ + +
+
+ + {#if generatedPassword} +
+
+
+

Mailbox created! Generated password:

+ {generatedPassword} +
+ +
+

Save this password now - it cannot be retrieved later.

+
+ {/if} + + {#if loadingMailboxes} +
+ +
+ {:else if mailboxes.length === 0} +
+ +

No mailboxes loaded. Click "Test Connection" to load mailboxes.

+
+ {:else} +
+ + + + + + + + + + + {#each mailboxes as mailbox} + + + + + + + {/each} + +
EmailDisplay NameStatusActions
+ {mailbox.address} + {#if mailbox.super_admin} + Admin + {/if} + {mailbox.name || '-'} + {#if mailbox.disabled} + + + Disabled + + {:else} + + + Active + + {/if} + +
{ + if (!confirm(`Delete mailbox ${mailbox.address}? This cannot be undone.`)) { + return async () => {}; + } + return async ({ result }) => { + if (result.type === 'success') { + await loadMailboxes(); + } + }; + }} + > + + +
+
+
+ {/if} +
+
+ {/if} + + + {#if activeTab === 'storage'} +
+
+

S3/MinIO Storage Configuration

+

Configure external S3-compatible storage for documents and files

+
+ + {#if s3TestResult} +
+ {#if s3TestResult.success} + + {:else} + + {/if} + {s3TestResult.message} +
+ {/if} + +
+ + + +
+ +
+ +

Use external S3/MinIO instead of Supabase Storage

+
+
+ + +
+

Connection Settings

+
+
+ + +

For MinIO, use your MinIO server URL. For AWS S3, leave empty or use regional endpoint.

+
+
+ + +
+
+ + +

Use us-east-1 for MinIO

+
+
+
+ + +
+

Credentials

+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+

Options

+
+
+ + +
+
+ +
+ +

Required for MinIO and some S3-compatible services

+
+
+
+
+ +
+ +
+
+ +
{ + testingS3 = true; + s3TestResult = null; + return async ({ result }) => { + testingS3 = false; + if (result.type === 'success' && result.data?.success) { + s3TestResult = { success: true, message: result.data.success }; + } else if (result.type === 'failure' && result.data?.error) { + s3TestResult = { success: false, message: result.data.error }; + } else { + s3TestResult = { success: false, message: 'S3 test failed. Please check your settings.' }; + } + }; + }} + > + +
+
+ {/if} + + + {#if activeTab === 'system'} +
+ +
+

Maintenance Mode

+
+ + +
+ +
+ +

When enabled, non-admin users will see the maintenance message

+
+
+ +
+ + +
+ + +
+
+ + +
+

Session & Security

+
+ + +
+ + +

Default is 168 hours (7 days). Max is 720 hours (30 days).

+
+ + +
+
+ + +
+

File Uploads

+
+ + +
+ + +
+ +
+ +

+ PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, JPEG, PNG, WEBP +

+

File type restrictions are enforced on upload

+
+ + +
+
+ + +
+

Public Access

+
+ + +
+
+ +
+ +

Allow non-members to view events marked as public

+
+
+ +
+ +
+ +

Allow non-members to RSVP to public events

+
+
+
+ + +
+
+
+ {/if} +
+ + +{#if showAddStatusModal} +
+
+
+

Add Membership Status

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + if (result.type === 'success') { + showAddStatusModal = false; + await invalidateAll(); + } + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} + + +{#if showAddTypeModal} +
+
+
+

Add Membership Type

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + if (result.type === 'success') { + showAddTypeModal = false; + await invalidateAll(); + } + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} + + +{#if showAddEventTypeModal} +
+
+
+

Add Event Type

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + if (result.type === 'success') { + showAddEventTypeModal = false; + await invalidateAll(); + } + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} + + +{#if showAddCategoryModal} +
+
+
+

Add Document Category

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + if (result.type === 'success') { + showAddCategoryModal = false; + await invalidateAll(); + } + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} + + +{#if showCreateMailboxModal} +
+
+
+
+
+ +
+

Create Email Account

+
+ +
+ +
{ + creatingMailbox = true; + return async ({ result }) => { + creatingMailbox = false; + if (result.type === 'success') { + showCreateMailboxModal = false; + if (result.data?.generatedPassword) { + generatedPassword = result.data.generatedPassword; + } + await loadMailboxes(); + } + }; + }} + class="space-y-4" + > +
+ +
+ + + @{getSetting('poste', 'poste_domain', 'monacousa.org')} + +
+

Letters, numbers, dots, dashes, and underscores only

+
+ +
+ + +
+ +
+ +
+ + +
+

If left empty, a secure password will be generated

+
+ +
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/board/+layout.server.ts b/src/routes/(app)/board/+layout.server.ts new file mode 100644 index 0000000..41f7dc7 --- /dev/null +++ b/src/routes/(app)/board/+layout.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ parent }) => { + const { member } = await parent(); + + // Only board and admin can access board pages + if (member?.role !== 'board' && member?.role !== 'admin') { + throw redirect(303, '/dashboard'); + } + + return {}; +}; diff --git a/src/routes/(app)/board/documents/+page.server.ts b/src/routes/(app)/board/documents/+page.server.ts new file mode 100644 index 0000000..f1bb830 --- /dev/null +++ b/src/routes/(app)/board/documents/+page.server.ts @@ -0,0 +1,480 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { uploadDocument, deleteDocument, getSignedUrl, isS3Enabled, getActiveDocumentUrl } from '$lib/server/storage'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const folderId = url.searchParams.get('folder'); + + // Load folders in current directory + let foldersQuery = locals.supabase + .from('document_folders') + .select(` + *, + creator:members!document_folders_created_by_fkey(first_name, last_name) + `) + .order('name', { ascending: true }); + + if (folderId) { + foldersQuery = foldersQuery.eq('parent_id', folderId); + } else { + foldersQuery = foldersQuery.is('parent_id', null); + } + + const { data: folders } = await foldersQuery; + + // Load documents in current folder + let documentsQuery = locals.supabase + .from('documents') + .select(` + *, + category:document_categories(id, name, display_name, icon), + uploader:members!documents_uploaded_by_fkey(first_name, last_name) + `) + .order('created_at', { ascending: false }); + + if (folderId) { + documentsQuery = documentsQuery.eq('folder_id', folderId); + } else { + documentsQuery = documentsQuery.is('folder_id', null); + } + + const { data: documents } = await documentsQuery; + + // Load current folder details for breadcrumbs + let currentFolder = null; + let breadcrumbs: { id: string | null; name: string }[] = [{ id: null, name: 'Documents' }]; + + if (folderId) { + const { data: folder } = await locals.supabase + .from('document_folders') + .select('*') + .eq('id', folderId) + .single(); + + currentFolder = folder; + + // Build breadcrumb path + if (folder?.path) { + const pathParts = folder.path.split('/'); + let currentPath = ''; + + // Get all ancestor folders + for (let i = 0; i < pathParts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${pathParts[i]}` : pathParts[i]; + const { data: ancestorFolder } = await locals.supabase + .from('document_folders') + .select('id, name') + .eq('path', currentPath) + .single(); + + if (ancestorFolder) { + breadcrumbs.push({ id: ancestorFolder.id, name: ancestorFolder.name }); + } + } + breadcrumbs.push({ id: folder.id, name: folder.name }); + } + } + + // Load categories + const { data: categories } = await locals.supabase + .from('document_categories') + .select('*') + .eq('is_active', true) + .order('sort_order', { ascending: true }); + + // Resolve active URL for each document based on current storage settings + const s3Enabled = await isS3Enabled(); + const documentsWithActiveUrl = (documents || []).map((doc: any) => ({ + ...doc, + // Compute active URL based on storage setting + active_url: s3Enabled + ? (doc.file_url_s3 || doc.file_path) + : (doc.file_url_local || doc.file_path) + })); + + return { + documents: documentsWithActiveUrl, + folders: folders || [], + categories: categories || [], + currentFolder, + currentFolderId: folderId, + breadcrumbs + }; +}; + +export const actions: Actions = { + createFolder: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to create folders' }); + } + + const formData = await request.formData(); + const name = (formData.get('name') as string)?.trim(); + const parentId = formData.get('parent_id') as string | null; + const visibility = (formData.get('visibility') as string) || 'members'; + + if (!name) { + return fail(400, { error: 'Folder name is required' }); + } + + // Validate folder name + if (name.includes('/') || name.includes('\\')) { + return fail(400, { error: 'Folder name cannot contain slashes' }); + } + + const { error } = await locals.supabase.from('document_folders').insert({ + name, + parent_id: parentId || null, + visibility, + created_by: member.id + }); + + if (error) { + console.error('Create folder error:', error); + if (error.code === '23505') { + return fail(400, { error: 'A folder with this name already exists here' }); + } + return fail(500, { error: 'Failed to create folder' }); + } + + return { success: 'Folder created!' }; + }, + + renameFolder: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to rename folders' }); + } + + const formData = await request.formData(); + const folderId = formData.get('folder_id') as string; + const name = (formData.get('name') as string)?.trim(); + + if (!folderId || !name) { + return fail(400, { error: 'Folder ID and name are required' }); + } + + if (name.includes('/') || name.includes('\\')) { + return fail(400, { error: 'Folder name cannot contain slashes' }); + } + + const { error } = await locals.supabase + .from('document_folders') + .update({ name }) + .eq('id', folderId); + + if (error) { + console.error('Rename folder error:', error); + if (error.code === '23505') { + return fail(400, { error: 'A folder with this name already exists here' }); + } + return fail(500, { error: 'Failed to rename folder' }); + } + + return { success: 'Folder renamed!' }; + }, + + deleteFolder: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can delete folders' }); + } + + const formData = await request.formData(); + const folderId = formData.get('folder_id') as string; + + if (!folderId) { + return fail(400, { error: 'Folder ID is required' }); + } + + // Check if folder has documents + const { data: docs } = await locals.supabase + .from('documents') + .select('id') + .eq('folder_id', folderId) + .limit(1); + + if (docs && docs.length > 0) { + return fail(400, { error: 'Cannot delete folder with documents. Move or delete documents first.' }); + } + + // Check if folder has subfolders + const { data: subfolders } = await locals.supabase + .from('document_folders') + .select('id') + .eq('parent_id', folderId) + .limit(1); + + if (subfolders && subfolders.length > 0) { + return fail(400, { error: 'Cannot delete folder with subfolders. Delete subfolders first.' }); + } + + const { error } = await locals.supabase + .from('document_folders') + .delete() + .eq('id', folderId); + + if (error) { + console.error('Delete folder error:', error); + return fail(500, { error: 'Failed to delete folder' }); + } + + return { success: 'Folder deleted!' }; + }, + + moveDocument: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to move documents' }); + } + + const formData = await request.formData(); + const documentId = formData.get('document_id') as string; + const folderId = formData.get('folder_id') as string | null; + + if (!documentId) { + return fail(400, { error: 'Document ID is required' }); + } + + const { error } = await locals.supabase + .from('documents') + .update({ + folder_id: folderId || null, + updated_at: new Date().toISOString() + }) + .eq('id', documentId); + + if (error) { + console.error('Move document error:', error); + return fail(500, { error: 'Failed to move document' }); + } + + return { success: 'Document moved!' }; + }, + + upload: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to upload documents' }); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const title = formData.get('title') as string; + const description = formData.get('description') as string; + const categoryId = formData.get('category_id') as string; + const visibility = formData.get('visibility') as string; + const folderId = formData.get('folder_id') as string | null; + + // Validation + if (!file || !file.size) { + return fail(400, { error: 'Please select a file to upload' }); + } + + if (!title) { + return fail(400, { error: 'Title is required' }); + } + + // Upload using dual-storage document service (uploads to both S3 and Supabase Storage) + const uploadResult = await uploadDocument(file); + + if (!uploadResult.success) { + console.error('Upload error:', uploadResult.error); + return fail(500, { error: uploadResult.error || 'Failed to upload file. Please try again.' }); + } + + // Create document record with both URLs for storage flexibility + const { error: insertError } = await locals.supabase.from('documents').insert({ + title, + description: description || null, + category_id: categoryId || null, + folder_id: folderId || null, + // Primary URL (computed based on active storage setting) + file_path: uploadResult.publicUrl || uploadResult.path, + // Dual storage URLs + file_url_local: uploadResult.localUrl || null, + file_url_s3: uploadResult.s3Url || null, + storage_path: uploadResult.path, + // File metadata + file_name: file.name, + file_size: file.size, + mime_type: file.type, + visibility: visibility || 'members', + uploaded_by: member.id + }); + + if (insertError) { + // Try to clean up uploaded files from both backends + if (uploadResult.path) { + await deleteDocument(uploadResult.path); + } + console.error('Insert error:', insertError); + return fail(500, { error: 'Failed to save document. Please try again.' }); + } + + return { success: 'Document uploaded successfully!' }; + }, + + delete: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can delete documents' }); + } + + const formData = await request.formData(); + const documentId = formData.get('document_id') as string; + + if (!documentId) { + return fail(400, { error: 'Document ID is required' }); + } + + // Get document to find storage path + const { data: doc } = await locals.supabase + .from('documents') + .select('storage_path, file_path') + .eq('id', documentId) + .single(); + + // Delete from database + const { error: deleteError } = await locals.supabase + .from('documents') + .delete() + .eq('id', documentId); + + if (deleteError) { + console.error('Delete error:', deleteError); + return fail(500, { error: 'Failed to delete document' }); + } + + // Delete from ALL storage backends (both S3 and Supabase Storage) + if (doc?.storage_path) { + // Use the storage_path directly + await deleteDocument(doc.storage_path); + } else if (doc?.file_path) { + // Fallback for older documents without storage_path + try { + let storagePath = doc.file_path; + + // If it's a URL, extract the path + if (doc.file_path.startsWith('http')) { + const url = new URL(doc.file_path); + // Handle Supabase storage URL format + const supabaseMatch = url.pathname.match(/\/storage\/v1\/object\/public\/documents\/(.+)/); + if (supabaseMatch) { + storagePath = supabaseMatch[1]; + } else { + // Handle S3 URL format + const s3Match = url.pathname.match(/\/documents\/(.+)/); + if (s3Match) { + storagePath = s3Match[1]; + } + } + } + + await deleteDocument(storagePath); + } catch (e) { + console.error('Failed to delete file from storage:', e); + } + } + + return { success: 'Document deleted successfully!' }; + }, + + updateVisibility: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to update documents' }); + } + + const formData = await request.formData(); + const documentId = formData.get('document_id') as string; + const visibility = formData.get('visibility') as string; + + if (!documentId || !visibility) { + return fail(400, { error: 'Document ID and visibility are required' }); + } + + const { error: updateError } = await locals.supabase + .from('documents') + .update({ + visibility, + updated_at: new Date().toISOString() + }) + .eq('id', documentId); + + if (updateError) { + console.error('Update error:', updateError); + return fail(500, { error: 'Failed to update document' }); + } + + return { success: 'Visibility updated!' }; + }, + + getPreviewUrl: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const documentId = formData.get('document_id') as string; + + if (!documentId) { + return fail(400, { error: 'Document ID is required' }); + } + + // Get document with all URL columns + const { data: doc } = await locals.supabase + .from('documents') + .select('*') + .eq('id', documentId) + .single(); + + if (!doc) { + return fail(404, { error: 'Document not found' }); + } + + // Check visibility permissions + const canAccess = + doc.visibility === 'public' || + (doc.visibility === 'members') || + (doc.visibility === 'board' && ['board', 'admin'].includes(member.role)) || + (doc.visibility === 'admin' && member.role === 'admin'); + + if (!canAccess) { + return fail(403, { error: 'You do not have permission to view this document' }); + } + + // Get the active URL based on current storage settings + const activeUrl = await getActiveDocumentUrl({ + file_url_s3: doc.file_url_s3, + file_url_local: doc.file_url_local, + file_path: doc.file_path + }); + + // If we have a public URL, return it + if (activeUrl && activeUrl.startsWith('http')) { + return { previewUrl: activeUrl }; + } + + // Generate signed URL for private storage using storage_path or file_path + const storagePath = doc.storage_path || doc.file_path; + const { url, error } = await getSignedUrl('documents', storagePath, 3600); + + if (error || !url) { + return fail(500, { error: 'Failed to generate preview URL' }); + } + + return { previewUrl: url }; + } +}; diff --git a/src/routes/(app)/board/documents/+page.svelte b/src/routes/(app)/board/documents/+page.svelte new file mode 100644 index 0000000..2eb1750 --- /dev/null +++ b/src/routes/(app)/board/documents/+page.svelte @@ -0,0 +1,719 @@ + + + + Document Management | Monaco USA + + +
+ +
+
+

Document Management

+

Upload and manage association documents

+
+ +
+ {#if canEdit} + + {/if} + + +
+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + + + {#if breadcrumbs && breadcrumbs.length > 0} +
+ +
+ {/if} + + +
+
+
+ + +
+ + +
+
+ + + {#if filteredFolders.length > 0} +
+

Folders

+
+ {#each filteredFolders as folder} + + {/each} +
+
+ {/if} + + +
+ {#if filteredFolders.length === 0 && filteredDocuments.length === 0} +
+ +

+ {currentFolderId ? 'This folder is empty' : 'No documents found'} +

+

+ {searchQuery || selectedCategory + ? 'Try adjusting your search or filters.' + : currentFolderId + ? 'Upload documents or create subfolders to get started.' + : 'Upload your first document to get started.'} +

+
+ {:else if filteredDocuments.length === 0} +
+ +

No documents in this location

+

+ Documents you upload here will appear in this list. +

+
+ {:else} +
+

+ Documents ({filteredDocuments.length}) +

+
+ + + + + + + + + + + + {#each filteredDocuments as doc} + {@const visInfo = getVisibilityLabel(doc.visibility)} + + + + + + + + {/each} + +
+ Document + + Category + + Visibility + + Uploaded + + Actions +
+ + + {doc.category?.display_name || '-'} + +
{ + return async ({ update }) => { + await invalidateAll(); + await update(); + }; + }} + class="inline" + > + + +
+
+
+

{formatDate(doc.created_at)}

+

+ by {doc.uploader?.first_name} {doc.uploader?.last_name} +

+
+
+
+ + + + + {#if canDelete} + + {/if} +
+
+
+
+ {/if} +
+
+ + +{#if showCreateFolderModal} + (showCreateFolderModal = false)} + /> +{/if} + + +{#if showPreviewModal && documentToPreview} + { + showPreviewModal = false; + documentToPreview = null; + }} + /> +{/if} + + +{#if showUploadModal} +
+
+
+

Upload Document

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + await invalidateAll(); + isSubmitting = false; + if (result.type === 'success') { + resetUploadForm(); + } + await update(); + }; + }} + class="space-y-4" + > + + {#if currentFolderId} + + {/if} + + +
+ + + {#if selectedFile} +
+ +
+

{selectedFile.name}

+

{formatFileSize(selectedFile.size)}

+
+ +
+ {:else} + +

+ Drag and drop or click to select +

+

+ PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, GIF (max 50MB) +

+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+{/if} + + +{#if showDeleteConfirm && documentToDelete} +
+
+
+ +

+ Delete {documentToDelete.isFolder ? 'Folder' : 'Document'} +

+
+ +

+ Are you sure you want to delete {documentToDelete.title || documentToDelete.name}? This action cannot be undone. +

+ +
+ +
{ + return async ({ update, result }) => { + if (result.type === 'success') { + showDeleteConfirm = false; + documentToDelete = null; + } + await invalidateAll(); + await update(); + }; + }} + class="flex-1" + > + + +
+
+
+
+{/if} + + +{#if showRenameFolderModal && renamingFolder} +
+
+
+

Rename Folder

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + await invalidateAll(); + isSubmitting = false; + if (result.type === 'success') { + showRenameFolderModal = false; + renamingFolder = null; + } + await update(); + }; + }} + class="space-y-4" + > + + +
+ + +
+ +
+ + +
+
+
+
+{/if} diff --git a/src/routes/(app)/board/dues/+page.server.ts b/src/routes/(app)/board/dues/+page.server.ts new file mode 100644 index 0000000..b6d6bb4 --- /dev/null +++ b/src/routes/(app)/board/dues/+page.server.ts @@ -0,0 +1,345 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { sendTemplatedEmail } from '$lib/server/email'; +import { logPaymentAction } from '$lib/server/audit'; +import { + sendBulkReminders, + sendDuesReminder, + getMembersNeedingReminder, + getDuesSettings, + type ReminderType +} from '$lib/server/dues'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const statusFilter = url.searchParams.get('status') || 'all'; + const searchQuery = url.searchParams.get('search') || ''; + const memberId = url.searchParams.get('member') || null; + + // If specific member requested, load their details + let selectedMember = null; + if (memberId) { + const { data } = await locals.supabase + .from('members_with_dues') + .select('*') + .eq('id', memberId) + .single(); + selectedMember = data; + } + + // Load all members with dues status + const { data: members } = await locals.supabase + .from('members_with_dues') + .select('*') + .order('last_name', { ascending: true }); + + // Filter by dues status + let filteredMembers = members || []; + if (statusFilter !== 'all') { + filteredMembers = filteredMembers.filter((m: any) => m.dues_status === statusFilter); + } + + // Filter by search + if (searchQuery) { + const lowerSearch = searchQuery.toLowerCase(); + filteredMembers = filteredMembers.filter( + (m: any) => + m.first_name?.toLowerCase().includes(lowerSearch) || + m.last_name?.toLowerCase().includes(lowerSearch) || + m.member_id?.toLowerCase().includes(lowerSearch) + ); + } + + // Calculate stats + const stats = { + total: members?.length || 0, + current: members?.filter((m: any) => m.dues_status === 'current').length || 0, + dueSoon: members?.filter((m: any) => m.dues_status === 'due_soon').length || 0, + overdue: members?.filter((m: any) => m.dues_status === 'overdue').length || 0, + neverPaid: members?.filter((m: any) => m.dues_status === 'never_paid').length || 0 + }; + + // Get recent payments + const { data: recentPayments } = await locals.supabase + .from('dues_payments') + .select( + ` + *, + member:members(id, first_name, last_name, member_id), + recorder:members!dues_payments_recorded_by_fkey(first_name, last_name) + ` + ) + .order('created_at', { ascending: false }) + .limit(10); + + // Get membership types for payment recording + const { data: membershipTypes } = await locals.supabase + .from('membership_types') + .select('*') + .eq('is_active', true) + .order('sort_order', { ascending: true }); + + return { + members: filteredMembers, + selectedMember, + stats, + recentPayments: recentPayments || [], + membershipTypes: membershipTypes || [], + filters: { + status: statusFilter, + search: searchQuery, + memberId + } + }; +}; + +export const actions: Actions = { + recordPayment: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to record payments' }); + } + + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + const amount = parseFloat(formData.get('amount') as string); + const paymentDate = formData.get('payment_date') as string; + const paymentMethod = formData.get('payment_method') as string; + const reference = formData.get('reference') as string; + const notes = formData.get('notes') as string; + const sendNotification = formData.get('send_notification') === 'on'; + + if (!memberId || !amount || !paymentDate) { + return fail(400, { error: 'Member, amount, and payment date are required' }); + } + + // Get member details for notification + const { data: payingMember } = await locals.supabase + .from('members') + .select('first_name, last_name, email') + .eq('id', memberId) + .single(); + + // Calculate due date (1 year from payment date) + const dueDate = new Date(paymentDate); + dueDate.setFullYear(dueDate.getFullYear() + 1); + const dueDateStr = dueDate.toISOString().split('T')[0]; + + // Record the payment + const { data: paymentData, error: paymentError } = await locals.supabase + .from('dues_payments') + .insert({ + member_id: memberId, + amount, + payment_date: paymentDate, + due_date: dueDateStr, + payment_method: paymentMethod || 'bank_transfer', + reference: reference || null, + notes: notes || null, + recorded_by: member.id + }) + .select('id') + .single(); + + if (paymentError) { + console.error('Payment recording error:', paymentError); + return fail(500, { error: 'Failed to record payment. Please try again.' }); + } + + // Log audit + await logPaymentAction( + 'record', + { id: member.id, email: member.email }, + { id: paymentData?.id, memberId, amount }, + { payment_date: paymentDate, payment_method: paymentMethod } + ); + + // Send email notification if requested + if (sendNotification && payingMember?.email) { + const formattedDate = new Date(paymentDate).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }); + const formattedDueDate = dueDate.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }); + + await sendTemplatedEmail( + 'payment_received', + payingMember.email, + { + first_name: payingMember.first_name, + amount: amount.toFixed(2), + payment_date: formattedDate, + reference: reference || 'N/A', + due_date: formattedDueDate + }, + { + recipientId: memberId, + recipientName: `${payingMember.first_name} ${payingMember.last_name}`, + sentBy: member.id, + baseUrl: url.origin + } + ); + } + + return { success: 'Payment recorded successfully!' + (sendNotification ? ' Notification sent.' : '') }; + }, + + /** + * Send individual reminder to a specific member + */ + sendReminder: async ({ request, locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to send reminders' }); + } + + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + const reminderType = formData.get('reminder_type') as ReminderType; + + if (!memberId) { + return fail(400, { error: 'Member ID is required' }); + } + + // Get member details + const { data: targetMember } = await supabaseAdmin + .from('members_with_dues') + .select('*') + .eq('id', memberId) + .single(); + + if (!targetMember) { + return fail(404, { error: 'Member not found' }); + } + + // Determine the appropriate reminder type based on their status + let effectiveReminderType = reminderType; + if (!effectiveReminderType) { + if (targetMember.dues_status === 'overdue') { + effectiveReminderType = 'overdue'; + } else if (targetMember.dues_status === 'due_soon') { + effectiveReminderType = 'due_soon_7'; + } else { + effectiveReminderType = 'due_soon_30'; + } + } + + const result = await sendDuesReminder(targetMember, effectiveReminderType, url.origin); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to send reminder' }); + } + + return { + success: `Reminder sent to ${targetMember.first_name} ${targetMember.last_name}` + }; + }, + + /** + * Send bulk reminders to all members in due_soon status + */ + sendBulkDueSoonReminders: async ({ locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to send bulk reminders' }); + } + + const results = { + due_soon_30: { sent: 0, errors: [] as string[] }, + due_soon_7: { sent: 0, errors: [] as string[] }, + due_soon_1: { sent: 0, errors: [] as string[] } + }; + + // Get settings to determine which reminder tiers to process + const settings = await getDuesSettings(); + const reminderDays = settings.reminder_days_before || [30, 7, 1]; + + let totalSent = 0; + const allErrors: string[] = []; + + for (const days of reminderDays) { + const reminderType = `due_soon_${days}` as ReminderType; + const result = await sendBulkReminders(reminderType, url.origin); + + results[reminderType as keyof typeof results] = { + sent: result.sent, + errors: result.errors + }; + totalSent += result.sent; + allErrors.push(...result.errors); + } + + if (totalSent === 0 && allErrors.length === 0) { + return { success: 'No members need due soon reminders at this time.' }; + } + + const errorMsg = allErrors.length > 0 ? ` (${allErrors.length} errors)` : ''; + return { + success: `Sent ${totalSent} due soon reminder(s)${errorMsg}`, + bulkResult: results + }; + }, + + /** + * Send bulk reminders to all overdue members + */ + sendBulkOverdueReminders: async ({ locals, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to send bulk reminders' }); + } + + const result = await sendBulkReminders('overdue', url.origin); + + if (result.sent === 0 && result.errors.length === 0) { + return { success: 'No overdue members need reminders at this time.' }; + } + + const errorMsg = result.errors.length > 0 ? ` (${result.errors.length} errors)` : ''; + return { + success: `Sent ${result.sent} overdue reminder(s)${errorMsg}`, + bulkResult: result + }; + }, + + /** + * Get preview counts for bulk operations + */ + getBulkPreview: async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Unauthorized' }); + } + + const settings = await getDuesSettings(); + const reminderDays = settings.reminder_days_before || [30, 7, 1]; + + const preview = { + dueSoon: 0, + overdue: 0, + breakdown: {} as Record + }; + + for (const days of reminderDays) { + const reminderType = `due_soon_${days}` as ReminderType; + const members = await getMembersNeedingReminder(reminderType); + preview.breakdown[reminderType] = members.length; + preview.dueSoon += members.length; + } + + const overdueMembers = await getMembersNeedingReminder('overdue'); + preview.overdue = overdueMembers.length; + + return { preview }; + } +}; diff --git a/src/routes/(app)/board/dues/+page.svelte b/src/routes/(app)/board/dues/+page.svelte new file mode 100644 index 0000000..f787000 --- /dev/null +++ b/src/routes/(app)/board/dues/+page.svelte @@ -0,0 +1,679 @@ + + + + Dues Management | Monaco USA + + +
+
+
+

Dues Management

+

Track and record membership dues payments

+
+
+ + + Reports + + + +
+
+ + +
+
+
+
+ +
+
+

{stats.total}

+

Total

+
+
+
+
+
+
+ +
+
+

{stats.current}

+

Current

+
+
+
+
+
+
+ +
+
+

{stats.dueSoon}

+

Due Soon

+
+
+
+
+
+
+ +
+
+

{stats.overdue}

+

Overdue

+
+
+
+
+
+
+ +
+
+

{stats.neverPaid}

+

Never Paid

+
+
+
+
+ +
+ +
+
+
+
+
+ + { + searchQuery = e.currentTarget.value; + handleSearch(e.currentTarget.value); + }} + class="h-10 pl-9" + /> +
+ + +
+
+ + {#if members.length === 0} +
+ +

No members found

+

Try adjusting your search or filters.

+
+ {:else} +
+ {#each members as member} + {@const duesInfo = getDuesInfo(member.dues_status)} +
+
+ {#if member.avatar_url} + + {:else} +
+ {member.first_name?.[0]}{member.last_name?.[0]} +
+ {/if} +
+

+ {member.first_name} {member.last_name} +

+

{member.member_id}

+
+
+ +
+
+ + + {duesInfo.label} + + {#if member.current_due_date} +

+ {member.dues_status === 'overdue' + ? `${member.days_overdue} days overdue` + : `Due: ${formatDate(member.current_due_date)}`} +

+ {/if} +
+ + {#if member.dues_status !== 'current' && member.dues_status !== 'never_paid'} +
{ + sendingReminderId = member.id; + return async ({ update }) => { + sendingReminderId = null; + await update(); + await invalidateAll(); + }; + }} + > + + +
+ {/if} + + +
+
+ {/each} +
+ {/if} +
+
+ + +
+
+
+

+ + Recent Payments +

+
+ + {#if recentPayments.length === 0} +
+

No payments recorded yet.

+
+ {:else} +
+ {#each recentPayments as payment} +
+
+
+

+ {payment.member?.first_name} {payment.member?.last_name} +

+

{payment.member?.member_id}

+
+

€{payment.amount.toFixed(2)}

+
+
+ {formatDate(payment.payment_date)} + by {payment.recorder?.first_name} +
+
+ {/each} +
+ {/if} +
+
+
+
+ + +{#if showPaymentModal && selectedMemberForPayment} +
+
+
+

Record Payment

+ +
+ + +
+ {#if selectedMemberForPayment.avatar_url} + + {:else} +
+ {selectedMemberForPayment.first_name?.[0]}{selectedMemberForPayment.last_name?.[0]} +
+ {/if} +
+

+ {selectedMemberForPayment.first_name} {selectedMemberForPayment.last_name} +

+

+ {selectedMemberForPayment.member_id} • + {selectedMemberForPayment.membership_type_name || 'Regular Member'} +

+
+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + +
{ + isSubmitting = true; + return async ({ update, result }) => { + isSubmitting = false; + if (result.type === 'success') { + await invalidateAll(); + setTimeout(closePaymentModal, 1500); + } + await update(); + }; + }} + class="space-y-4" + > + + +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+{/if} + + +{#if showBulkModal && bulkActionType} +
+
+
+

+ {bulkActionType === 'dueSoon' ? 'Send Due Soon Reminders' : 'Send Overdue Reminders'} +

+ +
+ + {#if bulkResult} + +
+
+

{bulkResult.success || bulkResult}

+
+ +
+ {:else} + +
+
+
+ {#if bulkActionType === 'dueSoon'} + + {:else} + + {/if} +
+

+ {bulkActionType === 'dueSoon' ? stats.dueSoon : stats.overdue} member(s) will receive reminders +

+

+ {bulkActionType === 'dueSoon' + ? 'Members with dues due within 30 days' + : 'Members with overdue payments'} +

+
+
+
+ +

+ This will send email reminders to all eligible members who haven't already received a reminder for their current dues period. +

+ +
{ + isBulkSubmitting = true; + return async ({ result, update }) => { + isBulkSubmitting = false; + if (result.type === 'success' && result.data) { + bulkResult = result.data; + await invalidateAll(); + } + await update(); + }; + }} + > +
+ + +
+
+
+ {/if} +
+
+{/if} + + +{#if form?.success && !showPaymentModal && !showBulkModal} +
+

{form.success}

+
+{/if} + +{#if form?.error && !showPaymentModal && !showBulkModal} +
+

{form.error}

+
+{/if} diff --git a/src/routes/(app)/board/dues/reports/+page.server.ts b/src/routes/(app)/board/dues/reports/+page.server.ts new file mode 100644 index 0000000..8f2a4cb --- /dev/null +++ b/src/routes/(app)/board/dues/reports/+page.server.ts @@ -0,0 +1,169 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { getDuesAnalytics, getDuesReportData, getReminderEffectiveness } from '$lib/server/dues'; + +export const load: PageServerLoad = async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return { error: 'Unauthorized' }; + } + + // Load analytics data + const analytics = await getDuesAnalytics(); + const effectiveness = await getReminderEffectiveness(); + + return { + analytics, + effectiveness + }; +}; + +export const actions: Actions = { + /** + * Export members report as CSV + */ + exportMembers: async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Unauthorized' }); + } + + const data = await getDuesReportData(); + + // Generate CSV + const headers = [ + 'Member ID', + 'Name', + 'Email', + 'Membership Type', + 'Status', + 'Dues Status', + 'Annual Dues', + 'Last Payment', + 'Due Date', + 'Days Overdue' + ]; + + const rows = data.members.map((m) => [ + m.member_id, + m.name, + m.email, + m.membership_type, + m.status, + m.dues_status, + m.annual_dues.toFixed(2), + m.last_payment_date || 'Never', + m.current_due_date || 'N/A', + m.days_overdue?.toString() || '0' + ]); + + const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n'); + + return { + csv, + filename: `dues-members-${new Date().toISOString().split('T')[0]}.csv` + }; + }, + + /** + * Export payments report as CSV + */ + exportPayments: async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Unauthorized' }); + } + + const data = await getDuesReportData(); + + // Generate CSV + const headers = [ + 'Member ID', + 'Member Name', + 'Amount', + 'Payment Date', + 'Payment Method', + 'Reference', + 'Recorded By' + ]; + + const rows = data.payments.map((p) => [ + p.member_id, + p.member_name, + p.amount.toFixed(2), + p.payment_date, + p.payment_method, + p.reference || '', + p.recorded_by || '' + ]); + + const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n'); + + return { + csv, + filename: `dues-payments-${new Date().toISOString().split('T')[0]}.csv` + }; + }, + + /** + * Export overdue members report as CSV + */ + exportOverdue: async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Unauthorized' }); + } + + const data = await getDuesReportData(); + + // Filter to overdue only + const overdueMembers = data.members.filter( + (m) => m.dues_status === 'overdue' || m.dues_status === 'never_paid' + ); + + // Generate CSV + const headers = [ + 'Member ID', + 'Name', + 'Email', + 'Membership Type', + 'Dues Status', + 'Amount Owed', + 'Due Date', + 'Days Overdue' + ]; + + const rows = overdueMembers.map((m) => [ + m.member_id, + m.name, + m.email, + m.membership_type, + m.dues_status, + m.annual_dues.toFixed(2), + m.current_due_date || 'N/A', + m.days_overdue?.toString() || 'N/A' + ]); + + const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n'); + + return { + csv, + filename: `dues-overdue-${new Date().toISOString().split('T')[0]}.csv` + }; + } +}; + +/** + * Escape a value for CSV + */ +function escapeCSV(value: string | number): string { + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} diff --git a/src/routes/(app)/board/dues/reports/+page.svelte b/src/routes/(app)/board/dues/reports/+page.svelte new file mode 100644 index 0000000..b4ff149 --- /dev/null +++ b/src/routes/(app)/board/dues/reports/+page.svelte @@ -0,0 +1,346 @@ + + + + Dues Reports | Monaco USA + + +
+ +
+
+ + + +
+

Dues Reports

+

Analytics and financial reporting

+
+
+
+ + +
+
+
+
+

Collected This Month

+

+ {formatCurrency(analytics?.totalCollectedThisMonth || 0)} +

+
+
+ +
+
+
+ +
+
+
+

Collected This Year

+

+ {formatCurrency(analytics?.totalCollectedThisYear || 0)} +

+
+
+ +
+
+
+ +
+
+
+

Outstanding

+

+ {formatCurrency(analytics?.totalOutstanding || 0)} +

+
+
+ +
+
+
+ +
+
+
+

Reminders Sent

+

+ {analytics?.remindersSentThisMonth || 0} +

+

This month

+
+
+ +
+
+
+
+ +
+ +
+
+

+ + Collection Trend (12 Months) +

+
+ +
+ {#if analytics?.paymentsByMonth && analytics.paymentsByMonth.length > 0} +
+ {#each analytics.paymentsByMonth as month, i} +
+
+

+ {month.month.split(' ')[0].slice(0, 3)} +

+
+ {/each} +
+ {:else} +
+

No payment data available

+
+ {/if} +
+
+ + +
+
+

+ + Member Status Breakdown +

+
+ +
+ {#if analytics?.statusBreakdown} + {@const statusConfig = { + current: { icon: CheckCircle2, color: 'bg-green-500', textColor: 'text-green-600', label: 'Current' }, + due_soon: { icon: Clock, color: 'bg-yellow-500', textColor: 'text-yellow-600', label: 'Due Soon' }, + overdue: { icon: AlertCircle, color: 'bg-red-500', textColor: 'text-red-600', label: 'Overdue' }, + never_paid: { icon: XCircle, color: 'bg-slate-400', textColor: 'text-slate-500', label: 'Never Paid' } + }} + {#each analytics.statusBreakdown as status} + {@const config = statusConfig[status.status as keyof typeof statusConfig]} +
+ +
+
+ {config.label} + + {status.count} ({status.percentage.toFixed(1)}%) + +
+
+
+
+
+
+ {/each} + {/if} +
+ +
+
+ Total Members + {analytics?.totalMembers || 0} +
+
+
+
+ + +
+
+

+ + Reminder Effectiveness +

+
+ +
+
+

Total Reminders Sent

+

{effectiveness?.totalRemindersSent || 0}

+
+
+

Paid Within 7 Days

+

{effectiveness?.paidWithin7Days || 0}

+
+
+

Paid Within 30 Days

+

{effectiveness?.paidWithin30Days || 0}

+
+
+

Effectiveness Rate

+

+ {(effectiveness?.effectivenessRate || 0).toFixed(1)}% +

+
+
+
+ + +
+
+

+ + Export Reports +

+

Download detailed reports as CSV files

+
+ +
+
{ + isExporting = 'members'; + return async ({ update }) => { + isExporting = null; + await update(); + }; + }} + > + +
+ +
{ + isExporting = 'payments'; + return async ({ update }) => { + isExporting = null; + await update(); + }; + }} + > + +
+ +
{ + isExporting = 'overdue'; + return async ({ update }) => { + isExporting = null; + await update(); + }; + }} + > + +
+
+
+
diff --git a/src/routes/(app)/board/events/+page.server.ts b/src/routes/(app)/board/events/+page.server.ts new file mode 100644 index 0000000..a70f45b --- /dev/null +++ b/src/routes/(app)/board/events/+page.server.ts @@ -0,0 +1,161 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const statusFilter = url.searchParams.get('status') || 'all'; + + // Load events with counts + let query = locals.supabase + .from('events_with_counts') + .select('*') + .order('start_datetime', { ascending: true }); + + if (statusFilter !== 'all') { + query = query.eq('status', statusFilter); + } + + const { data: events } = await query; + + // Load event types + const { data: eventTypes } = await locals.supabase + .from('event_types') + .select('*') + .eq('is_active', true) + .order('sort_order', { ascending: true }); + + // Calculate stats + const now = new Date(); + const stats = { + total: events?.length || 0, + upcoming: events?.filter((e: any) => new Date(e.start_datetime) > now && e.status === 'published').length || 0, + draft: events?.filter((e: any) => e.status === 'draft').length || 0, + past: events?.filter((e: any) => new Date(e.end_datetime) < now).length || 0 + }; + + return { + events: events || [], + eventTypes: eventTypes || [], + stats, + filters: { + status: statusFilter + } + }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to create events' }); + } + + const formData = await request.formData(); + + const title = formData.get('title') as string; + const description = formData.get('description') as string; + const eventTypeId = formData.get('event_type_id') as string; + const startDate = formData.get('start_date') as string; + const startTime = formData.get('start_time') as string; + const endDate = formData.get('end_date') as string; + const endTime = formData.get('end_time') as string; + const location = formData.get('location') as string; + const maxAttendees = formData.get('max_attendees') as string; + const maxGuests = formData.get('max_guests_per_member') as string; + const isPaid = formData.get('is_paid') === 'true'; + const memberPrice = formData.get('member_price') as string; + const nonMemberPrice = formData.get('non_member_price') as string; + const visibility = formData.get('visibility') as string; + const status = formData.get('status') as string; + + // Validation + if (!title || !startDate || !startTime || !endDate || !endTime) { + return fail(400, { error: 'Title, start date/time, and end date/time are required' }); + } + + // Construct datetime strings + const startDatetime = `${startDate}T${startTime}:00`; + const endDatetime = `${endDate}T${endTime}:00`; + + // Validate end is after start + if (new Date(endDatetime) <= new Date(startDatetime)) { + return fail(400, { error: 'End date/time must be after start date/time' }); + } + + const { error: insertError } = await locals.supabase.from('events').insert({ + title, + description: description || null, + event_type_id: eventTypeId || null, + start_datetime: startDatetime, + end_datetime: endDatetime, + location: location || null, + max_attendees: maxAttendees ? parseInt(maxAttendees) : null, + max_guests_per_member: maxGuests ? parseInt(maxGuests) : 1, + is_paid: isPaid, + member_price: isPaid && memberPrice ? parseFloat(memberPrice) : 0, + non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0, + visibility: visibility || 'members', + status: status || 'published', + created_by: member.id + }); + + if (insertError) { + console.error('Event creation error:', insertError); + return fail(500, { error: 'Failed to create event. Please try again.' }); + } + + return { success: 'Event created successfully!' }; + }, + + delete: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can delete events' }); + } + + const formData = await request.formData(); + const eventId = formData.get('event_id') as string; + + if (!eventId) { + return fail(400, { error: 'Event ID is required' }); + } + + const { error: deleteError } = await locals.supabase.from('events').delete().eq('id', eventId); + + if (deleteError) { + console.error('Event deletion error:', deleteError); + return fail(500, { error: 'Failed to delete event. Please try again.' }); + } + + return { success: 'Event deleted successfully!' }; + }, + + updateStatus: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to update events' }); + } + + const formData = await request.formData(); + const eventId = formData.get('event_id') as string; + const status = formData.get('status') as string; + + if (!eventId || !status) { + return fail(400, { error: 'Event ID and status are required' }); + } + + const { error: updateError } = await locals.supabase + .from('events') + .update({ status, updated_at: new Date().toISOString() }) + .eq('id', eventId); + + if (updateError) { + console.error('Event status update error:', updateError); + return fail(500, { error: 'Failed to update event status. Please try again.' }); + } + + return { success: 'Event status updated!' }; + } +}; diff --git a/src/routes/(app)/board/events/+page.svelte b/src/routes/(app)/board/events/+page.svelte new file mode 100644 index 0000000..9d88e83 --- /dev/null +++ b/src/routes/(app)/board/events/+page.svelte @@ -0,0 +1,580 @@ + + + + Event Management | Monaco USA + + +
+
+
+

Event Management

+

Create and manage association events

+
+ + +
+ + +
+
+
+
+ +
+
+

{stats.total}

+

Total Events

+
+
+
+
+
+
+ +
+
+

{stats.upcoming}

+

Upcoming

+
+
+
+
+
+
+ +
+
+

{stats.draft}

+

Drafts

+
+
+
+
+
+
+ +
+
+

{stats.past}

+

Past Events

+
+
+
+
+ + +
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + + +
+ {#if events.length === 0} +
+ +

No events found

+

+ {filters.status !== 'all' + ? 'Try adjusting your filters.' + : 'Create your first event to get started.'} +

+
+ {:else} +
+ + + + + + + + + + + + + {#each events as event} + {@const statusInfo = getStatusInfo(event.status)} + {@const eventPast = isPast(event.end_datetime)} + { + // Don't navigate if clicking on action buttons + if ((e.target as HTMLElement).closest('.actions-cell')) return; + goto(`/events/${event.id}`); + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + goto(`/events/${event.id}`); + } + }} + tabindex="0" + role="button" + > + + + + + + + + {/each} + +
+ Event + + Date & Time + + Attendees + + Visibility + + Status + + Actions +
+
+

{event.title}

+ {#if event.event_type_name} + + {event.event_type_name} + + {/if} +
+
+
+

{formatDate(event.start_datetime)}

+

+ {formatTime(event.start_datetime)} - {formatTime(event.end_datetime)} +

+
+
+
+ + {event.total_attendees || 0} + {#if event.max_attendees} + / {event.max_attendees} + {/if} +
+ {#if event.waitlist_count > 0} +

{event.waitlist_count} on waitlist

+ {/if} +
+ + {getVisibilityLabel(event.visibility)} + + + + + {statusInfo.label} + + e.stopPropagation()}> +
+ + + + + + + + + + {#if event.status === 'draft'} +
+ + + +
+ {/if} +
+
+
+ {/if} +
+
+ + +{#if showCreateModal} +
+
+
+
+

Create New Event

+ +
+ +
{ + isSubmitting = true; + return async ({ update, result }) => { + isSubmitting = false; + if (result.type === 'success') { + showCreateModal = false; + await invalidateAll(); + } + await update(); + }; + }} + class="space-y-4" + > +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + {#if isPaid} +
+ + +
+ +
+ + +
+ {/if} + +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+{/if} diff --git a/src/routes/(app)/board/events/[id]/attendees/+page.server.ts b/src/routes/(app)/board/events/[id]/attendees/+page.server.ts new file mode 100644 index 0000000..6ddb2d4 --- /dev/null +++ b/src/routes/(app)/board/events/[id]/attendees/+page.server.ts @@ -0,0 +1,449 @@ +import { fail, error } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { sendEmail } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, params }) => { + // Fetch the event + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + throw error(404, 'Event not found'); + } + + // Fetch all RSVPs with member details + // Note: Using explicit foreign key reference because event_rsvps has two FKs to members (member_id, checked_in_by) + const { data: rsvps, error: rsvpError } = await locals.supabase + .from('event_rsvps') + .select(` + *, + member:members!event_rsvps_member_id_fkey( + id, + first_name, + last_name, + email, + phone, + member_id + ) + `) + .eq('event_id', params.id) + .order('created_at', { ascending: true }); + + if (rsvpError) { + console.error('RSVP fetch error:', rsvpError); + } + + // Fetch public RSVPs (non-members) + const { data: publicRsvps } = await locals.supabase + .from('event_rsvps_public') + .select('*') + .eq('event_id', params.id) + .order('created_at', { ascending: true }); + + // Calculate stats + const memberRsvps = rsvps || []; + const nonMemberRsvps = publicRsvps || []; + + const stats = { + confirmed: memberRsvps.filter(r => r.status === 'confirmed').length + + nonMemberRsvps.filter(r => r.status === 'confirmed').length, + confirmedGuests: memberRsvps.filter(r => r.status === 'confirmed').reduce((sum, r) => sum + (r.guest_count || 0), 0) + + nonMemberRsvps.filter(r => r.status === 'confirmed').reduce((sum, r) => sum + (r.guest_count || 0), 0), + waitlist: memberRsvps.filter(r => r.status === 'waitlist').length + + nonMemberRsvps.filter(r => r.status === 'waitlist').length, + maybe: memberRsvps.filter(r => r.status === 'maybe').length, + declined: memberRsvps.filter(r => r.status === 'declined').length + + nonMemberRsvps.filter(r => r.status === 'declined').length, + attended: memberRsvps.filter(r => r.attended).length + + nonMemberRsvps.filter(r => r.attended).length, + totalMembers: memberRsvps.length, + totalNonMembers: nonMemberRsvps.length + }; + + stats.confirmedGuests; // Suppress unused warning + + // Get all members for invitation feature + const { data: allMembers } = await supabaseAdmin + .from('members') + .select('id, first_name, last_name, email, member_id') + .order('first_name', { ascending: true }); + + // Filter out members who have already RSVPed + const rsvpedMemberIds = new Set(memberRsvps.map(r => r.member_id)); + const uninvitedMembers = (allMembers || []).filter(m => !rsvpedMemberIds.has(m.id)); + + return { + event, + memberRsvps, + publicRsvps: nonMemberRsvps, + stats, + uninvitedMembers + }; +}; + +export const actions: Actions = { + checkIn: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to check in attendees' }); + } + + const formData = await request.formData(); + const rsvpId = formData.get('rsvp_id') as string; + const isPublic = formData.get('is_public') === 'true'; + const attended = formData.get('attended') === 'true'; + + if (!rsvpId) { + return fail(400, { error: 'RSVP ID is required' }); + } + + const table = isPublic ? 'event_rsvps_public' : 'event_rsvps'; + + const updateData: Record = { + attended, + updated_at: new Date().toISOString() + }; + + // Only set checked_in fields if checking in + if (attended) { + updateData.checked_in_at = new Date().toISOString(); + if (!isPublic) { + updateData.checked_in_by = member.id; + } + } else { + updateData.checked_in_at = null; + if (!isPublic) { + updateData.checked_in_by = null; + } + } + + const { error: updateError } = await locals.supabase + .from(table) + .update(updateData) + .eq('id', rsvpId); + + if (updateError) { + console.error('Check-in error:', updateError); + return fail(500, { error: 'Failed to update attendance. Please try again.' }); + } + + return { success: attended ? 'Checked in successfully!' : 'Check-in removed.' }; + }, + + updateRsvpStatus: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to update RSVPs' }); + } + + const formData = await request.formData(); + const rsvpId = formData.get('rsvp_id') as string; + const isPublic = formData.get('is_public') === 'true'; + const status = formData.get('status') as string; + + if (!rsvpId || !status) { + return fail(400, { error: 'RSVP ID and status are required' }); + } + + const validStatuses = ['confirmed', 'declined', 'maybe', 'waitlist', 'cancelled']; + if (!validStatuses.includes(status)) { + return fail(400, { error: 'Invalid status' }); + } + + const table = isPublic ? 'event_rsvps_public' : 'event_rsvps'; + + const { error: updateError } = await locals.supabase + .from(table) + .update({ + status, + updated_at: new Date().toISOString() + }) + .eq('id', rsvpId); + + if (updateError) { + console.error('RSVP status update error:', updateError); + return fail(500, { error: 'Failed to update RSVP status. Please try again.' }); + } + + return { success: 'RSVP status updated!' }; + }, + + promoteFromWaitlist: async ({ request, locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to manage waitlist' }); + } + + const formData = await request.formData(); + const rsvpId = formData.get('rsvp_id') as string; + const isPublic = formData.get('is_public') === 'true'; + + if (!rsvpId) { + return fail(400, { error: 'RSVP ID is required' }); + } + + // Get event to check capacity + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + return fail(404, { error: 'Event not found' }); + } + + // Get the RSVP to promote + const table = isPublic ? 'event_rsvps_public' : 'event_rsvps'; + const { data: rsvp } = await locals.supabase + .from(table) + .select('*') + .eq('id', rsvpId) + .single(); + + if (!rsvp) { + return fail(404, { error: 'RSVP not found' }); + } + + if (rsvp.status !== 'waitlist') { + return fail(400, { error: 'Only waitlisted RSVPs can be promoted' }); + } + + // Check capacity + const spotsNeeded = 1 + (rsvp.guest_count || 0); + if (event.max_attendees && event.total_attendees + spotsNeeded > event.max_attendees) { + return fail(400, { error: 'Not enough capacity for this attendee and their guests' }); + } + + // Promote to confirmed + const { error: updateError } = await locals.supabase + .from(table) + .update({ + status: 'confirmed', + updated_at: new Date().toISOString() + }) + .eq('id', rsvpId); + + if (updateError) { + console.error('Promotion error:', updateError); + return fail(500, { error: 'Failed to promote from waitlist. Please try again.' }); + } + + return { success: 'Successfully promoted from waitlist!' }; + }, + + deleteRsvp: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to delete RSVPs' }); + } + + const formData = await request.formData(); + const rsvpId = formData.get('rsvp_id') as string; + const isPublic = formData.get('is_public') === 'true'; + + if (!rsvpId) { + return fail(400, { error: 'RSVP ID is required' }); + } + + const table = isPublic ? 'event_rsvps_public' : 'event_rsvps'; + + const { error: deleteError } = await locals.supabase + .from(table) + .delete() + .eq('id', rsvpId); + + if (deleteError) { + console.error('Delete RSVP error:', deleteError); + return fail(500, { error: 'Failed to delete RSVP. Please try again.' }); + } + + return { success: 'RSVP removed successfully!' }; + }, + + markAsPaid: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to manage payments' }); + } + + const formData = await request.formData(); + const rsvpId = formData.get('rsvp_id') as string; + const isPublic = formData.get('is_public') === 'true'; + + if (!rsvpId) { + return fail(400, { error: 'RSVP ID is required' }); + } + + const table = isPublic ? 'event_rsvps_public' : 'event_rsvps'; + + const { error: updateError } = await locals.supabase + .from(table) + .update({ + payment_status: 'paid', + updated_at: new Date().toISOString() + }) + .eq('id', rsvpId); + + if (updateError) { + console.error('Mark as paid error:', updateError); + return fail(500, { error: 'Failed to update payment status. Please try again.' }); + } + + return { success: 'Payment marked as received!' }; + }, + + inviteMembers: async ({ request, locals, params, url }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to invite members' }); + } + + const formData = await request.formData(); + const memberIds = formData.getAll('member_ids') as string[]; + + if (!memberIds || memberIds.length === 0) { + return fail(400, { error: 'Please select at least one member to invite' }); + } + + // Get the event details + const { data: event } = await locals.supabase + .from('events') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + return fail(404, { error: 'Event not found' }); + } + + // Get the selected members + const { data: members } = await supabaseAdmin + .from('members') + .select('id, first_name, last_name, email') + .in('id', memberIds); + + if (!members || members.length === 0) { + return fail(400, { error: 'No valid members found' }); + } + + // Prepare email content + const baseUrl = url.origin; + const eventUrl = `${baseUrl}/events/${event.id}`; + const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`; + + // Format event date and time + const eventDate = new Date(event.start_time).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + const eventTime = new Date(event.start_time).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit' + }); + + let successCount = 0; + let failCount = 0; + + // Send invitations to each member + for (const invitee of members) { + const emailResult = await sendEmail({ + to: invitee.email, + subject: `You're Invited: ${event.title}`, + html: ` + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

You're Invited!

+

Dear ${invitee.first_name},

+

We'd love for you to join us at an upcoming Monaco USA event!

+ +
+

${event.title}

+

Date: ${eventDate}

+

Time: ${eventTime}

+ ${event.location ? `

Location: ${event.location}

` : ''} +
+ + ${event.description ? `

${event.description.substring(0, 200)}${event.description.length > 200 ? '...' : ''}

` : ''} + +
+ RSVP Now +
+ +

Click the button above to view the event details and RSVP.

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +`, + recipientId: invitee.id, + recipientName: `${invitee.first_name} ${invitee.last_name}`, + emailType: 'event_invitation', + sentBy: member.id + }); + + if (emailResult.success) { + successCount++; + } else { + failCount++; + console.error(`Failed to send invitation to ${invitee.email}:`, emailResult.error); + } + } + + if (failCount === 0) { + return { success: `Successfully sent ${successCount} invitation${successCount !== 1 ? 's' : ''}!` }; + } else if (successCount === 0) { + return fail(500, { error: 'Failed to send all invitations. Please check your email settings.' }); + } else { + return { success: `Sent ${successCount} invitation${successCount !== 1 ? 's' : ''}, but ${failCount} failed.` }; + } + } +}; diff --git a/src/routes/(app)/board/events/[id]/attendees/+page.svelte b/src/routes/(app)/board/events/[id]/attendees/+page.svelte new file mode 100644 index 0000000..ffc2297 --- /dev/null +++ b/src/routes/(app)/board/events/[id]/attendees/+page.svelte @@ -0,0 +1,919 @@ + + + + Attendees: {event?.title || 'Event'} | Monaco USA + + +
+ +
+
+ + + Back to events + +

Attendees

+ {#if event} +

{event.title}

+ {/if} +
+
+ + + Edit Event + +
+
+ + {#if form?.error} + + {/if} + + {#if form?.success} + + {/if} + + {#if event} + +
+
+
+ + {formatDate(event.start_datetime)} + {formatTime(event.start_datetime)} - {formatTime(event.end_datetime)} +
+ {#if event.location} +
+ + {event.location} +
+ {/if} +
+ + {event.total_attendees}{event.max_attendees ? ` / ${event.max_attendees}` : ''} attendees +
+
+
+ + +
+
+
+
+ +
+
+

{stats.confirmed}

+

Confirmed

+
+
+
+ {#if event?.is_paid} +
+
+
+ +
+
+

{pendingPaymentsCount}

+

Pending Payment

+
+
+
+ {/if} +
+
+
+ +
+
+

{stats.waitlist}

+

Waitlist

+
+
+
+
+
+
+ +
+
+

{stats.maybe}

+

Maybe

+
+
+
+
+
+
+ +
+
+

{stats.declined}

+

Declined

+
+
+
+
+
+
+ +
+
+

{stats.attended}

+

Checked In

+
+
+
+
+ + +
+ {#each [ + { value: 'all', label: 'All' }, + { value: 'confirmed', label: 'Confirmed' }, + { value: 'waitlist', label: 'Waitlist' }, + { value: 'maybe', label: 'Maybe' }, + { value: 'attended', label: 'Checked In' }, + { value: 'not_attended', label: 'Not Checked In' }, + ...(event?.is_paid ? [ + { value: 'pending_payment', label: 'Pending Payment' }, + { value: 'paid', label: 'Paid' } + ] : []) + ] as filter} + + {/each} +
+ + + {#if memberRsvps.length > 0} +
+
+

Members ({stats.totalMembers})

+
+
+ {#each filterRsvps(memberRsvps, statusFilter) as rsvp} +
+
+
+ +
{ + loading = true; + return async ({ update }) => { + await invalidateAll(); + loading = false; + await update(); + }; + }} + > + + + + +
+ + +
+
+

+ {rsvp.member?.first_name} {rsvp.member?.last_name} +

+ + {rsvp.status} + + {#if event?.is_paid && rsvp.payment_status === 'pending'} + + + Payment Pending + + {:else if event?.is_paid && rsvp.payment_status === 'paid'} + + + Paid + + {/if} +
+

{rsvp.member?.member_id}

+
+
+ +
+ {#if rsvp.guest_count > 0} + + +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''} + + {/if} + + + {#if event?.is_paid && rsvp.payment_status === 'pending'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+ {/if} + + + +
+
+ + + {#if expandedRsvp === rsvp.id} +
+
+ {#if rsvp.member?.email} + + + {rsvp.member.email} + + {/if} + {#if rsvp.member?.phone} + + + {rsvp.member.phone} + + {/if} +
+ + + {#if event?.is_paid && rsvp.payment_amount} +
+ Amount: €{rsvp.payment_amount.toFixed(2)} +
+ {/if} + + +
+ {#if event?.is_paid && rsvp.payment_status === 'pending'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+ {/if} + {#if rsvp.status === 'waitlist'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+ {/if} +
{ + if (!confirm('Remove this RSVP? This cannot be undone.')) { + return async () => {}; + } + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+
+
+ {/if} +
+ {/each} +
+
+ {/if} + + + {#if publicRsvps.length > 0} +
+
+

Non-Members ({stats.totalNonMembers})

+
+
+ {#each filterRsvps(publicRsvps, statusFilter) as rsvp} +
+
+
+ +
{ + loading = true; + return async ({ update }) => { + await invalidateAll(); + loading = false; + await update(); + }; + }} + > + + + + +
+ + +
+
+

+ {rsvp.full_name} +

+ + Guest + + + {rsvp.status} + + {#if event?.is_paid && rsvp.payment_status === 'pending'} + + + Payment Pending + + {:else if event?.is_paid && rsvp.payment_status === 'paid'} + + + Paid + + {/if} +
+

{rsvp.email}

+
+
+ +
+ {#if rsvp.guest_count > 0} + + +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''} + + {/if} + + + {#if event?.is_paid && rsvp.payment_status === 'pending'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+ {/if} + + + +
+
+ + + {#if expandedRsvp === `public-${rsvp.id}`} +
+
+ + + {rsvp.email} + + {#if rsvp.phone} + + + {rsvp.phone} + + {/if} +
+ + + {#if event?.is_paid && rsvp.payment_amount} +
+ Amount: €{rsvp.payment_amount.toFixed(2)} +
+ {/if} + + +
+ {#if event?.is_paid && rsvp.payment_status === 'pending'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+ {/if} + {#if rsvp.status === 'waitlist'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+ {/if} +
{ + if (!confirm('Remove this RSVP? This cannot be undone.')) { + return async () => {}; + } + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + + + +
+
+
+ {/if} +
+ {/each} +
+
+ {/if} + + + {#if memberRsvps.length === 0 && publicRsvps.length === 0} +
+ +

No RSVPs yet

+

No one has RSVP'd to this event yet.

+
+ {/if} + {:else} +
+

Event not found.

+ + View all events + +
+ {/if} +
+ + +{#if showInviteModal} +
+
+
+
+ +

Invite Members to Event

+
+ +
+ +

+ Select members to send an email invitation with a link to RSVP for "{event?.title}". +

+ + +
+ + +
+ + {#if uninvitedMembers.length === 0} +
+ +

All members have already been invited or RSVPed.

+
+ {:else} + +
+ + + {selectedMembers.size} of {filteredUninvitedMembers.length} selected + +
+ + +
+ {#each filteredUninvitedMembers as member (member.id)} + + {:else} +
+ No members match your search. +
+ {/each} +
+ {/if} + + +
+ +
{ + inviteLoading = true; + return async ({ update, result }) => { + inviteLoading = false; + if (result.type === 'success') { + closeInviteModal(); + await invalidateAll(); + } + await update(); + }; + }} + class="flex-1" + > + {#each Array.from(selectedMembers) as memberId} + + {/each} + +
+
+
+
+{/if} diff --git a/src/routes/(app)/board/events/[id]/edit/+page.server.ts b/src/routes/(app)/board/events/[id]/edit/+page.server.ts new file mode 100644 index 0000000..9702e43 --- /dev/null +++ b/src/routes/(app)/board/events/[id]/edit/+page.server.ts @@ -0,0 +1,124 @@ +import { fail, error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + throw redirect(303, '/dashboard'); + } + + // Fetch the event + const { data: event } = await locals.supabase + .from('events') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + throw error(404, 'Event not found'); + } + + // Load event types + const { data: eventTypes } = await locals.supabase + .from('event_types') + .select('*') + .eq('is_active', true) + .order('sort_order', { ascending: true }); + + return { + event, + eventTypes: eventTypes || [] + }; +}; + +export const actions: Actions = { + update: async ({ request, locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'You do not have permission to edit events' }); + } + + const formData = await request.formData(); + + const title = formData.get('title') as string; + const description = formData.get('description') as string; + const eventTypeId = formData.get('event_type_id') as string; + const startDate = formData.get('start_date') as string; + const startTime = formData.get('start_time') as string; + const endDate = formData.get('end_date') as string; + const endTime = formData.get('end_time') as string; + const location = formData.get('location') as string; + const locationUrl = formData.get('location_url') as string; + const maxAttendees = formData.get('max_attendees') as string; + const maxGuests = formData.get('max_guests_per_member') as string; + const isPaid = formData.get('is_paid') === 'true'; + const memberPrice = formData.get('member_price') as string; + const nonMemberPrice = formData.get('non_member_price') as string; + const visibility = formData.get('visibility') as string; + const status = formData.get('status') as string; + + // Validation + if (!title || !startDate || !startTime || !endDate || !endTime) { + return fail(400, { error: 'Title, start date/time, and end date/time are required' }); + } + + // Construct datetime strings + const startDatetime = `${startDate}T${startTime}:00`; + const endDatetime = `${endDate}T${endTime}:00`; + + // Validate end is after start + if (new Date(endDatetime) <= new Date(startDatetime)) { + return fail(400, { error: 'End date/time must be after start date/time' }); + } + + const { error: updateError } = await locals.supabase + .from('events') + .update({ + title, + description: description || null, + event_type_id: eventTypeId || null, + start_datetime: startDatetime, + end_datetime: endDatetime, + location: location || null, + location_url: locationUrl || null, + max_attendees: maxAttendees ? parseInt(maxAttendees) : null, + max_guests_per_member: maxGuests ? parseInt(maxGuests) : 1, + is_paid: isPaid, + member_price: isPaid && memberPrice ? parseFloat(memberPrice) : 0, + non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0, + visibility: visibility || 'members', + status: status || 'published', + updated_at: new Date().toISOString() + }) + .eq('id', params.id); + + if (updateError) { + console.error('Event update error:', updateError); + return fail(500, { error: 'Failed to update event. Please try again.' }); + } + + return { success: 'Event updated successfully!' }; + }, + + delete: async ({ locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member || member.role !== 'admin') { + return fail(403, { error: 'Only admins can delete events' }); + } + + const { error: deleteError } = await locals.supabase + .from('events') + .delete() + .eq('id', params.id); + + if (deleteError) { + console.error('Event deletion error:', deleteError); + return fail(500, { error: 'Failed to delete event. Please try again.' }); + } + + throw redirect(303, '/board/events?deleted=true'); + } +}; diff --git a/src/routes/(app)/board/events/[id]/edit/+page.svelte b/src/routes/(app)/board/events/[id]/edit/+page.svelte new file mode 100644 index 0000000..6551aa1 --- /dev/null +++ b/src/routes/(app)/board/events/[id]/edit/+page.svelte @@ -0,0 +1,416 @@ + + + + Edit Event | Monaco USA + + +
+ + + + Back to events + + +
+
+

Edit Event

+

Update event details and settings

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + +
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + class="space-y-6" + > + +
+

+ + Event Details +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

Date & Time

+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+
+
+ + +
+

+ + Location +

+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

+ + Capacity +

+ +
+
+ + +

Leave empty for unlimited

+
+ +
+ + +
+
+
+ + +
+

+ + Pricing +

+ + + + {#if isPaid} +
+
+ + +
+ +
+ + +
+
+ {/if} +
+ + +
+

+ + Visibility +

+ +
+ + +
+
+ + +
+ {#if member?.role === 'admin'} + + {:else} +
+ {/if} + +
+ + Cancel + + +
+
+
+
+
+ + +{#if showDeleteConfirm} +
+
+
+

Delete Event

+ +
+ +

+ Are you sure you want to delete {event.title}? This action cannot be undone. +

+ +

+ All RSVPs and associated data will also be deleted. +

+ +
+ +
+ +
+
+
+
+{/if} diff --git a/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts b/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts new file mode 100644 index 0000000..a1e74ed --- /dev/null +++ b/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts @@ -0,0 +1,281 @@ +import { fail, error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const load: PageServerLoad = async ({ locals, params }) => { + const { member } = await locals.safeGetSession(); + + // Only board and admin can access roll call + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + throw redirect(303, `/board/events/${params.id}`); + } + + // Fetch the event + const { data: event, error: eventError } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (eventError || !event) { + throw error(404, 'Event not found'); + } + + // Fetch all RSVPs with member details (only confirmed status for roll call) + const { data: rsvps, error: rsvpError } = await locals.supabase + .from('event_rsvps') + .select(` + *, + member:members!event_rsvps_member_id_fkey( + id, + first_name, + last_name, + email, + phone, + member_id, + avatar_url + ) + `) + .eq('event_id', params.id) + .in('status', ['confirmed', 'waitlist']) + .order('created_at', { ascending: true }); + + if (rsvpError) { + console.error('RSVP fetch error:', rsvpError); + } + + // Fetch public RSVPs (non-members with confirmed status) + const { data: publicRsvps, error: publicError } = await locals.supabase + .from('event_rsvps_public') + .select('*') + .eq('event_id', params.id) + .in('status', ['confirmed', 'waitlist']) + .order('created_at', { ascending: true }); + + if (publicError) { + console.error('Public RSVP fetch error:', publicError); + } + + // Calculate stats + const allMemberRsvps = rsvps || []; + const allPublicRsvps = publicRsvps || []; + const allRsvps = [...allMemberRsvps, ...allPublicRsvps]; + + const stats = { + total: allRsvps.length, + confirmed: allRsvps.filter(r => r.status === 'confirmed').length, + checkedIn: allRsvps.filter(r => r.attended).length, + waitlist: allRsvps.filter(r => r.status === 'waitlist').length + }; + + // Get all members for walk-in feature + const { data: allMembers } = await supabaseAdmin + .from('members') + .select('id, first_name, last_name, email, member_id, avatar_url') + .order('first_name', { ascending: true }); + + // Filter out members who have already RSVPed + const rsvpedMemberIds = new Set(allMemberRsvps.map(r => r.member_id)); + const availableMembers = (allMembers || []).filter(m => !rsvpedMemberIds.has(m.id)); + + return { + event, + memberRsvps: allMemberRsvps, + publicRsvps: allPublicRsvps, + stats, + availableMembers + }; +}; + +export const actions: Actions = { + checkIn: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Permission denied' }); + } + + const formData = await request.formData(); + const rsvpId = formData.get('rsvp_id') as string; + const isPublic = formData.get('is_public') === 'true'; + const attended = formData.get('attended') === 'true'; + + if (!rsvpId) { + return fail(400, { error: 'RSVP ID required' }); + } + + const table = isPublic ? 'event_rsvps_public' : 'event_rsvps'; + + const updateData: Record = { + attended, + updated_at: new Date().toISOString() + }; + + if (attended) { + updateData.checked_in_at = new Date().toISOString(); + if (!isPublic) { + updateData.checked_in_by = member.id; + } + } else { + updateData.checked_in_at = null; + if (!isPublic) { + updateData.checked_in_by = null; + } + } + + const { error: updateError } = await locals.supabase + .from(table) + .update(updateData) + .eq('id', rsvpId); + + if (updateError) { + console.error('Check-in error:', updateError); + return fail(500, { error: 'Check-in failed' }); + } + + return { success: true, attended }; + }, + + addWalkIn: async ({ request, locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Permission denied' }); + } + + const formData = await request.formData(); + const memberId = formData.get('member_id') as string; + const guestName = formData.get('guest_name') as string; + const guestEmail = formData.get('guest_email') as string; + + const eventId = params.id; + + // If it's an existing member + if (memberId) { + // Check if member already has an RSVP + const { data: existing } = await locals.supabase + .from('event_rsvps') + .select('id') + .eq('event_id', eventId) + .eq('member_id', memberId) + .single(); + + if (existing) { + return fail(400, { error: 'Member already has an RSVP for this event' }); + } + + // Create RSVP with immediate check-in + const { error: insertError } = await locals.supabase + .from('event_rsvps') + .insert({ + event_id: eventId, + member_id: memberId, + status: 'confirmed', + attended: true, + checked_in_at: new Date().toISOString(), + checked_in_by: member.id, + rsvp_source: 'walk_in', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }); + + if (insertError) { + console.error('Walk-in RSVP error:', insertError); + return fail(500, { error: 'Failed to add walk-in' }); + } + + return { success: true, message: 'Member added as walk-in' }; + } + + // If it's a non-member guest + if (guestName) { + const { error: insertError } = await locals.supabase + .from('event_rsvps_public') + .insert({ + event_id: eventId, + full_name: guestName, + email: guestEmail || null, + status: 'confirmed', + attended: true, + checked_in_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }); + + if (insertError) { + console.error('Walk-in guest error:', insertError); + return fail(500, { error: 'Failed to add guest' }); + } + + return { success: true, message: 'Guest added as walk-in' }; + } + + return fail(400, { error: 'Please provide a member or guest information' }); + }, + + bulkCheckIn: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member || (member.role !== 'board' && member.role !== 'admin')) { + return fail(403, { error: 'Permission denied' }); + } + + const formData = await request.formData(); + const rsvpIdsString = formData.get('rsvp_ids') as string; + const isPublicString = formData.get('is_public_ids') as string; + const attended = formData.get('attended') === 'true'; + + if (!rsvpIdsString) { + return fail(400, { error: 'No RSVPs selected' }); + } + + const rsvpIds = rsvpIdsString.split(','); + const isPublicIds = isPublicString ? isPublicString.split(',') : []; + + const updateData: Record = { + attended, + updated_at: new Date().toISOString() + }; + + if (attended) { + updateData.checked_in_at = new Date().toISOString(); + } else { + updateData.checked_in_at = null; + } + + // Update member RSVPs + const memberRsvpIds = rsvpIds.filter(id => !isPublicIds.includes(id)); + const publicRsvpIds = rsvpIds.filter(id => isPublicIds.includes(id)); + + if (memberRsvpIds.length > 0) { + const memberUpdateData = { ...updateData }; + if (attended) { + memberUpdateData.checked_in_by = member.id; + } else { + memberUpdateData.checked_in_by = null; + } + + const { error: memberError } = await locals.supabase + .from('event_rsvps') + .update(memberUpdateData) + .in('id', memberRsvpIds); + + if (memberError) { + console.error('Bulk member check-in error:', memberError); + } + } + + if (publicRsvpIds.length > 0) { + const { error: publicError } = await locals.supabase + .from('event_rsvps_public') + .update(updateData) + .in('id', publicRsvpIds); + + if (publicError) { + console.error('Bulk public check-in error:', publicError); + } + } + + return { success: true, count: rsvpIds.length }; + } +}; diff --git a/src/routes/(app)/board/events/[id]/roll-call/+page.svelte b/src/routes/(app)/board/events/[id]/roll-call/+page.svelte new file mode 100644 index 0000000..eda6f21 --- /dev/null +++ b/src/routes/(app)/board/events/[id]/roll-call/+page.svelte @@ -0,0 +1,513 @@ + + + + Roll Call: {event?.title || 'Event'} | Monaco USA + + + +
+ +
+
+
+ + + Back + +
+

+ {event?.title || 'Roll Call'} +

+
+ {#if event?.start_datetime} + {formatEventDate(event.start_datetime)} + {/if} + {#if event?.location} + • {event.location} + {/if} +
+
+ +
+
+ + +
+
+

{stats.checkedIn}

+

Checked In

+
+
+
+

{stats.confirmed}

+

Confirmed

+
+ {#if stats.waitlist > 0} +
+
+

{stats.waitlist}

+

Waitlist

+
+ {/if} +
+ + +
+
+ + + {#if searchQuery} + + {/if} +
+
+ + +
+ {#each [ + { value: 'all', label: 'All', count: stats.total }, + { value: 'confirmed', label: 'Confirmed', count: stats.confirmed }, + { value: 'checked', label: 'Here', count: stats.checkedIn }, + { value: 'unchecked', label: 'Not Here', count: stats.confirmed - stats.checkedIn } + ] as tab} + + {/each} +
+
+ + +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if filteredRsvps().length === 0} +
+ +

+ {searchQuery ? 'No attendees match your search' : 'No attendees to show'} +

+
+ {:else} +
+ {#each filteredRsvps() as rsvp (rsvp.id)} + {@const isMember = rsvp.type === 'member'} + {@const name = isMember ? `${rsvp.member?.first_name} ${rsvp.member?.last_name}` : rsvp.full_name} + {@const memberId = isMember ? rsvp.member?.member_id : null} + {@const avatarUrl = isMember ? rsvp.member?.avatar_url : null} + +
{ + return async ({ update }) => { + await invalidateAll(); + await update({ reset: false }); + }; + }} + > + + + + + +
+ {/each} +
+ {/if} +
+ + + +
+ + +{#if showWalkInModal} +
+
+ +
+

Add Walk-in

+ +
+ + +
+ + +
+ + +
+ {#if walkInTab === 'member'} + +
+ + +
+ + {#if filteredWalkInMembers.length === 0} +

+ {walkInMemberSearch ? 'No members found' : 'All members have already RSVPed'} +

+ {:else} +
+ {#each filteredWalkInMembers as member (member.id)} + + {/each} +
+ {/if} + + + {#if selectedWalkInMember} +
{ + walkInLoading = true; + return async ({ update, result }) => { + walkInLoading = false; + if (result.type === 'success') { + closeWalkInModal(); + await invalidateAll(); + } + await update({ reset: false }); + }; + }} + class="mt-4" + > + + +
+ {/if} + {:else} + +
{ + walkInLoading = true; + return async ({ update, result }) => { + walkInLoading = false; + if (result.type === 'success') { + closeWalkInModal(); + await invalidateAll(); + } + await update({ reset: false }); + }; + }} + class="space-y-4" + > +
+ + +
+
+ + +
+ +
+ {/if} +
+
+
+{/if} + + diff --git a/src/routes/(app)/board/members/+page.server.ts b/src/routes/(app)/board/members/+page.server.ts new file mode 100644 index 0000000..0b06b0e --- /dev/null +++ b/src/routes/(app)/board/members/+page.server.ts @@ -0,0 +1,62 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const searchQuery = url.searchParams.get('search') || ''; + const statusFilter = url.searchParams.get('status') || 'all'; + const roleFilter = url.searchParams.get('role') || 'all'; + + // Build the query + let query = locals.supabase + .from('members_with_dues') + .select('*') + .order('last_name', { ascending: true }); + + // Apply filters + if (statusFilter !== 'all') { + query = query.eq('status_name', statusFilter); + } + + if (roleFilter !== 'all') { + query = query.eq('role', roleFilter); + } + + const { data: members } = await query; + + // Filter by search query in application (for name/email search) + let filteredMembers = members || []; + if (searchQuery) { + const lowerSearch = searchQuery.toLowerCase(); + filteredMembers = filteredMembers.filter( + (m: any) => + m.first_name?.toLowerCase().includes(lowerSearch) || + m.last_name?.toLowerCase().includes(lowerSearch) || + m.email?.toLowerCase().includes(lowerSearch) || + m.member_id?.toLowerCase().includes(lowerSearch) + ); + } + + // Get membership statuses for filter dropdown + const { data: statuses } = await locals.supabase + .from('membership_statuses') + .select('*') + .order('sort_order', { ascending: true }); + + // Calculate stats + const stats = { + total: members?.length || 0, + active: members?.filter((m: any) => m.status_name === 'active').length || 0, + pending: members?.filter((m: any) => m.status_name === 'pending').length || 0, + inactive: members?.filter((m: any) => m.status_name === 'inactive').length || 0 + }; + + return { + members: filteredMembers, + statuses: statuses || [], + stats, + filters: { + search: searchQuery, + status: statusFilter, + role: roleFilter + } + }; +}; diff --git a/src/routes/(app)/board/members/+page.svelte b/src/routes/(app)/board/members/+page.svelte new file mode 100644 index 0000000..de21553 --- /dev/null +++ b/src/routes/(app)/board/members/+page.svelte @@ -0,0 +1,374 @@ + + + + Members Directory | Monaco USA + + +
+
+
+

Members Directory

+

View and manage association members

+
+
+ + +
+
+
+
+ +
+
+

{stats.total}

+

Total Members

+
+
+
+
+
+
+ +
+
+

{stats.active}

+

Active

+
+
+
+
+
+
+ +
+
+

{stats.pending}

+

Pending

+
+
+
+
+
+
+ +
+
+

{stats.inactive}

+

Inactive

+
+
+
+
+ + +
+
+
+ + { + searchQuery = e.currentTarget.value; + handleSearch(e.currentTarget.value); + }} + class="h-10 pl-9" + /> +
+ + +
+ + {#if showFilters} +
+
+ + +
+ +
+ + +
+
+ {/if} +
+ + +
+ {#if members.length === 0} +
+ +

No members found

+

+ {filters.search || filters.status !== 'all' || filters.role !== 'all' + ? 'Try adjusting your search or filters.' + : 'Members will appear here when added.'} +

+
+ {:else} +
+ + + + + + + + + + + + + {#each members as member} + {@const statusInfo = getStatusInfo(member.status_name)} + {@const duesInfo = getDuesInfo(member.dues_status)} + {@const roleBadge = getRoleBadge(member.role)} + + + + + + + + + {/each} + +
+ Member + + Contact + + Status + + Dues + + Member Since + + Actions +
+
+ {#if member.avatar_url} + + {:else} +
+ {member.first_name?.[0]}{member.last_name?.[0]} +
+ {/if} +
+
+

+ {member.first_name} {member.last_name} +

+ + {roleBadge.label} + + {#if member.nationality && member.nationality.length > 0} +
+ {#each member.nationality as code} + + {/each} +
+ {/if} +
+

{member.member_id}

+
+
+
+
+ + {#if member.phone} +
+ + {member.phone} +
+ {/if} +
+
+ + + {member.status_display_name || member.status_name || 'Unknown'} + + +
+ + {duesInfo.label} + + {#if member.current_due_date} +

+ Due: {formatDate(member.current_due_date)} +

+ {/if} +
+
+ {formatDate(member.member_since)} + + +
+
+ {/if} +
+
diff --git a/src/routes/(app)/board/reports/+page.server.ts b/src/routes/(app)/board/reports/+page.server.ts new file mode 100644 index 0000000..34f3055 --- /dev/null +++ b/src/routes/(app)/board/reports/+page.server.ts @@ -0,0 +1,141 @@ +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const reportType = url.searchParams.get('type') || 'membership'; + const year = parseInt(url.searchParams.get('year') || new Date().getFullYear().toString()); + + // Get all members with dues info + const { data: members } = await locals.supabase + .from('members_with_dues') + .select('*') + .order('last_name', { ascending: true }); + + // Get all payments for the year + const startOfYear = new Date(year, 0, 1).toISOString(); + const endOfYear = new Date(year, 11, 31, 23, 59, 59).toISOString(); + + const { data: payments } = await locals.supabase + .from('dues_payments') + .select(` + *, + member:members(first_name, last_name, email, member_id) + `) + .gte('payment_date', startOfYear) + .lte('payment_date', endOfYear) + .order('payment_date', { ascending: false }); + + // Get all events for the year with attendance data + const { data: events } = await locals.supabase + .from('events_with_counts') + .select('*') + .gte('start_datetime', startOfYear) + .lte('start_datetime', endOfYear) + .order('start_datetime', { ascending: false }); + + // Get event RSVPs for attendance report + const { data: rsvps } = await locals.supabase + .from('event_rsvps') + .select(` + *, + event:events(title, start_datetime), + member:members(first_name, last_name, email) + `) + .eq('status', 'confirmed'); + + // Calculate membership statistics + const membershipStats = { + total: members?.length || 0, + byStatus: {} as Record, + byRole: { + admin: members?.filter(m => m.role === 'admin').length || 0, + board: members?.filter(m => m.role === 'board').length || 0, + member: members?.filter(m => m.role === 'member').length || 0 + }, + byDuesStatus: { + current: members?.filter(m => m.dues_status === 'current').length || 0, + due_soon: members?.filter(m => m.dues_status === 'due_soon').length || 0, + overdue: members?.filter(m => m.dues_status === 'overdue').length || 0, + never_paid: members?.filter(m => m.dues_status === 'never_paid').length || 0 + } + }; + + // Group by status + for (const member of members || []) { + const status = member.status_display_name || 'Unknown'; + membershipStats.byStatus[status] = (membershipStats.byStatus[status] || 0) + 1; + } + + // Calculate dues collection statistics + const duesStats = { + totalCollected: payments?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0, + paymentCount: payments?.length || 0, + byMonth: {} as Record, + byMethod: {} as Record + }; + + // Group payments by month + for (const payment of payments || []) { + const month = new Date(payment.payment_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + if (!duesStats.byMonth[month]) { + duesStats.byMonth[month] = { amount: 0, count: 0 }; + } + duesStats.byMonth[month].amount += payment.amount || 0; + duesStats.byMonth[month].count++; + + const method = payment.payment_method || 'Unknown'; + if (!duesStats.byMethod[method]) { + duesStats.byMethod[method] = { amount: 0, count: 0 }; + } + duesStats.byMethod[method].amount += payment.amount || 0; + duesStats.byMethod[method].count++; + } + + // Calculate event attendance statistics + const eventStats = { + totalEvents: events?.length || 0, + totalAttendees: events?.reduce((sum, e) => sum + (e.total_attendees || 0), 0) || 0, + averageAttendance: events?.length + ? Math.round((events.reduce((sum, e) => sum + (e.total_attendees || 0), 0) / events.length)) + : 0, + byType: {} as Record + }; + + // Group events by type + for (const event of events || []) { + const type = event.event_type_name || 'General'; + if (!eventStats.byType[type]) { + eventStats.byType[type] = { count: 0, attendees: 0 }; + } + eventStats.byType[type].count++; + eventStats.byType[type].attendees += event.total_attendees || 0; + } + + // Available years for dropdown + const currentYear = new Date().getFullYear(); + const availableYears = Array.from({ length: 5 }, (_, i) => currentYear - i); + + return { + reportType, + year, + availableYears, + members: members || [], + payments: payments || [], + events: events || [], + rsvps: rsvps || [], + membershipStats, + duesStats, + eventStats + }; +}; + +export const actions: Actions = { + exportCsv: async ({ request, locals }) => { + const formData = await request.formData(); + const reportType = formData.get('report_type') as string; + const year = parseInt(formData.get('year') as string); + + // Data will be generated client-side for CSV export + // This action is a placeholder for server-side export if needed + return { success: true }; + } +}; diff --git a/src/routes/(app)/board/reports/+page.svelte b/src/routes/(app)/board/reports/+page.svelte new file mode 100644 index 0000000..67e9e7d --- /dev/null +++ b/src/routes/(app)/board/reports/+page.svelte @@ -0,0 +1,511 @@ + + + + Reports | Monaco USA + + +
+ +
+
+

Reports

+

Generate and export membership, dues, and event reports

+
+ +
+ + + + + +
+
+ + +
+ {#each reportTabs as tab} + + {/each} +
+ + + {#if reportType === 'membership'} +
+ +
+
+
+
+

Total Members

+

{membershipStats.total}

+
+ +
+
+ +
+
+
+

Current Dues

+

{membershipStats.byDuesStatus.current}

+
+ +
+
+ +
+
+
+

Overdue

+

{membershipStats.byDuesStatus.overdue}

+
+ +
+
+ +
+
+
+

Never Paid

+

{membershipStats.byDuesStatus.never_paid}

+
+ +
+
+
+ + +
+ +
+

Members by Role

+
+
+ Administrators + {membershipStats.byRole.admin} +
+
+ Board Members + {membershipStats.byRole.board} +
+
+ Regular Members + {membershipStats.byRole.member} +
+
+
+ + +
+

Members by Status

+
+ {#each Object.entries(membershipStats.byStatus) as [status, count]} +
+ {status} + {count} +
+ {/each} +
+
+
+ + +
+
+

All Members ({members.length})

+
+
+ + + + + + + + + + + + {#each members.slice(0, 20) as member} + + + + + + + + {/each} + +
MemberIDStatusDuesSince
+
+

{member.first_name} {member.last_name}

+

{member.email}

+
+
{member.member_id} + + {member.status_display_name || 'Unknown'} + + + + {member.dues_status.replace('_', ' ')} + + {formatDate(member.member_since)}
+
+ {#if members.length > 20} +
+ Showing 20 of {members.length} members. Export CSV for full list. +
+ {/if} +
+
+ {/if} + + + {#if reportType === 'dues'} +
+ +
+
+
+
+

Total Collected ({year})

+

{formatCurrency(duesStats.totalCollected)}

+
+ +
+
+ +
+
+
+

Payments

+

{duesStats.paymentCount}

+
+ +
+
+ +
+
+
+

Avg. Payment

+

+ {formatCurrency(duesStats.paymentCount > 0 ? duesStats.totalCollected / duesStats.paymentCount : 0)} +

+
+ +
+
+ +
+
+
+

Collection Rate

+

+ {Math.round((membershipStats.byDuesStatus.current / membershipStats.total) * 100)}% +

+
+ +
+
+
+ + +
+ +
+

Collection by Month

+
+ {#each Object.entries(duesStats.byMonth).slice(0, 12) as [month, data]} +
+ {month} +
+ {formatCurrency(data.amount)} + ({data.count}) +
+
+ {/each} +
+
+ + +
+

Collection by Payment Method

+
+ {#each Object.entries(duesStats.byMethod) as [method, data]} +
+ {method.replace('_', ' ')} +
+ {formatCurrency(data.amount)} + ({data.count}) +
+
+ {/each} +
+
+
+ + +
+
+

All Payments ({payments.length})

+
+
+ + + + + + + + + + + + {#each payments.slice(0, 20) as payment} + + + + + + + + {/each} + +
DateMemberAmountMethodReference
{formatDate(payment.payment_date)} +

{payment.member?.first_name} {payment.member?.last_name}

+
{formatCurrency(payment.amount)}{payment.payment_method?.replace('_', ' ')}{payment.reference || '-'}
+
+ {#if payments.length > 20} +
+ Showing 20 of {payments.length} payments. Export CSV for full list. +
+ {/if} +
+
+ {/if} + + + {#if reportType === 'events'} +
+ +
+
+
+
+

Total Events ({year})

+

{eventStats.totalEvents}

+
+ +
+
+ +
+
+
+

Total Attendees

+

{eventStats.totalAttendees}

+
+ +
+
+ +
+
+
+

Avg. Attendance

+

{eventStats.averageAttendance}

+
+ +
+
+ +
+
+
+

Event Types

+

{Object.keys(eventStats.byType).length}

+
+ +
+
+
+ + +
+

Attendance by Event Type

+
+ {#each Object.entries(eventStats.byType) as [type, data]} +
+

{type}

+
+ {data.count} events + {data.attendees} attendees +
+
+ {/each} +
+
+ + +
+
+

All Events ({events.length})

+
+
+ + + + + + + + + + + + + {#each events.slice(0, 20) as event} + + + + + + + + + {/each} + +
DateEventTypeAttendeesCapacityWaitlist
{formatDate(event.start_datetime)}{event.title} + + {event.event_type_name || 'General'} + + {event.total_attendees}{event.max_attendees || 'Unlimited'}{event.waitlist_count}
+
+ {#if events.length > 20} +
+ Showing 20 of {events.length} events. Export CSV for full list. +
+ {/if} +
+
+ {/if} +
diff --git a/src/routes/(app)/dashboard/+page.server.ts b/src/routes/(app)/dashboard/+page.server.ts new file mode 100644 index 0000000..63b8887 --- /dev/null +++ b/src/routes/(app)/dashboard/+page.server.ts @@ -0,0 +1,84 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, parent }) => { + const { member } = await parent(); + + // Fetch upcoming events + const { data: upcomingEvents } = await locals.supabase + .from('events_with_counts') + .select('*') + .in('visibility', getVisibleLevels(member?.role)) + .eq('status', 'published') + .gte('start_datetime', new Date().toISOString()) + .order('start_datetime', { ascending: true }) + .limit(5); + + // Fetch stats for board/admin + let stats = null; + + if (member?.role === 'board' || member?.role === 'admin') { + const isAdmin = member?.role === 'admin'; + + // Get member counts by status + const { data: memberCounts } = await locals.supabase + .from('members_with_dues') + .select('status_name, dues_status'); + + const totalMembers = memberCounts?.length || 0; + const activeMembers = memberCounts?.filter((m) => m.status_name === 'active').length || 0; + const pendingMembers = memberCounts?.filter((m) => m.status_name === 'pending').length || 0; + const inactiveMembers = memberCounts?.filter((m) => m.status_name === 'inactive').length || 0; + const duesOverdue = memberCounts?.filter((m) => m.dues_status === 'overdue').length || 0; + const duesSoon = memberCounts?.filter((m) => m.dues_status === 'due_soon').length || 0; + + // Get upcoming events count (next 30 days) + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + const { count: upcomingEventsCount } = await locals.supabase + .from('events') + .select('*', { count: 'exact', head: true }) + .eq('status', 'published') + .gte('start_datetime', new Date().toISOString()) + .lte('start_datetime', thirtyDaysFromNow.toISOString()); + + // Get total dues collected this year (admin only) + let totalDuesCollected = 0; + if (isAdmin) { + const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString(); + const { data: payments } = await locals.supabase + .from('dues_payments') + .select('amount') + .gte('payment_date', startOfYear); + + totalDuesCollected = payments?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0; + } + + stats = { + totalMembers, + activeMembers, + pendingMembers, + inactiveMembers, + duesOverdue, + duesSoon, + upcomingEventsCount: upcomingEventsCount || 0, + totalDuesCollected + }; + } + + return { + upcomingEvents: upcomingEvents || [], + stats + }; +}; + +function getVisibleLevels(role: string | undefined): string[] { + switch (role) { + case 'admin': + return ['public', 'members', 'board', 'admin']; + case 'board': + return ['public', 'members', 'board']; + default: + return ['public', 'members']; + } +} diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte new file mode 100644 index 0000000..0188d8c --- /dev/null +++ b/src/routes/(app)/dashboard/+page.svelte @@ -0,0 +1,110 @@ + + + + Dashboard | Monaco USA + + +
+ + {#if member} + + {/if} + + + {#if member} + + {/if} + + +
+ + {#if member} + + {/if} + + + +
+ + + {#if isBoard && stats} +
+

Board Overview

+
+ + + + +
+
+ {/if} + + + {#if isAdmin && stats} +
+

Admin Overview

+
+ + + +
+
+ {/if} +
diff --git a/src/routes/(app)/documents/+page.server.ts b/src/routes/(app)/documents/+page.server.ts new file mode 100644 index 0000000..9bce5ae --- /dev/null +++ b/src/routes/(app)/documents/+page.server.ts @@ -0,0 +1,49 @@ +import type { PageServerLoad } from './$types'; +import { isS3Enabled } from '$lib/server/storage'; + +export const load: PageServerLoad = async ({ locals, parent }) => { + const { member } = await parent(); + + // Get visible visibility levels + const visibleLevels = getVisibleLevels(member?.role); + + // Fetch documents with all URL columns + const { data: documents } = await locals.supabase + .from('documents') + .select('*') + .in('visibility', visibleLevels) + .order('created_at', { ascending: false }); + + // Fetch categories + const { data: categories } = await locals.supabase + .from('document_categories') + .select('*') + .eq('is_active', true) + .order('sort_order', { ascending: true }); + + // Resolve active URL for each document based on current storage settings + const s3Enabled = await isS3Enabled(); + const documentsWithActiveUrl = (documents || []).map((doc: any) => ({ + ...doc, + // Compute active URL based on storage setting + active_url: s3Enabled + ? (doc.file_url_s3 || doc.file_path) + : (doc.file_url_local || doc.file_path) + })); + + return { + documents: documentsWithActiveUrl, + categories: categories || [] + }; +}; + +function getVisibleLevels(role: string | undefined): string[] { + switch (role) { + case 'admin': + return ['public', 'members', 'board', 'admin']; + case 'board': + return ['public', 'members', 'board']; + default: + return ['public', 'members']; + } +} diff --git a/src/routes/(app)/documents/+page.svelte b/src/routes/(app)/documents/+page.svelte new file mode 100644 index 0000000..b5fbe80 --- /dev/null +++ b/src/routes/(app)/documents/+page.svelte @@ -0,0 +1,270 @@ + + + + Documents | Monaco USA + + +
+
+
+

Documents

+

Meeting minutes, bylaws, and resources

+
+ +
+
+ + +
+
+
+ + +
+ +
+ + +
+ + +
+ + {#each categories || [] as category} + + {/each} +
+
+ + + {#if filteredDocuments.length === 0} +
+ +

No documents found

+

+ {searchQuery || selectedCategory + ? 'Try adjusting your search or filters.' + : 'Documents will appear here when added.'} +

+
+ {:else if viewMode === 'grid'} +
+ {#each filteredDocuments as doc} + {@const category = getCategory(doc.category_id)} +
+
+
+ + + {getFileIcon(doc.mime_type)} + + {#if category} + + {category.display_name} + + {/if} +
+
+ +
+

{doc.title}

+ {#if doc.description} +

{doc.description}

+ {/if} + +
+ + + {formatDate(doc.created_at)} + + {formatFileSize(doc.file_size)} +
+ + +
+
+ {/each} +
+ {:else} + +
+ + + + + + + + + + + + {#each filteredDocuments as doc} + {@const category = getCategory(doc.category_id)} + + + + + + + + {/each} + +
+ Document + + Category + + Date + + Size + + Actions +
+
+ +
+

{doc.title}

+

{doc.file_name}

+
+
+
+ {category?.display_name || '-'} + + {formatDate(doc.created_at)} + + {formatFileSize(doc.file_size)} + + +
+
+ {/if} +
diff --git a/src/routes/(app)/events/+page.server.ts b/src/routes/(app)/events/+page.server.ts new file mode 100644 index 0000000..175d1ac --- /dev/null +++ b/src/routes/(app)/events/+page.server.ts @@ -0,0 +1,31 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, parent }) => { + const { member } = await parent(); + + // Get visible events based on user role + const visibleLevels = getVisibleLevels(member?.role); + + const { data: events } = await locals.supabase + .from('events_with_counts') + .select('*') + .in('visibility', visibleLevels) + .eq('status', 'published') + .gte('start_datetime', new Date().toISOString()) + .order('start_datetime', { ascending: true }); + + return { + events: events || [] + }; +}; + +function getVisibleLevels(role: string | undefined): string[] { + switch (role) { + case 'admin': + return ['public', 'members', 'board', 'admin']; + case 'board': + return ['public', 'members', 'board']; + default: + return ['public', 'members']; + } +} diff --git a/src/routes/(app)/events/+page.svelte b/src/routes/(app)/events/+page.svelte new file mode 100644 index 0000000..ce3777b --- /dev/null +++ b/src/routes/(app)/events/+page.svelte @@ -0,0 +1,340 @@ + + + + Events | Monaco USA + + +
+ +
+
+

Events

+

Upcoming events and activities

+
+ +
+
+ + +
+
+
+ + {#if viewMode === 'list'} + +
+ {#if groupedEvents.length === 0} +
+ +

No upcoming events

+

Check back later for new events and activities.

+
+ {:else} + {#each groupedEvents as group} + {@const groupDate = new Date(group.date)} +
+ +
+
+ + {groupDate.toLocaleDateString('en-US', { month: 'short' })} + + + {groupDate.getDate()} + +
+
+

+ {isToday(group.date) + ? 'Today' + : groupDate.toLocaleDateString('en-US', { weekday: 'long' })} +

+

+ {groupDate.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + })} +

+
+
+ + + +
+ {/each} + {/if} +
+ {:else} + +
+ +
+ +

{monthName}

+ +
+ + +
+ +
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+ + +
+ {#each calendarDays as day} + {@const dayEvents = getEventsForDate(day)} +
+ {#if day} +
+ {day.getDate()} +
+ {#each dayEvents.slice(0, 2) as event} + + {event.title} + + {/each} + {#if dayEvents.length > 2} + +{dayEvents.length - 2} more + {/if} + {/if} +
+ {/each} +
+
+
+ {/if} +
diff --git a/src/routes/(app)/events/[id]/+page.server.ts b/src/routes/(app)/events/[id]/+page.server.ts new file mode 100644 index 0000000..e772021 --- /dev/null +++ b/src/routes/(app)/events/[id]/+page.server.ts @@ -0,0 +1,314 @@ +import { fail, error } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { sendTemplatedEmail } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + const { member } = await parent(); + + // Fetch the event + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + throw error(404, 'Event not found'); + } + + // Check visibility permissions + const visibleLevels = getVisibleLevels(member?.role); + if (!visibleLevels.includes(event.visibility)) { + throw error(403, 'You do not have permission to view this event'); + } + + // Fetch user's RSVP if they have one + let rsvp = null; + if (member) { + const { data } = await locals.supabase + .from('event_rsvps') + .select('*') + .eq('event_id', params.id) + .eq('member_id', member.id) + .single(); + rsvp = data; + } + + return { + event, + rsvp + }; +}; + +export const actions: Actions = { + rsvp: async ({ request, locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'You must be logged in to RSVP' }); + } + + const formData = await request.formData(); + const guestCount = parseInt(formData.get('guest_count') as string) || 0; + + // Fetch the event to check capacity and guest limits + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + return fail(404, { error: 'Event not found' }); + } + + // Server-side guest count validation + if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) { + return fail(400, { + error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed per member` + }); + } + + // Check if event is full + const totalAttending = event.total_attendees + 1 + guestCount; + const isFull = event.max_attendees && totalAttending > event.max_attendees; + + // Create RSVP + const { error: rsvpError } = await locals.supabase.from('event_rsvps').insert({ + event_id: params.id, + member_id: member.id, + status: isFull ? 'waitlist' : 'confirmed', + guest_count: guestCount, + payment_status: event.is_paid ? 'pending' : 'not_required', + payment_amount: event.is_paid ? event.member_price * (1 + guestCount) : null + }); + + if (rsvpError) { + if (rsvpError.code === '23505') { + return fail(400, { error: 'You have already RSVP\'d to this event' }); + } + console.error('RSVP error:', rsvpError); + return fail(500, { error: 'Failed to submit RSVP. Please try again.' }); + } + + return { + success: isFull + ? 'You have been added to the waitlist. We will notify you if a spot opens up.' + : 'RSVP submitted successfully!' + }; + }, + + updateRsvp: async ({ request, locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'You must be logged in' }); + } + + const formData = await request.formData(); + const newStatus = formData.get('status') as string; + const guestCount = parseInt(formData.get('guest_count') as string) || 0; + + // Validate status + const validStatuses = ['confirmed', 'declined', 'maybe']; + if (!validStatuses.includes(newStatus)) { + return fail(400, { error: 'Invalid RSVP status' }); + } + + // Fetch the event for validation + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (!event) { + return fail(404, { error: 'Event not found' }); + } + + // Get current RSVP + const { data: currentRsvp } = await locals.supabase + .from('event_rsvps') + .select('*') + .eq('event_id', params.id) + .eq('member_id', member.id) + .single(); + + if (!currentRsvp) { + return fail(404, { error: 'RSVP not found' }); + } + + // Validate guest count + if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) { + return fail(400, { + error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed` + }); + } + + // If changing from waitlist/declined to confirmed, check capacity + if (newStatus === 'confirmed' && currentRsvp.status !== 'confirmed') { + const spotsNeeded = 1 + guestCount; + const currentConfirmed = event.total_attendees; + if (event.max_attendees && currentConfirmed + spotsNeeded > event.max_attendees) { + return fail(400, { error: 'Event is at capacity. You will remain on the waitlist.' }); + } + } + + // Update RSVP + const { error: updateError } = await locals.supabase + .from('event_rsvps') + .update({ + status: newStatus, + guest_count: guestCount, + payment_amount: event.is_paid ? event.member_price * (1 + guestCount) : null, + updated_at: new Date().toISOString() + }) + .eq('event_id', params.id) + .eq('member_id', member.id); + + if (updateError) { + console.error('Update RSVP error:', updateError); + return fail(500, { error: 'Failed to update RSVP. Please try again.' }); + } + + // If changing to declined from confirmed, try to promote someone from waitlist + if (newStatus === 'declined' && currentRsvp.status === 'confirmed') { + await promoteFromWaitlist(locals.supabase, params.id as string, event); + } + + const statusMessages: Record = { + confirmed: 'Great! You\'re now confirmed for this event.', + declined: 'You have declined this event.', + maybe: 'Your tentative response has been recorded.' + }; + + return { success: statusMessages[newStatus] || 'RSVP updated successfully.' }; + }, + + cancel: async ({ locals, params }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'You must be logged in' }); + } + + // Get current RSVP status before deleting + const { data: currentRsvp } = await locals.supabase + .from('event_rsvps') + .select('status') + .eq('event_id', params.id) + .eq('member_id', member.id) + .single(); + + const { error: deleteError } = await locals.supabase + .from('event_rsvps') + .delete() + .eq('event_id', params.id) + .eq('member_id', member.id); + + if (deleteError) { + console.error('Cancel RSVP error:', deleteError); + return fail(500, { error: 'Failed to cancel RSVP. Please try again.' }); + } + + // If cancelling a confirmed RSVP, try to promote someone from waitlist + if (currentRsvp?.status === 'confirmed') { + // Fetch event for capacity check + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .single(); + + if (event) { + await promoteFromWaitlist(locals.supabase, params.id as string, event); + } + } + + return { success: 'RSVP cancelled successfully.' }; + } +}; + +/** + * Promote the oldest waitlisted member to confirmed status + */ +async function promoteFromWaitlist( + supabase: typeof import('@supabase/supabase-js').SupabaseClient, + eventId: string, + event: { max_attendees: number | null; total_attendees: number; is_paid: boolean; member_price: number; title?: string; start_datetime?: string; location?: string } +) { + // Check if there's room + if (event.max_attendees && event.total_attendees >= event.max_attendees) { + return; // Still full + } + + // Get oldest waitlisted member with their info + const { data: waitlisted } = await supabase + .from('event_rsvps') + .select('id, member_id, guest_count, member:members(first_name, last_name, email)') + .eq('event_id', eventId) + .eq('status', 'waitlist') + .order('created_at', { ascending: true }) + .limit(1) + .single(); + + if (!waitlisted) { + return; // No one on waitlist + } + + // Check if promoting them (plus guests) would exceed capacity + const spotsNeeded = 1 + (waitlisted.guest_count || 0); + if (event.max_attendees && event.total_attendees + spotsNeeded > event.max_attendees) { + return; // Not enough room for this person + guests + } + + // Promote to confirmed + await supabase + .from('event_rsvps') + .update({ + status: 'confirmed', + updated_at: new Date().toISOString() + }) + .eq('id', waitlisted.id); + + // Send email notification to promoted member + const member = waitlisted.member as { first_name: string; last_name: string; email: string } | null; + if (member?.email) { + const eventDate = event.start_datetime + ? new Date(event.start_datetime).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }) + : 'TBD'; + + await sendTemplatedEmail( + 'waitlist_promotion', + member.email, + { + first_name: member.first_name, + event_title: event.title || 'Event', + event_date: eventDate, + event_location: event.location || 'TBD' + }, + { + recipientId: waitlisted.member_id, + recipientName: `${member.first_name} ${member.last_name}` + } + ); + + console.log(`Promoted member ${waitlisted.member_id} from waitlist for event ${eventId} and sent notification`); + } +} + +function getVisibleLevels(role: string | undefined): string[] { + switch (role) { + case 'admin': + return ['public', 'members', 'board', 'admin']; + case 'board': + return ['public', 'members', 'board']; + default: + return ['public', 'members']; + } +} diff --git a/src/routes/(app)/events/[id]/+page.svelte b/src/routes/(app)/events/[id]/+page.svelte new file mode 100644 index 0000000..0862ff9 --- /dev/null +++ b/src/routes/(app)/events/[id]/+page.svelte @@ -0,0 +1,483 @@ + + + + {event?.title || 'Event'} | Monaco USA + + +
+ + + + Back to events + + + {#if event} + +
+ {#if event.cover_image_url} +
+ {event.title} +
+ {/if} + +
+
+ + {event.event_type_name || 'Event'} + + {#if event.is_paid} + + + €{event.member_price} + + {:else} + + Free + + {/if} + {#if event.is_full} + + Full + + {/if} + {#if isPast} + + Past Event + + {/if} +
+ +

{event.title}

+ + {#if event.description} +

{event.description}

+ {/if} +
+
+ + +
+
+ +
+
+

Date & Time

+ {#if event && !isPast} + + {/if} +
+
+
+ +
+

{startDateTime.date}

+

+ {startDateTime.time} - {endDateTime.time} +

+
+
+ {#if event.location} +
+ +
+

{event.location}

+ {#if event.location_url} + + View on map + + + {/if} +
+
+ {/if} +
+
+ + +
+

Attendees

+
+ +
+

+ {event.total_attendees} + {event.max_attendees ? ` / ${event.max_attendees}` : ''} attending +

+ {#if event.waitlist_count > 0} +

{event.waitlist_count} on waitlist

+ {/if} +
+
+
+
+ + +
+
+

RSVP

+ + {#if form?.error} +
+ +
+ {/if} + + {#if form?.success} +
+ +
+ {/if} + + {#if isPast} +

This event has already ended.

+ {:else if rsvp} + +
+
+ {#if isPendingPayment} + + Processing - Payment Required + {:else if rsvp.status === 'confirmed'} + + You're attending! + {:else if rsvp.status === 'waitlist'} + + You're on the waitlist + {:else if rsvp.status === 'maybe'} + + You're tentative + {:else if rsvp.status === 'declined'} + + You declined + {/if} +
+ {#if isPendingPayment && rsvp.payment_amount} +
+
+ Amount Due: + €{rsvp.payment_amount.toFixed(2)} +
+

+ Your RSVP will be confirmed once payment is received. +

+
+ {/if} + {#if rsvp.guest_count > 0} +

+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''} +

+ {/if} +
+ + + {#if rsvp.status !== 'cancelled'} +
+

Change your response:

+
+ {#if rsvp.status !== 'confirmed' && !event.is_full} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + class="inline" + > + + + +
+ {/if} + {#if rsvp.status !== 'maybe'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + class="inline" + > + + + +
+ {/if} + {#if rsvp.status !== 'declined'} +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + class="inline" + > + + + +
+ {/if} +
+ + +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + > + +
+
+ {/if} + {:else} + +
{ + loading = true; + return async ({ update, result }) => { + loading = false; + if (result.type === 'success') { + await invalidateAll(); + } + await update(); + }; + }} + class="space-y-4" + > + {#if event.max_guests_per_member > 0} +
+ + +

+ Max {event.max_guests_per_member} guest{event.max_guests_per_member > 1 + ? 's' + : ''} allowed +

+
+ {/if} + + {#if event.is_paid} +
+

+ Total: €{(event.member_price * (1 + guestCount)).toFixed(2)} +

+

+ Payment instructions will be sent after RSVP +

+
+ {/if} + + +
+ {/if} +
+
+
+ {:else} +
+

Event not found.

+ + View all events + +
+ {/if} +
diff --git a/src/routes/(app)/payments/+page.server.ts b/src/routes/(app)/payments/+page.server.ts new file mode 100644 index 0000000..de90623 --- /dev/null +++ b/src/routes/(app)/payments/+page.server.ts @@ -0,0 +1,42 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, parent }) => { + const { member } = await parent(); + + // Fetch payment history + const { data: payments } = await locals.supabase + .from('dues_payments') + .select('*') + .eq('member_id', member?.id) + .order('payment_date', { ascending: false }); + + // Fetch payment settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'dues'); + + // Convert settings array to object + const paymentSettings: Record = {}; + if (settings) { + for (const setting of settings) { + const key = setting.setting_key.replace('payment_', ''); + const value = setting.setting_value; + // Handle JSON-encoded strings + if (typeof value === 'string' && value.startsWith('"')) { + try { + paymentSettings[key] = JSON.parse(value); + } catch { + paymentSettings[key] = value; + } + } else { + paymentSettings[key] = String(value); + } + } + } + + return { + payments: payments || [], + paymentSettings + }; +}; diff --git a/src/routes/(app)/payments/+page.svelte b/src/routes/(app)/payments/+page.svelte new file mode 100644 index 0000000..417d4dd --- /dev/null +++ b/src/routes/(app)/payments/+page.svelte @@ -0,0 +1,286 @@ + + + + Payments | Monaco USA + + +
+
+

Payments

+

View your dues status and payment history

+
+ +
+ +
+
+

+ + Dues Status +

+ +
+ +
+

{duesInfo.label}

+

{duesInfo.description}

+
+
+ +
+
+

Annual Dues

+

+ €{(member?.annual_dues || 50).toFixed(2)} +

+
+
+

Membership Type

+

+ {member?.membership_type_name || 'Regular'} +

+
+
+

Last Payment

+

+ {formatDate(member?.last_payment_date)} +

+
+
+

Next Due Date

+

+ {formatDate(member?.current_due_date)} +

+
+
+
+
+ + +
+
+

+ + Payment Details +

+ +

+ Please make your payment via bank transfer to: +

+ +
+ {#if paymentSettings?.bank_name} +
+

Bank

+

{paymentSettings.bank_name}

+
+ {/if} + +
+

Account Holder

+

+ {paymentSettings?.account_holder || 'ASSOCIATION MONACO USA'} +

+
+ +
+

IBAN

+
+ + {paymentSettings?.iban || 'MC58 1756 9000 0104 0050 1001 860'} + + +
+
+ +
+

Reference

+
+ + {member?.member_id} + + +
+
+
+ +

+ Please include your Member ID ({member?.member_id}) in the payment reference. +

+
+
+
+ + +
+
+

+ + Payment History +

+
+ + {#if payments && payments.length > 0} +
+ + + + + + + + + + + + {#each payments as payment} + + + + + + + + {/each} + +
+ Date + + Amount + + Period Covered + + Reference + + Method +
+ {formatDate(payment.payment_date)} + + €{payment.amount.toFixed(2)} + + Until {formatDate(payment.due_date)} + + {payment.reference || '-'} + + {payment.payment_method?.replace('_', ' ') || 'Bank Transfer'} +
+
+ {:else} +
+ +

No payments yet

+

+ Your payment history will appear here once you make your first payment. +

+
+ {/if} +
+
diff --git a/src/routes/(app)/profile/+page.server.ts b/src/routes/(app)/profile/+page.server.ts new file mode 100644 index 0000000..abb2668 --- /dev/null +++ b/src/routes/(app)/profile/+page.server.ts @@ -0,0 +1,167 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { uploadAvatar, deleteAvatar, isS3Enabled, getActiveAvatarUrl } from '$lib/server/storage'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const load: PageServerLoad = async ({ parent }) => { + const { member } = await parent(); + + // Resolve the correct avatar URL based on current storage settings + if (member) { + const activeAvatarUrl = await getActiveAvatarUrl(member); + return { + member: { + ...member, + avatar_url: activeAvatarUrl + } + }; + } + + return { member }; +}; + +export const actions: Actions = { + updateProfile: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const firstName = formData.get('first_name') as string; + const lastName = formData.get('last_name') as string; + const phone = formData.get('phone') as string; + const address = formData.get('address') as string; + const nationalityString = formData.get('nationality') as string; + + // Validation + if (!firstName || firstName.length < 2) { + return fail(400, { error: 'First name must be at least 2 characters' }); + } + + if (!lastName || lastName.length < 2) { + return fail(400, { error: 'Last name must be at least 2 characters' }); + } + + if (!phone) { + return fail(400, { error: 'Phone number is required' }); + } + + if (!address || address.length < 10) { + return fail(400, { error: 'Please enter a complete address' }); + } + + const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : []; + if (nationality.length === 0) { + return fail(400, { error: 'Please select at least one nationality' }); + } + + // Update member profile (use admin client to bypass RLS) + const { error } = await supabaseAdmin + .from('members') + .update({ + first_name: firstName, + last_name: lastName, + phone, + address, + nationality, + updated_at: new Date().toISOString() + }) + .eq('id', member.id); + + if (error) { + console.error('Failed to update profile:', error); + return fail(500, { error: 'Failed to update profile. Please try again.' }); + } + + return { success: 'Profile updated successfully!' }; + }, + + uploadAvatar: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const file = formData.get('avatar') as File; + + if (!file || !file.size) { + return fail(400, { error: 'Please select an image to upload' }); + } + + // First delete any existing avatar from both storage backends + if (member.avatar_path) { + await deleteAvatar(member.id, member.avatar_path); + } else { + await deleteAvatar(member.id); + } + + // Upload the avatar to appropriate storage (or both) + const result = await uploadAvatar(member.id, file); + + if (!result.success) { + return fail(400, { error: result.error || 'Failed to upload avatar' }); + } + + // Determine active URL based on current S3 setting + const s3Active = await isS3Enabled(); + const activeUrl = s3Active ? result.s3Url : result.localUrl; + + // Update member record with all avatar URLs (use admin client to bypass RLS) + const { error: updateError } = await supabaseAdmin + .from('members') + .update({ + avatar_url: activeUrl || result.publicUrl, + avatar_url_local: result.localUrl || null, + avatar_url_s3: result.s3Url || null, + avatar_path: result.path, + updated_at: new Date().toISOString() + }) + .eq('id', member.id); + + if (updateError) { + console.error('Failed to update avatar URL:', updateError); + return fail(500, { error: 'Failed to update profile with new avatar' }); + } + + return { success: 'Avatar uploaded successfully!' }; + }, + + removeAvatar: async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + // Delete the avatar from BOTH storage backends using the stored path + if (member.avatar_path) { + await deleteAvatar(member.id, member.avatar_path); + } else { + // Fallback: try to delete common extensions + await deleteAvatar(member.id); + } + + // Update member record to clear all avatar URLs (use admin client to bypass RLS) + const { error: updateError } = await supabaseAdmin + .from('members') + .update({ + avatar_url: null, + avatar_url_local: null, + avatar_url_s3: null, + avatar_path: null, + updated_at: new Date().toISOString() + }) + .eq('id', member.id); + + if (updateError) { + console.error('Failed to remove avatar URL:', updateError); + return fail(500, { error: 'Failed to update profile' }); + } + + return { success: 'Avatar removed successfully!' }; + } +}; diff --git a/src/routes/(app)/profile/+page.svelte b/src/routes/(app)/profile/+page.svelte new file mode 100644 index 0000000..039c996 --- /dev/null +++ b/src/routes/(app)/profile/+page.svelte @@ -0,0 +1,374 @@ + + + + My Profile | Monaco USA + + +
+ +
+
+ +
+ {#if member?.avatar_url} + {`${member.first_name} + {:else} +
+ {member?.first_name[0]}{member?.last_name[0]} +
+ {/if} + + +
{ + avatarLoading = true; + return async ({ update }) => { + await invalidateAll(); + avatarLoading = false; + await update(); + }; + }} + class="absolute bottom-0 right-0" + > + e.currentTarget.form?.requestSubmit()} + class="hidden" + /> + +
+ + + {#if member?.avatar_url} +
{ + avatarLoading = true; + return async ({ update }) => { + await invalidateAll(); + avatarLoading = false; + await update(); + }; + }} + class="absolute -bottom-2 -right-2" + > + +
+ {/if} +
+ +
+

+ {member?.first_name} + {member?.last_name} +

+

{member?.email}

+
+ + {member?.member_id} + + + {member?.status_display_name || 'Pending'} + + {#if member?.role !== 'member'} + + {member?.role} + + {/if} +
+
+
+
+ + +
+

Personal Information

+ + {#if form?.error} +
+ +
+ {/if} + + {#if form?.success} +
+ +
+ {/if} + +
{ + loading = true; + return async ({ update }) => { + await invalidateAll(); + loading = false; + await update(); + }; + }} + class="space-y-6" + > + +
+
+ + +
+
+ + +
+
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+ + +
+ +
+ +
+
+
+ + +
+

Membership Details

+ +
+
+
Member ID
+
{member?.member_id}
+
+
+
Membership Type
+
{member?.membership_type_name || 'Regular'}
+
+
+
Member Since
+
+ {member?.member_since + ? new Date(member.member_since).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : 'N/A'} +
+
+
+
Date of Birth
+
+ {member?.date_of_birth + ? new Date(member.date_of_birth).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : 'N/A'} +
+
+
+
+
diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts new file mode 100644 index 0000000..b45e978 --- /dev/null +++ b/src/routes/(app)/settings/+page.server.ts @@ -0,0 +1,422 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { uploadAvatar, deleteAvatar } from '$lib/server/storage'; +import { sendTemplatedEmail, wrapInMonacoTemplate, sendEmail } from '$lib/server/email'; +import { getMailbox, updateMailbox, type PosteConfig } from '$lib/server/poste'; + +export const load: PageServerLoad = async ({ parent, locals }) => { + const { member } = await parent(); + + // Load notification preferences + const { data: notificationPrefs } = await locals.supabase + .from('user_notification_preferences') + .select('*') + .eq('member_id', member.id) + .single(); + + // Check if member is board/admin and has a monacousa.org email + let monacoEmail: string | null = null; + let hasMonacoEmailAccount = false; + + if (member.role === 'board' || member.role === 'admin') { + // Check if they have a monacousa.org email stored + const { data: emailRecord } = await locals.supabase + .from('members') + .select('monaco_email') + .eq('id', member.id) + .single(); + + if (emailRecord?.monaco_email) { + monacoEmail = emailRecord.monaco_email; + hasMonacoEmailAccount = true; + } else if (member.email?.endsWith('@monacousa.org')) { + // Their primary email is a monacousa.org email + monacoEmail = member.email; + hasMonacoEmailAccount = true; + } + } + + return { + member, + notificationPrefs: notificationPrefs || { + email_event_rsvp_confirmation: true, + email_event_reminder: true, + email_event_updates: true, + email_waitlist_promotion: true, + email_dues_reminder: true, + email_payment_confirmation: true, + email_membership_updates: true, + email_announcements: true, + email_newsletter: true, + newsletter_frequency: 'monthly' + }, + monacoEmail, + hasMonacoEmailAccount + }; +}; + +export const actions: Actions = { + updateProfile: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const firstName = formData.get('first_name') as string; + const lastName = formData.get('last_name') as string; + const phone = formData.get('phone') as string; + const address = formData.get('address') as string; + const nationalityString = formData.get('nationality') as string; + + // Validation + if (!firstName || firstName.length < 2) { + return fail(400, { error: 'First name must be at least 2 characters' }); + } + + if (!lastName || lastName.length < 2) { + return fail(400, { error: 'Last name must be at least 2 characters' }); + } + + const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : []; + + // Update member profile + const { error } = await locals.supabase + .from('members') + .update({ + first_name: firstName, + last_name: lastName, + phone: phone || null, + address: address || null, + nationality, + updated_at: new Date().toISOString() + }) + .eq('id', member.id); + + if (error) { + console.error('Failed to update profile:', error); + return fail(500, { error: 'Failed to update profile. Please try again.' }); + } + + return { success: 'Profile updated successfully!' }; + }, + + uploadAvatar: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const file = formData.get('avatar') as File; + + if (!file || !file.size) { + return fail(400, { error: 'Please select an image to upload' }); + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return fail(400, { error: 'Please upload a valid image (JPEG, PNG, WebP, or GIF)' }); + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + return fail(400, { error: 'Image must be less than 5MB' }); + } + + // Upload the avatar - pass user's supabase client for RLS + const result = await uploadAvatar(member.id, file, locals.supabase); + + if (!result.success) { + return fail(400, { error: result.error || 'Failed to upload avatar' }); + } + + // Update member record with avatar URL + const { error: updateError } = await locals.supabase + .from('members') + .update({ + avatar_url: result.publicUrl, + updated_at: new Date().toISOString() + }) + .eq('id', member.id); + + if (updateError) { + console.error('Failed to update avatar URL:', updateError); + return fail(500, { error: 'Failed to update profile with new avatar' }); + } + + return { success: 'Profile picture updated successfully!' }; + }, + + removeAvatar: async ({ locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + // Delete the avatar from storage - pass user's supabase client for RLS + await deleteAvatar(member.id, locals.supabase); + + // Update member record to remove avatar URL + const { error: updateError } = await locals.supabase + .from('members') + .update({ + avatar_url: null, + updated_at: new Date().toISOString() + }) + .eq('id', member.id); + + if (updateError) { + console.error('Failed to remove avatar URL:', updateError); + return fail(500, { error: 'Failed to update profile' }); + } + + return { success: 'Profile picture removed!' }; + }, + + updateNotifications: async ({ request, locals }) => { + const { member } = await locals.safeGetSession(); + + if (!member) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + + const notificationPrefs = { + email_event_rsvp_confirmation: formData.get('email_event_rsvp_confirmation') === 'on', + email_event_reminder: formData.get('email_event_reminder') === 'on', + email_event_updates: formData.get('email_event_updates') === 'on', + email_waitlist_promotion: formData.get('email_waitlist_promotion') === 'on', + email_dues_reminder: formData.get('email_dues_reminder') === 'on', + email_payment_confirmation: formData.get('email_payment_confirmation') === 'on', + email_membership_updates: formData.get('email_membership_updates') === 'on', + email_announcements: formData.get('email_announcements') === 'on', + email_newsletter: formData.get('email_newsletter') === 'on', + newsletter_frequency: formData.get('newsletter_frequency') as string || 'monthly' + }; + + // Upsert notification preferences + const { error } = await locals.supabase + .from('user_notification_preferences') + .upsert({ + member_id: member.id, + ...notificationPrefs, + updated_at: new Date().toISOString() + }, { + onConflict: 'member_id' + }); + + if (error) { + console.error('Failed to update notification preferences:', error); + return fail(500, { error: 'Failed to update notification preferences' }); + } + + return { success: 'Notification preferences saved!' }; + }, + + updateEmail: async ({ request, locals }) => { + const { member, session } = await locals.safeGetSession(); + + if (!member || !session) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const newEmail = formData.get('email') as string; + + if (!newEmail || !newEmail.includes('@')) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + if (newEmail === member.email) { + return fail(400, { error: 'New email is the same as current email' }); + } + + // Update email in Supabase Auth (will send verification email) + const { error } = await locals.supabase.auth.updateUser({ + email: newEmail + }); + + if (error) { + console.error('Failed to update email:', error); + if (error.message.includes('already registered')) { + return fail(400, { error: 'This email is already in use by another account' }); + } + return fail(500, { error: 'Failed to update email. Please try again.' }); + } + + return { success: 'Verification email sent to your new address. Please check your inbox.' }; + }, + + updatePassword: async ({ request, locals }) => { + const { member, session } = await locals.safeGetSession(); + + if (!member || !session) { + return fail(401, { error: 'Not authenticated' }); + } + + const formData = await request.formData(); + const currentPassword = formData.get('current_password') as string; + const newPassword = formData.get('new_password') as string; + const confirmPassword = formData.get('confirm_password') as string; + + // Validate current password is provided + if (!currentPassword) { + return fail(400, { error: 'Current password is required' }); + } + + if (!newPassword || newPassword.length < 8) { + return fail(400, { error: 'New password must be at least 8 characters' }); + } + + if (newPassword !== confirmPassword) { + return fail(400, { error: 'New passwords do not match' }); + } + + // Verify current password by re-authenticating + const { error: authError } = await locals.supabase.auth.signInWithPassword({ + email: member.email, + password: currentPassword + }); + + if (authError) { + return fail(400, { error: 'Current password is incorrect' }); + } + + // Update password in Supabase Auth + const { error } = await locals.supabase.auth.updateUser({ + password: newPassword + }); + + if (error) { + console.error('Failed to update password:', error); + return fail(500, { error: 'Failed to update password. Please try again.' }); + } + + // Send password changed notification email + const changedAt = new Date().toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short' + }); + + // Try to send templated email, fall back to inline email if template doesn't exist + const templateResult = await sendTemplatedEmail( + 'password_changed', + member.email, + { + first_name: member.first_name, + changed_at: changedAt + }, + { + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}` + } + ); + + // If template doesn't exist, send a fallback email + if (!templateResult.success && templateResult.error?.includes('not found')) { + const fallbackContent = ` +

Hi ${member.first_name},

+

Your Monaco USA account password was successfully changed on ${changedAt}.

+
+

⚠️ Didn't make this change?

+

If you did not change your password, please contact us immediately at info@monacousa.org.

+
+

This is an automated security notification.

`; + + await sendEmail({ + to: member.email, + subject: 'Your Monaco USA Password Was Changed', + html: wrapInMonacoTemplate({ + title: 'Password Changed', + content: fallbackContent + }), + recipientId: member.id, + recipientName: `${member.first_name} ${member.last_name}`, + emailType: 'account' + }); + } + + return { success: 'Password updated successfully!' }; + }, + + updateMonacoEmailPassword: async ({ request, locals }) => { + const { member, session } = await locals.safeGetSession(); + + if (!member || !session) { + return fail(401, { error: 'Not authenticated' }); + } + + // Check if member has access to Monaco email + if (member.role !== 'board' && member.role !== 'admin') { + return fail(403, { error: 'Monaco email is only available for board members and admins' }); + } + + const formData = await request.formData(); + const monacoEmail = formData.get('monaco_email') as string; + const newPassword = formData.get('monaco_new_password') as string; + const confirmPassword = formData.get('monaco_confirm_password') as string; + + if (!monacoEmail || !monacoEmail.endsWith('@monacousa.org')) { + return fail(400, { error: 'Invalid Monaco USA email address' }); + } + + if (!newPassword || newPassword.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters' }); + } + + if (newPassword !== confirmPassword) { + return fail(400, { error: 'Passwords do not match' }); + } + + // Get Poste configuration from app_settings + const { data: posteSettings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'poste'); + + if (!posteSettings || posteSettings.length === 0) { + return fail(500, { error: 'Email server not configured. Please contact an administrator.' }); + } + + const config: PosteConfig = { + host: '', + adminEmail: '', + adminPassword: '' + }; + + for (const setting of posteSettings) { + let value = setting.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + if (setting.setting_key === 'poste_api_host') config.host = value as string; + if (setting.setting_key === 'poste_admin_email') config.adminEmail = value as string; + if (setting.setting_key === 'poste_admin_password') config.adminPassword = value as string; + } + + if (!config.host || !config.adminEmail || !config.adminPassword) { + return fail(500, { error: 'Email server not properly configured. Please contact an administrator.' }); + } + + // Update the mailbox password + const result = await updateMailbox(config, monacoEmail, { password: newPassword }); + + if (!result.success) { + console.error('Failed to update Monaco email password:', result.error); + return fail(500, { error: result.error || 'Failed to update email password' }); + } + + return { success: 'Monaco USA email password updated successfully!' }; + } +}; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..411f83a --- /dev/null +++ b/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,1186 @@ + + + + + + Settings | Monaco USA + + +
+
+

Settings

+

Manage your profile, notifications, and account security

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if form?.success} +
+ {form.success} +
+ {/if} + + +
+ +
+ + + {#if activeTab === 'profile'} +
+ +
+

Profile Picture

+
+
+ {#if avatarPreview || member?.avatar_url} + Profile + {:else} +
+ {getInitials(member?.first_name || '', member?.last_name || '')} +
+ {/if} +
+ +
+
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + avatarPreview = null; + }; + }} + > + + {#if avatarPreview} + + {:else} + + {/if} +
+ + {#if member?.avatar_url} +
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + > + +
+ {/if} +
+

+ JPG, PNG, WebP or GIF. Max 5MB. +

+
+
+ + +
+

Personal Information

+
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+
+ + +
+
+ + +
+
+ + +
+ +

Select all nationalities/citizenships that apply

+ + + + {#if selectedNationalities.length > 0} +
+ {#each selectedNationalities as code} + {@const lowerCode = code.toLowerCase()} + + + {getCountryName(code)} + + + {/each} +
+ {/if} + + +
+ + + {#if nationalityDropdownOpen} +
+ +
+
+ + +
+
+ + +
+ {#each filteredCountries as country} + + {/each} + {#if filteredCountries.length === 0} +

No countries found

+ {/if} +
+
+ {/if} +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + + + +
+ +
+
+
+
+ {/if} + + + {#if activeTab === 'notifications'} +
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + class="space-y-6" + > + +
+
+
+ +
+
+

Event Notifications

+

Control emails about events and RSVPs

+
+
+ +
+ + + + + + + +
+
+ + +
+
+
+ +
+
+

Membership & Payments

+

Notifications about your membership and dues

+
+
+ +
+ + + + + +
+
+ + +
+
+
+ +
+
+

News & Updates

+

Stay informed about Monaco USA activities

+
+
+ +
+ + + +
+
+ +
+ +
+
+ {/if} + + + {#if activeTab === 'account'} +
+ + {#if data.hasMonacoEmailAccount && data.monacoEmail} +
+
+
+ +
+
+

Monaco USA Email

+

Manage your @monacousa.org email account

+
+
+ +
+
+ + {data.monacoEmail} +
+

+ Access your email at mail.monacousa.org +

+
+ +
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > + + +
+ +
+ + +
+
+ +
+ +
+ + +
+

+ Password must be at least 8 characters long. +

+
+ + +
+
+ {/if} + + +
+
+
+ +
+
+

Email Address

+

Update your email address

+
+
+ +
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+ + +
+ +
+ + +

+ A verification link will be sent to your new email address. +

+
+ + +
+
+ + +
+
+
+ +
+
+

Password

+

Change your account password

+
+
+ +
{ + isSubmitting = true; + return async ({ update }) => { + await invalidateAll(); + await update(); + isSubmitting = false; + }; + }} + class="space-y-4" + > +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+

+ Password must be at least 8 characters long. +

+
+ + +
+
+ + +
+

Membership Information

+
+
+

Member ID

+

{member?.member_id || 'N/A'}

+
+
+

Membership Type

+

{member?.membership_type?.display_name || 'N/A'}

+
+
+

Member Since

+

+ {member?.member_since + ? new Date(member.member_since).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : 'N/A'} +

+
+
+

Status

+

{member?.membership_status?.display_name || 'N/A'}

+
+
+
+
+ {/if} +
diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte new file mode 100644 index 0000000..4a673fa --- /dev/null +++ b/src/routes/(auth)/+layout.svelte @@ -0,0 +1,57 @@ + + +
+ +
+ +
+
+ + +
+
+
+
+
+ +
+ + + + +
+ {@render children()} +
+ + +

+ © 2026 Monaco USA. All rights reserved. +

+
+
diff --git a/src/routes/(auth)/forgot-password/+page.server.ts b/src/routes/(auth)/forgot-password/+page.server.ts new file mode 100644 index 0000000..22734f7 --- /dev/null +++ b/src/routes/(auth)/forgot-password/+page.server.ts @@ -0,0 +1,34 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return {}; +}; + +export const actions: Actions = { + default: async ({ request, locals, url }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + + if (!email || !email.includes('@')) { + return fail(400, { + error: 'Please enter a valid email address', + email + }); + } + + const { error } = await locals.supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${url.origin}/auth/reset-password` + }); + + if (error) { + // Don't reveal if email exists or not for security + console.error('Password reset error:', error); + } + + // Always show success message (don't reveal if email exists) + return { + success: 'If an account exists with this email, you will receive a password reset link shortly.' + }; + } +}; diff --git a/src/routes/(auth)/forgot-password/+page.svelte b/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 0000000..d242b31 --- /dev/null +++ b/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,83 @@ + + + + Forgot Password | Monaco USA + + +
+
+

Forgot your password?

+

+ Enter your email and we'll send you a reset link +

+
+ + {#if form?.error} + + {/if} + + {#if form?.success} + + + {:else} +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" + > + + + + + + + {/if} +
diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts new file mode 100644 index 0000000..b2cd18c --- /dev/null +++ b/src/routes/(auth)/login/+page.server.ts @@ -0,0 +1,90 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, locals }) => { + const { session } = await locals.safeGetSession(); + + // If already logged in, redirect to dashboard + if (session) { + throw redirect(303, '/dashboard'); + } + + // Handle URL-based error messages + const errorCode = url.searchParams.get('error'); + let errorMessage: string | null = null; + + if (errorCode === 'no_profile') { + errorMessage = 'Your account is not properly configured. Please contact support or try signing up again.'; + } else if (errorCode === 'expired') { + errorMessage = 'Your session has expired. Please sign in again.'; + } else if (errorCode) { + errorMessage = decodeURIComponent(errorCode); + } + + return { + redirectTo: url.searchParams.get('redirectTo') || '/dashboard', + urlError: errorMessage + }; +}; + +export const actions: Actions = { + default: async ({ request, locals, url }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + const redirectTo = url.searchParams.get('redirectTo') || '/dashboard'; + + if (!email || !password) { + return fail(400, { + error: 'Please enter your email and password', + email + }); + } + + const { data, error } = await locals.supabase.auth.signInWithPassword({ + email, + password + }); + + if (error) { + // Handle specific error cases + if (error.message.includes('Invalid login credentials')) { + return fail(400, { + error: 'Invalid email or password. Please try again.', + email + }); + } + + if (error.message.includes('Email not confirmed')) { + return fail(400, { + error: 'Please verify your email address before signing in. Check your inbox for the verification link.', + email + }); + } + + return fail(400, { + error: error.message, + email + }); + } + + // Check if member profile exists + const { data: member } = await locals.supabase + .from('members') + .select('id') + .eq('id', data.user.id) + .single(); + + if (!member) { + // User exists in auth but not in members table - unusual situation + // They may have been deleted or there was a signup issue + await locals.supabase.auth.signOut(); + return fail(400, { + error: 'Your account is not properly configured. Please contact support.', + email + }); + } + + throw redirect(303, redirectTo); + } +}; diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte new file mode 100644 index 0000000..0929262 --- /dev/null +++ b/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,110 @@ + + + + Sign In | Monaco USA + + +
+
+

Welcome back

+

Sign in to your member account

+
+ + {#if data.urlError} + + {/if} + + {#if form?.error} + + {/if} + + {#if form?.success} + + {/if} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" + > + + + + +
+ + + + Forgot password? + +
+ + + + +
+

+ Don't have an account? + + Sign up + +

+
+
diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts new file mode 100644 index 0000000..1b5cf88 --- /dev/null +++ b/src/routes/(auth)/signup/+page.server.ts @@ -0,0 +1,235 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const load: PageServerLoad = async ({ locals }) => { + const { session } = await locals.safeGetSession(); + + // If already logged in, redirect to dashboard + if (session) { + throw redirect(303, '/dashboard'); + } + + return {}; +}; + +export const actions: Actions = { + default: async ({ request, locals, url }) => { + const formData = await request.formData(); + + // Extract form fields + const firstName = formData.get('first_name') as string; + const lastName = formData.get('last_name') as string; + const email = formData.get('email') as string; + const phone = formData.get('phone') as string; + const dateOfBirth = formData.get('date_of_birth') as string; + const address = formData.get('address') as string; + const nationalityString = formData.get('nationality') as string; + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirm_password') as string; + const terms = formData.get('terms'); + + // Validation + const errors: Record = {}; + + if (!firstName || firstName.length < 2) { + errors.first_name = 'First name must be at least 2 characters'; + } + + if (!lastName || lastName.length < 2) { + errors.last_name = 'Last name must be at least 2 characters'; + } + + if (!email || !email.includes('@')) { + errors.email = 'Please enter a valid email address'; + } + + if (!phone) { + errors.phone = 'Phone number is required'; + } + + if (!dateOfBirth) { + errors.date_of_birth = 'Date of birth is required'; + } else { + // Check if 18+ + const birthDate = new Date(dateOfBirth); + const today = new Date(); + const age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + const dayDiff = today.getDate() - birthDate.getDate(); + const actualAge = monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age; + + if (actualAge < 18) { + errors.date_of_birth = 'You must be at least 18 years old to join'; + } + } + + if (!address || address.length < 10) { + errors.address = 'Please enter a complete address'; + } + + const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : []; + if (nationality.length === 0) { + errors.nationality = 'Please select at least one nationality'; + } + + if (!password || password.length < 8) { + errors.password = 'Password must be at least 8 characters'; + } + + if (password !== confirmPassword) { + errors.confirm_password = 'Passwords do not match'; + } + + if (!terms) { + errors.terms = 'You must accept the terms and conditions'; + } + + // Return validation errors + if (Object.keys(errors).length > 0) { + return fail(400, { + error: Object.values(errors)[0], + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + // Create Supabase auth user + const { data: authData, error: authError } = await locals.supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${url.origin}/auth/callback`, + data: { + first_name: firstName, + last_name: lastName + } + } + }); + + if (authError) { + if (authError.message.includes('already registered')) { + return fail(400, { + error: 'An account with this email already exists. Try signing in instead.', + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + return fail(400, { + error: authError.message, + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + if (!authData.user) { + return fail(500, { + error: 'Failed to create account. Please try again.', + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + // Get the default membership status (pending) + const { data: defaultStatus, error: statusError } = await locals.supabase + .from('membership_statuses') + .select('id') + .eq('is_default', true) + .single(); + + // Get the default membership type + const { data: defaultType, error: typeError } = await locals.supabase + .from('membership_types') + .select('id') + .eq('is_default', true) + .single(); + + // Validate that default status and type exist + if (statusError || !defaultStatus?.id) { + console.error('No default membership status found:', statusError); + // Clean up the auth user since we can't complete registration + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { + error: 'System configuration error. Please contact support.', + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + if (typeError || !defaultType?.id) { + console.error('No default membership type found:', typeError); + // Clean up the auth user since we can't complete registration + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { + error: 'System configuration error. Please contact support.', + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + // Create member profile + const { error: memberError } = await locals.supabase.from('members').insert({ + id: authData.user.id, + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address, + nationality, + role: 'member', + membership_status_id: defaultStatus.id, + membership_type_id: defaultType.id + }); + + if (memberError) { + // Clean up the auth user since member profile creation failed + console.error('Failed to create member profile:', memberError); + try { + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + } catch (deleteError) { + console.error('Failed to clean up auth user:', deleteError); + } + return fail(500, { + error: 'Failed to create member profile. Please try again or contact support.', + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address + }); + } + + // Return success - user needs to verify email + return { + success: + 'Account created! Please check your email to verify your account before signing in.' + }; + } +}; diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte new file mode 100644 index 0000000..fec2221 --- /dev/null +++ b/src/routes/(auth)/signup/+page.svelte @@ -0,0 +1,256 @@ + + + + Sign Up | Monaco USA + + +
+
+

Create your account

+

Join the Monaco USA community

+
+ + {#if form?.error} + + {/if} + + {#if form?.success} + + {:else} +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" + > + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +

You must be at least 18 years old to join.

+
+ + +
+ + +
+ + +
+ + + {#if selectedNationalities.length === 0} +

Select at least one nationality.

+ {/if} +
+ + +
+ + +

At least 8 characters.

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ {/if} + +
+

+ Already have an account? + Sign in +

+
+
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..a42a112 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,143 @@ + + + + {status} - {errorInfo.title} | Monaco USA + + +
+ +
+ +
+
+ + +
+
+
+
+
+ +
+ + + + +
+ +
+ + + +
+ + +
{status}
+ + +

{errorInfo.title}

+ + +

{errorInfo.message}

+ + +
+ + + +
+
+ + +

+ Need help? + + Contact support + +

+ + +

© 2026 Monaco USA. All rights reserved.

+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..a424b90 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,22 @@ + + + + + Monaco USA Portal + + + + + + +
+ {@render children()} +
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..915bea8 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const { session } = await locals.safeGetSession(); + + // If logged in, go to dashboard; otherwise go to login + if (session) { + throw redirect(303, '/dashboard'); + } else { + throw redirect(303, '/login'); + } +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..50beda5 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,222 @@ + + +
+ +
+
+
+
+
+ + +
+ +
+
+ Member Portal 2026 +
+ +

+ Monaco USA +

+ +

+ Americans in Monaco - Your gateway to community events, member resources, and association + management. +

+ +
+ + +
+
+ + +
+ + +
+ + + +
+ Member Directory + + Connect with fellow Americans in Monaco through our comprehensive member directory. + +
+
+ + + +
+ + + +
+ Events Calendar + + Stay updated with social gatherings, meetings, and special events in the community. + +
+
+ + + +
+ + + +
+ Documents & Resources + + Access meeting minutes, bylaws, and important association documents anytime. + +
+
+ + + +
+ + + +
+ Dues Management + + Track your membership dues, view payment history, and manage your subscription. + +
+
+ + + +
+ + + +
+ Notifications + + Receive timely reminders about events, dues, and important announcements. + +
+
+ + + +
+ + + +
+ Secure Access + + Role-based access ensures members, board, and admins see exactly what they need. + +
+
+
+ + +
+
+
+

150+

+

Active Members

+
+
+

50+

+

Events Per Year

+
+
+

25+

+

Years Active

+
+
+

100%

+

Community Driven

+
+
+
+ + +
+

© 2026 Monaco USA. All rights reserved.

+

Americans in Monaco since 1999

+
+
+
diff --git a/src/routes/api/auth/check-verification/+server.ts b/src/routes/api/auth/check-verification/+server.ts new file mode 100644 index 0000000..b781e4e --- /dev/null +++ b/src/routes/api/auth/check-verification/+server.ts @@ -0,0 +1,16 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ locals }) => { + const { session, user } = await locals.safeGetSession(); + + if (!session || !user) { + return json({ verified: false, error: 'Not authenticated' }, { status: 401 }); + } + + // Check if email is verified + // In Supabase, this is stored in user metadata + const emailVerified = user.email_confirmed_at !== null; + + return json({ verified: emailVerified }); +}; diff --git a/src/routes/api/auth/resend-verification/+server.ts b/src/routes/api/auth/resend-verification/+server.ts new file mode 100644 index 0000000..36b1de7 --- /dev/null +++ b/src/routes/api/auth/resend-verification/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ locals, url }) => { + const { session, user } = await locals.safeGetSession(); + + if (!session || !user) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + if (!user.email) { + return json({ error: 'No email associated with account' }, { status: 400 }); + } + + // Resend verification email + const { error } = await locals.supabase.auth.resend({ + type: 'signup', + email: user.email, + options: { + emailRedirectTo: `${url.origin}/join?verified=true` + } + }); + + if (error) { + console.error('Failed to resend verification email:', error); + return json({ error: error.message }, { status: 500 }); + } + + return json({ success: true, message: 'Verification email sent' }); +}; diff --git a/src/routes/api/calendar/events/[id]/+server.ts b/src/routes/api/calendar/events/[id]/+server.ts new file mode 100644 index 0000000..c4c8c5f --- /dev/null +++ b/src/routes/api/calendar/events/[id]/+server.ts @@ -0,0 +1,97 @@ +/** + * API endpoint for downloading a single event as an .ics file + * Requires authentication for non-public events + */ + +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { generateSingleEventIcal } from '$lib/server/ical'; + +export const GET: RequestHandler = async ({ params, locals, url }) => { + const eventId = params.id; + + // Get user session + const { member } = await locals.safeGetSession(); + + // Fetch the event + const { data: event, error: fetchError } = await locals.supabase + .from('events') + .select(` + id, + title, + description, + start_datetime, + end_datetime, + location, + location_url, + timezone, + status, + visibility, + all_day, + event_type:event_types(name) + `) + .eq('id', eventId) + .single(); + + if (fetchError || !event) { + throw error(404, 'Event not found'); + } + + // Check visibility permissions + const canView = checkVisibility(event.visibility, member?.role); + if (!canView) { + throw error(403, 'You do not have permission to view this event'); + } + + // Generate iCal content + const baseUrl = url.origin || 'https://monacousa.org'; + const icalContent = generateSingleEventIcal( + { + id: event.id, + title: event.title, + description: event.description || undefined, + start_datetime: event.start_datetime, + end_datetime: event.end_datetime, + location: event.location, + location_url: event.location_url, + timezone: event.timezone || 'Europe/Monaco', + status: event.status as 'published' | 'cancelled' | 'draft', + event_type_name: (event.event_type as { name: string } | null)?.name, + organizer_name: 'Monaco USA', + organizer_email: 'events@monacousa.org', + all_day: event.all_day || false + }, + baseUrl + ); + + // Generate filename + const sanitizedTitle = event.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const filename = `monaco-usa-${sanitizedTitle}.ics`; + + return new Response(icalContent, { + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + }); +}; + +function checkVisibility(visibility: string, role?: string): boolean { + switch (visibility) { + case 'public': + return true; + case 'members': + return !!role; // Any authenticated member + case 'board': + return role === 'board' || role === 'admin'; + case 'admin': + return role === 'admin'; + default: + return false; + } +} diff --git a/src/routes/api/calendar/feed/+server.ts b/src/routes/api/calendar/feed/+server.ts new file mode 100644 index 0000000..0fa16bf --- /dev/null +++ b/src/routes/api/calendar/feed/+server.ts @@ -0,0 +1,117 @@ +/** + * API endpoint for subscribing to Monaco USA events calendar feed + * Returns iCal feed of upcoming events + * + * Usage: + * - /api/calendar/feed - Public events only (no auth required) + * - /api/calendar/feed?token=xxx - Member events with auth token + * + * Subscribe URL: webcal://yourdomain.com/api/calendar/feed + */ + +import type { RequestHandler } from './$types'; +import { generateCalendarFeed, type ICalEvent } from '$lib/server/ical'; +import { supabaseAdmin } from '$lib/server/supabase'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const token = url.searchParams.get('token'); + const includePrivate = url.searchParams.get('private') === 'true'; + + // Determine visibility level based on authentication + let visibilityLevels = ['public']; + let calendarName = 'Monaco USA Public Events'; + + // Check if user is authenticated (via session or token) + const { member } = await locals.safeGetSession(); + + if (member) { + // Authenticated user - include member events + visibilityLevels = getVisibilityLevels(member.role); + calendarName = 'Monaco USA Events'; + } else if (token) { + // Token-based access (for calendar subscriptions) + // Verify the token against a member's calendar token + const { data: memberWithToken } = await supabaseAdmin + .from('members') + .select('id, role') + .eq('calendar_token', token) + .single(); + + if (memberWithToken) { + visibilityLevels = getVisibilityLevels(memberWithToken.role); + calendarName = 'Monaco USA Events'; + } + } + + // Fetch upcoming events + const now = new Date(); + const threeMonthsFromNow = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); + + const { data: events, error: fetchError } = await supabaseAdmin + .from('events') + .select(` + id, + title, + description, + start_datetime, + end_datetime, + location, + location_url, + timezone, + status, + visibility, + all_day, + event_type:event_types(name) + `) + .in('visibility', visibilityLevels) + .eq('status', 'published') + .gte('start_datetime', now.toISOString()) + .lte('start_datetime', threeMonthsFromNow.toISOString()) + .order('start_datetime', { ascending: true }); + + if (fetchError) { + console.error('Error fetching events for feed:', fetchError); + return new Response('Error fetching events', { status: 500 }); + } + + // Convert to ICalEvent format + const baseUrl = url.origin || 'https://monacousa.org'; + const icalEvents: ICalEvent[] = (events || []).map(event => ({ + id: event.id, + title: event.title, + description: event.description || undefined, + start_datetime: event.start_datetime, + end_datetime: event.end_datetime, + location: event.location, + location_url: event.location_url, + timezone: event.timezone || 'Europe/Monaco', + status: event.status as 'published' | 'cancelled' | 'draft', + event_type_name: (event.event_type as { name: string } | null)?.name, + organizer_name: 'Monaco USA', + organizer_email: 'events@monacousa.org', + all_day: event.all_day || false + })); + + // Generate iCal feed + const icalContent = generateCalendarFeed(icalEvents, calendarName, baseUrl); + + return new Response(icalContent, { + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour + 'X-WR-CALNAME': calendarName + } + }); +}; + +function getVisibilityLevels(role?: string): string[] { + switch (role) { + case 'admin': + return ['public', 'members', 'board', 'admin']; + case 'board': + return ['public', 'members', 'board']; + case 'member': + default: + return ['public', 'members']; + } +} diff --git a/src/routes/api/calendar/public/events/[id]/+server.ts b/src/routes/api/calendar/public/events/[id]/+server.ts new file mode 100644 index 0000000..689b53b --- /dev/null +++ b/src/routes/api/calendar/public/events/[id]/+server.ts @@ -0,0 +1,77 @@ +/** + * API endpoint for downloading a public event as an .ics file + * No authentication required - only works for public visibility events + */ + +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { generateSingleEventIcal } from '$lib/server/ical'; + +export const GET: RequestHandler = async ({ params, url }) => { + const eventId = params.id; + + // Fetch the event using supabaseAdmin since this is a public endpoint + const { supabaseAdmin } = await import('$lib/server/supabase'); + + const { data: event, error: fetchError } = await supabaseAdmin + .from('events') + .select(` + id, + title, + description, + start_datetime, + end_datetime, + location, + location_url, + timezone, + status, + visibility, + all_day, + event_type:event_types(name) + `) + .eq('id', eventId) + .eq('visibility', 'public') + .eq('status', 'published') + .single(); + + if (fetchError || !event) { + throw error(404, 'Event not found or not publicly accessible'); + } + + // Generate iCal content + const baseUrl = url.origin || 'https://monacousa.org'; + const icalContent = generateSingleEventIcal( + { + id: event.id, + title: event.title, + description: event.description || undefined, + start_datetime: event.start_datetime, + end_datetime: event.end_datetime, + location: event.location, + location_url: event.location_url, + timezone: event.timezone || 'Europe/Monaco', + status: event.status as 'published' | 'cancelled' | 'draft', + event_type_name: (event.event_type as { name: string } | null)?.name, + organizer_name: 'Monaco USA', + organizer_email: 'events@monacousa.org', + all_day: event.all_day || false + }, + baseUrl + ); + + // Generate filename + const sanitizedTitle = event.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const filename = `monaco-usa-${sanitizedTitle}.ics`; + + return new Response(icalContent, { + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'public, max-age=300' // Cache for 5 minutes + } + }); +}; diff --git a/src/routes/api/cron/dues-reminders/+server.ts b/src/routes/api/cron/dues-reminders/+server.ts new file mode 100644 index 0000000..1a7902d --- /dev/null +++ b/src/routes/api/cron/dues-reminders/+server.ts @@ -0,0 +1,256 @@ +/** + * Cron API Endpoint for Automated Dues Reminders + * + * This endpoint should be called daily by an external cron service + * (e.g., Vercel Cron, GitHub Actions, or a server cron job) + * + * Security: Requires CRON_SECRET header for authentication + * + * Example cron setup (daily at 9 AM): + * 0 9 * * * curl -X POST https://yourdomain.com/api/cron/dues-reminders \ + * -H "Authorization: Bearer YOUR_CRON_SECRET" + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { + getDuesSettings, + sendBulkReminders, + processGracePeriodExpirations, + getMembersNeedingReminder, + getMembersNeedingOnboardingReminder, + sendOnboardingReminders, + processOnboardingExpirations, + type ReminderType, + type OnboardingReminderType +} from '$lib/server/dues'; + +const CRON_SECRET = env.CRON_SECRET; + +export const POST: RequestHandler = async ({ request, url }) => { + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!CRON_SECRET) { + console.error('CRON_SECRET not configured'); + return json({ error: 'Server not configured for cron jobs' }, { status: 500 }); + } + + if (token !== CRON_SECRET) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org'; + const dryRun = url.searchParams.get('dry_run') === 'true'; + + try { + // Load reminder settings + const settings = await getDuesSettings(); + const reminderDays = settings.reminder_days_before || [30, 7, 1]; + + const results = { + timestamp: new Date().toISOString(), + dryRun, + settings: { + reminder_days_before: reminderDays, + grace_period_days: settings.grace_period_days, + auto_inactive_enabled: settings.auto_inactive_enabled + }, + reminders: { + due_soon_30: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }, + due_soon_7: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }, + due_soon_1: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] } + } as Record, + overdue: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }, + graceWarning: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }, + // Onboarding reminders for new members with payment_deadline + onboarding: { + reminder_7: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }, + reminder_1: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }, + expired: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] } + }, + inactivated: [] as Array<{ id: string; name: string; email: string }>, + onboardingInactivated: [] as Array<{ id: string; name: string; email: string }>, + summary: { + totalRemindersSent: 0, + totalErrors: 0 + } + }; + + // Process each reminder tier + for (const days of reminderDays) { + const reminderType = `due_soon_${days}` as ReminderType; + + // Get eligible members + const eligibleMembers = await getMembersNeedingReminder(reminderType); + results.reminders[reminderType] = { + eligible: eligibleMembers.length, + sent: 0, + skipped: 0, + errors: [] + }; + + if (!dryRun && eligibleMembers.length > 0) { + const result = await sendBulkReminders(reminderType, baseUrl); + results.reminders[reminderType].sent = result.sent; + results.reminders[reminderType].skipped = result.skipped; + results.reminders[reminderType].errors = result.errors; + results.summary.totalRemindersSent += result.sent; + results.summary.totalErrors += result.errors.length; + } + } + + // Process overdue notifications + const overdueMembers = await getMembersNeedingReminder('overdue'); + results.overdue.eligible = overdueMembers.length; + + if (!dryRun && overdueMembers.length > 0) { + const overdueResult = await sendBulkReminders('overdue', baseUrl); + results.overdue.sent = overdueResult.sent; + results.overdue.skipped = overdueResult.skipped; + results.overdue.errors = overdueResult.errors; + results.summary.totalRemindersSent += overdueResult.sent; + results.summary.totalErrors += overdueResult.errors.length; + } + + // Process grace period warnings + const graceMembers = await getMembersNeedingReminder('grace_period'); + results.graceWarning.eligible = graceMembers.length; + + if (!dryRun && graceMembers.length > 0) { + const graceResult = await sendBulkReminders('grace_period', baseUrl); + results.graceWarning.sent = graceResult.sent; + results.graceWarning.skipped = graceResult.skipped; + results.graceWarning.errors = graceResult.errors; + results.summary.totalRemindersSent += graceResult.sent; + results.summary.totalErrors += graceResult.errors.length; + } + + // Process grace period expirations (mark members as inactive) + if (!dryRun && settings.auto_inactive_enabled) { + const inactivationResult = await processGracePeriodExpirations(baseUrl); + results.inactivated = inactivationResult.members; + } + + // ============================================ + // ONBOARDING REMINDERS (new members with payment_deadline) + // ============================================ + + // Process 7-day onboarding reminders + const onboarding7Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_7'); + results.onboarding.reminder_7.eligible = onboarding7Members.length; + + if (!dryRun && onboarding7Members.length > 0) { + const onboarding7Result = await sendOnboardingReminders('onboarding_reminder_7', baseUrl); + results.onboarding.reminder_7.sent = onboarding7Result.sent; + results.onboarding.reminder_7.skipped = onboarding7Result.skipped; + results.onboarding.reminder_7.errors = onboarding7Result.errors; + results.summary.totalRemindersSent += onboarding7Result.sent; + results.summary.totalErrors += onboarding7Result.errors.length; + } + + // Process 1-day onboarding reminders (final warning) + const onboarding1Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_1'); + results.onboarding.reminder_1.eligible = onboarding1Members.length; + + if (!dryRun && onboarding1Members.length > 0) { + const onboarding1Result = await sendOnboardingReminders('onboarding_reminder_1', baseUrl); + results.onboarding.reminder_1.sent = onboarding1Result.sent; + results.onboarding.reminder_1.skipped = onboarding1Result.skipped; + results.onboarding.reminder_1.errors = onboarding1Result.errors; + results.summary.totalRemindersSent += onboarding1Result.sent; + results.summary.totalErrors += onboarding1Result.errors.length; + } + + // Process expired onboarding deadlines (mark as inactive) + if (!dryRun && settings.auto_inactive_enabled) { + const onboardingExpiredResult = await processOnboardingExpirations(baseUrl); + results.onboardingInactivated = onboardingExpiredResult.members; + } + + return json(results); + } catch (error) { + console.error('Cron job error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return json( + { + error: 'Internal server error', + message: errorMessage, + timestamp: new Date().toISOString() + }, + { status: 500 } + ); + } +}; + +/** + * GET endpoint for checking cron status and getting preview of pending actions + */ +export const GET: RequestHandler = async ({ request, url }) => { + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!CRON_SECRET || token !== CRON_SECRET) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const settings = await getDuesSettings(); + const reminderDays = settings.reminder_days_before || [30, 7, 1]; + + const preview = { + timestamp: new Date().toISOString(), + settings: { + reminder_days_before: reminderDays, + grace_period_days: settings.grace_period_days, + auto_inactive_enabled: settings.auto_inactive_enabled + }, + pendingActions: { + due_soon_30: 0, + due_soon_7: 0, + due_soon_1: 0, + overdue: 0, + grace_period: 0 + } as Record, + pendingOnboarding: { + onboarding_reminder_7: 0, + onboarding_reminder_1: 0, + onboarding_expired: 0 + } as Record + }; + + // Count pending for each type + for (const days of reminderDays) { + const reminderType = `due_soon_${days}` as ReminderType; + const members = await getMembersNeedingReminder(reminderType); + preview.pendingActions[reminderType] = members.length; + } + + // Count overdue + const overdueMembers = await getMembersNeedingReminder('overdue'); + preview.pendingActions.overdue = overdueMembers.length; + + // Count grace period warnings + const graceMembers = await getMembersNeedingReminder('grace_period'); + preview.pendingActions.grace_period = graceMembers.length; + + // Count onboarding reminders + const onboarding7Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_7'); + preview.pendingOnboarding.onboarding_reminder_7 = onboarding7Members.length; + + const onboarding1Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_1'); + preview.pendingOnboarding.onboarding_reminder_1 = onboarding1Members.length; + + const onboardingExpiredMembers = await getMembersNeedingOnboardingReminder('onboarding_expired'); + preview.pendingOnboarding.onboarding_expired = onboardingExpiredMembers.length; + + return json(preview); + } catch (error) { + console.error('Cron preview error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return json({ error: 'Internal server error', message: errorMessage }, { status: 500 }); + } +}; diff --git a/src/routes/api/cron/event-reminders/+server.ts b/src/routes/api/cron/event-reminders/+server.ts new file mode 100644 index 0000000..cfbd32f --- /dev/null +++ b/src/routes/api/cron/event-reminders/+server.ts @@ -0,0 +1,158 @@ +/** + * Cron API Endpoint for Automated Event Reminders + * + * This endpoint should be called hourly by an external cron service + * to send reminder emails to members 24 hours before events. + * + * Security: Requires CRON_SECRET header for authentication + * + * Example cron setup (hourly): + * 0 * * * * curl -X POST https://yourdomain.com/api/cron/event-reminders \ + * -H "Authorization: Bearer YOUR_CRON_SECRET" + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { + getEventReminderSettings, + getEventsNeedingReminders, + sendEventReminders, + getEventReminderStats +} from '$lib/server/event-reminders'; + +const CRON_SECRET = env.CRON_SECRET; + +export const POST: RequestHandler = async ({ request, url }) => { + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!CRON_SECRET) { + console.error('CRON_SECRET not configured'); + return json({ error: 'Server not configured for cron jobs' }, { status: 500 }); + } + + if (token !== CRON_SECRET) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org'; + const dryRun = url.searchParams.get('dry_run') === 'true'; + + try { + // Load settings + const settings = await getEventReminderSettings(); + + const results = { + timestamp: new Date().toISOString(), + dryRun, + settings: { + event_reminders_enabled: settings.event_reminders_enabled, + event_reminder_hours_before: settings.event_reminder_hours_before + }, + eligible: 0, + sent: 0, + skipped: 0, + errors: [] as string[], + reminders: [] as Array<{ + eventId: string; + eventTitle: string; + memberId: string; + memberName: string; + email: string; + status: string; + error?: string; + }> + }; + + if (!settings.event_reminders_enabled) { + return json({ + ...results, + message: 'Event reminders are disabled' + }); + } + + // Get events needing reminders + const eventsNeeding = await getEventsNeedingReminders(); + results.eligible = eventsNeeding.length; + + if (dryRun) { + // Just show what would be sent + results.reminders = eventsNeeding.map(e => ({ + eventId: e.event_id, + eventTitle: e.event_title, + memberId: e.member_id, + memberName: `${e.first_name} ${e.last_name}`, + email: e.email, + status: 'would_send' + })); + return json(results); + } + + // Send the reminders + if (eventsNeeding.length > 0) { + const sendResult = await sendEventReminders(baseUrl); + results.sent = sendResult.sent; + results.skipped = sendResult.skipped; + results.errors = sendResult.errors; + results.reminders = sendResult.reminders; + } + + return json(results); + } catch (error) { + console.error('Event reminders cron job error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return json( + { + error: 'Internal server error', + message: errorMessage, + timestamp: new Date().toISOString() + }, + { status: 500 } + ); + } +}; + +/** + * GET endpoint for checking status and getting preview of pending reminders + */ +export const GET: RequestHandler = async ({ request }) => { + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!CRON_SECRET || token !== CRON_SECRET) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const settings = await getEventReminderSettings(); + const eventsNeeding = await getEventsNeedingReminders(); + const stats = await getEventReminderStats(); + + const preview = { + timestamp: new Date().toISOString(), + settings: { + event_reminders_enabled: settings.event_reminders_enabled, + event_reminder_hours_before: settings.event_reminder_hours_before + }, + pendingReminders: eventsNeeding.length, + pendingDetails: eventsNeeding.map(e => ({ + eventId: e.event_id, + eventTitle: e.event_title, + eventStart: e.start_datetime, + memberName: `${e.first_name} ${e.last_name}`, + email: e.email, + guestCount: e.guest_count + })), + stats + }; + + return json(preview); + } catch (error) { + console.error('Event reminders preview error:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return json({ error: 'Internal server error', message: errorMessage }, { status: 500 }); + } +}; diff --git a/src/routes/auth/callback/+server.ts b/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..ca00b0d --- /dev/null +++ b/src/routes/auth/callback/+server.ts @@ -0,0 +1,32 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +/** + * Auth callback handler for email verification and OAuth redirects + * This endpoint exchanges the auth code for a session + */ +export const GET: RequestHandler = async ({ url, locals }) => { + const code = url.searchParams.get('code'); + const next = url.searchParams.get('next') || '/dashboard'; + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + // Handle error from Supabase auth + if (error) { + console.error('Auth callback error:', error, errorDescription); + throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || error)}`); + } + + // Exchange the code for a session + if (code) { + const { error: exchangeError } = await locals.supabase.auth.exchangeCodeForSession(code); + + if (exchangeError) { + console.error('Failed to exchange code for session:', exchangeError); + throw redirect(303, `/login?error=${encodeURIComponent('Failed to verify email. Please try again.')}`); + } + } + + // Redirect to the next page or dashboard + throw redirect(303, next); +}; diff --git a/src/routes/auth/reset-password/+page.server.ts b/src/routes/auth/reset-password/+page.server.ts new file mode 100644 index 0000000..9c90f85 --- /dev/null +++ b/src/routes/auth/reset-password/+page.server.ts @@ -0,0 +1,111 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, locals }) => { + // Check for token in URL (from email link via /auth/verify redirect) + const token = url.searchParams.get('token'); + const type = url.searchParams.get('type'); + const error = url.searchParams.get('error'); + + // If there's an error from a previous attempt, show it + if (error) { + return { error }; + } + + // If there's a token, we need to verify it to establish a session + if (token) { + try { + // For recovery/invite tokens, verify the OTP + const otpType = type === 'invite' ? 'invite' : 'recovery'; + + const { data, error: verifyError } = await locals.supabase.auth.verifyOtp({ + token_hash: token, + type: otpType + }); + + if (verifyError) { + console.error('Token verification error:', verifyError); + // Token invalid or expired + throw redirect( + 303, + `/forgot-password?error=${encodeURIComponent(verifyError.message || 'Invalid or expired reset link. Please request a new one.')}` + ); + } + + if (data.session) { + // Session established - user can now reset password + return { + isInvite: type === 'invite', + email: data.user?.email + }; + } + } catch (e) { + // Check if it's a redirect (which is expected) + if (e && typeof e === 'object' && 'status' in e) { + throw e; + } + console.error('Verification error:', e); + throw redirect(303, '/forgot-password?error=expired'); + } + } + + // No token - check if user has an existing session (from successful verification) + const { session } = await locals.safeGetSession(); + + if (!session) { + // No session and no token - invalid access + throw redirect(303, '/forgot-password?error=expired'); + } + + return { + email: session.user?.email + }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + const formData = await request.formData(); + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirm_password') as string; + + // Validation + if (!password || password.length < 8) { + return fail(400, { + error: 'Password must be at least 8 characters long' + }); + } + + if (password !== confirmPassword) { + return fail(400, { + error: 'Passwords do not match' + }); + } + + // Check if user has a session + const { session } = await locals.safeGetSession(); + if (!session) { + return fail(401, { + error: 'Session expired. Please request a new password reset link.' + }); + } + + // Update the password + const { error } = await locals.supabase.auth.updateUser({ + password + }); + + if (error) { + console.error('Password update error:', error); + return fail(400, { + error: error.message + }); + } + + // Sign out after password change so they can sign in fresh + await locals.supabase.auth.signOut(); + + return { + success: 'Your password has been set successfully! You can now sign in with your new password.' + }; + } +}; diff --git a/src/routes/auth/reset-password/+page.svelte b/src/routes/auth/reset-password/+page.svelte new file mode 100644 index 0000000..0c42dd5 --- /dev/null +++ b/src/routes/auth/reset-password/+page.svelte @@ -0,0 +1,125 @@ + + + + {isInvite ? 'Set Your Password' : 'Reset Password'} | Monaco USA + + +
+ +
+
+
+
+ +
+ + + +
+
+
+

+ {isInvite ? 'Welcome to Monaco USA!' : 'Reset your password'} +

+

+ {#if isInvite} + Set a password to activate your account + {:else} + Enter a new password for your account + {/if} +

+ {#if email} +

{email}

+ {/if} +
+ + {#if form?.error} + + {/if} + + {#if form?.success} + + + {:else} +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" + > + + + + +

Password must be at least 8 characters long.

+ + + + {/if} +
+
+
+
diff --git a/src/routes/auth/verify/+server.ts b/src/routes/auth/verify/+server.ts new file mode 100644 index 0000000..40200cd --- /dev/null +++ b/src/routes/auth/verify/+server.ts @@ -0,0 +1,66 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +/** + * Auth verify handler for email links from Supabase/GoTrue + * This handles invite, recovery, confirmation, and email change tokens + * + * Flow: + * 1. User clicks link in email (e.g., password reset) + * 2. Link goes to /auth/verify?token=...&type=recovery&redirect_to=... + * 3. This handler extracts parameters and redirects to the appropriate SvelteKit page + */ +export const GET: RequestHandler = async ({ url, locals }) => { + const token = url.searchParams.get('token'); + const type = url.searchParams.get('type'); + const redirectTo = url.searchParams.get('redirect_to'); + + console.log('Auth verify handler:', { token: token?.substring(0, 20) + '...', type, redirectTo }); + + // Handle different verification types + if (type === 'recovery' || type === 'rec') { + // Password reset - redirect to reset password page with token + const resetUrl = new URL('/auth/reset-password', url.origin); + if (token) resetUrl.searchParams.set('token', token); + if (type) resetUrl.searchParams.set('type', type); + throw redirect(303, resetUrl.toString()); + } + + if (type === 'invite' || type === 'inv') { + // Member invitation - redirect to set password page + const resetUrl = new URL('/auth/reset-password', url.origin); + if (token) resetUrl.searchParams.set('token', token); + resetUrl.searchParams.set('type', 'invite'); + throw redirect(303, resetUrl.toString()); + } + + if (type === 'signup' || type === 'confirmation' || type === 'email_change') { + // Email confirmation - try to verify directly then redirect + if (token) { + try { + const { error } = await locals.supabase.auth.verifyOtp({ + token_hash: token, + type: type === 'email_change' ? 'email_change' : 'signup' + }); + + if (error) { + console.error('Email verification error:', error); + throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`); + } + + // Success - redirect to dashboard + throw redirect(303, redirectTo || '/dashboard'); + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) { + // This is a redirect, rethrow it + throw e; + } + console.error('Verification error:', e); + throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please try again.')}`); + } + } + } + + // Default: redirect to login with error + throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link')}`); +}; diff --git a/src/routes/join/+layout.svelte b/src/routes/join/+layout.svelte new file mode 100644 index 0000000..911074a --- /dev/null +++ b/src/routes/join/+layout.svelte @@ -0,0 +1,57 @@ + + +
+ +
+ +
+
+ + +
+
+
+
+
+ +
+ + + + + {@render children()} + + +

+ © 2026 Monaco USA. All rights reserved. +

+
+
diff --git a/src/routes/join/+page.server.ts b/src/routes/join/+page.server.ts new file mode 100644 index 0000000..992871c --- /dev/null +++ b/src/routes/join/+page.server.ts @@ -0,0 +1,350 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { supabaseAdmin } from '$lib/server/supabase'; +import { sendTemplatedEmail } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const { session } = await locals.safeGetSession(); + + // If already logged in, check if onboarding is completed + if (session) { + // Get member profile to check onboarding status + const { data: member } = await locals.supabase + .from('members') + .select('onboarding_completed_at') + .eq('id', session.user.id) + .single(); + + // Only redirect to dashboard if onboarding is completed + if (member?.onboarding_completed_at) { + throw redirect(303, '/dashboard'); + } + // Otherwise, let them continue the onboarding wizard + } + + // Get payment settings for the payment step + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'dues'); + + const paymentSettings: Record = {}; + if (settings) { + for (const s of settings) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + paymentSettings[s.setting_key.replace('payment_', '')] = value as string; + } + } + + // Get default membership type for dues amount + const { data: defaultType } = await locals.supabase + .from('membership_types') + .select('annual_dues') + .eq('is_default', true) + .single(); + + // If logged in but not completed onboarding, get member data + let member = null; + if (session) { + const { data } = await locals.supabase + .from('members') + .select('id, first_name, email, member_id') + .eq('id', session.user.id) + .single(); + member = data; + } + + return { + paymentSettings, + duesAmount: defaultType?.annual_dues || 150, + session: session || null, + member + }; +}; + +export const actions: Actions = { + createAccount: async ({ request, locals, url }) => { + const formData = await request.formData(); + + // Extract form fields + const firstName = formData.get('first_name') as string; + const lastName = formData.get('last_name') as string; + const email = formData.get('email') as string; + const phone = formData.get('phone') as string; + const dateOfBirth = formData.get('date_of_birth') as string; + const address = formData.get('address') as string; + const nationalityString = formData.get('nationality') as string; + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirm_password') as string; + const terms = formData.get('terms'); + + // Validation + if (!firstName || firstName.length < 2) { + return fail(400, { error: 'First name must be at least 2 characters', step: 2 }); + } + + if (!lastName || lastName.length < 2) { + return fail(400, { error: 'Last name must be at least 2 characters', step: 2 }); + } + + if (!email || !email.includes('@')) { + return fail(400, { error: 'Please enter a valid email address', step: 2 }); + } + + if (!phone) { + return fail(400, { error: 'Phone number is required', step: 2 }); + } + + if (!dateOfBirth) { + return fail(400, { error: 'Date of birth is required', step: 2 }); + } else { + // Check if 18+ + const birthDate = new Date(dateOfBirth); + const today = new Date(); + const age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + const dayDiff = today.getDate() - birthDate.getDate(); + const actualAge = monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age; + + if (actualAge < 18) { + return fail(400, { error: 'You must be at least 18 years old to join', step: 2 }); + } + } + + if (!address || address.length < 10) { + return fail(400, { error: 'Please enter a complete address', step: 2 }); + } + + const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : []; + if (nationality.length === 0) { + return fail(400, { error: 'Please select at least one nationality', step: 2 }); + } + + if (!password || password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters', step: 2 }); + } + + if (password !== confirmPassword) { + return fail(400, { error: 'Passwords do not match', step: 2 }); + } + + if (!terms) { + return fail(400, { error: 'You must accept the terms and conditions', step: 2 }); + } + + // Create Supabase auth user + const { data: authData, error: authError } = await locals.supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${url.origin}/join?verified=true`, + data: { + first_name: firstName, + last_name: lastName + } + } + }); + + if (authError) { + if (authError.message.includes('already registered')) { + return fail(400, { + error: 'An account with this email already exists. Try signing in instead.', + step: 2 + }); + } + return fail(400, { error: authError.message, step: 2 }); + } + + if (!authData.user) { + return fail(500, { error: 'Failed to create account. Please try again.', step: 2 }); + } + + // Get the pending membership status + const { data: pendingStatus } = await locals.supabase + .from('membership_statuses') + .select('id') + .eq('name', 'pending') + .single(); + + // Get the default membership type + const { data: defaultType } = await locals.supabase + .from('membership_types') + .select('id') + .eq('is_default', true) + .single(); + + if (!pendingStatus?.id || !defaultType?.id) { + console.error('Missing default status or type'); + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + return fail(500, { error: 'System configuration error. Please contact support.', step: 2 }); + } + + // Generate member ID + const year = new Date().getFullYear(); + const { count } = await locals.supabase + .from('members') + .select('*', { count: 'exact', head: true }); + + const memberNumber = String((count || 0) + 1).padStart(4, '0'); + const memberId = `MUSA-${year}-${memberNumber}`; + + // Create member profile + const { error: memberError } = await locals.supabase.from('members').insert({ + id: authData.user.id, + first_name: firstName, + last_name: lastName, + email, + phone, + date_of_birth: dateOfBirth, + address, + nationality, + member_id: memberId, + role: 'member', + membership_status_id: pendingStatus.id, + membership_type_id: defaultType.id + }); + + if (memberError) { + console.error('Failed to create member profile:', memberError); + try { + await supabaseAdmin.auth.admin.deleteUser(authData.user.id); + } catch (deleteError) { + console.error('Failed to clean up auth user:', deleteError); + } + return fail(500, { + error: 'Failed to create member profile. Please try again.', + step: 2 + }); + } + + // Sign in the user so they can continue the wizard + const { error: signInError } = await locals.supabase.auth.signInWithPassword({ + email, + password + }); + + if (signInError) { + console.error('Failed to sign in after account creation:', signInError); + // Continue anyway - they can verify email and sign in later + } + + return { + success: true, + step: 2, + memberId, + email + }; + }, + + uploadPhoto: async ({ request, locals }) => { + const { session } = await locals.safeGetSession(); + if (!session) { + return fail(401, { error: 'Not authenticated', step: 3 }); + } + + // For now, just proceed to next step + // Avatar upload can be handled via the profile page later + // or we can add proper file handling here + + return { + success: true, + step: 3 + }; + }, + + complete: async ({ locals }) => { + const { session } = await locals.safeGetSession(); + if (!session) { + return fail(401, { error: 'Not authenticated', step: 6 }); + } + + // Set payment deadline (30 days from now) + const paymentDeadline = new Date(); + paymentDeadline.setDate(paymentDeadline.getDate() + 30); + + // Update member with onboarding completion + const { error: updateError } = await locals.supabase + .from('members') + .update({ + payment_deadline: paymentDeadline.toISOString(), + onboarding_completed_at: new Date().toISOString() + }) + .eq('id', session.user.id); + + if (updateError) { + console.error('Failed to update member:', updateError); + return fail(500, { error: 'Failed to complete onboarding', step: 6 }); + } + + // Get member data for email + const { data: member } = await locals.supabase + .from('members') + .select('first_name, member_id, email') + .eq('id', session.user.id) + .single(); + + // Get payment settings + const { data: settings } = await locals.supabase + .from('app_settings') + .select('setting_key, setting_value') + .eq('category', 'dues'); + + const paymentSettings: Record = {}; + if (settings) { + for (const s of settings) { + let value = s.setting_value; + if (typeof value === 'string') { + value = value.replace(/^"|"$/g, ''); + } + paymentSettings[s.setting_key.replace('payment_', '')] = value as string; + } + } + + // Get default membership dues amount + const { data: defaultType } = await locals.supabase + .from('membership_types') + .select('annual_dues') + .eq('is_default', true) + .single(); + + // Send welcome email with payment instructions + if (member) { + try { + await sendTemplatedEmail( + 'onboarding_welcome', + member.email, + { + first_name: member.first_name, + member_id: member.member_id || 'N/A', + amount: `€${defaultType?.annual_dues || 150}`, + payment_deadline: paymentDeadline.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }), + account_holder: paymentSettings.account_holder || 'Monaco USA', + bank_name: paymentSettings.bank_name || 'Credit Foncier de Monaco', + iban: paymentSettings.iban || 'Contact for details' + }, + { + recipientId: session.user.id, + recipientName: `${member.first_name}`, + sentBy: 'system' + } + ); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + // Continue anyway - not critical + } + } + + return { + success: true, + step: 6 + }; + } +}; diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte new file mode 100644 index 0000000..2faae76 --- /dev/null +++ b/src/routes/join/+page.svelte @@ -0,0 +1,773 @@ + + + + Join Monaco USA + + + + +
+
+ {#each steps as step} +
+
+ {#if currentStep > step.num} + + {:else} + {step.num} + {/if} +
+ {#if step.num < 6} + + {/if} +
+ {/each} +
+

+ Step {currentStep} of 6: {steps[currentStep - 1].title} +

+
+ + +
+ {#key currentStep} +
+ + {#if currentStep === 1} +
+
+

Welcome to Monaco USA

+

Join our vibrant community of Americans in Monaco

+
+ +
+ {#each benefits as benefit, i} +
+
+ +
+

{benefit.title}

+

{benefit.description}

+
+ {/each} +
+ +
+ +
+ +

+ Already have an account? + Sign in +

+
+ {/if} + + + {#if currentStep === 2} +
+
+
+ +
+

Your Information

+

Tell us about yourself to create your account

+
+ + {#if form?.error} +
+ +
+ {/if} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="mt-4 space-y-3" + > + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +

You must be at least 18 years old.

+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+
+
+ {/if} + + + {#if currentStep === 3} +
+
+

Add a Profile Photo

+

Help other members recognize you (optional)

+
+ +
+
+ {#if avatarPreview} + Profile preview + {:else} +
+ +
+ {/if} + +
+ +

+ JPEG, PNG, or WebP. Max 5MB. +

+ +
{ + avatarUploading = true; + return async ({ update }) => { + avatarUploading = false; + await update(); + }; + }} + class="mt-6 w-full" + > + {#if avatarFile} + + {/if} + +
+ + + {#if avatarFile} + + {/if} +
+
+
+
+ {/if} + + + {#if currentStep === 4} +
+
+

Explore Your New Home

+

Here's what you'll have access to as a member

+
+ +
+ {#each features as feature, i} +
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+ {/each} +
+ +
+ + +
+
+ {/if} + + + {#if currentStep === 5} +
+
+
+ +
+

Verify Your Email

+

+ We sent a verification link to
+ {memberEmail || email} +

+
+ +
+

+ Click the link in your email to verify your address, then click the button below. +

+
+ +
+ + + +
+ +
+ +
+
+ {/if} + + + {#if currentStep === 6} +
+
+
+ +
+

You're Almost There!

+

+ Complete your membership by paying your annual dues within 30 days +

+
+ +
+
+

Annual Membership

+

€{data?.duesAmount || '150'}

+
+
+ +
+

Bank Transfer Details

+
+

+ Account Holder: + {data?.paymentSettings?.account_holder || 'Monaco USA'} +

+

+ Bank: + {data?.paymentSettings?.bank_name || 'Credit Foncier de Monaco'} +

+

+ IBAN: + {data?.paymentSettings?.iban || 'MC58...'} +

+

+ Reference: + {memberId || 'MUSA-2026-XXXX'} +

+
+
+ +
+

What Happens Next?

+
    +
  • • Check your email for confirmation
  • +
  • • Make your bank transfer within 30 days
  • +
  • • We'll activate your account once payment is received
  • +
+
+ +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="mt-4" + > + +
+
+ {/if} +
+ {/key} +
diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts new file mode 100644 index 0000000..f0e03b2 --- /dev/null +++ b/src/routes/logout/+server.ts @@ -0,0 +1,12 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ locals }) => { + await locals.supabase.auth.signOut(); + throw redirect(303, '/login'); +}; + +export const GET: RequestHandler = async ({ locals }) => { + await locals.supabase.auth.signOut(); + throw redirect(303, '/login'); +}; diff --git a/src/routes/public/events/[id]/+page.server.ts b/src/routes/public/events/[id]/+page.server.ts new file mode 100644 index 0000000..79b8aab --- /dev/null +++ b/src/routes/public/events/[id]/+page.server.ts @@ -0,0 +1,105 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, params }) => { + // Fetch the event (only public events) + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .eq('visibility', 'public') + .eq('status', 'published') + .single(); + + if (!event) { + throw error(404, 'Event not found or not publicly accessible'); + } + + return { + event + }; +}; + +export const actions: Actions = { + rsvp: async ({ request, locals, params }) => { + const formData = await request.formData(); + const fullName = (formData.get('full_name') as string)?.trim(); + const email = (formData.get('email') as string)?.trim().toLowerCase(); + const phone = (formData.get('phone') as string)?.trim() || null; + const guestCount = parseInt(formData.get('guest_count') as string) || 0; + + // Validation + if (!fullName || fullName.length < 2) { + return fail(400, { error: 'Please enter your full name' }); + } + + if (!email) { + return fail(400, { error: 'Please enter your email address' }); + } + + // Email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return fail(400, { error: 'Please enter a valid email address' }); + } + + // Fetch the event to check capacity + const { data: event } = await locals.supabase + .from('events_with_counts') + .select('*') + .eq('id', params.id) + .eq('visibility', 'public') + .eq('status', 'published') + .single(); + + if (!event) { + return fail(404, { error: 'Event not found' }); + } + + // Validate guest count + if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) { + return fail(400, { + error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed` + }); + } + + // Check if this email already RSVP'd + const { data: existingRsvp } = await locals.supabase + .from('event_rsvps_public') + .select('id') + .eq('event_id', params.id) + .eq('email', email) + .single(); + + if (existingRsvp) { + return fail(400, { error: 'This email has already registered for this event' }); + } + + // Check if event is full + const totalAttending = event.total_attendees + 1 + guestCount; + const isFull = event.max_attendees && totalAttending > event.max_attendees; + + // Create public RSVP + const { error: rsvpError } = await locals.supabase.from('event_rsvps_public').insert({ + event_id: params.id, + full_name: fullName, + email, + phone, + status: isFull ? 'waitlist' : 'confirmed', + guest_count: guestCount, + payment_status: event.is_paid ? 'pending' : 'not_required', + payment_amount: event.is_paid ? event.non_member_price * (1 + guestCount) : null + }); + + if (rsvpError) { + console.error('Public RSVP error:', rsvpError); + return fail(500, { error: 'Failed to submit RSVP. Please try again.' }); + } + + return { + success: isFull + ? 'You have been added to the waitlist. We will notify you if a spot opens up.' + : 'Registration successful! We look forward to seeing you at the event.' + }; + } +}; diff --git a/src/routes/public/events/[id]/+page.svelte b/src/routes/public/events/[id]/+page.svelte new file mode 100644 index 0000000..891ef93 --- /dev/null +++ b/src/routes/public/events/[id]/+page.svelte @@ -0,0 +1,369 @@ + + + + {event.title} | Monaco USA + + + +
+ +
+
+
+ + + {#if event.event_type_name} + + {event.event_type_name} + + {/if} + +

{event.title}

+ +
+
+ + {formatDate(eventDate)} +
+ {#if !event.all_day} +
+ + {formatTime(eventDate)} - {formatTime(eventEndDate)} +
+ {/if} + {#if event.location} +
+ + {event.location} +
+ {/if} +
+
+
+ + +
+
+ +
+ + {#if event.description} +
+

About This Event

+
+ {@html event.description.replace(/\n/g, '
')} +
+
+ {/if} + + +
+
+

Event Details

+ +
+ +
+
+
+ +
+
+

Date & Time

+

+ {formatDate(eventDate)} + {#if !event.all_day} +
{formatTime(eventDate)} - {formatTime(eventEndDate)} ({event.timezone}) + {:else} +
All day event + {/if} +

+
+
+ + {#if event.location} +
+
+ +
+
+

Location

+

{event.location}

+ {#if event.location_url} + + View on map + + {/if} +
+
+ {/if} + + {#if event.is_paid} +
+
+ +
+
+

Price

+

+ Non-members: {formatCurrency(event.non_member_price)} + {#if event.pricing_notes} +
{event.pricing_notes} + {/if} +

+
+
+ {/if} + +
+
+ +
+
+

Attendance

+

+ {event.total_attendees} registered + {#if event.max_attendees} + / {event.max_attendees} capacity + {/if} +

+ {#if event.waitlist_count > 0} +

{event.waitlist_count} on waitlist

+ {/if} +
+
+
+
+
+ + +
+
+ {#if form?.success} +
+
+
+ +
+
+

You're Registered!

+

{form.success}

+
+ {:else} +

Register for This Event

+ + {#if spotsRemaining !== null && spotsRemaining <= 5} +
+ + + {#if spotsRemaining === 0} + Event is full - Join waitlist + {:else} + Only {spotsRemaining} spot{spotsRemaining === 1 ? '' : 's'} left! + {/if} + +
+ {/if} + + {#if form?.error} +
+ +
+ {/if} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="mt-6 space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if event.max_guests_per_member !== 0} +
+ + +
+ {/if} + + {#if event.is_paid} +
+
+ Total + + {formatCurrency(event.non_member_price * (1 + guestCount))} + +
+

Payment details will be provided after registration

+
+ {/if} + + + +

+ Already a Monaco USA member? + Sign in + to RSVP with your account. +

+
+ {/if} +
+
+
+
+ + +
+
+

+ © 2026 Monaco USA. All rights reserved. +

+

+ Home + | + Member Login +

+
+
+
diff --git a/static/MONACOUSA-Flags_376x376.png b/static/MONACOUSA-Flags_376x376.png new file mode 100644 index 0000000..24314cf Binary files /dev/null and b/static/MONACOUSA-Flags_376x376.png differ diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..951e456 Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png new file mode 100644 index 0000000..b60117a Binary files /dev/null and b/static/favicon-32x32.png differ diff --git a/static/flags/ad.svg b/static/flags/ad.svg new file mode 100644 index 0000000..199ff19 --- /dev/null +++ b/static/flags/ad.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ae.svg b/static/flags/ae.svg new file mode 100644 index 0000000..651ac85 --- /dev/null +++ b/static/flags/ae.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/af.svg b/static/flags/af.svg new file mode 100644 index 0000000..4dbe455 --- /dev/null +++ b/static/flags/af.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ag.svg b/static/flags/ag.svg new file mode 100644 index 0000000..243c3d8 --- /dev/null +++ b/static/flags/ag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/ai.svg b/static/flags/ai.svg new file mode 100644 index 0000000..9c2ea33 --- /dev/null +++ b/static/flags/ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/al.svg b/static/flags/al.svg new file mode 100644 index 0000000..e85d95f --- /dev/null +++ b/static/flags/al.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/am.svg b/static/flags/am.svg new file mode 100644 index 0000000..99fa4dc --- /dev/null +++ b/static/flags/am.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/ao.svg b/static/flags/ao.svg new file mode 100644 index 0000000..b73b1ec --- /dev/null +++ b/static/flags/ao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/aq.svg b/static/flags/aq.svg new file mode 100644 index 0000000..c7e3536 --- /dev/null +++ b/static/flags/aq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/ar.svg b/static/flags/ar.svg new file mode 100644 index 0000000..c753da1 --- /dev/null +++ b/static/flags/ar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/arab.svg b/static/flags/arab.svg new file mode 100644 index 0000000..9ef079f --- /dev/null +++ b/static/flags/arab.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/as.svg b/static/flags/as.svg new file mode 100644 index 0000000..82459de --- /dev/null +++ b/static/flags/as.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/asean.svg b/static/flags/asean.svg new file mode 100644 index 0000000..189ae02 --- /dev/null +++ b/static/flags/asean.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/at.svg b/static/flags/at.svg new file mode 100644 index 0000000..9d2775c --- /dev/null +++ b/static/flags/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/au.svg b/static/flags/au.svg new file mode 100644 index 0000000..96e8076 --- /dev/null +++ b/static/flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/aw.svg b/static/flags/aw.svg new file mode 100644 index 0000000..413b7c4 --- /dev/null +++ b/static/flags/aw.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ax.svg b/static/flags/ax.svg new file mode 100644 index 0000000..0584d71 --- /dev/null +++ b/static/flags/ax.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/flags/az.svg b/static/flags/az.svg new file mode 100644 index 0000000..3557522 --- /dev/null +++ b/static/flags/az.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/ba.svg b/static/flags/ba.svg new file mode 100644 index 0000000..93bd9cf --- /dev/null +++ b/static/flags/ba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/bb.svg b/static/flags/bb.svg new file mode 100644 index 0000000..cecd5cc --- /dev/null +++ b/static/flags/bb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/bd.svg b/static/flags/bd.svg new file mode 100644 index 0000000..16b794d --- /dev/null +++ b/static/flags/bd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/be.svg b/static/flags/be.svg new file mode 100644 index 0000000..ac706a0 --- /dev/null +++ b/static/flags/be.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/bf.svg b/static/flags/bf.svg new file mode 100644 index 0000000..4713822 --- /dev/null +++ b/static/flags/bf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/bg.svg b/static/flags/bg.svg new file mode 100644 index 0000000..af2d0d0 --- /dev/null +++ b/static/flags/bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/bh.svg b/static/flags/bh.svg new file mode 100644 index 0000000..7a2ea54 --- /dev/null +++ b/static/flags/bh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/bi.svg b/static/flags/bi.svg new file mode 100644 index 0000000..a4434a9 --- /dev/null +++ b/static/flags/bi.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/bj.svg b/static/flags/bj.svg new file mode 100644 index 0000000..0846724 --- /dev/null +++ b/static/flags/bj.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/bl.svg b/static/flags/bl.svg new file mode 100644 index 0000000..f84cbba --- /dev/null +++ b/static/flags/bl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/bm.svg b/static/flags/bm.svg new file mode 100644 index 0000000..f43a5eb --- /dev/null +++ b/static/flags/bm.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/bn.svg b/static/flags/bn.svg new file mode 100644 index 0000000..f544c25 --- /dev/null +++ b/static/flags/bn.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/bo.svg b/static/flags/bo.svg new file mode 100644 index 0000000..7658e3f --- /dev/null +++ b/static/flags/bo.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/bq.svg b/static/flags/bq.svg new file mode 100644 index 0000000..0e6bc76 --- /dev/null +++ b/static/flags/bq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/br.svg b/static/flags/br.svg new file mode 100644 index 0000000..719a763 --- /dev/null +++ b/static/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/bs.svg b/static/flags/bs.svg new file mode 100644 index 0000000..5cc918e --- /dev/null +++ b/static/flags/bs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/bt.svg b/static/flags/bt.svg new file mode 100644 index 0000000..20aef3a --- /dev/null +++ b/static/flags/bt.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/bv.svg b/static/flags/bv.svg new file mode 100644 index 0000000..40e16d9 --- /dev/null +++ b/static/flags/bv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/bw.svg b/static/flags/bw.svg new file mode 100644 index 0000000..3435608 --- /dev/null +++ b/static/flags/bw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/by.svg b/static/flags/by.svg new file mode 100644 index 0000000..948784f --- /dev/null +++ b/static/flags/by.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/flags/bz.svg b/static/flags/bz.svg new file mode 100644 index 0000000..d81b16c --- /dev/null +++ b/static/flags/bz.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ca.svg b/static/flags/ca.svg new file mode 100644 index 0000000..c9b23b4 --- /dev/null +++ b/static/flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/cc.svg b/static/flags/cc.svg new file mode 100644 index 0000000..a42dec6 --- /dev/null +++ b/static/flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/cd.svg b/static/flags/cd.svg new file mode 100644 index 0000000..b9cf528 --- /dev/null +++ b/static/flags/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/cefta.svg b/static/flags/cefta.svg new file mode 100644 index 0000000..f748d08 --- /dev/null +++ b/static/flags/cefta.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/cf.svg b/static/flags/cf.svg new file mode 100644 index 0000000..a6cd367 --- /dev/null +++ b/static/flags/cf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/cg.svg b/static/flags/cg.svg new file mode 100644 index 0000000..f5a0e42 --- /dev/null +++ b/static/flags/cg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/ch.svg b/static/flags/ch.svg new file mode 100644 index 0000000..b42d670 --- /dev/null +++ b/static/flags/ch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/ci.svg b/static/flags/ci.svg new file mode 100644 index 0000000..e400f0c --- /dev/null +++ b/static/flags/ci.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/ck.svg b/static/flags/ck.svg new file mode 100644 index 0000000..18e547b --- /dev/null +++ b/static/flags/ck.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/cl.svg b/static/flags/cl.svg new file mode 100644 index 0000000..5b3c72f --- /dev/null +++ b/static/flags/cl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/cm.svg b/static/flags/cm.svg new file mode 100644 index 0000000..70adc8b --- /dev/null +++ b/static/flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/cn.svg b/static/flags/cn.svg new file mode 100644 index 0000000..10d3489 --- /dev/null +++ b/static/flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/flags/co.svg b/static/flags/co.svg new file mode 100644 index 0000000..ebd0a0f --- /dev/null +++ b/static/flags/co.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/cp.svg b/static/flags/cp.svg new file mode 100644 index 0000000..b8aa9cf --- /dev/null +++ b/static/flags/cp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/cr.svg b/static/flags/cr.svg new file mode 100644 index 0000000..5a409ee --- /dev/null +++ b/static/flags/cr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/cu.svg b/static/flags/cu.svg new file mode 100644 index 0000000..053c9ee --- /dev/null +++ b/static/flags/cu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/cv.svg b/static/flags/cv.svg new file mode 100644 index 0000000..aec8994 --- /dev/null +++ b/static/flags/cv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/cw.svg b/static/flags/cw.svg new file mode 100644 index 0000000..bb0ece2 --- /dev/null +++ b/static/flags/cw.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/cx.svg b/static/flags/cx.svg new file mode 100644 index 0000000..3a83c23 --- /dev/null +++ b/static/flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/cy.svg b/static/flags/cy.svg new file mode 100644 index 0000000..ee4b0c7 --- /dev/null +++ b/static/flags/cy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/cz.svg b/static/flags/cz.svg new file mode 100644 index 0000000..7913de3 --- /dev/null +++ b/static/flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/de.svg b/static/flags/de.svg new file mode 100644 index 0000000..71aa2d2 --- /dev/null +++ b/static/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/dg.svg b/static/flags/dg.svg new file mode 100644 index 0000000..dfee2bb --- /dev/null +++ b/static/flags/dg.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/dj.svg b/static/flags/dj.svg new file mode 100644 index 0000000..9b00a82 --- /dev/null +++ b/static/flags/dj.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/dk.svg b/static/flags/dk.svg new file mode 100644 index 0000000..563277f --- /dev/null +++ b/static/flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/dm.svg b/static/flags/dm.svg new file mode 100644 index 0000000..5aa9cea --- /dev/null +++ b/static/flags/dm.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/do.svg b/static/flags/do.svg new file mode 100644 index 0000000..6de2b26 --- /dev/null +++ b/static/flags/do.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/dz.svg b/static/flags/dz.svg new file mode 100644 index 0000000..5ff29a7 --- /dev/null +++ b/static/flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/eac.svg b/static/flags/eac.svg new file mode 100644 index 0000000..59d02d2 --- /dev/null +++ b/static/flags/eac.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ec.svg b/static/flags/ec.svg new file mode 100644 index 0000000..88c50bf --- /dev/null +++ b/static/flags/ec.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ee.svg b/static/flags/ee.svg new file mode 100644 index 0000000..8b98c2c --- /dev/null +++ b/static/flags/ee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/eg.svg b/static/flags/eg.svg new file mode 100644 index 0000000..88e32b3 --- /dev/null +++ b/static/flags/eg.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/eh.svg b/static/flags/eh.svg new file mode 100644 index 0000000..6aec728 --- /dev/null +++ b/static/flags/eh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/er.svg b/static/flags/er.svg new file mode 100644 index 0000000..48a13b4 --- /dev/null +++ b/static/flags/er.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/es-ct.svg b/static/flags/es-ct.svg new file mode 100644 index 0000000..4d85911 --- /dev/null +++ b/static/flags/es-ct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/es-ga.svg b/static/flags/es-ga.svg new file mode 100644 index 0000000..573ca45 --- /dev/null +++ b/static/flags/es-ga.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/es-pv.svg b/static/flags/es-pv.svg new file mode 100644 index 0000000..63c19f4 --- /dev/null +++ b/static/flags/es-pv.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/es.svg b/static/flags/es.svg new file mode 100644 index 0000000..a296ebf --- /dev/null +++ b/static/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/et.svg b/static/flags/et.svg new file mode 100644 index 0000000..3f99be4 --- /dev/null +++ b/static/flags/et.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/eu.svg b/static/flags/eu.svg new file mode 100644 index 0000000..b0874c1 --- /dev/null +++ b/static/flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/fi.svg b/static/flags/fi.svg new file mode 100644 index 0000000..470be2d --- /dev/null +++ b/static/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/fj.svg b/static/flags/fj.svg new file mode 100644 index 0000000..332ae61 --- /dev/null +++ b/static/flags/fj.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/fk.svg b/static/flags/fk.svg new file mode 100644 index 0000000..a0dace8 --- /dev/null +++ b/static/flags/fk.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/fm.svg b/static/flags/fm.svg new file mode 100644 index 0000000..c1b7c97 --- /dev/null +++ b/static/flags/fm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/flags/fo.svg b/static/flags/fo.svg new file mode 100644 index 0000000..f802d28 --- /dev/null +++ b/static/flags/fo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/fr.svg b/static/flags/fr.svg new file mode 100644 index 0000000..4110e59 --- /dev/null +++ b/static/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/ga.svg b/static/flags/ga.svg new file mode 100644 index 0000000..76edab4 --- /dev/null +++ b/static/flags/ga.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/gb-eng.svg b/static/flags/gb-eng.svg new file mode 100644 index 0000000..12e3b67 --- /dev/null +++ b/static/flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/gb-nir.svg b/static/flags/gb-nir.svg new file mode 100644 index 0000000..e22190a --- /dev/null +++ b/static/flags/gb-nir.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/gb-sct.svg b/static/flags/gb-sct.svg new file mode 100644 index 0000000..f50cd32 --- /dev/null +++ b/static/flags/gb-sct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/gb-wls.svg b/static/flags/gb-wls.svg new file mode 100644 index 0000000..d7f5791 --- /dev/null +++ b/static/flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/gb.svg b/static/flags/gb.svg new file mode 100644 index 0000000..7991383 --- /dev/null +++ b/static/flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/gd.svg b/static/flags/gd.svg new file mode 100644 index 0000000..b3d250d --- /dev/null +++ b/static/flags/gd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ge.svg b/static/flags/ge.svg new file mode 100644 index 0000000..ab08a9a --- /dev/null +++ b/static/flags/ge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/gf.svg b/static/flags/gf.svg new file mode 100644 index 0000000..f8fe94c --- /dev/null +++ b/static/flags/gf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/gg.svg b/static/flags/gg.svg new file mode 100644 index 0000000..f8216c8 --- /dev/null +++ b/static/flags/gg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/gh.svg b/static/flags/gh.svg new file mode 100644 index 0000000..5c3e3e6 --- /dev/null +++ b/static/flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/gi.svg b/static/flags/gi.svg new file mode 100644 index 0000000..a5d7570 --- /dev/null +++ b/static/flags/gi.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/gl.svg b/static/flags/gl.svg new file mode 100644 index 0000000..eb5a52e --- /dev/null +++ b/static/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/gm.svg b/static/flags/gm.svg new file mode 100644 index 0000000..8fe9d66 --- /dev/null +++ b/static/flags/gm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/gn.svg b/static/flags/gn.svg new file mode 100644 index 0000000..40d6ad4 --- /dev/null +++ b/static/flags/gn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/gp.svg b/static/flags/gp.svg new file mode 100644 index 0000000..ee55c4b --- /dev/null +++ b/static/flags/gp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/gq.svg b/static/flags/gq.svg new file mode 100644 index 0000000..64c8eb2 --- /dev/null +++ b/static/flags/gq.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/gr.svg b/static/flags/gr.svg new file mode 100644 index 0000000..599741e --- /dev/null +++ b/static/flags/gr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/gs.svg b/static/flags/gs.svg new file mode 100644 index 0000000..29db9b9 --- /dev/null +++ b/static/flags/gs.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/gt.svg b/static/flags/gt.svg new file mode 100644 index 0000000..7df9df5 --- /dev/null +++ b/static/flags/gt.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/gu.svg b/static/flags/gu.svg new file mode 100644 index 0000000..3b95219 --- /dev/null +++ b/static/flags/gu.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/gw.svg b/static/flags/gw.svg new file mode 100644 index 0000000..d470bac --- /dev/null +++ b/static/flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/gy.svg b/static/flags/gy.svg new file mode 100644 index 0000000..569fb56 --- /dev/null +++ b/static/flags/gy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/hk.svg b/static/flags/hk.svg new file mode 100644 index 0000000..4fd55bc --- /dev/null +++ b/static/flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/hm.svg b/static/flags/hm.svg new file mode 100644 index 0000000..815c482 --- /dev/null +++ b/static/flags/hm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/hn.svg b/static/flags/hn.svg new file mode 100644 index 0000000..11fde67 --- /dev/null +++ b/static/flags/hn.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/flags/hr.svg b/static/flags/hr.svg new file mode 100644 index 0000000..dde825c --- /dev/null +++ b/static/flags/hr.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ht.svg b/static/flags/ht.svg new file mode 100644 index 0000000..8e8efc4 --- /dev/null +++ b/static/flags/ht.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/hu.svg b/static/flags/hu.svg new file mode 100644 index 0000000..baddf7f --- /dev/null +++ b/static/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/ic.svg b/static/flags/ic.svg new file mode 100644 index 0000000..81e6ee2 --- /dev/null +++ b/static/flags/ic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/id.svg b/static/flags/id.svg new file mode 100644 index 0000000..3b7c8fc --- /dev/null +++ b/static/flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/ie.svg b/static/flags/ie.svg new file mode 100644 index 0000000..049be14 --- /dev/null +++ b/static/flags/ie.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/il.svg b/static/flags/il.svg new file mode 100644 index 0000000..f43be7e --- /dev/null +++ b/static/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/im.svg b/static/flags/im.svg new file mode 100644 index 0000000..fe6a59a --- /dev/null +++ b/static/flags/im.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/in.svg b/static/flags/in.svg new file mode 100644 index 0000000..bc47d74 --- /dev/null +++ b/static/flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/io.svg b/static/flags/io.svg new file mode 100644 index 0000000..3058f7d --- /dev/null +++ b/static/flags/io.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/iq.svg b/static/flags/iq.svg new file mode 100644 index 0000000..8044514 --- /dev/null +++ b/static/flags/iq.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/flags/ir.svg b/static/flags/ir.svg new file mode 100644 index 0000000..8c6d516 --- /dev/null +++ b/static/flags/ir.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/is.svg b/static/flags/is.svg new file mode 100644 index 0000000..a6588af --- /dev/null +++ b/static/flags/is.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/it.svg b/static/flags/it.svg new file mode 100644 index 0000000..20a8bfd --- /dev/null +++ b/static/flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/je.svg b/static/flags/je.svg new file mode 100644 index 0000000..70a8754 --- /dev/null +++ b/static/flags/je.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/jm.svg b/static/flags/jm.svg new file mode 100644 index 0000000..269df03 --- /dev/null +++ b/static/flags/jm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/jo.svg b/static/flags/jo.svg new file mode 100644 index 0000000..d6f927d --- /dev/null +++ b/static/flags/jo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/jp.svg b/static/flags/jp.svg new file mode 100644 index 0000000..cc1c181 --- /dev/null +++ b/static/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/flags/ke.svg b/static/flags/ke.svg new file mode 100644 index 0000000..3a67ca3 --- /dev/null +++ b/static/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/kg.svg b/static/flags/kg.svg new file mode 100644 index 0000000..e26db95 --- /dev/null +++ b/static/flags/kg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/kh.svg b/static/flags/kh.svg new file mode 100644 index 0000000..a7d52f2 --- /dev/null +++ b/static/flags/kh.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ki.svg b/static/flags/ki.svg new file mode 100644 index 0000000..fda03f3 --- /dev/null +++ b/static/flags/ki.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/km.svg b/static/flags/km.svg new file mode 100644 index 0000000..414d65e --- /dev/null +++ b/static/flags/km.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/kn.svg b/static/flags/kn.svg new file mode 100644 index 0000000..47fe64d --- /dev/null +++ b/static/flags/kn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/kp.svg b/static/flags/kp.svg new file mode 100644 index 0000000..ad1b713 --- /dev/null +++ b/static/flags/kp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/kr.svg b/static/flags/kr.svg new file mode 100644 index 0000000..6947eab --- /dev/null +++ b/static/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/kw.svg b/static/flags/kw.svg new file mode 100644 index 0000000..3dd89e9 --- /dev/null +++ b/static/flags/kw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/ky.svg b/static/flags/ky.svg new file mode 100644 index 0000000..aeaa7e0 --- /dev/null +++ b/static/flags/ky.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/kz.svg b/static/flags/kz.svg new file mode 100644 index 0000000..2fac45b --- /dev/null +++ b/static/flags/kz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/la.svg b/static/flags/la.svg new file mode 100644 index 0000000..6aea6b7 --- /dev/null +++ b/static/flags/la.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/lb.svg b/static/flags/lb.svg new file mode 100644 index 0000000..bde2581 --- /dev/null +++ b/static/flags/lb.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/lc.svg b/static/flags/lc.svg new file mode 100644 index 0000000..bb25654 --- /dev/null +++ b/static/flags/lc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/li.svg b/static/flags/li.svg new file mode 100644 index 0000000..7a4d183 --- /dev/null +++ b/static/flags/li.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/lk.svg b/static/flags/lk.svg new file mode 100644 index 0000000..cbd660a --- /dev/null +++ b/static/flags/lk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/lr.svg b/static/flags/lr.svg new file mode 100644 index 0000000..e482ab9 --- /dev/null +++ b/static/flags/lr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/ls.svg b/static/flags/ls.svg new file mode 100644 index 0000000..a7c01a9 --- /dev/null +++ b/static/flags/ls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/lt.svg b/static/flags/lt.svg new file mode 100644 index 0000000..90ec5d2 --- /dev/null +++ b/static/flags/lt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/lu.svg b/static/flags/lu.svg new file mode 100644 index 0000000..cc12206 --- /dev/null +++ b/static/flags/lu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/lv.svg b/static/flags/lv.svg new file mode 100644 index 0000000..6a9e75e --- /dev/null +++ b/static/flags/lv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/ly.svg b/static/flags/ly.svg new file mode 100644 index 0000000..1eaa51e --- /dev/null +++ b/static/flags/ly.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/ma.svg b/static/flags/ma.svg new file mode 100644 index 0000000..7ce56ef --- /dev/null +++ b/static/flags/ma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/mc.svg b/static/flags/mc.svg new file mode 100644 index 0000000..9cb6c9e --- /dev/null +++ b/static/flags/mc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/md.svg b/static/flags/md.svg new file mode 100644 index 0000000..e9ba506 --- /dev/null +++ b/static/flags/md.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/me.svg b/static/flags/me.svg new file mode 100644 index 0000000..297888c --- /dev/null +++ b/static/flags/me.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/mf.svg b/static/flags/mf.svg new file mode 100644 index 0000000..6305edc --- /dev/null +++ b/static/flags/mf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/mg.svg b/static/flags/mg.svg new file mode 100644 index 0000000..5fa2d24 --- /dev/null +++ b/static/flags/mg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/mh.svg b/static/flags/mh.svg new file mode 100644 index 0000000..7b9f490 --- /dev/null +++ b/static/flags/mh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/mk.svg b/static/flags/mk.svg new file mode 100644 index 0000000..4f5cae7 --- /dev/null +++ b/static/flags/mk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/ml.svg b/static/flags/ml.svg new file mode 100644 index 0000000..6f6b716 --- /dev/null +++ b/static/flags/ml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/mm.svg b/static/flags/mm.svg new file mode 100644 index 0000000..42b4dee --- /dev/null +++ b/static/flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/mn.svg b/static/flags/mn.svg new file mode 100644 index 0000000..6a38a71 --- /dev/null +++ b/static/flags/mn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/mo.svg b/static/flags/mo.svg new file mode 100644 index 0000000..f638b6c --- /dev/null +++ b/static/flags/mo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/mp.svg b/static/flags/mp.svg new file mode 100644 index 0000000..26bfa22 --- /dev/null +++ b/static/flags/mp.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/mq.svg b/static/flags/mq.svg new file mode 100644 index 0000000..b221951 --- /dev/null +++ b/static/flags/mq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/mr.svg b/static/flags/mr.svg new file mode 100644 index 0000000..d859972 --- /dev/null +++ b/static/flags/mr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/ms.svg b/static/flags/ms.svg new file mode 100644 index 0000000..4367505 --- /dev/null +++ b/static/flags/ms.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/mt.svg b/static/flags/mt.svg new file mode 100644 index 0000000..5d5d7c8 --- /dev/null +++ b/static/flags/mt.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/mu.svg b/static/flags/mu.svg new file mode 100644 index 0000000..82d7a3b --- /dev/null +++ b/static/flags/mu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/mv.svg b/static/flags/mv.svg new file mode 100644 index 0000000..10450f9 --- /dev/null +++ b/static/flags/mv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/mw.svg b/static/flags/mw.svg new file mode 100644 index 0000000..137ff87 --- /dev/null +++ b/static/flags/mw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/flags/mx.svg b/static/flags/mx.svg new file mode 100644 index 0000000..e3ec2bc --- /dev/null +++ b/static/flags/mx.svg @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/my.svg b/static/flags/my.svg new file mode 100644 index 0000000..115f864 --- /dev/null +++ b/static/flags/my.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/mz.svg b/static/flags/mz.svg new file mode 100644 index 0000000..0f94c3a --- /dev/null +++ b/static/flags/mz.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/na.svg b/static/flags/na.svg new file mode 100644 index 0000000..35b9f78 --- /dev/null +++ b/static/flags/na.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/nc.svg b/static/flags/nc.svg new file mode 100644 index 0000000..fa15551 --- /dev/null +++ b/static/flags/nc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/ne.svg b/static/flags/ne.svg new file mode 100644 index 0000000..39a82b8 --- /dev/null +++ b/static/flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/nf.svg b/static/flags/nf.svg new file mode 100644 index 0000000..fd61b25 --- /dev/null +++ b/static/flags/nf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/ng.svg b/static/flags/ng.svg new file mode 100644 index 0000000..81eb35f --- /dev/null +++ b/static/flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/ni.svg b/static/flags/ni.svg new file mode 100644 index 0000000..e4861f5 --- /dev/null +++ b/static/flags/ni.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/nl.svg b/static/flags/nl.svg new file mode 100644 index 0000000..e90f5b0 --- /dev/null +++ b/static/flags/nl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/no.svg b/static/flags/no.svg new file mode 100644 index 0000000..a5f2a15 --- /dev/null +++ b/static/flags/no.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/np.svg b/static/flags/np.svg new file mode 100644 index 0000000..6242856 --- /dev/null +++ b/static/flags/np.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/nr.svg b/static/flags/nr.svg new file mode 100644 index 0000000..ff394c4 --- /dev/null +++ b/static/flags/nr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/flags/nu.svg b/static/flags/nu.svg new file mode 100644 index 0000000..4067baf --- /dev/null +++ b/static/flags/nu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/flags/nz.svg b/static/flags/nz.svg new file mode 100644 index 0000000..935d8a7 --- /dev/null +++ b/static/flags/nz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/om.svg b/static/flags/om.svg new file mode 100644 index 0000000..4f1461a --- /dev/null +++ b/static/flags/om.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/pa.svg b/static/flags/pa.svg new file mode 100644 index 0000000..8dc03bc --- /dev/null +++ b/static/flags/pa.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/pc.svg b/static/flags/pc.svg new file mode 100644 index 0000000..5202d6d --- /dev/null +++ b/static/flags/pc.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/pe.svg b/static/flags/pe.svg new file mode 100644 index 0000000..33e6cfd --- /dev/null +++ b/static/flags/pe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/pf.svg b/static/flags/pf.svg new file mode 100644 index 0000000..bea0354 --- /dev/null +++ b/static/flags/pf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/pg.svg b/static/flags/pg.svg new file mode 100644 index 0000000..7b7e77a --- /dev/null +++ b/static/flags/pg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/ph.svg b/static/flags/ph.svg new file mode 100644 index 0000000..b910e24 --- /dev/null +++ b/static/flags/ph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/pk.svg b/static/flags/pk.svg new file mode 100644 index 0000000..4ddc19f --- /dev/null +++ b/static/flags/pk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/pl.svg b/static/flags/pl.svg new file mode 100644 index 0000000..0fa5145 --- /dev/null +++ b/static/flags/pl.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/pm.svg b/static/flags/pm.svg new file mode 100644 index 0000000..19a9330 --- /dev/null +++ b/static/flags/pm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/pn.svg b/static/flags/pn.svg new file mode 100644 index 0000000..209ea71 --- /dev/null +++ b/static/flags/pn.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/pr.svg b/static/flags/pr.svg new file mode 100644 index 0000000..ec51831 --- /dev/null +++ b/static/flags/pr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/ps.svg b/static/flags/ps.svg new file mode 100644 index 0000000..362d435 --- /dev/null +++ b/static/flags/ps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/pt.svg b/static/flags/pt.svg new file mode 100644 index 0000000..2767cd4 --- /dev/null +++ b/static/flags/pt.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/pw.svg b/static/flags/pw.svg new file mode 100644 index 0000000..9f89c5f --- /dev/null +++ b/static/flags/pw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/flags/py.svg b/static/flags/py.svg new file mode 100644 index 0000000..abccd87 --- /dev/null +++ b/static/flags/py.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/qa.svg b/static/flags/qa.svg new file mode 100644 index 0000000..901f3fa --- /dev/null +++ b/static/flags/qa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/re.svg b/static/flags/re.svg new file mode 100644 index 0000000..64e788e --- /dev/null +++ b/static/flags/re.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/ro.svg b/static/flags/ro.svg new file mode 100644 index 0000000..fda0f7b --- /dev/null +++ b/static/flags/ro.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/rs.svg b/static/flags/rs.svg new file mode 100644 index 0000000..6d4f74d --- /dev/null +++ b/static/flags/rs.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/ru.svg b/static/flags/ru.svg new file mode 100644 index 0000000..cf24301 --- /dev/null +++ b/static/flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/rw.svg b/static/flags/rw.svg new file mode 100644 index 0000000..06e26ae --- /dev/null +++ b/static/flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/sa.svg b/static/flags/sa.svg new file mode 100644 index 0000000..596cf48 --- /dev/null +++ b/static/flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sb.svg b/static/flags/sb.svg new file mode 100644 index 0000000..6066f94 --- /dev/null +++ b/static/flags/sb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/sc.svg b/static/flags/sc.svg new file mode 100644 index 0000000..9a46b36 --- /dev/null +++ b/static/flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/sd.svg b/static/flags/sd.svg new file mode 100644 index 0000000..12818b4 --- /dev/null +++ b/static/flags/sd.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/se.svg b/static/flags/se.svg new file mode 100644 index 0000000..8ba745a --- /dev/null +++ b/static/flags/se.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/sg.svg b/static/flags/sg.svg new file mode 100644 index 0000000..c4dd4ac --- /dev/null +++ b/static/flags/sg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/sh-ac.svg b/static/flags/sh-ac.svg new file mode 100644 index 0000000..c43b301 --- /dev/null +++ b/static/flags/sh-ac.svg @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sh-hl.svg b/static/flags/sh-hl.svg new file mode 100644 index 0000000..2150bf6 --- /dev/null +++ b/static/flags/sh-hl.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sh-ta.svg b/static/flags/sh-ta.svg new file mode 100644 index 0000000..ba39063 --- /dev/null +++ b/static/flags/sh-ta.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sh.svg b/static/flags/sh.svg new file mode 100644 index 0000000..7aba0ae --- /dev/null +++ b/static/flags/sh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/si.svg b/static/flags/si.svg new file mode 100644 index 0000000..1bbdd94 --- /dev/null +++ b/static/flags/si.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sj.svg b/static/flags/sj.svg new file mode 100644 index 0000000..bb2799c --- /dev/null +++ b/static/flags/sj.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/sk.svg b/static/flags/sk.svg new file mode 100644 index 0000000..676018e --- /dev/null +++ b/static/flags/sk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/sl.svg b/static/flags/sl.svg new file mode 100644 index 0000000..a07baf7 --- /dev/null +++ b/static/flags/sl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/sm.svg b/static/flags/sm.svg new file mode 100644 index 0000000..e41d2f7 --- /dev/null +++ b/static/flags/sm.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sn.svg b/static/flags/sn.svg new file mode 100644 index 0000000..7c0673d --- /dev/null +++ b/static/flags/sn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/so.svg b/static/flags/so.svg new file mode 100644 index 0000000..a581ac6 --- /dev/null +++ b/static/flags/so.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/flags/sr.svg b/static/flags/sr.svg new file mode 100644 index 0000000..5e71c40 --- /dev/null +++ b/static/flags/sr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/ss.svg b/static/flags/ss.svg new file mode 100644 index 0000000..b257aa0 --- /dev/null +++ b/static/flags/ss.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/st.svg b/static/flags/st.svg new file mode 100644 index 0000000..1294bcb --- /dev/null +++ b/static/flags/st.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/sv.svg b/static/flags/sv.svg new file mode 100644 index 0000000..cbc674a --- /dev/null +++ b/static/flags/sv.svg @@ -0,0 +1,593 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sx.svg b/static/flags/sx.svg new file mode 100644 index 0000000..ac78561 --- /dev/null +++ b/static/flags/sx.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/sy.svg b/static/flags/sy.svg new file mode 100644 index 0000000..97c05cf --- /dev/null +++ b/static/flags/sy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/sz.svg b/static/flags/sz.svg new file mode 100644 index 0000000..eb538e4 --- /dev/null +++ b/static/flags/sz.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/tc.svg b/static/flags/tc.svg new file mode 100644 index 0000000..1258971 --- /dev/null +++ b/static/flags/tc.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/td.svg b/static/flags/td.svg new file mode 100644 index 0000000..fa3bd92 --- /dev/null +++ b/static/flags/td.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/tf.svg b/static/flags/tf.svg new file mode 100644 index 0000000..fba2335 --- /dev/null +++ b/static/flags/tf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/static/flags/tg.svg b/static/flags/tg.svg new file mode 100644 index 0000000..9d6ea6c --- /dev/null +++ b/static/flags/tg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/flags/th.svg b/static/flags/th.svg new file mode 100644 index 0000000..1e93a61 --- /dev/null +++ b/static/flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/tj.svg b/static/flags/tj.svg new file mode 100644 index 0000000..f8c9a03 --- /dev/null +++ b/static/flags/tj.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/tk.svg b/static/flags/tk.svg new file mode 100644 index 0000000..05d3e86 --- /dev/null +++ b/static/flags/tk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/tl.svg b/static/flags/tl.svg new file mode 100644 index 0000000..3d0701a --- /dev/null +++ b/static/flags/tl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/tm.svg b/static/flags/tm.svg new file mode 100644 index 0000000..4154ed7 --- /dev/null +++ b/static/flags/tm.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/tn.svg b/static/flags/tn.svg new file mode 100644 index 0000000..5735c19 --- /dev/null +++ b/static/flags/tn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/to.svg b/static/flags/to.svg new file mode 100644 index 0000000..d072337 --- /dev/null +++ b/static/flags/to.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/flags/tr.svg b/static/flags/tr.svg new file mode 100644 index 0000000..b96da21 --- /dev/null +++ b/static/flags/tr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/tt.svg b/static/flags/tt.svg new file mode 100644 index 0000000..bc24938 --- /dev/null +++ b/static/flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/tv.svg b/static/flags/tv.svg new file mode 100644 index 0000000..675210e --- /dev/null +++ b/static/flags/tv.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/tw.svg b/static/flags/tw.svg new file mode 100644 index 0000000..57fd98b --- /dev/null +++ b/static/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/tz.svg b/static/flags/tz.svg new file mode 100644 index 0000000..a2cfbca --- /dev/null +++ b/static/flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/flags/ua.svg b/static/flags/ua.svg new file mode 100644 index 0000000..a339eb1 --- /dev/null +++ b/static/flags/ua.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/flags/ug.svg b/static/flags/ug.svg new file mode 100644 index 0000000..520eee5 --- /dev/null +++ b/static/flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/um.svg b/static/flags/um.svg new file mode 100644 index 0000000..9e9edda --- /dev/null +++ b/static/flags/um.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/un.svg b/static/flags/un.svg new file mode 100644 index 0000000..632bbb4 --- /dev/null +++ b/static/flags/un.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/static/flags/us.svg b/static/flags/us.svg new file mode 100644 index 0000000..9cfd0c9 --- /dev/null +++ b/static/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/flags/uy.svg b/static/flags/uy.svg new file mode 100644 index 0000000..62c36f8 --- /dev/null +++ b/static/flags/uy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/uz.svg b/static/flags/uz.svg new file mode 100644 index 0000000..0ccca1b --- /dev/null +++ b/static/flags/uz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/va.svg b/static/flags/va.svg new file mode 100644 index 0000000..3e297d6 --- /dev/null +++ b/static/flags/va.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/vc.svg b/static/flags/vc.svg new file mode 100644 index 0000000..f26c2d8 --- /dev/null +++ b/static/flags/vc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/flags/ve.svg b/static/flags/ve.svg new file mode 100644 index 0000000..314e7f5 --- /dev/null +++ b/static/flags/ve.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/vg.svg b/static/flags/vg.svg new file mode 100644 index 0000000..ac90088 --- /dev/null +++ b/static/flags/vg.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/vi.svg b/static/flags/vi.svg new file mode 100644 index 0000000..d88d68f --- /dev/null +++ b/static/flags/vi.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/vn.svg b/static/flags/vn.svg new file mode 100644 index 0000000..7e4bac8 --- /dev/null +++ b/static/flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/flags/vu.svg b/static/flags/vu.svg new file mode 100644 index 0000000..326d29e --- /dev/null +++ b/static/flags/vu.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/wf.svg b/static/flags/wf.svg new file mode 100644 index 0000000..054c57d --- /dev/null +++ b/static/flags/wf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/ws.svg b/static/flags/ws.svg new file mode 100644 index 0000000..0e758a7 --- /dev/null +++ b/static/flags/ws.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/xk.svg b/static/flags/xk.svg new file mode 100644 index 0000000..0e8958d --- /dev/null +++ b/static/flags/xk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/xx.svg b/static/flags/xx.svg new file mode 100644 index 0000000..9333be3 --- /dev/null +++ b/static/flags/xx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/flags/ye.svg b/static/flags/ye.svg new file mode 100644 index 0000000..1c9e6d6 --- /dev/null +++ b/static/flags/ye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/flags/yt.svg b/static/flags/yt.svg new file mode 100644 index 0000000..e7776b3 --- /dev/null +++ b/static/flags/yt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/flags/za.svg b/static/flags/za.svg new file mode 100644 index 0000000..d563adb --- /dev/null +++ b/static/flags/za.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/static/flags/zm.svg b/static/flags/zm.svg new file mode 100644 index 0000000..360f37a --- /dev/null +++ b/static/flags/zm.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/flags/zw.svg b/static/flags/zw.svg new file mode 100644 index 0000000..93aac4f --- /dev/null +++ b/static/flags/zw.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/icon-192x192.png b/static/icon-192x192.png new file mode 100644 index 0000000..203f490 Binary files /dev/null and b/static/icon-192x192.png differ diff --git a/static/icon-512x512.png b/static/icon-512x512.png new file mode 100644 index 0000000..8c611a2 Binary files /dev/null and b/static/icon-512x512.png differ diff --git a/static/monaco_high_res.jpg b/static/monaco_high_res.jpg new file mode 100644 index 0000000..7fa65f3 Binary files /dev/null and b/static/monaco_high_res.jpg differ diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/supabase/docker/kong.yml b/supabase/docker/kong.yml new file mode 100644 index 0000000..10b19eb --- /dev/null +++ b/supabase/docker/kong.yml @@ -0,0 +1,196 @@ +_format_version: "2.1" +_transform: true + +### +### Consumers / Users +### +consumers: + - username: ANON + keyauth_credentials: + - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.b_lMH2mc5km7S9Lw_sRGGqE9IeiahYu-caevDcacKiY + - username: SERVICE_ROLE + keyauth_credentials: + - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.kcyKZAiwnnBG9t6IVGO17bcVw574pVynTHYVdF4q-p0 + +### +### 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/supabase/fix_rls_now.sql b/supabase/fix_rls_now.sql new file mode 100644 index 0000000..09a4a03 --- /dev/null +++ b/supabase/fix_rls_now.sql @@ -0,0 +1,146 @@ +-- ============================================ +-- IMMEDIATE FIX FOR RLS ISSUES +-- Run this SQL directly in Supabase Studio SQL Editor +-- ============================================ + +-- ===================== +-- STEP 1: FIX STORAGE.OBJECTS POLICIES +-- ===================== + +-- Drop any existing service_role policies with various names +DROP POLICY IF EXISTS "Service role can insert avatars" ON storage.objects; +DROP POLICY IF EXISTS "Service role can update avatars" ON storage.objects; +DROP POLICY IF EXISTS "Service role can delete avatars" ON storage.objects; +DROP POLICY IF EXISTS "Service role can read avatars" ON storage.objects; +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; +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 universal service_role policies for ALL storage operations +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 permissions +GRANT ALL ON storage.objects TO service_role; +GRANT ALL ON storage.buckets TO service_role; +GRANT USAGE ON SCHEMA storage TO service_role; + +-- ===================== +-- STEP 2: FIX PUBLIC.MEMBERS POLICIES +-- ===================== + +-- Drop any existing service_role policies on members +DROP POLICY IF EXISTS "service_role_all_members" ON public.members; +DROP POLICY IF EXISTS "service_role_select_members" ON public.members; +DROP POLICY IF EXISTS "service_role_insert_members" ON public.members; +DROP POLICY IF EXISTS "service_role_update_members" ON public.members; +DROP POLICY IF EXISTS "service_role_delete_members" ON public.members; + +-- Create universal service_role policy for members table +CREATE POLICY "service_role_all_members" ON public.members +FOR ALL TO service_role +USING (true) +WITH CHECK (true); + +-- Grant permissions +GRANT ALL ON public.members TO service_role; + +-- ===================== +-- STEP 3: ENSURE STORAGE BUCKETS EXIST +-- ===================== + +-- Avatars bucket (public) +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; + +-- Documents bucket (public for direct URL access - visibility controlled at app level) +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; + +-- ===================== +-- STEP 4: TRY TO GRANT BYPASSRLS (may fail, that's OK) +-- ===================== + +DO $$ +BEGIN + ALTER ROLE service_role BYPASSRLS; + RAISE NOTICE 'SUCCESS: Granted BYPASSRLS to service_role'; +EXCEPTION + WHEN insufficient_privilege THEN + RAISE NOTICE 'INFO: Could not grant BYPASSRLS (using explicit policies instead)'; + WHEN OTHERS THEN + RAISE NOTICE 'INFO: BYPASSRLS not needed or already set'; +END $$; + +-- ===================== +-- STEP 5: VERIFY SETUP +-- ===================== + +-- Check service_role policies on storage.objects +SELECT + policyname, + permissive, + roles, + cmd, + qual, + with_check +FROM pg_policies +WHERE schemaname = 'storage' + AND tablename = 'objects' + AND 'service_role' = ANY(roles); + +-- Check service_role policies on public.members +SELECT + policyname, + permissive, + roles, + cmd, + qual, + with_check +FROM pg_policies +WHERE schemaname = 'public' + AND tablename = 'members' + AND 'service_role' = ANY(roles); + +-- Check if service_role has BYPASSRLS +SELECT rolname, rolbypassrls +FROM pg_roles +WHERE rolname = 'service_role'; diff --git a/supabase/migrations/001_initial_schema.sql b/supabase/migrations/001_initial_schema.sql new file mode 100644 index 0000000..4c16f42 --- /dev/null +++ b/supabase/migrations/001_initial_schema.sql @@ -0,0 +1,786 @@ +-- Monaco USA Portal 2026 - Initial Database Schema +-- Run this migration to set up all tables, views, triggers, and RLS policies + +-- ============================================ +-- 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, + + -- Personal Info + 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 '{}', + + -- Membership + 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, + + -- Profile + avatar_url TEXT, + + -- Admin + notes TEXT, + + -- Timestamps + 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(), + + -- Basic Info + title TEXT NOT NULL, + description TEXT, + event_type_id UUID REFERENCES public.event_types(id), + + -- Date/Time + start_datetime TIMESTAMPTZ NOT NULL, + end_datetime TIMESTAMPTZ NOT NULL, + all_day BOOLEAN DEFAULT FALSE, + timezone TEXT DEFAULT 'Europe/Monaco', + + -- Location + location TEXT, + location_url TEXT, + + -- Capacity + max_attendees INTEGER, + max_guests_per_member INTEGER DEFAULT 1, + + -- Pricing + is_paid BOOLEAN DEFAULT FALSE, + member_price DECIMAL(10,2) DEFAULT 0, + non_member_price DECIMAL(10,2) DEFAULT 0, + pricing_notes TEXT, + + -- Visibility + 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')), + + -- Media + cover_image_url TEXT, + + -- Meta + 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 + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + payment_reference TEXT, + payment_amount DECIMAL(10,2), + + -- Attendance + 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 + payment_status TEXT DEFAULT 'not_required' + CHECK (payment_status IN ('not_required', 'pending', 'paid')), + payment_reference TEXT, + payment_amount DECIMAL(10,2), + + -- Attendance + 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 Info + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + mime_type TEXT NOT NULL, + + -- Visibility + visibility TEXT NOT NULL DEFAULT 'members' + CHECK (visibility IN ('public', 'members', 'board', 'admin')), + allowed_member_ids UUID[], + + -- Version tracking + version INTEGER DEFAULT 1, + replaces_document_id UUID REFERENCES public.documents(id), + + -- Meeting-specific fields + meeting_date DATE, + meeting_attendees UUID[], + + -- Meta + 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 + ('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 + ('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 + ('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); diff --git a/supabase/migrations/002_admin_integrations_settings.sql b/supabase/migrations/002_admin_integrations_settings.sql new file mode 100644 index 0000000..661f63d --- /dev/null +++ b/supabase/migrations/002_admin_integrations_settings.sql @@ -0,0 +1,35 @@ +-- ============================================ +-- ADMIN INTEGRATION SETTINGS +-- SMTP, S3/MinIO, and expanded system settings +-- ============================================ + +-- Add SMTP settings +INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES + -- Email/SMTP Configuration + ('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), + + -- S3/MinIO Storage Configuration + ('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), + + -- Additional System Settings + ('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; diff --git a/supabase/migrations/003_storage_buckets_and_audit.sql b/supabase/migrations/003_storage_buckets_and_audit.sql new file mode 100644 index 0000000..f82d507 --- /dev/null +++ b/supabase/migrations/003_storage_buckets_and_audit.sql @@ -0,0 +1,504 @@ +-- Monaco USA Portal 2026 +-- Migration 003: Storage Buckets and Audit Logging +-- ================================================ + +-- ============================================ +-- STORAGE BUCKETS +-- ============================================ + +-- Documents bucket (public for direct URL access - visibility controlled at app level) +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'documents', + 'documents', + true, + 52428800, -- 50MB + 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 (public for display) +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'avatars', + 'avatars', + true, + 5242880, -- 5MB + 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 (public for display) +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'event-images', + 'event-images', + true, + 10485760, -- 10MB + 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 +-- ============================================ + +-- Documents bucket 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' + ) +); + +-- Avatars bucket policies (public read, user-specific write) +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 +WITH CHECK ( + bucket_id = 'avatars' + AND auth.role() = 'authenticated' + AND (storage.foldername(name))[1] = auth.uid()::text +); + +DROP POLICY IF EXISTS "avatars_update_policy" ON storage.objects; +CREATE POLICY "avatars_update_policy" ON storage.objects FOR UPDATE +USING ( + bucket_id = 'avatars' + AND auth.role() = 'authenticated' + AND (storage.foldername(name))[1] = auth.uid()::text +); + +DROP POLICY IF EXISTS "avatars_delete_policy" ON storage.objects; +CREATE POLICY "avatars_delete_policy" ON storage.objects FOR DELETE +USING ( + bucket_id = 'avatars' + AND auth.role() = 'authenticated' + AND (storage.foldername(name))[1] = auth.uid()::text +); + +-- Event images bucket policies +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() +); + +-- Index for querying audit logs +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); + +-- RLS for audit logs (only admins can read, service role can write) +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' + ) +); + +-- ============================================ +-- DEFAULT EMAIL TEMPLATES +-- ============================================ + +-- Insert default email templates if they don't exist +-- Using Monaco-branded design matching the login screen +INSERT INTO email_templates (template_key, template_name, category, subject, body_html, body_text, is_system) +VALUES + ( + 'welcome', + 'Welcome Email', + 'member', + 'Welcome to Monaco USA, {{first_name}}!', + ' + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Welcome to Monaco USA!

+

Dear {{first_name}},

+

We are thrilled to welcome you to the Monaco USA community! Your membership has been created and you can now access all member features.

+

To get started:

+
    +
  1. Set up your password using the separate email we sent
  2. +
  3. Complete your profile with your details
  4. +
  5. Explore upcoming events and connect with fellow members
  6. +
+

If you have any questions, please don''t hesitate to reach out to our board members.

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +', + 'Welcome to Monaco USA, {{first_name}}! Your membership has been created. Please set up your password and complete your profile.', + true + ), + ( + 'waitlist_promotion', + 'Waitlist Promotion', + 'event', + 'Great news! You''re confirmed for {{event_title}}', + ' + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

You''re In!

+

Dear {{first_name}},

+

Great news! A spot has opened up for {{event_title}} and you have been moved from the waitlist to confirmed!

+
+

Event Details

+

Date: {{event_date}}

+

Location: {{event_location}}

+
+

We look forward to seeing you there!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +', + 'Great news! A spot has opened up for {{event_title}} and you''ve been confirmed. See you on {{event_date}} at {{event_location}}!', + true + ), + ( + 'rsvp_confirmation', + 'RSVP Confirmation', + 'event', + 'RSVP Confirmed: {{event_title}}', + ' + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

RSVP Confirmed!

+

Dear {{first_name}},

+

Your RSVP for {{event_title}} has been confirmed.

+
+

Event Details

+

Date: {{event_date}}

+

Time: {{event_time}}

+

Location: {{event_location}}

+

Guests: {{guest_count}}

+
+

We look forward to seeing you!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +', + 'Your RSVP for {{event_title}} is confirmed! See you on {{event_date}} at {{event_location}}.', + true + ), + ( + 'payment_received', + 'Payment Received', + 'dues', + 'Payment Received - Monaco USA', + ' + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Payment Received

+

Dear {{first_name}},

+

We have received your payment. Thank you!

+
+

Payment Details

+

Amount: ${{amount}}

+

Date: {{payment_date}}

+

Reference: {{reference}}

+
+

Your membership dues are now paid through {{due_date}}.

+

Thank you for your continued support of Monaco USA!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +', + 'Payment of ${{amount}} received on {{payment_date}}. Your dues are paid through {{due_date}}. Thank you!', + true + ), + ( + 'dues_reminder', + 'Dues Reminder', + 'dues', + 'Monaco USA Membership Dues Reminder', + ' + + + + + + + + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Membership Dues Reminder

+

Dear {{first_name}},

+

This is a friendly reminder that your Monaco USA membership dues {{status}}.

+
+

Dues Details

+

Amount Due: ${{amount}}

+

Due Date: {{due_date}}

+
+

Please log in to your member portal to view payment instructions or contact the treasurer for assistance.

+

Thank you for your continued membership!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+ +', + 'Reminder: Your Monaco USA membership dues ({{amount}}) {{status}}. Due date: {{due_date}}. Please log in to your portal for payment instructions.', + true + ) +ON CONFLICT (template_key) DO NOTHING; + +-- Grant permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON audit_logs TO authenticated; +GRANT ALL ON audit_logs TO service_role; diff --git a/supabase/migrations/004_user_notification_preferences.sql b/supabase/migrations/004_user_notification_preferences.sql new file mode 100644 index 0000000..1cb2ecd --- /dev/null +++ b/supabase/migrations/004_user_notification_preferences.sql @@ -0,0 +1,102 @@ +-- User Notification Preferences +-- Allows members to control what email notifications they receive + +-- Create user notification preferences table +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, + + -- Event notifications + 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, + + -- Membership notifications + email_dues_reminder BOOLEAN DEFAULT true, + email_payment_confirmation BOOLEAN DEFAULT true, + email_membership_updates BOOLEAN DEFAULT true, + + -- General notifications + email_announcements BOOLEAN DEFAULT true, + email_newsletter BOOLEAN DEFAULT true, + + -- Newsletter frequency (if subscribed) + newsletter_frequency TEXT DEFAULT 'monthly' CHECK (newsletter_frequency IN ('weekly', 'monthly', 'quarterly', 'never')), + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_notification_prefs_member ON user_notification_preferences(member_id); + +-- Enable RLS +ALTER TABLE user_notification_preferences ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +-- Members can view their own preferences +CREATE POLICY "Members can view own notification preferences" +ON user_notification_preferences FOR SELECT +USING (member_id = auth.uid()); + +-- Members can insert their own preferences +CREATE POLICY "Members can insert own notification preferences" +ON user_notification_preferences FOR INSERT +WITH CHECK (member_id = auth.uid()); + +-- Members can update their own preferences +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()); + +-- Admins can view all preferences (for admin reports) +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' + ) +); + +-- Function to create default preferences for new members +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; + +-- Trigger to create preferences when a new member is created +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 default preferences for existing members +INSERT INTO user_notification_preferences (member_id) +SELECT id FROM members +ON CONFLICT (member_id) DO NOTHING; + +-- Add updated_at trigger +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(); diff --git a/supabase/migrations/005_fix_avatars_storage_policy.sql b/supabase/migrations/005_fix_avatars_storage_policy.sql new file mode 100644 index 0000000..b05afb2 --- /dev/null +++ b/supabase/migrations/005_fix_avatars_storage_policy.sql @@ -0,0 +1,37 @@ +-- Monaco USA Portal 2026 +-- Migration 005: Fix Avatars Storage Policy +-- ================================================ +-- This fixes the RLS policy for avatars bucket to allow authenticated users to upload + +-- Drop existing restrictive policies +DROP POLICY IF EXISTS "avatars_insert_policy" ON storage.objects; +DROP POLICY IF EXISTS "avatars_update_policy" ON storage.objects; +DROP POLICY IF EXISTS "avatars_delete_policy" ON storage.objects; + +-- Create new permissive policy for authenticated users +-- Avatars bucket is public for reading, so we just need to ensure authenticated users can upload +CREATE POLICY "avatars_insert_policy" ON storage.objects FOR INSERT +TO authenticated +WITH CHECK (bucket_id = 'avatars'); + +CREATE POLICY "avatars_update_policy" ON storage.objects FOR UPDATE +TO authenticated +USING (bucket_id = 'avatars'); + +CREATE POLICY "avatars_delete_policy" ON storage.objects FOR DELETE +TO authenticated +USING (bucket_id = 'avatars'); + +-- Ensure the avatars bucket exists and is public +INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +VALUES ( + 'avatars', + 'avatars', + true, + 5242880, -- 5MB + 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; diff --git a/supabase/migrations/006_document_folders.sql b/supabase/migrations/006_document_folders.sql new file mode 100644 index 0000000..93d22db --- /dev/null +++ b/supabase/migrations/006_document_folders.sql @@ -0,0 +1,100 @@ +-- Monaco USA Portal 2026 - Document Folders +-- Adds hierarchical folder support for document organization + +-- ============================================ +-- DOCUMENT FOLDERS TABLE +-- ============================================ +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, -- Full path for breadcrumb support + 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(), + + -- Ensure unique folder names within same parent + UNIQUE(name, parent_id) +); + +-- Add updated_at trigger +CREATE TRIGGER document_folders_updated_at + BEFORE UPDATE ON public.document_folders + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); + +-- ============================================ +-- ADD FOLDER_ID TO DOCUMENTS TABLE +-- ============================================ +ALTER TABLE public.documents +ADD COLUMN folder_id UUID REFERENCES public.document_folders(id) ON DELETE SET NULL; + +-- ============================================ +-- PATH UPDATE TRIGGER +-- ============================================ +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(); + +-- ============================================ +-- RLS POLICIES FOR FOLDERS +-- ============================================ +ALTER TABLE public.document_folders ENABLE ROW LEVEL SECURITY; + +-- Everyone can view folders based on visibility +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' + )) + ); + +-- Board and admin can create folders +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')) + ); + +-- Board and admin can update folders +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')) + ); + +-- Only admin can delete folders +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') + ); + +-- ============================================ +-- INDEX FOR FOLDER QUERIES +-- ============================================ +CREATE INDEX idx_document_folders_parent ON public.document_folders(parent_id); +CREATE INDEX idx_documents_folder ON public.documents(folder_id); diff --git a/supabase/migrations/007_dues_reminders.sql b/supabase/migrations/007_dues_reminders.sql new file mode 100644 index 0000000..21d58f0 --- /dev/null +++ b/supabase/migrations/007_dues_reminders.sql @@ -0,0 +1,307 @@ +-- Monaco USA Portal 2026 - Dues Reminders Enhancement +-- Track sent reminders to avoid duplicates and enable analytics + +-- ============================================ +-- DUES REMINDER LOGS TABLE +-- ============================================ +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')), + due_date DATE NOT NULL, + sent_at TIMESTAMPTZ DEFAULT NOW(), + email_log_id UUID REFERENCES public.email_logs(id), + -- Prevent duplicate reminders for same member/type/period + UNIQUE(member_id, reminder_type, due_date) +); + +-- Enable RLS +ALTER TABLE public.dues_reminder_logs ENABLE ROW LEVEL SECURITY; + +-- Board and admin can view reminder logs +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')) + ); + +-- Only service role can insert reminder logs (from cron/server) +CREATE POLICY "Service role can manage reminder logs" ON public.dues_reminder_logs + FOR ALL USING (true) + WITH CHECK (true); + +-- Index for fast lookups +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); + +-- ============================================ +-- ADD EMAIL TEMPLATES FOR DUES REMINDERS +-- ============================================ + +-- 30 days before due reminder +INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES +( + 'dues_reminder_30', + 'Dues Reminder - 30 Days', + 'payment', + 'Your Monaco USA Membership Dues Are Coming Up', + '

Dear {{first_name}},

+

This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}}.

+
+

Payment Details:

+

Amount Due: {{amount}}

+

Due Date: {{due_date}}

+

Member ID: {{member_id}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

You can also view your payment status and history in the member portal:

+

+ View My Account +

+

Thank you for being a valued member of Monaco USA!

', + 'Dear {{first_name}}, + +This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}}. + +Payment Details: +- Amount Due: {{amount}} +- Due Date: {{due_date}} +- Member ID: {{member_id}} + +Bank Transfer Details: +- Account Holder: {{account_holder}} +- Bank: {{bank_name}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +Visit the member portal to view your payment status: {{portal_url}} + +Thank you for being a valued member of Monaco USA!', + true, + true, + '{"first_name": "Member first name", "due_date": "Dues due date", "amount": "Amount due", "member_id": "Member ID for reference", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}' +), +( + 'dues_reminder_7', + 'Dues Reminder - 7 Days', + 'payment', + 'Reminder: Monaco USA Dues Due in 7 Days', + '

Dear {{first_name}},

+

Your Monaco USA membership dues will be due in 7 days on {{due_date}}.

+
+

Payment Information:

+

Amount: {{amount}}

+

Due Date: {{due_date}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now +

+

Questions? Contact us at contact@monacousa.org

', + 'Dear {{first_name}}, + +Your Monaco USA membership dues will be due in 7 days on {{due_date}}. + +Amount: {{amount}} +IBAN: {{iban}} +Reference: {{member_id}} + +Visit the portal to pay: {{portal_url}}', + true, + true, + '{"first_name": "Member first name", "due_date": "Dues due date", "amount": "Amount due", "member_id": "Member ID", "iban": "IBAN number", "portal_url": "Portal URL"}' +), +( + 'dues_reminder_1', + 'Dues Reminder - 1 Day', + 'payment', + 'URGENT: Monaco USA Dues Due Tomorrow', + '

Dear {{first_name}},

+

Your Monaco USA membership dues are due tomorrow ({{due_date}}).

+
+

Payment Required:

+

Amount: {{amount}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

To maintain your active membership status and continued access to member benefits, please ensure payment is made by the due date.

+

+ Pay Now +

', + 'Dear {{first_name}}, + +URGENT: Your Monaco USA membership dues are due tomorrow ({{due_date}}). + +Amount: {{amount}} +IBAN: {{iban}} +Reference: {{member_id}} + +To maintain your active membership, please pay by the due date. + +Pay now: {{portal_url}}', + true, + true, + '{"first_name": "Member first name", "due_date": "Dues due date", "amount": "Amount due", "member_id": "Member ID", "iban": "IBAN number", "portal_url": "Portal URL"}' +), +( + 'dues_overdue', + 'Dues Overdue Notice', + 'payment', + 'ACTION REQUIRED: Monaco USA Dues Are Now Overdue', + '

Dear {{first_name}},

+

Your Monaco USA membership dues are now {{days_overdue}} days overdue.

+
+

Overdue Payment:

+

Amount: {{amount}}

+

Original Due Date: {{due_date}}

+

Days Overdue: {{days_overdue}}

+
+
+

Grace Period: You have {{grace_days_remaining}} days remaining in your grace period. After this, your membership status will be changed to inactive.

+
+

Please remit payment as soon as possible to maintain your membership benefits.

+
+

Payment Details:

+

Account: {{account_holder}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now +

', + 'Dear {{first_name}}, + +Your Monaco USA membership dues are now {{days_overdue}} days overdue. + +Amount: {{amount}} +Original Due Date: {{due_date}} +Days Overdue: {{days_overdue}} + +Grace Period: You have {{grace_days_remaining}} days remaining. After this, your membership will be marked inactive. + +Payment Details: +- Account: {{account_holder}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +Pay now: {{portal_url}}', + true, + true, + '{"first_name": "Member first name", "due_date": "Original due date", "amount": "Amount due", "days_overdue": "Number of days overdue", "grace_days_remaining": "Days left in grace period", "member_id": "Member ID", "account_holder": "Account holder", "iban": "IBAN", "portal_url": "Portal URL"}' +), +( + 'dues_grace_warning', + 'Grace Period Ending Warning', + 'payment', + 'WARNING: Monaco USA Grace Period Ending Soon', + '

Dear {{first_name}},

+

Your grace period ends in {{grace_days_remaining}} days.

+

Your membership dues of {{amount}} were due on {{due_date}} and are now {{days_overdue}} days overdue.

+
+

If payment is not received by {{grace_end_date}}, your membership status will automatically change to INACTIVE and you will lose access to member benefits.

+
+

Please make your payment immediately to avoid interruption:

+
+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now - Urgent +

', + 'Dear {{first_name}}, + +WARNING: Your grace period ends in {{grace_days_remaining}} days. + +Your dues of {{amount}} were due on {{due_date}} and are now {{days_overdue}} days overdue. + +If payment is not received by {{grace_end_date}}, your membership will become INACTIVE. + +IBAN: {{iban}} +Reference: {{member_id}} + +Pay now: {{portal_url}}', + true, + true, + '{"first_name": "Member first name", "due_date": "Original due date", "amount": "Amount due", "days_overdue": "Days overdue", "grace_days_remaining": "Days until grace period ends", "grace_end_date": "Date grace period ends", "member_id": "Member ID", "iban": "IBAN", "portal_url": "Portal URL"}' +), +( + 'dues_inactive_notice', + 'Membership Marked Inactive', + 'payment', + 'Notice: Your Monaco USA Membership Is Now Inactive', + '

Dear {{first_name}},

+

Due to non-payment of membership dues, your Monaco USA membership has been marked as INACTIVE.

+
+

Status Change:

+

Previous Status: Active

+

New Status: Inactive

+

Outstanding Amount: {{amount}}

+
+

As an inactive member, you will no longer have access to:

+
    +
  • Member-only events
  • +
  • Member directory
  • +
  • Member communications
  • +
  • Voting rights
  • +
+

To reactivate your membership, please pay your outstanding dues:

+
+

Account: {{account_holder}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Reactivate My Membership +

+

If you believe this is an error or have questions, please contact us at contact@monacousa.org

', + 'Dear {{first_name}}, + +Due to non-payment of membership dues, your Monaco USA membership has been marked as INACTIVE. + +Outstanding Amount: {{amount}} + +As an inactive member, you no longer have access to member-only events, directory, communications, or voting rights. + +To reactivate, please pay your dues: +- Account: {{account_holder}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +Reactivate: {{portal_url}} + +Questions? Contact contact@monacousa.org', + true, + true, + '{"first_name": "Member first name", "amount": "Outstanding amount", "member_id": "Member ID", "account_holder": "Account holder", "iban": "IBAN", "portal_url": "Portal URL"}' +) +ON CONFLICT (template_key) DO NOTHING; + +-- ============================================ +-- HELPER FUNCTION: Get dues settings +-- ============================================ +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; diff --git a/supabase/migrations/008_s3_public_endpoint.sql b/supabase/migrations/008_s3_public_endpoint.sql new file mode 100644 index 0000000..c9489e0 --- /dev/null +++ b/supabase/migrations/008_s3_public_endpoint.sql @@ -0,0 +1,11 @@ +-- ============================================ +-- S3 PUBLIC ENDPOINT SETTING +-- Separate URL for browser-accessible S3 files +-- ============================================ + +-- Add S3 public endpoint setting for browser access +-- The regular s3_endpoint is for server-to-S3 communication (internal Docker) +-- The s3_public_endpoint is for browser access to files (external URL) +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; diff --git a/supabase/migrations/009_dual_avatar_urls.sql b/supabase/migrations/009_dual_avatar_urls.sql new file mode 100644 index 0000000..10a2f42 --- /dev/null +++ b/supabase/migrations/009_dual_avatar_urls.sql @@ -0,0 +1,22 @@ +-- ============================================ +-- DUAL AVATAR URL COLUMNS +-- Separate columns for S3 and local storage URLs +-- ============================================ + +-- Add separate columns for S3 and local (Supabase Storage) avatar URLs +-- This allows switching between storage backends without losing URLs + +-- Add local avatar URL column +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_url_local TEXT; + +-- Add S3 avatar URL column +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_url_s3 TEXT; + +-- Add avatar storage path column (for deletion purposes) +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_path TEXT; + +-- Comment explaining the columns +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)'; diff --git a/supabase/migrations/010_storage_service_role_policies.sql b/supabase/migrations/010_storage_service_role_policies.sql new file mode 100644 index 0000000..b251fcd --- /dev/null +++ b/supabase/migrations/010_storage_service_role_policies.sql @@ -0,0 +1,79 @@ +-- ============================================ +-- STORAGE SERVICE ROLE POLICIES +-- Allow service_role to perform all operations on avatars bucket +-- This fixes RLS issues when using supabaseAdmin for storage operations +-- ============================================ + +-- First, drop any existing service role policies (in case they exist with different names) +DROP POLICY IF EXISTS "Service role can insert avatars" ON storage.objects; +DROP POLICY IF EXISTS "Service role can update avatars" ON storage.objects; +DROP POLICY IF EXISTS "Service role can delete avatars" ON storage.objects; +DROP POLICY IF EXISTS "Service role can read avatars" ON storage.objects; +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; + +-- Service role INSERT policy for avatars +CREATE POLICY "service_role_insert_avatars" ON storage.objects +FOR INSERT TO service_role +WITH CHECK (bucket_id = 'avatars'); + +-- Service role UPDATE policy for avatars +CREATE POLICY "service_role_update_avatars" ON storage.objects +FOR UPDATE TO service_role +USING (bucket_id = 'avatars'); + +-- Service role DELETE policy for avatars +CREATE POLICY "service_role_delete_avatars" ON storage.objects +FOR DELETE TO service_role +USING (bucket_id = 'avatars'); + +-- Service role SELECT policy for avatars +CREATE POLICY "service_role_select_avatars" ON storage.objects +FOR SELECT TO service_role +USING (bucket_id = 'avatars'); + +-- Also add service_role policies for documents bucket +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'); + +-- Also add service_role policies for event-images bucket +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'); diff --git a/supabase/migrations/011_fix_service_role_rls.sql b/supabase/migrations/011_fix_service_role_rls.sql new file mode 100644 index 0000000..7adb131 --- /dev/null +++ b/supabase/migrations/011_fix_service_role_rls.sql @@ -0,0 +1,98 @@ +-- ============================================ +-- FIX SERVICE ROLE RLS BYPASS +-- Ensure service_role can properly bypass RLS for storage operations +-- ============================================ + +-- The service_role should have BYPASSRLS attribute in Supabase +-- But in self-hosted setups, this might not be configured correctly +-- This migration ensures proper access through multiple approaches + +-- Approach 1: Grant service_role BYPASSRLS (if not already set) +-- Note: This requires superuser privileges, which the migration might not have +-- If this fails, the explicit policies below will still work +DO $$ +BEGIN + -- Check if service_role exists and doesn't have bypassrls + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role' AND NOT rolbypassrls) THEN + ALTER ROLE service_role BYPASSRLS; + RAISE NOTICE 'Granted BYPASSRLS to service_role'; + ELSE + RAISE NOTICE 'service_role already has BYPASSRLS or does not exist'; + END IF; +EXCEPTION + WHEN insufficient_privilege THEN + RAISE NOTICE 'Could not grant BYPASSRLS (insufficient privileges) - using explicit policies instead'; + WHEN OTHERS THEN + RAISE NOTICE 'Error granting BYPASSRLS: % - using explicit policies instead', SQLERRM; +END $$; + +-- Approach 2: Ensure RLS is properly configured on storage.objects +-- Check if RLS is enabled and ensure our policies exist +DO $$ +BEGIN + -- Ensure RLS is enabled on storage.objects (it should be by default) + IF NOT EXISTS ( + SELECT 1 FROM pg_tables + WHERE schemaname = 'storage' AND tablename = 'objects' AND rowsecurity = true + ) THEN + ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; + RAISE NOTICE 'Enabled RLS on storage.objects'; + END IF; +END $$; + +-- Approach 3: Create permissive policies for service_role on ALL storage buckets +-- These use a single policy per operation type that covers all buckets + +-- First, clean up any existing service_role policies +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 universal service_role policies (allow access to ALL buckets) +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); + +-- Approach 4: Grant necessary table permissions to service_role +GRANT ALL ON storage.objects TO service_role; +GRANT ALL ON storage.buckets TO service_role; +GRANT USAGE ON SCHEMA storage TO service_role; + +-- Also ensure service_role can use sequences in storage schema +DO $$ +DECLARE + seq_name text; +BEGIN + FOR seq_name IN + SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'storage' + LOOP + EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE storage.%I TO service_role', seq_name); + END LOOP; +END $$; + +-- Verify the setup +DO $$ +DECLARE + policy_count int; +BEGIN + SELECT COUNT(*) INTO policy_count + FROM pg_policies + WHERE schemaname = 'storage' + AND tablename = 'objects' + AND roles @> ARRAY['service_role']::name[]; + + RAISE NOTICE 'service_role has % policies on storage.objects', policy_count; +END $$; diff --git a/supabase/migrations/012_dual_document_urls.sql b/supabase/migrations/012_dual_document_urls.sql new file mode 100644 index 0000000..a0d7628 --- /dev/null +++ b/supabase/migrations/012_dual_document_urls.sql @@ -0,0 +1,49 @@ +-- ============================================ +-- DUAL STORAGE SUPPORT FOR DOCUMENTS +-- Store URLs for both Supabase Storage and S3 backends +-- Mirrors the avatar dual-storage pattern from migration 009 +-- ============================================ + +-- Add local storage URL column +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS file_url_local TEXT; + +-- Add S3 storage URL column +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS file_url_s3 TEXT; + +-- Add storage path column (relative path used for both backends) +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_path TEXT; + +-- Add comments for documentation +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)'; + +-- Migrate existing file_path values to storage_path and file_url_local +-- This handles documents uploaded before dual-storage was implemented +UPDATE public.documents +SET + storage_path = CASE + WHEN file_path LIKE 'http%' THEN + -- Extract filename from URL + CASE + WHEN file_path LIKE '%/storage/v1/object/public/documents/%' THEN + substring(file_path from '/storage/v1/object/public/documents/([^?]+)') + WHEN file_path LIKE '%/documents/%' THEN + substring(file_path from '/documents/([^?]+)') + ELSE file_path + END + ELSE file_path + END, + file_url_local = CASE + WHEN file_path LIKE 'http%' AND file_path LIKE '%/storage/v1/object/public/documents/%' THEN file_path + ELSE NULL + END, + file_url_s3 = CASE + WHEN file_path LIKE 'http%' AND file_path NOT LIKE '%/storage/v1/object/public/documents/%' THEN file_path + ELSE NULL + END +WHERE storage_path IS NULL; + +-- Create index for faster lookups +CREATE INDEX IF NOT EXISTS idx_documents_storage_path ON public.documents(storage_path); diff --git a/supabase/migrations/013_email_background_images.sql b/supabase/migrations/013_email_background_images.sql new file mode 100644 index 0000000..b07ac77 --- /dev/null +++ b/supabase/migrations/013_email_background_images.sql @@ -0,0 +1,671 @@ +-- ============================================ +-- Migration 013: Update Email Templates with Background Image +-- Adds S3-hosted Monaco background image to all email templates +-- Matches login screen styling: image + gradient overlay +-- ============================================ + +-- Background image URL +-- Using: https://s3.monacousa.org/public/monaco_high_res.jpg +-- Gradient overlay: from-slate-900/80 via-slate-900/60 to-monaco-900/70 + +-- ===================== +-- Update Welcome Email Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Welcome to Monaco USA!

+

Dear {{first_name}},

+

We are thrilled to welcome you to the Monaco USA community! Your membership has been created and you can now access all member features.

+

To get started:

+
    +
  1. Set up your password using the separate email we sent
  2. +
  3. Complete your profile with your details
  4. +
  5. Explore upcoming events and connect with fellow members
  6. +
+

If you have any questions, please don''t hesitate to reach out to our board members.

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'welcome'; + +-- ===================== +-- Update RSVP Confirmation Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

RSVP Confirmed!

+

Dear {{first_name}},

+

Your RSVP has been confirmed for the following event:

+
+

{{event_title}}

+

Date: {{event_date}}

+

Location: {{event_location}}

+

Guests: {{guest_count}}

+
+

We look forward to seeing you there!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'rsvp_confirmation'; + +-- ===================== +-- Update Payment Received Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Payment Received

+

Dear {{first_name}},

+

Thank you! We have received your membership dues payment.

+
+

Payment Details:

+

Amount: {{amount}}

+

Payment Date: {{payment_date}}

+

Period: {{period_start}} - {{period_end}}

+

Member ID: {{member_id}}

+
+

Your membership is now active through {{period_end}}. Thank you for your continued support of Monaco USA!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'payment_received'; + +-- ===================== +-- Update Waitlist Promotion Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Great News!

+

Dear {{first_name}},

+

A spot has opened up! You have been promoted from the waitlist for:

+
+

{{event_title}}

+

Date: {{event_date}}

+

Location: {{event_location}}

+
+

Your attendance is now confirmed. We look forward to seeing you!

+

Best regards,
The Monaco USA Team

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'waitlist_promotion'; + +-- ===================== +-- Update Dues Reminder 30 Days Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Dues Reminder

+

Dear {{first_name}},

+

This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}}.

+
+

Payment Details:

+

Amount Due: {{amount}}

+

Due Date: {{due_date}}

+

Member ID: {{member_id}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

You can also view your payment status and history in the member portal:

+

+ View My Account +

+

Thank you for being a valued member of Monaco USA!

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'dues_reminder_30'; + +-- ===================== +-- Update Dues Reminder 7 Days Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Dues Due Soon

+

Dear {{first_name}},

+

Your Monaco USA membership dues are due in 7 days on {{due_date}}.

+
+

Payment Details:

+

Amount Due: {{amount}}

+

Due Date: {{due_date}}

+

Member ID: {{member_id}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now +

+

Thank you for your continued support!

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'dues_reminder_7'; + +-- ===================== +-- Update Dues Reminder 1 Day Template +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Final Reminder

+

Dear {{first_name}},

+

This is a final reminder that your Monaco USA membership dues are due tomorrow ({{due_date}}).

+
+

Urgent - Payment Required:

+

Amount Due: {{amount}}

+

Due Date: {{due_date}}

+

Member ID: {{member_id}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now +

+

Please disregard this email if you have already made your payment.

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'dues_reminder_1'; + +-- ===================== +-- Update Dues Reminder (Generic) Template if exists +-- ===================== +UPDATE public.email_templates +SET body_html = ' + + + + + + + + + + + + +
+
+ + + + +
+ + + + + +
+
+ Monaco USA +
+

Monaco USA

+

Americans in Monaco

+
+ + + + + + +
+

Membership Dues Reminder

+

Dear {{first_name}},

+

This is a reminder about your Monaco USA membership dues.

+
+

Payment Details:

+

Amount Due: {{amount}}

+

Due Date: {{due_date}}

+

Member ID: {{member_id}}

+
+

+ View My Account +

+

Thank you for being a valued member of Monaco USA!

+
+ + + + + + +
+

© 2026 Monaco USA. All rights reserved.

+
+
+
+
+ +' +WHERE template_key = 'dues_reminder'; diff --git a/supabase/migrations/014_event_reminders.sql b/supabase/migrations/014_event_reminders.sql new file mode 100644 index 0000000..ba3fc87 --- /dev/null +++ b/supabase/migrations/014_event_reminders.sql @@ -0,0 +1,133 @@ +-- Monaco USA Portal 2026 - Event Reminder Emails +-- Automated reminders sent 24 hours before events to RSVPed members + +-- ============================================ +-- EVENT REMINDER LOGS TABLE +-- ============================================ +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), + -- Prevent duplicate reminders for same event/member/type + UNIQUE(event_id, member_id, reminder_type) +); + +-- Enable RLS +ALTER TABLE public.event_reminder_logs ENABLE ROW LEVEL SECURITY; + +-- Board and admin can view reminder logs +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')) + ); + +-- Service role can manage reminder logs (from cron/server) +CREATE POLICY "Service role can manage event reminder logs" ON public.event_reminder_logs + FOR ALL USING (true) + WITH CHECK (true); + +-- Indexes for fast lookups +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); + +-- ============================================ +-- ADD APP SETTINGS FOR EVENT REMINDERS +-- ============================================ +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; + +-- ============================================ +-- ADD EMAIL TEMPLATE FOR EVENT REMINDER +-- ============================================ +INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES +( + 'event_reminder_24hr', + 'Event Reminder - 24 Hours', + 'events', + 'Reminder: {{event_title}} is Tomorrow!', + '

Hi {{first_name}},

+

This is a friendly reminder that {{event_title}} is happening tomorrow!

+
+

Event Details:

+

Date: {{event_date}}

+

Time: {{event_time}}

+

Location: {{event_location}}

+ {{#if guest_count}} +

You''re bringing {{guest_count}} guest(s)

+ {{/if}} +
+

We look forward to seeing you there!

+

+ View Event Details +

+

Can''t make it? Please update your RSVP so we can offer your spot to someone on the waitlist.

', + 'Hi {{first_name}}, + +This is a friendly reminder that {{event_title}} is happening tomorrow! + +Event Details: +- Date: {{event_date}} +- Time: {{event_time}} +- Location: {{event_location}} +{{#if guest_count}} +- You''re bringing {{guest_count}} guest(s) +{{/if}} + +We look forward to seeing you there! + +View event: {{portal_url}} + +Can''t make it? Please update your RSVP so we can offer your spot to someone on the waitlist.', + true, + true, + '{"first_name": "Member first name", "event_title": "Event title", "event_date": "Event date", "event_time": "Event start time", "event_location": "Event location", "guest_count": "Number of guests", "portal_url": "Event URL in portal"}' +) +ON CONFLICT (template_key) DO NOTHING; + +-- ============================================ +-- VIEW: Events needing reminders +-- ============================================ +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 + -- Event is published + e.status = 'published' + -- Event starts within 24-25 hours from now (hourly cron window) + AND e.start_datetime > NOW() + AND e.start_datetime <= NOW() + INTERVAL '25 hours' + AND e.start_datetime > NOW() + INTERVAL '23 hours' + -- Member has confirmed RSVP + AND r.status = 'confirmed' + -- No reminder already sent for this event/member + 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' + ) + -- Member has email + AND m.email IS NOT NULL; diff --git a/supabase/migrations/015_fix_email_template_styling.sql b/supabase/migrations/015_fix_email_template_styling.sql new file mode 100644 index 0000000..88ae8b8 --- /dev/null +++ b/supabase/migrations/015_fix_email_template_styling.sql @@ -0,0 +1,192 @@ +-- Monaco USA Portal 2026 - Fix Email Template Styling +-- Update all email templates with proper text centering and styling + +-- ============================================ +-- UPDATE DUES REMINDER TEMPLATES +-- ============================================ + +-- 30 days before due reminder +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}}.

+
+

Payment Details:

+

Amount Due: {{amount}}

+

Due Date: {{due_date}}

+

Member ID: {{member_id}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

You can also view your payment status and history in the member portal:

+

+ View My Account +

+

Thank you for being a valued member of Monaco USA!

' +WHERE template_key = 'dues_reminder_30'; + +-- 7 days before due reminder +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

Your Monaco USA membership dues will be due in 7 days on {{due_date}}.

+
+

Payment Information:

+

Amount: {{amount}}

+

Due Date: {{due_date}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now +

+

Questions? Contact us at contact@monacousa.org

' +WHERE template_key = 'dues_reminder_7'; + +-- 1 day before due reminder +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

Your Monaco USA membership dues are due tomorrow ({{due_date}}).

+
+

Payment Required:

+

Amount: {{amount}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

To maintain your active membership status and continued access to member benefits, please ensure payment is made by the due date.

+

+ Pay Now +

' +WHERE template_key = 'dues_reminder_1'; + +-- Overdue notice +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

Your Monaco USA membership dues are now {{days_overdue}} days overdue.

+
+

Overdue Payment:

+

Amount: {{amount}}

+

Original Due Date: {{due_date}}

+

Days Overdue: {{days_overdue}}

+
+
+

Grace Period: You have {{grace_days_remaining}} days remaining in your grace period. After this, your membership status will be changed to inactive.

+
+

Please remit payment as soon as possible to maintain your membership benefits.

+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now +

' +WHERE template_key = 'dues_overdue'; + +-- Grace period warning +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

Your grace period ends in {{grace_days_remaining}} days.

+

Your membership dues of {{amount}} were due on {{due_date}} and are now {{days_overdue}} days overdue.

+
+

If payment is not received by {{grace_end_date}}, your membership status will automatically change to INACTIVE and you will lose access to member benefits.

+
+

Please make your payment immediately to avoid interruption:

+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Pay Now - Urgent +

' +WHERE template_key = 'dues_grace_warning'; + +-- Inactive notice +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

Due to non-payment of membership dues, your Monaco USA membership has been marked as INACTIVE.

+
+

Status Change:

+

Previous Status: Active

+

New Status: Inactive

+

Outstanding Amount: {{amount}}

+
+

As an inactive member, you will no longer have access to:

+
    +
  • Member-only events
  • +
  • Member directory
  • +
  • Member communications
  • +
  • Voting rights
  • +
+

To reactivate your membership, please pay your outstanding dues:

+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Reactivate My Membership +

+

If you believe this is an error or have questions, please contact us at contact@monacousa.org

' +WHERE template_key = 'dues_inactive_notice'; + +-- ============================================ +-- UPDATE EVENT REMINDER TEMPLATE +-- ============================================ +UPDATE public.email_templates +SET body_html = '

Hi {{first_name}},

+

This is a friendly reminder that {{event_title}} is happening tomorrow!

+
+

Event Details:

+

Date: {{event_date}}

+

Time: {{event_time}}

+

Location: {{event_location}}

+ {{#if guest_count}} +

You''re bringing {{guest_count}} guest(s)

+ {{/if}} +
+

We look forward to seeing you there!

+

+ View Event Details +

+

Can''t make it? Please update your RSVP so we can offer your spot to someone on the waitlist.

' +WHERE template_key = 'event_reminder_24hr'; + +-- ============================================ +-- UPDATE RSVP CONFIRMATION TEMPLATE (content-only version) +-- ============================================ +-- Note: The original is a full HTML template, so we'll create a content-only version +-- that works with wrapInMonacoTemplate + +-- Update waitlist promotion to be content-only with proper styling +UPDATE public.email_templates +SET body_html = '

Dear {{first_name}},

+

Great news! A spot has opened up for {{event_title}} and you have been moved from the waitlist to confirmed!

+
+

Event Details

+

Date: {{event_date}}

+

Location: {{event_location}}

+
+

We look forward to seeing you there!

+

Best regards,
The Monaco USA Team

' +WHERE template_key = 'waitlist_promotion'; diff --git a/supabase/migrations/016_onboarding_payment_tracking.sql b/supabase/migrations/016_onboarding_payment_tracking.sql new file mode 100644 index 0000000..8af7ad0 --- /dev/null +++ b/supabase/migrations/016_onboarding_payment_tracking.sql @@ -0,0 +1,237 @@ +-- Monaco USA Portal 2026 - Onboarding Payment Tracking +-- Track new member payment deadlines for the 30-day payment window + +-- ============================================ +-- ADD PAYMENT TRACKING COLUMNS TO MEMBERS +-- ============================================ + +-- Payment deadline for new signups (30 days from onboarding completion) +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ; + +-- Track when onboarding was completed +ALTER TABLE public.members ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ; + +-- Index for efficient reminder queries (only index non-null deadlines) +CREATE INDEX IF NOT EXISTS idx_members_payment_deadline ON public.members(payment_deadline) + WHERE payment_deadline IS NOT NULL; + +-- ============================================ +-- ADD ONBOARDING REMINDER TYPES TO LOGS TABLE +-- ============================================ + +-- Update the check constraint on dues_reminder_logs to include onboarding types +ALTER TABLE public.dues_reminder_logs DROP CONSTRAINT IF EXISTS dues_reminder_logs_reminder_type_check; +ALTER TABLE public.dues_reminder_logs ADD CONSTRAINT dues_reminder_logs_reminder_type_check + 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' + )); + +-- ============================================ +-- ADD EMAIL TEMPLATES FOR ONBOARDING REMINDERS +-- ============================================ + +-- Welcome email with payment instructions (sent immediately after onboarding) +INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES +( + 'onboarding_welcome', + 'Welcome - Complete Your Membership', + 'onboarding', + 'Welcome to Monaco USA - Complete Your Membership', + '

Dear {{first_name}},

+

Welcome to Monaco USA! We''re thrilled to have you join our community of Americans living in and connected to Monaco.

+

Your account has been created and you now have 30 days to complete your membership by paying your annual dues.

+
+

Your Membership Details:

+

Member ID: {{member_id}}

+

Annual Dues: {{amount}}

+

Payment Deadline: {{payment_deadline}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

In the meantime, explore your new member dashboard and connect with our community:

+

+ Go to Dashboard +

+

Questions? Contact us at contact@monacousa.org

', + 'Dear {{first_name}}, + +Welcome to Monaco USA! We''re thrilled to have you join our community of Americans living in and connected to Monaco. + +Your account has been created and you now have 30 days to complete your membership by paying your annual dues. + +Your Membership Details: +- Member ID: {{member_id}} +- Annual Dues: {{amount}} +- Payment Deadline: {{payment_deadline}} + +Bank Transfer Details: +- Account Holder: {{account_holder}} +- Bank: {{bank_name}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +Visit your dashboard: {{portal_url}} + +Questions? Contact us at contact@monacousa.org', + true, + true, + '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "payment_deadline": "Payment deadline date", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}' +) ON CONFLICT (template_key) DO NOTHING; + +-- 7 days left reminder +INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES +( + 'onboarding_reminder_7', + 'Onboarding Reminder - 7 Days Left', + 'onboarding', + '7 Days Left to Complete Your Monaco USA Membership', + '

Dear {{first_name}},

+

You have 7 days left to complete your Monaco USA membership by paying your annual dues.

+
+

Payment Due:

+

Amount: {{amount}}

+

Deadline: {{payment_deadline}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

After the deadline, your account will be marked as inactive and you''ll lose access to member features.

+

+ View My Account +

', + 'Dear {{first_name}}, + +You have 7 days left to complete your Monaco USA membership by paying your annual dues. + +Payment Due: +- Amount: {{amount}} +- Deadline: {{payment_deadline}} + +Bank Transfer Details: +- Account Holder: {{account_holder}} +- Bank: {{bank_name}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +After the deadline, your account will be marked as inactive. + +Visit: {{portal_url}}', + true, + true, + '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "payment_deadline": "Payment deadline date", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}' +) ON CONFLICT (template_key) DO NOTHING; + +-- Last day reminder (urgent) +INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES +( + 'onboarding_reminder_1', + 'Onboarding Reminder - Last Day', + 'onboarding', + 'URGENT: Last Day to Complete Your Monaco USA Membership', + '

Dear {{first_name}},

+

Today is your last day to complete your Monaco USA membership payment.

+
+

Payment Required Today:

+

Amount: {{amount}}

+

Deadline: {{payment_deadline}}

+
+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

If we don''t receive your payment today, your account will be marked as inactive tomorrow.

+

+ Complete Payment Now +

', + 'Dear {{first_name}}, + +TODAY IS YOUR LAST DAY to complete your Monaco USA membership payment. + +Payment Required Today: +- Amount: {{amount}} +- Deadline: {{payment_deadline}} + +Bank Transfer Details: +- Account Holder: {{account_holder}} +- Bank: {{bank_name}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +If we don''t receive your payment today, your account will be marked as inactive tomorrow. + +Visit: {{portal_url}}', + true, + true, + '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "payment_deadline": "Payment deadline date", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}' +) ON CONFLICT (template_key) DO NOTHING; + +-- Account marked inactive (deadline passed) +INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES +( + 'onboarding_expired', + 'Onboarding Expired - Account Inactive', + 'onboarding', + 'Your Monaco USA Account Has Been Marked Inactive', + '

Dear {{first_name}},

+

Your 30-day payment window has expired and your Monaco USA account has been marked as INACTIVE.

+
+

Account Status:

+

Status: Inactive

+

Outstanding Amount: {{amount}}

+
+

As an inactive member, you no longer have access to:

+
    +
  • Member-only events
  • +
  • Member directory
  • +
  • Member communications
  • +
+

To reactivate your membership, please complete your dues payment:

+
+

Bank Transfer Details:

+

Account Holder: {{account_holder}}

+

Bank: {{bank_name}}

+

IBAN: {{iban}}

+

Reference: {{member_id}}

+
+

+ Reactivate My Account +

+

Questions? Contact us at contact@monacousa.org

', + 'Dear {{first_name}}, + +Your 30-day payment window has expired and your Monaco USA account has been marked as INACTIVE. + +Account Status: +- Status: Inactive +- Outstanding Amount: {{amount}} + +As an inactive member, you no longer have access to member-only events, directory, and communications. + +To reactivate your membership, please pay your dues: + +Bank Transfer Details: +- Account Holder: {{account_holder}} +- Bank: {{bank_name}} +- IBAN: {{iban}} +- Reference: {{member_id}} + +Visit: {{portal_url}} + +Questions? Contact us at contact@monacousa.org', + true, + true, + '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}' +) ON CONFLICT (template_key) DO NOTHING; diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..e0a641e --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9b140e6 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + css: { + // Prevent Vite from picking up postcss config from parent directories + postcss: {} + } +});