Files
Spanglish/backend/src/db/schema.ts
T
MichilisandClaude Opus 5 e296e80e48 Rebuild the Scanner page into a unified door check-in screen.
Most attendees arrive without their QR open, and taking money at the
door meant leaving the scanner for the event dashboard, where the Add
Ticket modal demanded an email and recorded no payment method. The
screen now leads with manual name search, keeps the camera one tap away
behind a fullscreen overlay, and creates and charges walk-ins inline.

Check-in and payment are one action: anything done here is born
confirmed, paid (or comp) and checked in through a single endpoint,
POST /api/events/:eventId/door-checkin. There are no confirm dialogs
anywhere, because they stall the queue; a ten-second Undo replaces
them, reversing exactly what the action changed via the undo state
recorded alongside its idempotency key. Writes fire in the background
with retries, so venue wifi never blocks the person at the door, and a
capacity limit only warns, since staff at the door are the authority.

Every write carries a client-generated idempotency key, inserted in the
same transaction as the writes it guards, so a double tap or a retry
after a timeout cannot produce a second ticket, payment or check-in.
Search runs entirely in memory over one preloaded list: names are
matched accent- and case-insensitively in both directions, per word,
prefix before substring, with a mostly-numeric query searching phone
digits so two people with the same name can be told apart.

Door money is recorded as payments.source 'door' plus payments.method
(cash, bitcoin, transfer or guest) while provider keeps its existing
value, so capacity counting, the stale-booking sweeps and the admin
payment lists are unaffected and revenue can still be split pre-sale
versus door. Bitcoin records the payment as made, on the same trust
model as cash, with no invoice generated; lib/doorPayments.ts is where
a real Lightning flow slots in later.

Also fixes the SQLite tickets DDL, which still created the pre-split
attendee_name column with NOT NULL email and phone. Only fresh
databases were affected -- existing ones were relaxed by later ALTERs
-- but on those, door walk-ins (and any other ticket) could not be
inserted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:09:55 +00:00

826 lines
40 KiB
TypeScript

