Files
Spanglish/backend/src/db/migrate.ts
T
MichilisandClaude Opus 5 733d2459df Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:04 +00:00

1344 lines
49 KiB
TypeScript

import 'dotenv/config';
import { db, dbAll, events } from './index.js';
import { sql, eq } 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`
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_name TEXT NOT NULL,
attendee_email TEXT NOT NULL,
attendee_phone TEXT NOT NULL,
preferred_language TEXT,
status TEXT NOT NULL DEFAULT 'pending',
checkin_at TEXT,
qr_code 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 */ }
// 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 */ }
// 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 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
`);
}
// 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);
});