Google sign-in only worked for people who already had a linked google row in auth_accounts. Anyone who first appeared another way -- a guest ticket purchase, or an email/password signup made after the Better Auth migration -- got a 401 "account not linked". trustedProviders: ['google'] defeats only one of better-auth's two linking gates. The second, requireLocalEmailVerified, defaults to true and refuses the link whenever the LOCAL users.email_verified is false, independently of whether the provider is trusted. That flag is false for every guest-booking row and for every post-migration signup, since requireEmailVerification is off and no verification mail is sent. Turn that gate off: the Google id_token is signature-verified against Google's JWKS with issuer/audience/max-age checks and carries its own email_verified, so the local column proves nothing extra here. Linking alone was not enough. getAuthUser() rejects any session whose user is not 'active', so a ticket buyer would link Google, receive a cookie, and still look logged out. A databaseHooks.account.create.after hook now promotes unclaimed rows to claimed/active when a google account is attached, scoped in the WHERE clause so a suspended account is never reactivated this way. Also normalize users.email. The unique index is case-sensitive while better-auth lowercases every lookup, so someone who booked as John@Gmail.com was invisible to sign-in and Google minted a SECOND user row, stranding their tickets on the first. normalizeEmail() covers the find-or-create sites in tickets.ts and door.ts plus the claim-eligibility lookup, and an idempotent migration lowercases existing rows -- skipping any that would collide and reporting those for manual merge, since merging two people's tickets and payments is not a migration's call. tickets.attendeeEmail still stores the address exactly as typed. Tests drive the real signInSocial id-token path with Google stubbed by signing tokens with a throwaway RS256 key and serving our own JWKS, so the actual verification runs without network or credentials. That also makes the deprecation risk loud: requireLocalEmailVerified is marked for removal upstream, and an upgrade that drops it now fails CI instead of silently locking ticket buyers out again. Frontend carries error.code through so OAUTH_LINK_ERROR renders an actionable message in both locales rather than a bare "account not linked". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1443 lines
54 KiB
TypeScript
1443 lines
54 KiB
TypeScript
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);
|
|
});
|