import 'dotenv/config'; import { db, dbAll, dbGet, events, users } from './index.js'; import { sql, eq, ne } from 'drizzle-orm'; import { uniqueSlug } from '../lib/slugify.js'; const dbType = process.env.DB_TYPE || 'sqlite'; console.log(`Database type: ${dbType}`); // Do not log DATABASE_URL: it may contain credentials. async function migrate() { console.log('Running migrations...'); if (dbType === 'sqlite') { // SQLite migrations await (db as any).run(sql` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password TEXT, name TEXT NOT NULL, phone TEXT, role TEXT NOT NULL DEFAULT 'user', language_preference TEXT, is_claimed INTEGER NOT NULL DEFAULT 1, google_id TEXT, ruc_number TEXT, account_status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); // Add new user columns if they don't exist (for existing databases) try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN is_claimed INTEGER NOT NULL DEFAULT 1`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN google_id TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN ruc_number TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } // Magic link tokens table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS magic_link_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), token TEXT NOT NULL UNIQUE, type TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL ) `); // User sessions table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), token TEXT NOT NULL UNIQUE, user_agent TEXT, ip_address TEXT, last_active_at TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL ) `); await (db as any).run(sql` CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, title TEXT NOT NULL, title_es TEXT, description TEXT NOT NULL, description_es TEXT, short_description TEXT, short_description_es TEXT, start_datetime TEXT NOT NULL, end_datetime TEXT, location TEXT NOT NULL, location_url TEXT, price REAL NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'PYG', capacity INTEGER NOT NULL DEFAULT 50, status TEXT NOT NULL DEFAULT 'draft', banner_url TEXT, external_booking_enabled INTEGER NOT NULL DEFAULT 0, external_booking_url TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); // Add external booking columns to events if they don't exist (for existing databases) try { await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_enabled INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`); } catch (e) { /* column may already exist */ } // Add short description columns to events try { await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`); } catch (e) { /* column may already exist */ } // Add slug column to events (backfilled below) try { await (db as any).run(sql`ALTER TABLE events ADD COLUMN slug TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`); } catch (e) { /* index may already exist */ } // Historical slugs that still resolve (and redirect) to an event's canonical slug await (db as any).run(sql` CREATE TABLE IF NOT EXISTS event_slug_aliases ( slug TEXT PRIMARY KEY, event_id TEXT NOT NULL REFERENCES events(id), created_at TEXT NOT NULL ) `); await (db as any).run(sql` -- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone -- shape only survives in databases created before the split into -- first/last name, where the ALTERs below relaxed it; a fresh database -- must not recreate constraints the app no longer satisfies (door -- walk-ins have neither an email nor a phone). CREATE TABLE IF NOT EXISTS tickets ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), event_id TEXT NOT NULL REFERENCES events(id), attendee_first_name TEXT NOT NULL, attendee_last_name TEXT, attendee_email TEXT, attendee_phone TEXT, attendee_ruc TEXT, preferred_language TEXT, status TEXT NOT NULL DEFAULT 'pending', checkin_at TEXT, qr_code TEXT, admin_note TEXT, created_at TEXT NOT NULL ) `); // Add new columns if they don't exist (for existing databases) try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_name TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_email TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_phone TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN preferred_language TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN admin_note TEXT`); } catch (e) { /* column may already exist */ } // Migration: Add first/last name columns try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_first_name TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_last_name TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc TEXT`); } catch (e) { /* column may already exist */ } // Migration: Copy data from attendee_name to attendee_first_name if attendee_first_name is empty try { await (db as any).run(sql` UPDATE tickets SET attendee_first_name = attendee_name WHERE attendee_first_name IS NULL AND attendee_name IS NOT NULL `); } catch (e) { /* migration may have already run */ } // Migration: Add checked_in_by_admin_id column to tickets try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`); } catch (e) { /* column may already exist */ } // Migration: Add booking_id column to tickets for multi-ticket bookings try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`); } catch (e) { /* column may already exist */ } // Migration: Add is_guest column to tickets try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } // Migration: Add payment_status column to tickets (paid | unpaid | comp), // backfilled from is_guest and the payments table on first run try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`); await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); await (db as any).run(sql` UPDATE tickets SET payment_status = 'paid' WHERE is_guest = 0 AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') `); } catch (e) { /* column may already exist */ } // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // SQLite doesn't support altering column constraints, so we'll just ensure new entries work await (db as any).run(sql` CREATE TABLE IF NOT EXISTS payments ( id TEXT PRIMARY KEY, ticket_id TEXT NOT NULL REFERENCES tickets(id), provider TEXT NOT NULL, amount REAL NOT NULL, currency TEXT NOT NULL DEFAULT 'PYG', status TEXT NOT NULL DEFAULT 'pending', reference TEXT, paid_at TEXT, paid_by_admin_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); // Add new columns if they don't exist (for existing databases) try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN paid_at TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN paid_by_admin_id TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN user_marked_paid_at TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN payer_name TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); } catch (e) { /* column may already exist */ } // Door check-in screen: split pre-sale vs door revenue and record the tender try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`); } catch (e) { /* column may already exist */ } // Idempotency records for door check-in actions (retries / double taps) await (db as any).run(sql` CREATE TABLE IF NOT EXISTS idempotency_keys ( key TEXT PRIMARY KEY, scope TEXT NOT NULL, result TEXT NOT NULL, undo_state TEXT, undone_at TEXT, created_at TEXT NOT NULL ) `); // Invoices table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS invoices ( id TEXT PRIMARY KEY, payment_id TEXT NOT NULL REFERENCES payments(id), user_id TEXT NOT NULL REFERENCES users(id), invoice_number TEXT NOT NULL UNIQUE, ruc_number TEXT, legal_name TEXT, amount REAL NOT NULL, currency TEXT NOT NULL DEFAULT 'PYG', pdf_url TEXT, status TEXT NOT NULL DEFAULT 'generated', created_at TEXT NOT NULL ) `); // Payment options table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS payment_options ( id TEXT PRIMARY KEY, tpago_enabled INTEGER NOT NULL DEFAULT 0, tpago_link TEXT, tpago_link_2 TEXT, tpago_link_3 TEXT, tpago_link_4 TEXT, tpago_link_5 TEXT, tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER NOT NULL DEFAULT 0, bank_name TEXT, bank_account_holder TEXT, bank_account_number TEXT, bank_alias TEXT, bank_phone TEXT, bank_notes TEXT, bank_notes_es TEXT, lightning_enabled INTEGER NOT NULL DEFAULT 1, cash_enabled INTEGER NOT NULL DEFAULT 1, cash_instructions TEXT, cash_instructions_es TEXT, updated_at TEXT NOT NULL, updated_by TEXT REFERENCES users(id) ) `); // Add allow_duplicate_bookings column to payment_options if it doesn't exist try { await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } // Add per-quantity TPago link columns to payment_options if they don't exist for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { try { await (db as any).run(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} TEXT`)); } catch (e) { /* column may already exist */ } } // Event payment overrides table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS event_payment_overrides ( id TEXT PRIMARY KEY, event_id TEXT NOT NULL REFERENCES events(id), tpago_enabled INTEGER, tpago_link TEXT, tpago_link_2 TEXT, tpago_link_3 TEXT, tpago_link_4 TEXT, tpago_link_5 TEXT, tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER, bank_name TEXT, bank_account_holder TEXT, bank_account_number TEXT, bank_alias TEXT, bank_phone TEXT, bank_notes TEXT, bank_notes_es TEXT, lightning_enabled INTEGER, cash_enabled INTEGER, cash_instructions TEXT, cash_instructions_es TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); // Add per-quantity TPago link columns to event_payment_overrides if they don't exist for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { try { await (db as any).run(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} TEXT`)); } catch (e) { /* column may already exist */ } } await (db as any).run(sql` CREATE TABLE IF NOT EXISTS contacts ( id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'new', created_at TEXT NOT NULL ) `); await (db as any).run(sql` CREATE TABLE IF NOT EXISTS email_subscribers ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL ) `); await (db as any).run(sql` CREATE TABLE IF NOT EXISTS media ( id TEXT PRIMARY KEY, file_url TEXT NOT NULL, type TEXT NOT NULL, related_id TEXT, related_type TEXT, created_at TEXT NOT NULL ) `); await (db as any).run(sql` CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id), action TEXT NOT NULL, target TEXT, target_id TEXT, details TEXT, timestamp TEXT NOT NULL ) `); // Email system tables await (db as any).run(sql` CREATE TABLE IF NOT EXISTS email_templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE, subject TEXT NOT NULL, subject_es TEXT, body_html TEXT NOT NULL, body_html_es TEXT, body_text TEXT, body_text_es TEXT, description TEXT, variables TEXT, is_system INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); await (db as any).run(sql` CREATE TABLE IF NOT EXISTS email_logs ( id TEXT PRIMARY KEY, template_id TEXT REFERENCES email_templates(id), event_id TEXT REFERENCES events(id), recipient_email TEXT NOT NULL, recipient_name TEXT, subject TEXT NOT NULL, body_html TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, sent_at TEXT, sent_by TEXT REFERENCES users(id), created_at TEXT NOT NULL ) `); try { await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`); } catch (e) { /* column may already exist */ } await (db as any).run(sql` CREATE TABLE IF NOT EXISTS email_settings ( id TEXT PRIMARY KEY, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, updated_at TEXT NOT NULL ) `); await (db as any).run(sql` CREATE TABLE IF NOT EXISTS email_queue ( id TEXT PRIMARY KEY, params TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at TEXT NOT NULL, processed_at TEXT ) `); // Site settings table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS site_settings ( id TEXT PRIMARY KEY, timezone TEXT NOT NULL DEFAULT 'America/Asuncion', site_name TEXT NOT NULL DEFAULT 'Spanglish', site_description TEXT, site_description_es TEXT, contact_email TEXT, contact_phone TEXT, facebook_url TEXT, instagram_url TEXT, twitter_url TEXT, linkedin_url TEXT, featured_event_id TEXT REFERENCES events(id), maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_message TEXT, maintenance_message_es TEXT, updated_at TEXT NOT NULL, updated_by TEXT REFERENCES users(id) ) `); // Add featured_event_id column to site_settings if it doesn't exist try { await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`); } catch (e) { /* column may already exist */ } // Legal pages table for admin-editable legal content await (db as any).run(sql` CREATE TABLE IF NOT EXISTS legal_pages ( id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, title_es TEXT, content_text TEXT NOT NULL, content_text_es TEXT, content_markdown TEXT NOT NULL, content_markdown_es TEXT, updated_at TEXT NOT NULL, updated_by TEXT REFERENCES users(id), created_at TEXT NOT NULL ) `); // FAQ questions table await (db as any).run(sql` CREATE TABLE IF NOT EXISTS faq_questions ( id TEXT PRIMARY KEY, question TEXT NOT NULL, question_es TEXT, answer TEXT NOT NULL, answer_es TEXT, enabled INTEGER NOT NULL DEFAULT 1, show_on_homepage INTEGER NOT NULL DEFAULT 0, rank INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `); // Legal settings table for legal page placeholder values await (db as any).run(sql` CREATE TABLE IF NOT EXISTS legal_settings ( id TEXT PRIMARY KEY, company_name TEXT, legal_entity_name TEXT, ruc_number TEXT, company_address TEXT, company_city TEXT, company_country TEXT, support_email TEXT, legal_email TEXT, governing_law TEXT, jurisdiction_city TEXT, updated_at TEXT NOT NULL, updated_by TEXT REFERENCES users(id) ) `); // ==================== Better Auth ==================== // Better Auth core + admin plugin columns on the existing users table try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`); } catch (e) { /* column may already exist */ } // Better Auth sessions (replaces the legacy user_sessions table). // Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode). await (db as any).run(sql` CREATE TABLE IF NOT EXISTS auth_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, expires_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, impersonated_by TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Better Auth accounts: credential (password hash) and OAuth provider links await (db as any).run(sql` CREATE TABLE IF NOT EXISTS auth_accounts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Better Auth verification values (magic links, password reset tokens) await (db as any).run(sql` CREATE TABLE IF NOT EXISTS auth_verifications ( id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) `); // Better Auth rate limiting (used when Redis is not configured) await (db as any).run(sql` CREATE TABLE IF NOT EXISTS auth_rate_limits ( id TEXT PRIMARY KEY, key TEXT, count INTEGER, last_request INTEGER ) `); } else { // PostgreSQL migrations await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255), name VARCHAR(255) NOT NULL, phone VARCHAR(50), role VARCHAR(20) NOT NULL DEFAULT 'user', language_preference VARCHAR(10), is_claimed INTEGER NOT NULL DEFAULT 1, google_id VARCHAR(255), ruc_number VARCHAR(15), account_status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Add new user columns for existing PostgreSQL databases try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN is_claimed INTEGER NOT NULL DEFAULT 1`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN google_id VARCHAR(255)`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN ruc_number VARCHAR(15)`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } // Magic link tokens table await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS magic_link_tokens ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id), token VARCHAR(255) NOT NULL UNIQUE, type VARCHAR(30) NOT NULL, expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP, created_at TIMESTAMP NOT NULL ) `); // User sessions table await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS user_sessions ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id), token VARCHAR(500) NOT NULL UNIQUE, user_agent TEXT, ip_address VARCHAR(45), last_active_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS events ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, title_es VARCHAR(255), description TEXT NOT NULL, description_es TEXT, short_description VARCHAR(300), short_description_es VARCHAR(300), start_datetime TIMESTAMPTZ NOT NULL, end_datetime TIMESTAMPTZ, location VARCHAR(500) NOT NULL, location_url VARCHAR(500), price DECIMAL(10, 2) NOT NULL DEFAULT 0, currency VARCHAR(10) NOT NULL DEFAULT 'PYG', capacity INTEGER NOT NULL DEFAULT 50, status VARCHAR(20) NOT NULL DEFAULT 'draft', banner_url VARCHAR(500), external_booking_enabled INTEGER NOT NULL DEFAULT 0, external_booking_url VARCHAR(500), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Add external booking columns to events if they don't exist (for existing databases) try { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_enabled INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`); } catch (e) { /* column may already exist */ } // Add short description columns to events try { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`); } catch (e) { /* column may already exist */ } // Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for // unambiguous UTC storage (eliminates pg driver timezone interpretation). try { await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`); } catch (e) { /* already timestamptz or other issue */ } try { await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`); } catch (e) { /* already timestamptz or other issue */ } // Add slug column to events (backfilled below) try { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN slug VARCHAR(255)`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`); } catch (e) { /* index may already exist */ } // Historical slugs that still resolve (and redirect) to an event's canonical slug await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS event_slug_aliases ( slug VARCHAR(255) PRIMARY KEY, event_id UUID NOT NULL REFERENCES events(id), created_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS tickets ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id), event_id UUID NOT NULL REFERENCES events(id), attendee_first_name VARCHAR(255) NOT NULL, attendee_last_name VARCHAR(255), attendee_email VARCHAR(255), attendee_phone VARCHAR(50), attendee_ruc VARCHAR(15), preferred_language VARCHAR(10), status VARCHAR(20) NOT NULL DEFAULT 'pending', checkin_at TIMESTAMP, qr_code VARCHAR(255), admin_note TEXT, created_at TIMESTAMP NOT NULL ) `); // Add attendee_ruc column if it doesn't exist try { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc VARCHAR(15)`); } catch (e) { /* column may already exist */ } // Add checked_in_by_admin_id column to tickets try { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`); } catch (e) { /* column may already exist */ } // Migration: Add booking_id column to tickets for multi-ticket bookings try { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`); } catch (e) { /* column may already exist */ } // Migration: Add is_guest column to tickets try { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } // Migration: Add payment_status column to tickets (paid | unpaid | comp), // backfilled from is_guest and the payments table on first run try { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`); await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); await (db as any).execute(sql` UPDATE tickets SET payment_status = 'paid' WHERE is_guest = 0 AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') `); } catch (e) { /* column may already exist */ } await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( id UUID PRIMARY KEY, ticket_id UUID NOT NULL REFERENCES tickets(id), provider VARCHAR(50) NOT NULL, amount DECIMAL(10, 2) NOT NULL, currency VARCHAR(10) NOT NULL DEFAULT 'PYG', status VARCHAR(20) NOT NULL DEFAULT 'pending', reference VARCHAR(255), user_marked_paid_at TIMESTAMP, payer_name VARCHAR(255), paid_at TIMESTAMP, paid_by_admin_id UUID, admin_note TEXT, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Add payer_name column if it doesn't exist try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); } catch (e) { /* column may already exist */ } // Door check-in screen: split pre-sale vs door revenue and record the tender try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`); } catch (e) { /* column may already exist */ } // Idempotency records for door check-in actions (retries / double taps) await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS idempotency_keys ( key VARCHAR(128) PRIMARY KEY, scope VARCHAR(64) NOT NULL, result TEXT NOT NULL, undo_state TEXT, undone_at TIMESTAMP, created_at TIMESTAMP NOT NULL ) `); // Invoices table await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS invoices ( id UUID PRIMARY KEY, payment_id UUID NOT NULL REFERENCES payments(id), user_id UUID NOT NULL REFERENCES users(id), invoice_number VARCHAR(50) NOT NULL UNIQUE, ruc_number VARCHAR(15), legal_name VARCHAR(255), amount DECIMAL(10, 2) NOT NULL, currency VARCHAR(10) NOT NULL DEFAULT 'PYG', pdf_url VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'generated', created_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payment_options ( id UUID PRIMARY KEY, tpago_enabled INTEGER NOT NULL DEFAULT 0, tpago_link VARCHAR(500), tpago_link_2 VARCHAR(500), tpago_link_3 VARCHAR(500), tpago_link_4 VARCHAR(500), tpago_link_5 VARCHAR(500), tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER NOT NULL DEFAULT 0, bank_name VARCHAR(255), bank_account_holder VARCHAR(255), bank_account_number VARCHAR(100), bank_alias VARCHAR(100), bank_phone VARCHAR(50), bank_notes TEXT, bank_notes_es TEXT, lightning_enabled INTEGER NOT NULL DEFAULT 1, cash_enabled INTEGER NOT NULL DEFAULT 1, cash_instructions TEXT, cash_instructions_es TEXT, updated_at TIMESTAMP NOT NULL, updated_by UUID REFERENCES users(id) ) `); // Add allow_duplicate_bookings column to payment_options if it doesn't exist try { await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } // Add per-quantity TPago link columns to payment_options if they don't exist for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { try { await (db as any).execute(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} VARCHAR(500)`)); } catch (e) { /* column may already exist */ } } await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS event_payment_overrides ( id UUID PRIMARY KEY, event_id UUID NOT NULL REFERENCES events(id), tpago_enabled INTEGER, tpago_link VARCHAR(500), tpago_link_2 VARCHAR(500), tpago_link_3 VARCHAR(500), tpago_link_4 VARCHAR(500), tpago_link_5 VARCHAR(500), tpago_instructions TEXT, tpago_instructions_es TEXT, bank_transfer_enabled INTEGER, bank_name VARCHAR(255), bank_account_holder VARCHAR(255), bank_account_number VARCHAR(100), bank_alias VARCHAR(100), bank_phone VARCHAR(50), bank_notes TEXT, bank_notes_es TEXT, lightning_enabled INTEGER, cash_enabled INTEGER, cash_instructions TEXT, cash_instructions_es TEXT, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Add per-quantity TPago link columns to event_payment_overrides if they don't exist for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) { try { await (db as any).execute(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} VARCHAR(500)`)); } catch (e) { /* column may already exist */ } } await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS contacts ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'new', created_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS email_subscribers ( id UUID PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(255), status VARCHAR(20) NOT NULL DEFAULT 'active', created_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS media ( id UUID PRIMARY KEY, file_url VARCHAR(500) NOT NULL, type VARCHAR(20) NOT NULL, related_id UUID, related_type VARCHAR(50), created_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS audit_logs ( id UUID PRIMARY KEY, user_id UUID REFERENCES users(id), action VARCHAR(100) NOT NULL, target VARCHAR(100), target_id UUID, details TEXT, timestamp TIMESTAMP NOT NULL ) `); // Email system tables await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS email_templates ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, slug VARCHAR(100) NOT NULL UNIQUE, subject VARCHAR(500) NOT NULL, subject_es VARCHAR(500), body_html TEXT NOT NULL, body_html_es TEXT, body_text TEXT, body_text_es TEXT, description TEXT, variables TEXT, is_system INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS email_logs ( id UUID PRIMARY KEY, template_id UUID REFERENCES email_templates(id), event_id UUID REFERENCES events(id), recipient_email VARCHAR(255) NOT NULL, recipient_name VARCHAR(255), subject VARCHAR(500) NOT NULL, body_html TEXT, status VARCHAR(20) NOT NULL DEFAULT 'pending', error_message TEXT, sent_at TIMESTAMP, sent_by UUID REFERENCES users(id), created_at TIMESTAMP NOT NULL ) `); try { await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`); } catch (e) { /* column may already exist */ } await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS email_settings ( id UUID PRIMARY KEY, key VARCHAR(100) NOT NULL UNIQUE, value TEXT NOT NULL, updated_at TIMESTAMP NOT NULL ) `); await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS email_queue ( id UUID PRIMARY KEY, params TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at TIMESTAMP NOT NULL, processed_at TIMESTAMP ) `); // Site settings table await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS site_settings ( id UUID PRIMARY KEY, timezone VARCHAR(100) NOT NULL DEFAULT 'America/Asuncion', site_name VARCHAR(255) NOT NULL DEFAULT 'Spanglish', site_description TEXT, site_description_es TEXT, contact_email VARCHAR(255), contact_phone VARCHAR(50), facebook_url VARCHAR(500), instagram_url VARCHAR(500), twitter_url VARCHAR(500), linkedin_url VARCHAR(500), featured_event_id UUID REFERENCES events(id), maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_message TEXT, maintenance_message_es TEXT, updated_at TIMESTAMP NOT NULL, updated_by UUID REFERENCES users(id) ) `); // Add featured_event_id column to site_settings if it doesn't exist try { await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`); } catch (e) { /* column may already exist */ } // Legal pages table for admin-editable legal content await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS legal_pages ( id UUID PRIMARY KEY, slug VARCHAR(100) NOT NULL UNIQUE, title VARCHAR(255) NOT NULL, title_es VARCHAR(255), content_text TEXT NOT NULL, content_text_es TEXT, content_markdown TEXT NOT NULL, content_markdown_es TEXT, updated_at TIMESTAMP NOT NULL, updated_by UUID REFERENCES users(id), created_at TIMESTAMP NOT NULL ) `); // FAQ questions table await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS faq_questions ( id UUID PRIMARY KEY, question TEXT NOT NULL, question_es TEXT, answer TEXT NOT NULL, answer_es TEXT, enabled INTEGER NOT NULL DEFAULT 1, show_on_homepage INTEGER NOT NULL DEFAULT 0, rank INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Legal settings table for legal page placeholder values await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS legal_settings ( id UUID PRIMARY KEY, company_name VARCHAR(255), legal_entity_name VARCHAR(255), ruc_number VARCHAR(50), company_address TEXT, company_city VARCHAR(100), company_country VARCHAR(100), support_email VARCHAR(255), legal_email VARCHAR(255), governing_law VARCHAR(255), jurisdiction_city VARCHAR(100), updated_at TIMESTAMP NOT NULL, updated_by UUID REFERENCES users(id) ) `); // ==================== Better Auth ==================== // Better Auth core + admin plugin columns on the existing users table try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`); } catch (e) { /* column may already exist */ } try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`); } catch (e) { /* column may already exist */ } // Better Auth sessions (replaces the legacy user_sessions table) await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS auth_sessions ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, ip_address VARCHAR(45), user_agent TEXT, impersonated_by UUID, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Better Auth accounts: credential (password hash) and OAuth provider links await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS auth_accounts ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, account_id VARCHAR(255) NOT NULL, provider_id VARCHAR(100) NOT NULL, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at TIMESTAMP, refresh_token_expires_at TIMESTAMP, scope TEXT, password TEXT, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Better Auth verification values (magic links, password reset tokens) await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS auth_verifications ( id UUID PRIMARY KEY, identifier VARCHAR(255) NOT NULL, value TEXT NOT NULL, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ) `); // Better Auth rate limiting (used when Redis is not configured) await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS auth_rate_limits ( id VARCHAR(64) PRIMARY KEY, key VARCHAR(255), count BIGINT, last_request BIGINT ) `); } // Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines) const indexStatements = [ `CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`, `CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`, `CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`, `CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`, `CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`, `CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`, `CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`, `CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`, `CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`, `CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`, `CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`, `CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`, `CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`, `CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`, ]; for (const stmt of indexStatements) { try { if (dbType === 'sqlite') { await (db as any).run(sql.raw(stmt)); } else { await (db as any).execute(sql.raw(stmt)); } } catch (e) { /* index may already exist */ } } // ==================== Better Auth data backfill ==================== // Idempotent: every statement is guarded so re-running migrate is safe, and // legacy users are distinguished from Better-Auth-created users by having // users.password / users.google_id set (Better Auth never writes either). if (dbType === 'sqlite') { // Legacy password hashes -> credential accounts (argon2 and bcrypt hashes // both stay valid via the custom password verifier in lib/betterAuth.ts) await (db as any).run(sql` INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password, CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 FROM users u WHERE u.password IS NOT NULL AND u.password != '' AND NOT EXISTS ( SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential' ) `); // Legacy Google links -> google provider accounts await (db as any).run(sql` INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at) SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google', CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 FROM users u WHERE u.google_id IS NOT NULL AND u.google_id != '' AND NOT EXISTS ( SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google' ) `); // Claimed legacy accounts proved their email (register/claim link/Google) await (db as any).run(sql` UPDATE users SET email_verified = 1 WHERE email_verified = 0 AND is_claimed = 1 AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL) `); // Suspended -> banned (admin plugin field); users.ts keeps them in sync await (db as any).run(sql` UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended' WHERE account_status = 'suspended' AND banned = 0 `); } else { await (db as any).execute(sql` INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW() FROM users u WHERE u.password IS NOT NULL AND u.password != '' AND NOT EXISTS ( SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential' ) `); await (db as any).execute(sql` INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at) SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW() FROM users u WHERE u.google_id IS NOT NULL AND u.google_id != '' AND NOT EXISTS ( SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google' ) `); await (db as any).execute(sql` UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE AND is_claimed = 1 AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL) `); await (db as any).execute(sql` UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended' WHERE account_status = 'suspended' AND banned = FALSE `); } // ==================== users.email normalization ==================== // Better Auth lowercases the address on every lookup and write it performs, // but the users.email unique index is case-sensitive on both dialects. Rows // written outside Better Auth (guest bookings, door sales, admin-added // tickets) used to keep the address exactly as typed, so a buyer who entered // "John@Gmail.com" was invisible to sign-in and to Google account linking: // signing in with Google minted a SECOND user row and left their tickets // stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this // fixes the rows already in the table. // // Idempotent, and deliberately conservative: a row is only lowercased when // nothing already occupies the lowercase address. A genuine collision means // two user rows for the same person, each with its own tickets, invoices and // payments — merging those is a judgement call, not a migration, so they are // reported for manual review instead. const lowercaseEmailsSql = ` UPDATE users SET email = LOWER(email) WHERE email <> LOWER(email) AND NOT EXISTS ( SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email) ) `; if (dbType === 'sqlite') { await (db as any).run(sql.raw(lowercaseEmailsSql)); } else { await (db as any).execute(sql.raw(lowercaseEmailsSql)); } // Whatever still differs from its own lowercase form is exactly the set the // UPDATE refused to touch, i.e. the collisions. const collisions = await dbAll<{ id: string; email: string }>( (db as any) .select({ id: (users as any).id, email: (users as any).email }) .from(users) .where(ne((users as any).email, sql`LOWER(${(users as any).email})`)) ); if (collisions.length > 0) { console.warn( `WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` + `lowercase address is already taken. Sign-in and Google linking only ever reach ` + `the lowercase row, so these need a manual merge:` ); for (const row of collisions) { const canonical = row.email.toLowerCase(); const existing = await dbGet<{ id: string }>( (db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical)) ); console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`); } } // Backfill slugs for any events that don't have one yet (shared across DB types). // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( (db as any).select().from(events).orderBy((events as any).createdAt) ); const assignedSlugs: string[] = allEvents .filter((e) => e.slug) .map((e) => e.slug as string); let backfilled = 0; for (const ev of allEvents) { if (ev.slug) continue; const slug = uniqueSlug(ev.title || 'event', assignedSlugs); assignedSlugs.push(slug); await (db as any).update(events).set({ slug }).where(eq((events as any).id, ev.id)); backfilled++; } if (backfilled > 0) { console.log(`Backfilled slugs for ${backfilled} event(s).`); // Bust the frontend cache so the homepage / sitemap pick up the new slugs // immediately instead of serving stale (pre-slug) data for up to the // revalidate window. Awaited (not fire-and-forget) so it runs before exit. const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; const secret = process.env.REVALIDATE_SECRET; if (secret) { try { const res = await fetch(`${frontendUrl}/api/revalidate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }), }); console.log(res.ok ? 'Frontend cache revalidated.' : `Frontend revalidation returned ${res.status}.`); } catch (e: any) { console.warn('Frontend revalidation skipped (frontend not reachable):', e?.message || e); } } } console.log('Migrations completed successfully!'); process.exit(0); } migrate().catch((err) => { console.error('Migration failed:', err); process.exit(1); });