import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
// Type to determine which schema to use
const dbType = process.env.DB_TYPE || 'sqlite';
// ==================== SQLite Schema ====================
export const sqliteUsers = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
password: text('password'), // Nullable for unclaimed accounts
name: text('name').notNull(),
phone: text('phone'),
role: text('role', { enum: ['admin', 'organizer', 'staff', 'marketing', 'user'] }).notNull().default('user'),
languagePreference: text('language_preference'),
// New fields for progressive accounts and OAuth
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
googleId: text('google_id'),
rucNumber: text('ruc_number'),
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Magic link tokens for passwordless login
export const sqliteMagicLinkTokens = sqliteTable('magic_link_tokens', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => sqliteUsers.id),
token: text('token').notNull().unique(),
type: text('type', { enum: ['login', 'reset_password', 'claim_account', 'email_verification'] }).notNull(),
expiresAt: text('expires_at').notNull(),
usedAt: text('used_at'),
createdAt: text('created_at').notNull(),
});
// User sessions for session management
export const sqliteUserSessions = sqliteTable('user_sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => sqliteUsers.id),
token: text('token').notNull().unique(),
userAgent: text('user_agent'),
ipAddress: text('ip_address'),
lastActiveAt: text('last_active_at').notNull(),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull(),
});
// Invoices table
export const sqliteInvoices = sqliteTable('invoices', {
id: text('id').primaryKey(),
paymentId: text('payment_id').notNull().references(() => sqlitePayments.id),
userId: text('user_id').notNull().references(() => sqliteUsers.id),
invoiceNumber: text('invoice_number').notNull().unique(),
rucNumber: text('ruc_number'),
legalName: text('legal_name'),
amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'),
pdfUrl: text('pdf_url'),
status: text('status', { enum: ['generated', 'voided'] }).notNull().default('generated'),
createdAt: text('created_at').notNull(),
});
export const sqliteEvents = sqliteTable('events', {
id: text('id').primaryKey(),
slug: text('slug').unique(),
title: text('title').notNull(),
titleEs: text('title_es'),
description: text('description').notNull(),
descriptionEs: text('description_es'),
shortDescription: text('short_description'),
shortDescriptionEs: text('short_description_es'),
startDatetime: text('start_datetime').notNull(),
endDatetime: text('end_datetime'),
location: text('location').notNull(),
locationUrl: text('location_url'),
price: real('price').notNull().default(0),
currency: text('currency').notNull().default('PYG'),
capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Historical slugs that still resolve (and redirect) to an event's canonical slug
export const sqliteEventSlugAliases = sqliteTable('event_slug_aliases', {
slug: text('slug').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
createdAt: text('created_at').notNull(),
});
export const sqliteTickets = sqliteTable('tickets', {
id: text('id').primaryKey(),
bookingId: text('booking_id'), // Groups multiple tickets from same booking
userId: text('user_id').notNull().references(() => sqliteUsers.id),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
attendeeFirstName: text('attendee_first_name').notNull(),
attendeeLastName: text('attendee_last_name'),
attendeeEmail: text('attendee_email'),
attendeePhone: text('attendee_phone'),
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
preferredLanguage: text('preferred_language'),
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
checkinAt: text('checkin_at'),
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
qrCode: text('qr_code'),
adminNote: text('admin_note'),
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
createdAt: text('created_at').notNull(),
});
export const sqlitePayments = sqliteTable('payments', {
id: text('id').primaryKey(),
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
reference: text('reference'),
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
payerName: text('payer_name'), // Name of payer if different from attendee
paidAt: text('paid_at'),
paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Idempotency records for door check-in actions.
//
// The door screen fires check-ins / walk-in creations optimistically and retries
// on flaky venue wifi, so every action carries a client-generated key. The first
// request stores its response here; replays return that stored response instead
// of creating a second ticket, payment or check-in. `undoState` holds exactly
// what the action changed so the 10-second Undo can reverse it precisely.
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
key: text('key').primaryKey(),
scope: text('scope').notNull(),
result: text('result').notNull(), // JSON response body of the original request
undoState: text('undo_state'), // JSON describing how to reverse the action
undoneAt: text('undone_at'),
createdAt: text('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const sqlitePaymentOptions = sqliteTable('payment_options', {
id: text('id').primaryKey(),
// TPago configuration
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
tpagoLink: text('tpago_link'),
tpagoLink2: text('tpago_link_2'),
tpagoLink3: text('tpago_link_3'),
tpagoLink4: text('tpago_link_4'),
tpagoLink5: text('tpago_link_5'),
tpagoInstructions: text('tpago_instructions'),
tpagoInstructionsEs: text('tpago_instructions_es'),
// Bank Transfer configuration
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }).notNull().default(false),
bankName: text('bank_name'),
bankAccountHolder: text('bank_account_holder'),
bankAccountNumber: text('bank_account_number'),
bankAlias: text('bank_alias'),
bankPhone: text('bank_phone'),
bankNotes: text('bank_notes'),
bankNotesEs: text('bank_notes_es'),
// Lightning configuration
lightningEnabled: integer('lightning_enabled', { mode: 'boolean' }).notNull().default(true),
// Cash configuration
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'),
// Booking settings
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
// Metadata
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// Event-specific payment overrides
export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides', {
id: text('id').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
// Override flags (null means use global)
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
tpagoLink: text('tpago_link'),
tpagoLink2: text('tpago_link_2'),
tpagoLink3: text('tpago_link_3'),
tpagoLink4: text('tpago_link_4'),
tpagoLink5: text('tpago_link_5'),
tpagoInstructions: text('tpago_instructions'),
tpagoInstructionsEs: text('tpago_instructions_es'),
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
bankName: text('bank_name'),
bankAccountHolder: text('bank_account_holder'),
bankAccountNumber: text('bank_account_number'),
bankAlias: text('bank_alias'),
bankPhone: text('bank_phone'),
bankNotes: text('bank_notes'),
bankNotesEs: text('bank_notes_es'),
lightningEnabled: integer('lightning_enabled', { mode: 'boolean' }),
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'),
// Metadata
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteContacts = sqliteTable('contacts', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
message: text('message').notNull(),
status: text('status', { enum: ['new', 'read', 'replied'] }).notNull().default('new'),
createdAt: text('created_at').notNull(),
});
export const sqliteEmailSubscribers = sqliteTable('email_subscribers', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
status: text('status', { enum: ['active', 'unsubscribed'] }).notNull().default('active'),
createdAt: text('created_at').notNull(),
});
export const sqliteMedia = sqliteTable('media', {
id: text('id').primaryKey(),
fileUrl: text('file_url').notNull(),
type: text('type', { enum: ['image', 'video', 'document'] }).notNull(),
relatedId: text('related_id'),
relatedType: text('related_type'),
createdAt: text('created_at').notNull(),
});
export const sqliteAuditLogs = sqliteTable('audit_logs', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => sqliteUsers.id),
action: text('action').notNull(),
target: text('target'),
targetId: text('target_id'),
details: text('details'),
timestamp: text('timestamp').notNull(),
});
export const sqliteEmailTemplates = sqliteTable('email_templates', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
slug: text('slug').notNull().unique(),
subject: text('subject').notNull(),
subjectEs: text('subject_es'),
bodyHtml: text('body_html').notNull(),
bodyHtmlEs: text('body_html_es'),
bodyText: text('body_text'),
bodyTextEs: text('body_text_es'),
description: text('description'),
variables: text('variables'), // JSON array of available variables
isSystem: integer('is_system', { mode: 'boolean' }).notNull().default(false),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteEmailLogs = sqliteTable('email_logs', {
id: text('id').primaryKey(),
templateId: text('template_id').references(() => sqliteEmailTemplates.id),
eventId: text('event_id').references(() => sqliteEvents.id),
recipientEmail: text('recipient_email').notNull(),
recipientName: text('recipient_name'),
subject: text('subject').notNull(),
bodyHtml: text('body_html'),
status: text('status', { enum: ['pending', 'sent', 'failed', 'bounced'] }).notNull().default('pending'),
errorMessage: text('error_message'),
sentAt: text('sent_at'),
sentBy: text('sent_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
resendAttempts: integer('resend_attempts').notNull().default(0),
lastResentAt: text('last_resent_at'),
});
export const sqliteEmailSettings = sqliteTable('email_settings', {
id: text('id').primaryKey(),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const sqliteEmailQueue = sqliteTable('email_queue', {
id: text('id').primaryKey(),
params: text('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: text('status', { enum: ['pending', 'processing', 'sent', 'failed'] }).notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
lastError: text('last_error'),
createdAt: text('created_at').notNull(),
processedAt: text('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const sqliteLegalPages = sqliteTable('legal_pages', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
title: text('title').notNull(), // English title
titleEs: text('title_es'), // Spanish title
contentText: text('content_text').notNull(), // Plain text edited by admin (English)
contentTextEs: text('content_text_es'), // Plain text edited by admin (Spanish)
contentMarkdown: text('content_markdown').notNull(), // Generated markdown for public display (English)
contentMarkdownEs: text('content_markdown_es'), // Generated markdown for public display (Spanish)
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
});
// FAQ questions table (admin-managed, shown on /faq and optionally on homepage)
export const sqliteFaqQuestions = sqliteTable('faq_questions', {
id: text('id').primaryKey(),
question: text('question').notNull(),
questionEs: text('question_es'),
answer: text('answer').notNull(),
answerEs: text('answer_es'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
showOnHomepage: integer('show_on_homepage', { mode: 'boolean' }).notNull().default(false),
rank: integer('rank').notNull().default(0),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Legal Settings table for legal page placeholder values
export const sqliteLegalSettings = sqliteTable('legal_settings', {
id: text('id').primaryKey(),
companyName: text('company_name'),
legalEntityName: text('legal_entity_name'),
rucNumber: text('ruc_number'),
companyAddress: text('company_address'),
companyCity: text('company_city'),
companyCountry: text('company_country'),
supportEmail: text('support_email'),
legalEmail: text('legal_email'),
governingLaw: text('governing_law'),
jurisdictionCity: text('jurisdiction_city'),
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(),
// Timezone configuration
timezone: text('timezone').notNull().default('America/Asuncion'),
// Site info
siteName: text('site_name').notNull().default('Spanglish'),
siteDescription: text('site_description'),
siteDescriptionEs: text('site_description_es'),
// Contact info
contactEmail: text('contact_email'),
contactPhone: text('contact_phone'),
// Social links (can also be stored here as fallback)
facebookUrl: text('facebook_url'),
instagramUrl: text('instagram_url'),
twitterUrl: text('twitter_url'),
linkedinUrl: text('linkedin_url'),
// Featured event - manually promoted event shown on homepage/linktree
featuredEventId: text('featured_event_id').references(() => sqliteEvents.id),
// Other settings
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
maintenanceMessage: text('maintenance_message'),
maintenanceMessageEs: text('maintenance_message_es'),
// Metadata
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// ==================== PostgreSQL Schema ====================
export const pgUsers = pgTable('users', {
id: uuid('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }), // Nullable for unclaimed accounts
name: varchar('name', { length: 255 }).notNull(),
phone: varchar('phone', { length: 50 }),
role: varchar('role', { length: 20 }).notNull().default('user'),
languagePreference: varchar('language_preference', { length: 10 }),
// New fields for progressive accounts and OAuth
isClaimed: pgInteger('is_claimed').notNull().default(1),
googleId: varchar('google_id', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 15 }),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Magic link tokens for passwordless login
export const pgMagicLinkTokens = pgTable('magic_link_tokens', {
id: uuid('id').primaryKey(),
userId: uuid('user_id').notNull().references(() => pgUsers.id),
token: varchar('token', { length: 255 }).notNull().unique(),
type: varchar('type', { length: 30 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'),
createdAt: timestamp('created_at').notNull(),
});
// User sessions for session management
export const pgUserSessions = pgTable('user_sessions', {
id: uuid('id').primaryKey(),
userId: uuid('user_id').notNull().references(() => pgUsers.id),
token: varchar('token', { length: 500 }).notNull().unique(),
userAgent: pgText('user_agent'),
ipAddress: varchar('ip_address', { length: 45 }),
lastActiveAt: timestamp('last_active_at').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull(),
});
// Invoices table
export const pgInvoices = pgTable('invoices', {
id: uuid('id').primaryKey(),
paymentId: uuid('payment_id').notNull().references(() => pgPayments.id),
userId: uuid('user_id').notNull().references(() => pgUsers.id),
invoiceNumber: varchar('invoice_number', { length: 50 }).notNull().unique(),
rucNumber: varchar('ruc_number', { length: 15 }),
legalName: varchar('legal_name', { length: 255 }),
amount: decimal('amount', { precision: 10, scale: 2 }).notNull(),
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
pdfUrl: varchar('pdf_url', { length: 500 }),
status: varchar('status', { length: 20 }).notNull().default('generated'),
createdAt: timestamp('created_at').notNull(),
});
export const pgEvents = pgTable('events', {
id: uuid('id').primaryKey(),
slug: varchar('slug', { length: 255 }).unique(),
title: varchar('title', { length: 255 }).notNull(),
titleEs: varchar('title_es', { length: 255 }),
description: pgText('description').notNull(),
descriptionEs: pgText('description_es'),
shortDescription: varchar('short_description', { length: 300 }),
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(),
endDatetime: timestamp('end_datetime', { withTimezone: true }),
location: varchar('location', { length: 500 }).notNull(),
locationUrl: varchar('location_url', { length: 500 }),
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
capacity: pgInteger('capacity').notNull().default(50),
status: varchar('status', { length: 20 }).notNull().default('draft'),
bannerUrl: varchar('banner_url', { length: 500 }),
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Historical slugs that still resolve (and redirect) to an event's canonical slug
export const pgEventSlugAliases = pgTable('event_slug_aliases', {
slug: varchar('slug', { length: 255 }).primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
createdAt: timestamp('created_at').notNull(),
});
export const pgTickets = pgTable('tickets', {
id: uuid('id').primaryKey(),
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
userId: uuid('user_id').notNull().references(() => pgUsers.id),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(),
attendeeLastName: varchar('attendee_last_name', { length: 255 }),
attendeeEmail: varchar('attendee_email', { length: 255 }),
attendeePhone: varchar('attendee_phone', { length: 50 }),
attendeeRuc: varchar('attendee_ruc', { length: 15 }), // Paraguayan tax ID for invoicing
preferredLanguage: varchar('preferred_language', { length: 10 }),
status: varchar('status', { length: 20 }).notNull().default('pending'),
checkinAt: timestamp('checkin_at'),
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'),
isGuest: pgInteger('is_guest').notNull().default(0),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
createdAt: timestamp('created_at').notNull(),
});
export const pgPayments = pgTable('payments', {
id: uuid('id').primaryKey(),
ticketId: uuid('ticket_id').notNull().references(() => pgTickets.id),
provider: varchar('provider', { length: 50 }).notNull(),
amount: decimal('amount', { precision: 10, scale: 2 }).notNull(),
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
status: varchar('status', { length: 20 }).notNull().default('pending'),
reference: varchar('reference', { length: 255 }),
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: timestamp('user_marked_paid_at'),
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
paidAt: timestamp('paid_at'),
paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'),
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: varchar('source', { length: 20 }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: varchar('method', { length: 20 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
key: varchar('key', { length: 128 }).primaryKey(),
scope: varchar('scope', { length: 64 }).notNull(),
result: pgText('result').notNull(),
undoState: pgText('undo_state'),
undoneAt: timestamp('undone_at'),
createdAt: timestamp('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(),
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
tpagoLink: varchar('tpago_link', { length: 500 }),
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
tpagoInstructions: pgText('tpago_instructions'),
tpagoInstructionsEs: pgText('tpago_instructions_es'),
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
bankName: varchar('bank_name', { length: 255 }),
bankAccountHolder: varchar('bank_account_holder', { length: 255 }),
bankAccountNumber: varchar('bank_account_number', { length: 100 }),
bankAlias: varchar('bank_alias', { length: 100 }),
bankPhone: varchar('bank_phone', { length: 50 }),
bankNotes: pgText('bank_notes'),
bankNotesEs: pgText('bank_notes_es'),
lightningEnabled: pgInteger('lightning_enabled').notNull().default(1),
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'),
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// Event-specific payment overrides
export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
id: uuid('id').primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
tpagoEnabled: pgInteger('tpago_enabled'),
tpagoLink: varchar('tpago_link', { length: 500 }),
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
tpagoInstructions: pgText('tpago_instructions'),
tpagoInstructionsEs: pgText('tpago_instructions_es'),
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
bankName: varchar('bank_name', { length: 255 }),
bankAccountHolder: varchar('bank_account_holder', { length: 255 }),
bankAccountNumber: varchar('bank_account_number', { length: 100 }),
bankAlias: varchar('bank_alias', { length: 100 }),
bankPhone: varchar('bank_phone', { length: 50 }),
bankNotes: pgText('bank_notes'),
bankNotesEs: pgText('bank_notes_es'),
lightningEnabled: pgInteger('lightning_enabled'),
cashEnabled: pgInteger('cash_enabled'),
cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgContacts = pgTable('contacts', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull(),
message: pgText('message').notNull(),
status: varchar('status', { length: 20 }).notNull().default('new'),
createdAt: timestamp('created_at').notNull(),
});
export const pgEmailSubscribers = pgTable('email_subscribers', {
id: uuid('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }),
status: varchar('status', { length: 20 }).notNull().default('active'),
createdAt: timestamp('created_at').notNull(),
});
export const pgMedia = pgTable('media', {
id: uuid('id').primaryKey(),
fileUrl: varchar('file_url', { length: 500 }).notNull(),
type: varchar('type', { length: 20 }).notNull(),
relatedId: uuid('related_id'),
relatedType: varchar('related_type', { length: 50 }),
createdAt: timestamp('created_at').notNull(),
});
export const pgAuditLogs = pgTable('audit_logs', {
id: uuid('id').primaryKey(),
userId: uuid('user_id').references(() => pgUsers.id),
action: varchar('action', { length: 100 }).notNull(),
target: varchar('target', { length: 100 }),
targetId: uuid('target_id'),
details: pgText('details'),
timestamp: timestamp('timestamp').notNull(),
});
export const pgEmailTemplates = pgTable('email_templates', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull().unique(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
subject: varchar('subject', { length: 500 }).notNull(),
subjectEs: varchar('subject_es', { length: 500 }),
bodyHtml: pgText('body_html').notNull(),
bodyHtmlEs: pgText('body_html_es'),
bodyText: pgText('body_text'),
bodyTextEs: pgText('body_text_es'),
description: pgText('description'),
variables: pgText('variables'), // JSON array of available variables
isSystem: pgInteger('is_system').notNull().default(0),
isActive: pgInteger('is_active').notNull().default(1),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgEmailLogs = pgTable('email_logs', {
id: uuid('id').primaryKey(),
templateId: uuid('template_id').references(() => pgEmailTemplates.id),
eventId: uuid('event_id').references(() => pgEvents.id),
recipientEmail: varchar('recipient_email', { length: 255 }).notNull(),
recipientName: varchar('recipient_name', { length: 255 }),
subject: varchar('subject', { length: 500 }).notNull(),
bodyHtml: pgText('body_html'),
status: varchar('status', { length: 20 }).notNull().default('pending'),
errorMessage: pgText('error_message'),
sentAt: timestamp('sent_at'),
sentBy: uuid('sent_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
lastResentAt: timestamp('last_resent_at'),
});
export const pgEmailSettings = pgTable('email_settings', {
id: uuid('id').primaryKey(),
key: varchar('key', { length: 100 }).notNull().unique(),
value: pgText('value').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const pgEmailQueue = pgTable('email_queue', {
id: uuid('id').primaryKey(),
params: pgText('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: varchar('status', { length: 20 }).notNull().default('pending'),
attempts: pgInteger('attempts').notNull().default(0),
lastError: pgText('last_error'),
createdAt: timestamp('created_at').notNull(),
processedAt: timestamp('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const pgLegalPages = pgTable('legal_pages', {
id: uuid('id').primaryKey(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
title: varchar('title', { length: 255 }).notNull(), // English title
titleEs: varchar('title_es', { length: 255 }), // Spanish title
contentText: pgText('content_text').notNull(), // Plain text edited by admin (English)
contentTextEs: pgText('content_text_es'), // Plain text edited by admin (Spanish)
contentMarkdown: pgText('content_markdown').notNull(), // Generated markdown for public display (English)
contentMarkdownEs: pgText('content_markdown_es'), // Generated markdown for public display (Spanish)
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
});
// FAQ questions table (admin-managed)
export const pgFaqQuestions = pgTable('faq_questions', {
id: uuid('id').primaryKey(),
question: pgText('question').notNull(),
questionEs: pgText('question_es'),
answer: pgText('answer').notNull(),
answerEs: pgText('answer_es'),
enabled: pgInteger('enabled').notNull().default(1),
showOnHomepage: pgInteger('show_on_homepage').notNull().default(0),
rank: pgInteger('rank').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Legal Settings table for legal page placeholder values
export const pgLegalSettings = pgTable('legal_settings', {
id: uuid('id').primaryKey(),
companyName: varchar('company_name', { length: 255 }),
legalEntityName: varchar('legal_entity_name', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 50 }),
companyAddress: pgText('company_address'),
companyCity: varchar('company_city', { length: 100 }),
companyCountry: varchar('company_country', { length: 100 }),
supportEmail: varchar('support_email', { length: 255 }),
legalEmail: varchar('legal_email', { length: 255 }),
governingLaw: varchar('governing_law', { length: 255 }),
jurisdictionCity: varchar('jurisdiction_city', { length: 100 }),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(),
// Timezone configuration
timezone: varchar('timezone', { length: 100 }).notNull().default('America/Asuncion'),
// Site info
siteName: varchar('site_name', { length: 255 }).notNull().default('Spanglish'),
siteDescription: pgText('site_description'),
siteDescriptionEs: pgText('site_description_es'),
// Contact info
contactEmail: varchar('contact_email', { length: 255 }),
contactPhone: varchar('contact_phone', { length: 50 }),
// Social links
facebookUrl: varchar('facebook_url', { length: 500 }),
instagramUrl: varchar('instagram_url', { length: 500 }),
twitterUrl: varchar('twitter_url', { length: 500 }),
linkedinUrl: varchar('linkedin_url', { length: 500 }),
// Featured event - manually promoted event shown on homepage/linktree
featuredEventId: uuid('featured_event_id').references(() => pgEvents.id),
// Other settings
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
maintenanceMessage: pgText('maintenance_message'),
maintenanceMessageEs: pgText('maintenance_message_es'),
// Metadata
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
// Type exports
export type User = typeof sqliteUsers.$inferSelect;
export type NewUser = typeof sqliteUsers.$inferInsert;
export type Event = typeof sqliteEvents.$inferSelect;
export type NewEvent = typeof sqliteEvents.$inferInsert;
export type Ticket = typeof sqliteTickets.$inferSelect;
export type NewTicket = typeof sqliteTickets.$inferInsert;
export type Payment = typeof sqlitePayments.$inferSelect;
export type NewPayment = typeof sqlitePayments.$inferInsert;
export type Contact = typeof sqliteContacts.$inferSelect;
export type NewContact = typeof sqliteContacts.$inferInsert;
export type EmailTemplate = typeof sqliteEmailTemplates.$inferSelect;
export type NewEmailTemplate = typeof sqliteEmailTemplates.$inferInsert;
export type EmailLog = typeof sqliteEmailLogs.$inferSelect;
export type NewEmailLog = typeof sqliteEmailLogs.$inferInsert;
export type PaymentOptions = typeof sqlitePaymentOptions.$inferSelect;
export type NewPaymentOptions = typeof sqlitePaymentOptions.$inferInsert;
export type EventPaymentOverride = typeof sqliteEventPaymentOverrides.$inferSelect;
export type NewEventPaymentOverride = typeof sqliteEventPaymentOverrides.$inferInsert;
export type MagicLinkToken = typeof sqliteMagicLinkTokens.$inferSelect;
export type NewMagicLinkToken = typeof sqliteMagicLinkTokens.$inferInsert;
export type UserSession = typeof sqliteUserSessions.$inferSelect;
export type NewUserSession = typeof sqliteUserSessions.$inferInsert;
export type Invoice = typeof sqliteInvoices.$inferSelect;
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
export type LegalPage = typeof sqliteLegalPages.$inferSelect;
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;