first commit

This commit is contained in:
Michaël
2026-01-29 14:13:11 -03:00
commit 2302748c87
105 changed files with 93301 additions and 0 deletions

33
backend/src/db/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
import Database from 'better-sqlite3';
import pg from 'pg';
import * as schema from './schema.js';
import { existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
const dbType = process.env.DB_TYPE || 'sqlite';
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
if (dbType === 'postgres') {
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
});
db = drizzlePg(pool, { schema });
} else {
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
// Ensure data directory exists
const dir = dirname(dbPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
db = drizzleSqlite(sqlite, { schema });
}
export { db };
export * from './schema.js';

624
backend/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,624 @@
import { db } from './index.js';
import { sql } from 'drizzle-orm';
const dbType = process.env.DB_TYPE || 'sqlite';
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 */ }
// 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,
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,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
event_id TEXT NOT NULL REFERENCES events(id),
attendee_name TEXT NOT NULL,
attendee_email TEXT NOT NULL,
attendee_phone TEXT NOT NULL,
preferred_language TEXT,
status TEXT NOT NULL DEFAULT 'pending',
checkin_at TEXT,
qr_code TEXT,
created_at TEXT NOT NULL
)
`);
// Add new columns if they don't exist (for existing databases)
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_name TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_email TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_phone TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN preferred_language TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN admin_note TEXT`);
} catch (e) { /* column may already exist */ }
// Migration: Add first/last name columns
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_first_name TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_last_name TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc TEXT`);
} catch (e) { /* column may already exist */ }
// Migration: Copy data from attendee_name to attendee_first_name if attendee_first_name is empty
try {
await (db as any).run(sql`
UPDATE tickets
SET attendee_first_name = attendee_name
WHERE attendee_first_name IS NULL AND attendee_name IS NOT NULL
`);
} catch (e) { /* migration may have already run */ }
// 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 */ }
// 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_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)
)
`);
// 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_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
)
`);
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
)
`);
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
)
`);
} 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 */ }
// 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,
start_datetime TIMESTAMP NOT NULL,
end_datetime TIMESTAMP,
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),
created_at TIMESTAMP NOT NULL,
updated_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 */ }
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,
paid_at TIMESTAMP,
paid_by_admin_id UUID,
admin_note TEXT,
created_at TIMESTAMP NOT NULL,
updated_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_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)
)
`);
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_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
)
`);
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
)
`);
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
)
`);
}
console.log('Migrations completed successfully!');
process.exit(0);
}
migrate().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

518
backend/src/db/schema.ts Normal file
View File

@@ -0,0 +1,518 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } 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'),
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(),
title: text('title').notNull(),
titleEs: text('title_es'),
description: text('description').notNull(),
descriptionEs: text('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', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
export const sqliteTickets = sqliteTable('tickets', {
id: text('id').primaryKey(),
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'] }).notNull().default('pending'),
checkinAt: text('checkin_at'),
qrCode: text('qr_code'),
adminNote: text('admin_note'),
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'] }).notNull().default('pending'),
reference: text('reference'),
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
paidAt: text('paid_at'),
paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes
createdAt: text('created_at').notNull(),
updatedAt: text('updated_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'),
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'),
// 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'),
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(),
});
export const sqliteEmailSettings = sqliteTable('email_settings', {
id: text('id').primaryKey(),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: text('updated_at').notNull(),
});
// ==================== 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'),
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(),
title: varchar('title', { length: 255 }).notNull(),
titleEs: varchar('title_es', { length: 255 }),
description: pgText('description').notNull(),
descriptionEs: pgText('description_es'),
startDatetime: timestamp('start_datetime').notNull(),
endDatetime: timestamp('end_datetime'),
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 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgTickets = pgTable('tickets', {
id: uuid('id').primaryKey(),
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'),
qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'),
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 }),
userMarkedPaidAt: timestamp('user_marked_paid_at'),
paidAt: timestamp('paid_at'),
paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_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 }),
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'),
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 }),
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(),
});
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(),
});
// Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
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 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;
// 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;

1720
backend/src/index.ts Normal file

File diff suppressed because it is too large Load Diff

248
backend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,248 @@
import * as jose from 'jose';
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono';
import { db, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm';
import { generateId, getNow } from './utils.js';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
const JWT_ISSUER = 'spanglish';
const JWT_AUDIENCE = 'spanglish-app';
export interface JWTPayload {
sub: string;
email: string;
role: string;
iat: number;
exp: number;
}
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Generate secure random token for magic links
export function generateSecureToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Create magic link token
export async function createMagicLinkToken(
userId: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
expiresInMinutes: number = 10
): Promise<string> {
const token = generateSecureToken();
const now = getNow();
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString();
await (db as any).insert(magicLinkTokens).values({
id: generateId(),
userId,
token,
type,
expiresAt,
createdAt: now,
});
return token;
}
// Verify and consume magic link token
export async function verifyMagicLinkToken(
token: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow();
const tokenRecord = await (db as any)
.select()
.from(magicLinkTokens)
.where(
and(
eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
)
.get();
if (!tokenRecord) {
return { valid: false, error: 'Invalid token' };
}
if (tokenRecord.usedAt) {
return { valid: false, error: 'Token already used' };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: 'Token expired' };
}
// Mark token as used
await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
return { valid: true, userId: tokenRecord.userId };
}
// Create user session
export async function createUserSession(
userId: string,
userAgent?: string,
ipAddress?: string
): Promise<string> {
const sessionToken = generateSecureToken();
const now = getNow();
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
await (db as any).insert(userSessions).values({
id: generateId(),
userId,
token: sessionToken,
userAgent: userAgent || null,
ipAddress: ipAddress || null,
lastActiveAt: now,
expiresAt,
createdAt: now,
});
return sessionToken;
}
// Get user's active sessions
export async function getUserSessions(userId: string) {
const now = getNow();
return (db as any)
.select()
.from(userSessions)
.where(
and(
eq((userSessions as any).userId, userId),
gt((userSessions as any).expiresAt, now)
)
)
.all();
}
// Invalidate a specific session
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
const result = await (db as any)
.delete(userSessions)
.where(
and(
eq((userSessions as any).id, sessionId),
eq((userSessions as any).userId, userId)
)
);
return true;
}
// Invalidate all user sessions (logout everywhere)
export async function invalidateAllUserSessions(userId: string): Promise<void> {
await (db as any)
.delete(userSessions)
.where(eq((userSessions as any).userId, userId));
}
// Password validation (min 10 characters per spec)
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('7d')
.sign(JWT_SECRET);
return token;
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setExpirationTime('30d')
.sign(JWT_SECRET);
return token;
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload as unknown as JWTPayload;
} catch {
return null;
}
}
export async function getAuthUser(c: Context) {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return null;
}
const user = await (db as any).select().from(users).where(eq((users as any).id, payload.sub)).get();
return user || null;
}
export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
if (roles && !roles.includes(user.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', user);
await next();
};
}
export async function isFirstUser(): Promise<boolean> {
const result = await (db as any).select().from(users).limit(1).all();
return !result || result.length === 0;
}

784
backend/src/lib/email.ts Normal file
View File

@@ -0,0 +1,784 @@
// Email service for Spanglish platform
// Supports multiple email providers: Resend, SMTP (Nodemailer)
import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { getNow } from './utils.js';
import {
replaceTemplateVariables,
wrapInBaseTemplate,
defaultTemplates,
type DefaultTemplate
} from './emailTemplates.js';
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
// ==================== Types ====================
interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
}
interface SendEmailResult {
success: boolean;
messageId?: string;
error?: string;
}
type EmailProvider = 'resend' | 'smtp' | 'console';
// ==================== Provider Configuration ====================
function getEmailProvider(): EmailProvider {
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
return provider;
}
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
return 'console';
}
function getFromEmail(): string {
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
}
function getFromName(): string {
return process.env.EMAIL_FROM_NAME || 'Spanglish';
}
// ==================== SMTP Configuration ====================
interface SMTPConfig {
host: string;
port: number;
secure: boolean;
auth?: {
user: string;
pass: string;
};
}
function getSMTPConfig(): SMTPConfig | null {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
if (!host) {
return null;
}
const config: SMTPConfig = {
host,
port,
secure,
};
if (user && pass) {
config.auth = { user, pass };
}
return config;
}
// Cached SMTP transporter
let smtpTransporter: Transporter | null = null;
function getSMTPTransporter(): Transporter | null {
if (smtpTransporter) {
return smtpTransporter;
}
const config = getSMTPConfig();
if (!config) {
console.error('[Email] SMTP configuration missing');
return null;
}
smtpTransporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
// Additional options for better deliverability
pool: true,
maxConnections: 5,
maxMessages: 100,
// TLS options
tls: {
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
},
});
// Verify connection configuration
smtpTransporter.verify((error, success) => {
if (error) {
console.error('[Email] SMTP connection verification failed:', error.message);
} else {
console.log('[Email] SMTP server is ready to send emails');
}
});
return smtpTransporter;
}
// ==================== Email Providers ====================
/**
* Send email using Resend API
*/
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
const fromEmail = getFromEmail();
const fromName = getFromName();
if (!apiKey) {
console.error('[Email] Resend API key not configured');
return { success: false, error: 'Resend API key not configured' };
}
try {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: `${fromName} <${fromEmail}>`,
to: Array.isArray(options.to) ? options.to : [options.to],
subject: options.subject,
html: options.html,
text: options.text,
reply_to: options.replyTo,
}),
});
const data = await response.json();
if (!response.ok) {
console.error('[Email] Resend API error:', data);
return {
success: false,
error: data.message || data.error || 'Failed to send email'
};
}
console.log('[Email] Email sent via Resend:', data.id);
return {
success: true,
messageId: data.id
};
} catch (error: any) {
console.error('[Email] Resend error:', error);
return {
success: false,
error: error.message || 'Failed to send email via Resend'
};
}
}
/**
* Send email using SMTP (Nodemailer)
*/
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
const transporter = getSMTPTransporter();
if (!transporter) {
return { success: false, error: 'SMTP not configured' };
}
const fromEmail = getFromEmail();
const fromName = getFromName();
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
replyTo: options.replyTo,
subject: options.subject,
html: options.html,
text: options.text,
});
console.log('[Email] Email sent via SMTP:', info.messageId);
return {
success: true,
messageId: info.messageId
};
} catch (error: any) {
console.error('[Email] SMTP error:', error);
return {
success: false,
error: error.message || 'Failed to send email via SMTP'
};
}
}
/**
* Console logger for development/testing (no actual email sent)
*/
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
console.log('\n========================================');
console.log('[Email] Console Mode - Email Preview');
console.log('========================================');
console.log(`To: ${to}`);
console.log(`Subject: ${options.subject}`);
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
console.log('----------------------------------------');
console.log('HTML Body (truncated):');
console.log(options.html?.substring(0, 500) + '...');
console.log('========================================\n');
return {
success: true,
messageId: `console-${Date.now()}`
};
}
/**
* Main send function that routes to the appropriate provider
*/
async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const provider = getEmailProvider();
console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`);
switch (provider) {
case 'resend':
return sendWithResend(options);
case 'smtp':
return sendWithSMTP(options);
case 'console':
default:
return sendWithConsole(options);
}
}
// ==================== Email Service ====================
export const emailService = {
/**
* Get current email provider info
*/
getProviderInfo(): { provider: EmailProvider; configured: boolean } {
const provider = getEmailProvider();
let configured = false;
switch (provider) {
case 'resend':
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
break;
case 'smtp':
configured = !!process.env.SMTP_HOST;
break;
case 'console':
configured = true;
break;
}
return { provider, configured };
},
/**
* Test email configuration by sending a test email
*/
async testConnection(to: string): Promise<SendEmailResult> {
const { provider, configured } = this.getProviderInfo();
if (!configured) {
return { success: false, error: `Email provider "${provider}" is not configured` };
}
return sendEmail({
to,
subject: 'Spanglish - Email Test',
html: `
<h2>Email Configuration Test</h2>
<p>This is a test email from your Spanglish platform.</p>
<p><strong>Provider:</strong> ${provider}</p>
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
<p>If you received this email, your email configuration is working correctly!</p>
`,
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
});
},
/**
* Get common variables for all emails
*/
getCommonVariables(): Record<string, string> {
return {
siteName: 'Spanglish',
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
currentYear: new Date().getFullYear().toString(),
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
};
},
/**
* Format date for emails
*/
formatDate(dateStr: string, locale: string = 'en'): string {
const date = new Date(dateStr);
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
},
/**
* Format time for emails
*/
formatTime(dateStr: string, locale: string = 'en'): string {
const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
},
/**
* Format currency
*/
formatCurrency(amount: number, currency: string = 'PYG'): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
},
/**
* Get a template by slug
*/
async getTemplate(slug: string): Promise<any | null> {
const template = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug))
.get();
return template || null;
},
/**
* Seed default templates if they don't exist
*/
async seedDefaultTemplates(): Promise<void> {
console.log('[Email] Checking for default templates...');
for (const template of defaultTemplates) {
const existing = await this.getTemplate(template.slug);
if (!existing) {
console.log(`[Email] Creating template: ${template.name}`);
const now = getNow();
await (db as any).insert(emailTemplates).values({
id: nanoid(),
name: template.name,
slug: template.slug,
subject: template.subject,
subjectEs: template.subjectEs,
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs,
bodyText: template.bodyText,
bodyTextEs: template.bodyTextEs,
description: template.description,
variables: JSON.stringify(template.variables),
isSystem: template.isSystem ? 1 : 0,
isActive: 1,
createdAt: now,
updatedAt: now,
});
}
}
console.log('[Email] Default templates check complete');
},
/**
* Send an email using a template
*/
async sendTemplateEmail(params: {
templateSlug: string;
to: string;
toName?: string;
variables: Record<string, any>;
locale?: string;
eventId?: string;
sentBy?: string;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
// Get template
const template = await this.getTemplate(templateSlug);
if (!template) {
return { success: false, error: `Template "${templateSlug}" not found` };
}
// Build variables
const allVariables = {
...this.getCommonVariables(),
lang: locale,
...variables,
};
// Get localized content
const subject = locale === 'es' && template.subjectEs
? template.subjectEs
: template.subject;
const bodyHtml = locale === 'es' && template.bodyHtmlEs
? template.bodyHtmlEs
: template.bodyHtml;
const bodyText = locale === 'es' && template.bodyTextEs
? template.bodyTextEs
: template.bodyText;
// Replace variables
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
// Create log entry
const logId = nanoid();
const now = getNow();
await (db as any).insert(emailLogs).values({
id: logId,
templateId: template.id,
eventId: eventId || null,
recipientEmail: to,
recipientName: toName || null,
subject: finalSubject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy: sentBy || null,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject: finalSubject,
html: finalBodyHtml,
text: finalBodyText,
});
// Update log with result
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: getNow(),
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
logId,
error: result.error
};
},
/**
* Send booking confirmation email
*/
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return this.sendTemplateEmail({
templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
qrCode: ticket.qrCode || '',
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale),
eventTime: this.formatTime(event.startDatetime, locale),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
eventPrice: this.formatCurrency(event.price, event.currency),
},
});
},
/**
* Send payment receipt email
*/
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment with ticket and event info
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
.get();
if (!payment) {
return { success: false, error: 'Payment not found' };
}
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' },
};
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return this.sendTemplateEmail({
templateSlug: 'payment-receipt',
to: ticket.attendeeEmail,
toName: receiptFullName,
locale,
eventId: event.id,
variables: {
attendeeName: receiptFullName,
ticketId: ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale),
paymentAmount: this.formatCurrency(payment.amount, payment.currency),
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id,
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale),
},
});
},
/**
* Send custom email to event attendees
*/
async sendToEventAttendees(params: {
eventId: string;
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
sentBy: string;
}): Promise<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }> {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Get event
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
.get();
if (!event) {
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
}
// Get tickets based on filter
let ticketQuery = (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, eventId));
if (recipientFilter !== 'all') {
ticketQuery = ticketQuery.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, recipientFilter)
)
);
}
const eventTickets = await ticketQuery.all();
if (eventTickets.length === 0) {
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
}
let sentCount = 0;
let failedCount = 0;
const errors: string[] = [];
// Send to each attendee
for (const ticket of eventTickets) {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const bulkFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
const result = await this.sendTemplateEmail({
templateSlug,
to: ticket.attendeeEmail,
toName: bulkFullName,
locale,
eventId: event.id,
sentBy,
variables: {
attendeeName: bulkFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale),
eventTime: this.formatTime(event.startDatetime, locale),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
...customVariables,
},
});
if (result.success) {
sentCount++;
} else {
failedCount++;
errors.push(`Failed to send to ${ticket.attendeeEmail}: ${result.error}`);
}
}
return {
success: failedCount === 0,
sentCount,
failedCount,
errors,
};
},
/**
* Send a custom email (not from template)
*/
async sendCustomEmail(params: {
to: string;
toName?: string;
subject: string;
bodyHtml: string;
bodyText?: string;
eventId?: string;
sentBy: string;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
const allVariables = {
...this.getCommonVariables(),
subject,
};
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
// Create log entry
const logId = nanoid();
const now = getNow();
await (db as any).insert(emailLogs).values({
id: logId,
templateId: null,
eventId: eventId || null,
recipientEmail: to,
recipientName: toName || null,
subject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject,
html: finalBodyHtml,
text: bodyText,
});
// Update log
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: getNow(),
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
logId,
error: result.error
};
},
};
// Export the main sendEmail function for direct use
export { sendEmail };
export default emailService;

View File

@@ -0,0 +1,675 @@
// Email templates for Spanglish platform
// These are the default templates that get seeded into the database
export interface EmailVariable {
name: string;
description: string;
example: string;
}
export interface DefaultTemplate {
name: string;
slug: string;
subject: string;
subjectEs: string;
bodyHtml: string;
bodyHtmlEs: string;
bodyText: string;
bodyTextEs: string;
description: string;
variables: EmailVariable[];
isSystem: boolean;
}
// Common variables available in all templates
export const commonVariables: EmailVariable[] = [
{ name: 'siteName', description: 'Website name', example: 'Spanglish' },
{ name: 'siteUrl', description: 'Website URL', example: 'https://spanglish.com' },
{ name: 'currentYear', description: 'Current year', example: '2026' },
{ name: 'supportEmail', description: 'Support email address', example: 'hello@spanglish.com' },
];
// Booking-specific variables
export const bookingVariables: EmailVariable[] = [
{ name: 'attendeeName', description: 'Attendee full name', example: 'John Doe' },
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
{ name: 'eventLocation', description: 'Event location', example: 'Casa Cultural, Asunción' },
{ name: 'eventLocationUrl', description: 'Google Maps link', example: 'https://maps.google.com/...' },
{ name: 'eventPrice', description: 'Event price with currency', example: '50,000 PYG' },
];
// Payment-specific variables
export const paymentVariables: EmailVariable[] = [
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
{ name: 'paymentMethod', description: 'Payment method used', example: 'Lightning' },
{ name: 'paymentReference', description: 'Payment reference ID', example: 'PAY-XYZ789' },
{ name: 'paymentDate', description: 'Payment date', example: 'January 28, 2026' },
];
// Base HTML wrapper for all emails
export const baseEmailWrapper = `
<!DOCTYPE html>
<html lang="{{lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
}
.header {
background-color: #1a1a1a;
padding: 24px;
text-align: center;
}
.header h1 {
margin: 0;
color: #fff;
font-size: 24px;
}
.header h1 span {
color: #f4d03f;
}
.content {
padding: 32px 24px;
}
.content h2 {
color: #1a1a1a;
margin-top: 0;
}
.event-card {
background-color: #f9f9f9;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.event-card h3 {
margin-top: 0;
color: #1a1a1a;
}
.event-detail {
display: flex;
margin: 8px 0;
}
.event-detail strong {
min-width: 80px;
color: #666;
}
.ticket-box {
background-color: #f4d03f;
border-radius: 8px;
padding: 16px;
text-align: center;
margin: 20px 0;
}
.ticket-box p {
margin: 4px 0;
font-weight: 600;
color: #1a1a1a;
}
.ticket-id {
font-size: 20px;
font-family: monospace;
letter-spacing: 2px;
}
.btn {
display: inline-block;
background-color: #f4d03f;
color: #1a1a1a;
text-decoration: none;
padding: 12px 24px;
border-radius: 6px;
font-weight: 600;
margin: 16px 0;
}
.btn:hover {
background-color: #e6c230;
}
.footer {
background-color: #f5f5f5;
padding: 24px;
text-align: center;
font-size: 14px;
color: #666;
}
.footer a {
color: #333;
}
.qr-code {
text-align: center;
margin: 20px 0;
}
.qr-code img {
max-width: 150px;
height: auto;
}
.divider {
height: 1px;
background-color: #eee;
margin: 24px 0;
}
.note {
background-color: #fff9e6;
border-left: 4px solid #f4d03f;
padding: 12px 16px;
margin: 16px 0;
font-size: 14px;
}
@media (max-width: 600px) {
.content {
padding: 24px 16px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Span<span>glish</span></h1>
</div>
<div class="content">
{{content}}
</div>
<div class="footer">
<p>{{siteName}} - Language Exchange Community in Asunción</p>
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>
<p>Questions? Contact us at <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
<p>&copy; {{currentYear}} {{siteName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
// Default templates
export const defaultTemplates: DefaultTemplate[] = [
{
name: 'Booking Confirmation',
slug: 'booking-confirmation',
subject: 'Your Spanglish ticket is confirmed 🎉',
subjectEs: 'Tu entrada de Spanglish está confirmada 🎉',
bodyHtml: `
<h2>Your Booking is Confirmed!</h2>
<p>Hi {{attendeeName}},</p>
<p>Great news! Your spot for <strong>{{eventTitle}}</strong> has been confirmed. We can't wait to see you there!</p>
<div class="event-card">
<h3>📅 Event Details</h3>
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
{{#if eventLocationUrl}}
<p><a href="{{eventLocationUrl}}" class="btn">📍 View on Map</a></p>
{{/if}}
</div>
<div class="ticket-box">
<p>Your Ticket ID</p>
<p class="ticket-id">{{ticketId}}</p>
</div>
{{#if qrCode}}
<div class="qr-code">
<p><strong>Show this QR code at check-in:</strong></p>
<img src="{{qrCode}}" alt="Check-in QR Code" />
</div>
{{/if}}
<div class="note">
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
</div>
<p>See you at Spanglish!</p>
<p>The Spanglish Team</p>
`,
bodyHtmlEs: `
<h2>¡Tu Reserva está Confirmada!</h2>
<p>Hola {{attendeeName}},</p>
<p>¡Excelentes noticias! Tu lugar para <strong>{{eventTitle}}</strong> ha sido confirmado. ¡No podemos esperar a verte ahí!</p>
<div class="event-card">
<h3>📅 Detalles del Evento</h3>
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
{{#if eventLocationUrl}}
<p><a href="{{eventLocationUrl}}" class="btn">📍 Ver en el Mapa</a></p>
{{/if}}
</div>
<div class="ticket-box">
<p>Tu ID de Ticket</p>
<p class="ticket-id">{{ticketId}}</p>
</div>
{{#if qrCode}}
<div class="qr-code">
<p><strong>Muestra este código QR en el check-in:</strong></p>
<img src="{{qrCode}}" alt="Código QR de Check-in" />
</div>
{{/if}}
<div class="note">
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
</div>
<p>¡Nos vemos en Spanglish!</p>
<p>El Equipo de Spanglish</p>
`,
bodyText: `Your Booking is Confirmed!
Hi {{attendeeName}},
Great news! Your spot for {{eventTitle}} has been confirmed.
Event Details:
- Event: {{eventTitle}}
- Date: {{eventDate}}
- Time: {{eventTime}}
- Location: {{eventLocation}}
Your Ticket ID: {{ticketId}}
Important: Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
See you at Spanglish!
The Spanglish Team`,
bodyTextEs: `¡Tu Reserva está Confirmada!
Hola {{attendeeName}},
¡Excelentes noticias! Tu lugar para {{eventTitle}} ha sido confirmado.
Detalles del Evento:
- Evento: {{eventTitle}}
- Fecha: {{eventDate}}
- Hora: {{eventTime}}
- Ubicación: {{eventLocation}}
Tu ID de Ticket: {{ticketId}}
Importante: Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
¡Nos vemos en Spanglish!
El Equipo de Spanglish`,
description: 'Sent automatically when a booking is confirmed after payment',
variables: [...commonVariables, ...bookingVariables],
isSystem: true,
},
{
name: 'Payment Receipt',
slug: 'payment-receipt',
subject: 'Payment Receipt - Spanglish',
subjectEs: 'Recibo de Pago - Spanglish',
bodyHtml: `
<h2>Payment Received</h2>
<p>Hi {{attendeeName}},</p>
<p>Thank you for your payment! Here's your receipt for your records.</p>
<div class="event-card">
<h3>💳 Payment Details</h3>
<div class="event-detail"><strong>Amount:</strong> {{paymentAmount}}</div>
<div class="event-detail"><strong>Method:</strong> {{paymentMethod}}</div>
<div class="event-detail"><strong>Reference:</strong> {{paymentReference}}</div>
<div class="event-detail"><strong>Date:</strong> {{paymentDate}}</div>
</div>
<div class="divider"></div>
<div class="event-card">
<h3>📅 Event</h3>
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Ticket ID:</strong> {{ticketId}}</div>
</div>
<p>Keep this email as your payment confirmation.</p>
<p>The Spanglish Team</p>
`,
bodyHtmlEs: `
<h2>Pago Recibido</h2>
<p>Hola {{attendeeName}},</p>
<p>¡Gracias por tu pago! Aquí está tu recibo para tus registros.</p>
<div class="event-card">
<h3>💳 Detalles del Pago</h3>
<div class="event-detail"><strong>Monto:</strong> {{paymentAmount}}</div>
<div class="event-detail"><strong>Método:</strong> {{paymentMethod}}</div>
<div class="event-detail"><strong>Referencia:</strong> {{paymentReference}}</div>
<div class="event-detail"><strong>Fecha:</strong> {{paymentDate}}</div>
</div>
<div class="divider"></div>
<div class="event-card">
<h3>📅 Evento</h3>
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>ID de Ticket:</strong> {{ticketId}}</div>
</div>
<p>Guarda este email como tu confirmación de pago.</p>
<p>El Equipo de Spanglish</p>
`,
bodyText: `Payment Received
Hi {{attendeeName}},
Thank you for your payment! Here's your receipt:
Payment Details:
- Amount: {{paymentAmount}}
- Method: {{paymentMethod}}
- Reference: {{paymentReference}}
- Date: {{paymentDate}}
Event: {{eventTitle}}
Date: {{eventDate}}
Ticket ID: {{ticketId}}
Keep this email as your payment confirmation.
The Spanglish Team`,
bodyTextEs: `Pago Recibido
Hola {{attendeeName}},
¡Gracias por tu pago! Aquí está tu recibo:
Detalles del Pago:
- Monto: {{paymentAmount}}
- Método: {{paymentMethod}}
- Referencia: {{paymentReference}}
- Fecha: {{paymentDate}}
Evento: {{eventTitle}}
Fecha: {{eventDate}}
ID de Ticket: {{ticketId}}
Guarda este email como tu confirmación de pago.
El Equipo de Spanglish`,
description: 'Sent automatically after payment is processed',
variables: [...commonVariables, ...bookingVariables, ...paymentVariables],
isSystem: true,
},
{
name: 'Event Update',
slug: 'event-update',
subject: 'Important Update: {{eventTitle}}',
subjectEs: 'Actualización Importante: {{eventTitle}}',
bodyHtml: `
<h2>Important Event Update</h2>
<p>Hi {{attendeeName}},</p>
<p>We have an important update regarding <strong>{{eventTitle}}</strong>.</p>
<div class="event-card">
<h3>📢 Message</h3>
<p>{{customMessage}}</p>
</div>
<div class="event-card">
<h3>📅 Event Details</h3>
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
</div>
<p>If you have any questions, please don't hesitate to contact us.</p>
<p>The Spanglish Team</p>
`,
bodyHtmlEs: `
<h2>Actualización Importante del Evento</h2>
<p>Hola {{attendeeName}},</p>
<p>Tenemos una actualización importante sobre <strong>{{eventTitle}}</strong>.</p>
<div class="event-card">
<h3>📢 Mensaje</h3>
<p>{{customMessage}}</p>
</div>
<div class="event-card">
<h3>📅 Detalles del Evento</h3>
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
</div>
<p>Si tienes alguna pregunta, no dudes en contactarnos.</p>
<p>El Equipo de Spanglish</p>
`,
bodyText: `Important Event Update
Hi {{attendeeName}},
We have an important update regarding {{eventTitle}}.
Message:
{{customMessage}}
Event Details:
- Event: {{eventTitle}}
- Date: {{eventDate}}
- Time: {{eventTime}}
- Location: {{eventLocation}}
If you have any questions, please don't hesitate to contact us.
The Spanglish Team`,
bodyTextEs: `Actualización Importante del Evento
Hola {{attendeeName}},
Tenemos una actualización importante sobre {{eventTitle}}.
Mensaje:
{{customMessage}}
Detalles del Evento:
- Evento: {{eventTitle}}
- Fecha: {{eventDate}}
- Hora: {{eventTime}}
- Ubicación: {{eventLocation}}
Si tienes alguna pregunta, no dudes en contactarnos.
El Equipo de Spanglish`,
description: 'Template for sending event updates to attendees (sent manually)',
variables: [
...commonVariables,
...bookingVariables,
{ name: 'customMessage', description: 'Custom message from admin', example: 'The venue has changed...' }
],
isSystem: true,
},
{
name: 'Post-Event Follow-Up',
slug: 'post-event-followup',
subject: 'Thanks for joining {{eventTitle}}! 🙏',
subjectEs: '¡Gracias por asistir a {{eventTitle}}! 🙏',
bodyHtml: `
<h2>Thank You for Joining Us!</h2>
<p>Hi {{attendeeName}},</p>
<p>Thank you so much for being part of <strong>{{eventTitle}}</strong>! We hope you had a great time practicing languages and meeting new people.</p>
<div class="event-card">
<h3>💬 Share Your Experience</h3>
<p>{{customMessage}}</p>
</div>
{{#if nextEventTitle}}
<div class="event-card">
<h3>📅 Next Event</h3>
<div class="event-detail"><strong>Event:</strong> {{nextEventTitle}}</div>
<div class="event-detail"><strong>Date:</strong> {{nextEventDate}}</div>
<p style="text-align: center; margin-top: 16px;">
<a href="{{nextEventUrl}}" class="btn">Reserve Your Spot</a>
</p>
</div>
{{/if}}
<p>Follow us on social media for updates and photos from the event!</p>
<p>See you at the next Spanglish!</p>
<p>The Spanglish Team</p>
`,
bodyHtmlEs: `
<h2>¡Gracias por Unirte!</h2>
<p>Hola {{attendeeName}},</p>
<p>¡Muchas gracias por ser parte de <strong>{{eventTitle}}</strong>! Esperamos que hayas pasado un gran momento practicando idiomas y conociendo gente nueva.</p>
<div class="event-card">
<h3>💬 Comparte tu Experiencia</h3>
<p>{{customMessage}}</p>
</div>
{{#if nextEventTitle}}
<div class="event-card">
<h3>📅 Próximo Evento</h3>
<div class="event-detail"><strong>Evento:</strong> {{nextEventTitle}}</div>
<div class="event-detail"><strong>Fecha:</strong> {{nextEventDate}}</div>
<p style="text-align: center; margin-top: 16px;">
<a href="{{nextEventUrl}}" class="btn">Reserva tu Lugar</a>
</p>
</div>
{{/if}}
<p>¡Síguenos en redes sociales para actualizaciones y fotos del evento!</p>
<p>¡Nos vemos en el próximo Spanglish!</p>
<p>El Equipo de Spanglish</p>
`,
bodyText: `Thank You for Joining Us!
Hi {{attendeeName}},
Thank you so much for being part of {{eventTitle}}! We hope you had a great time.
{{customMessage}}
Follow us on social media for updates and photos from the event!
See you at the next Spanglish!
The Spanglish Team`,
bodyTextEs: `¡Gracias por Unirte!
Hola {{attendeeName}},
¡Muchas gracias por ser parte de {{eventTitle}}! Esperamos que hayas pasado un gran momento.
{{customMessage}}
¡Síguenos en redes sociales para actualizaciones y fotos del evento!
¡Nos vemos en el próximo Spanglish!
El Equipo de Spanglish`,
description: 'Template for post-event follow-up emails (sent manually)',
variables: [
...commonVariables,
...bookingVariables,
{ name: 'customMessage', description: 'Custom message from admin', example: 'We would love to hear your feedback!' },
{ name: 'nextEventTitle', description: 'Next event title (optional)', example: 'Spanglish Night - February' },
{ name: 'nextEventDate', description: 'Next event date (optional)', example: 'February 25, 2026' },
{ name: 'nextEventUrl', description: 'Next event booking URL (optional)', example: 'https://spanglish.com/book/...' },
],
isSystem: true,
},
{
name: 'Custom Email',
slug: 'custom-email',
subject: '{{customSubject}}',
subjectEs: '{{customSubject}}',
bodyHtml: `
<h2>{{customTitle}}</h2>
<p>Hi {{attendeeName}},</p>
<div class="event-card">
{{customMessage}}
</div>
<p>The Spanglish Team</p>
`,
bodyHtmlEs: `
<h2>{{customTitle}}</h2>
<p>Hola {{attendeeName}},</p>
<div class="event-card">
{{customMessage}}
</div>
<p>El Equipo de Spanglish</p>
`,
bodyText: `{{customTitle}}
Hi {{attendeeName}},
{{customMessage}}
The Spanglish Team`,
bodyTextEs: `{{customTitle}}
Hola {{attendeeName}},
{{customMessage}}
El Equipo de Spanglish`,
description: 'Blank template for fully custom emails',
variables: [
...commonVariables,
{ name: 'attendeeName', description: 'Recipient name', example: 'John Doe' },
{ name: 'customSubject', description: 'Email subject', example: 'Special Announcement' },
{ name: 'customTitle', description: 'Email title/heading', example: 'Special Announcement' },
{ name: 'customMessage', description: 'Email body content (supports HTML)', example: '<p>Your message here...</p>' },
],
isSystem: true,
},
];
// Helper function to replace template variables
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
let result = template;
// Handle conditional blocks {{#if variable}}...{{/if}}
const conditionalRegex = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
result = result.replace(conditionalRegex, (match, varName, content) => {
return variables[varName] ? content : '';
});
// Replace simple variables {{variable}}
const variableRegex = /\{\{(\w+)\}\}/g;
result = result.replace(variableRegex, (match, varName) => {
return variables[varName] !== undefined ? String(variables[varName]) : match;
});
return result;
}
// Helper to wrap content in the base template
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
return replaceTemplateVariables(wrappedContent, variables);
}
// Get all available variables for a template by slug
export function getTemplateVariables(slug: string): EmailVariable[] {
const template = defaultTemplates.find(t => t.slug === slug);
return template?.variables || commonVariables;
}

212
backend/src/lib/lnbits.ts Normal file
View File

@@ -0,0 +1,212 @@
/**
* LNbits API client for Lightning Network payments
*
* Uses LNbits API to create and manage Lightning invoices with webhook support
* for payment detection.
*/
// Read environment variables dynamically to ensure dotenv has loaded
function getConfig() {
return {
url: process.env.LNBITS_URL || '',
apiKey: process.env.LNBITS_API_KEY || '', // Invoice/read key
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
};
}
export interface LNbitsInvoice {
paymentHash: string;
paymentRequest: string; // BOLT11 invoice string
checkingId: string;
amount: number; // Amount in satoshis (after conversion)
fiatAmount?: number; // Original fiat amount
fiatCurrency?: string; // Original fiat currency
memo: string;
expiry: string;
status: string;
extra?: Record<string, any>;
}
export interface CreateInvoiceParams {
amount: number; // Amount in the specified unit
unit?: string; // Currency unit: 'sat', 'USD', 'PYG', etc. (default: 'sat')
memo: string;
webhookUrl?: string;
expiry?: number; // Expiry in seconds (default 3600 = 1 hour)
extra?: Record<string, any>; // Additional metadata
}
/**
* Check if LNbits is configured
*/
export function isLNbitsConfigured(): boolean {
const config = getConfig();
return !!(config.url && config.apiKey);
}
/**
* Create a Lightning invoice using LNbits
* LNbits supports fiat currencies directly - it will convert to sats automatically
*/
export async function createInvoice(params: CreateInvoiceParams): Promise<LNbitsInvoice> {
const config = getConfig();
if (!config.url || !config.apiKey) {
throw new Error('LNbits is not configured. Please set LNBITS_URL and LNBITS_API_KEY.');
}
const apiEndpoint = '/api/v1/payments';
// LNbits supports fiat currencies via the 'unit' parameter
// It will automatically convert to sats using its exchange rate provider
const payload: any = {
out: false, // false = create invoice for receiving payment
amount: params.amount,
unit: params.unit || 'sat', // Support fiat currencies like 'USD', 'PYG', etc.
memo: params.memo,
};
if (params.webhookUrl) {
payload.webhook = params.webhookUrl;
}
if (params.expiry) {
payload.expiry = params.expiry;
}
if (params.extra) {
payload.extra = params.extra;
}
console.log('Creating LNbits invoice:', {
url: `${config.url}${apiEndpoint}`,
amount: params.amount,
unit: payload.unit,
memo: params.memo,
webhook: params.webhookUrl,
});
const response = await fetch(`${config.url}${apiEndpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': config.apiKey,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('LNbits invoice creation failed:', {
status: response.status,
statusText: response.statusText,
error: errorText,
});
throw new Error(`Failed to create Lightning invoice: ${errorText}`);
}
const data = await response.json();
// LNbits returns amount in millisatoshis for the actual invoice
// Convert to satoshis for display
const amountSats = data.amount ? Math.round(data.amount / 1000) : params.amount;
console.log('LNbits invoice created successfully:', {
paymentHash: data.payment_hash,
amountMsats: data.amount,
amountSats,
hasPaymentRequest: !!data.payment_request,
});
return {
paymentHash: data.payment_hash,
paymentRequest: data.payment_request || data.bolt11,
checkingId: data.checking_id,
amount: amountSats, // Amount in satoshis
fiatAmount: params.unit && params.unit !== 'sat' ? params.amount : undefined,
fiatCurrency: params.unit && params.unit !== 'sat' ? params.unit : undefined,
memo: params.memo,
expiry: data.expiry || '',
status: data.status || 'pending',
extra: params.extra,
};
}
/**
* Get invoice/payment status from LNbits
*/
export async function getPaymentStatus(paymentHash: string): Promise<{
paid: boolean;
status: string;
preimage?: string;
} | null> {
const config = getConfig();
if (!config.url || !config.apiKey) {
throw new Error('LNbits is not configured');
}
const apiEndpoint = `/api/v1/payments/${paymentHash}`;
const response = await fetch(`${config.url}${apiEndpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': config.apiKey,
},
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error('Failed to get payment status from LNbits');
}
const data = await response.json();
// LNbits payment status: "pending", "complete", "failed"
// For incoming payments, "complete" means paid
const isPaid = data.status === 'complete' || data.paid === true;
return {
paid: isPaid,
status: data.status,
preimage: data.preimage,
};
}
/**
* Verify webhook payload from LNbits
* LNbits webhooks don't have signature verification by default,
* but we can verify the payment hash matches and check payment status
*/
export async function verifyWebhookPayment(paymentHash: string): Promise<boolean> {
try {
const status = await getPaymentStatus(paymentHash);
return status?.paid === true;
} catch (error) {
console.error('Error verifying webhook payment:', error);
return false;
}
}
/**
* Payment status types
*/
export type LNbitsPaymentStatus = 'pending' | 'complete' | 'failed' | 'expired';
/**
* Check if payment is complete
*/
export function isPaymentComplete(status: string): boolean {
return status === 'complete';
}
/**
* Check if payment is still pending
*/
export function isPaymentPending(status: string): boolean {
return status === 'pending';
}

37
backend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,37 @@
import { nanoid } from 'nanoid';
export function generateId(): string {
return nanoid(21);
}
export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`;
}
export function getNow(): string {
return new Date().toISOString();
}
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
return new Intl.NumberFormat('es-PY', {
style: 'currency',
currency,
}).format(amount);
}
export function calculateAvailableSeats(capacity: number, bookedCount: number): number {
return Math.max(0, capacity - bookedCount);
}
export function isEventSoldOut(capacity: number, bookedCount: number): boolean {
return bookedCount >= capacity;
}
export function sanitizeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

284
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,284 @@
import { Hono } from 'hono';
import { db, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
import { eq, and, gte, sql, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
const adminRouter = new Hono();
// Dashboard overview stats (admin)
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
// Get upcoming events
const upcomingEvents = await (db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(5)
.all();
// Get recent tickets
const recentTickets = await (db as any)
.select()
.from(tickets)
.orderBy(desc((tickets as any).createdAt))
.limit(10)
.all();
// Get total stats
const totalUsers = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.get();
const totalEvents = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(events)
.get();
const totalTickets = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.get();
const confirmedTickets = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).status, 'confirmed'))
.get();
const pendingPayments = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending'))
.get();
const paidPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).status, 'paid'))
.all();
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
const newContacts = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(contacts)
.where(eq((contacts as any).status, 'new'))
.get();
const totalSubscribers = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(emailSubscribers)
.where(eq((emailSubscribers as any).status, 'active'))
.get();
return c.json({
dashboard: {
stats: {
totalUsers: totalUsers?.count || 0,
totalEvents: totalEvents?.count || 0,
totalTickets: totalTickets?.count || 0,
confirmedTickets: confirmedTickets?.count || 0,
pendingPayments: pendingPayments?.count || 0,
totalRevenue,
newContacts: newContacts?.count || 0,
totalSubscribers: totalSubscribers?.count || 0,
},
upcomingEvents,
recentTickets,
},
});
});
// Get analytics data (admin)
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
// Get events with ticket counts
const allEvents = await (db as any).select().from(events).all();
const eventStats = await Promise.all(
allEvents.map(async (event: any) => {
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).eventId, event.id))
.get();
const confirmedCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
const checkedInCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in')
)
)
.get();
return {
id: event.id,
title: event.title,
date: event.startDatetime,
capacity: event.capacity,
totalBookings: ticketCount?.count || 0,
confirmedBookings: confirmedCount?.count || 0,
checkedIn: checkedInCount?.count || 0,
revenue: (confirmedCount?.count || 0) * event.price,
};
})
);
return c.json({
analytics: {
events: eventStats,
},
});
});
// Export data (admin)
adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
const eventId = c.req.query('eventId');
let query = (db as any).select().from(tickets);
if (eventId) {
query = query.where(eq((tickets as any).eventId, eventId));
}
const ticketList = await query.all();
// Get user and event details for each ticket
const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => {
const user = await (db as any)
.select()
.from(users)
.where(eq((users as any).id, ticket.userId))
.get();
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
return {
ticketId: ticket.id,
ticketStatus: ticket.status,
qrCode: ticket.qrCode,
checkinAt: ticket.checkinAt,
userName: user?.name,
userEmail: user?.email,
userPhone: user?.phone,
eventTitle: event?.title,
eventDate: event?.startDatetime,
paymentStatus: payment?.status,
paymentAmount: payment?.amount,
createdAt: ticket.createdAt,
};
})
);
return c.json({ tickets: enrichedTickets });
});
// Export financial data (admin)
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
const startDate = c.req.query('startDate');
const endDate = c.req.query('endDate');
const eventId = c.req.query('eventId');
// Get all payments
let query = (db as any).select().from(payments);
const allPayments = await query.all();
// Enrich with event and ticket data
const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
if (!ticket) return null;
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
// Apply filters
if (eventId && ticket.eventId !== eventId) return null;
if (startDate && payment.createdAt < startDate) return null;
if (endDate && payment.createdAt > endDate) return null;
return {
paymentId: payment.id,
amount: payment.amount,
currency: payment.currency,
provider: payment.provider,
status: payment.status,
reference: payment.reference,
paidAt: payment.paidAt,
createdAt: payment.createdAt,
ticketId: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
eventId: event?.id,
eventTitle: event?.title,
eventDate: event?.startDatetime,
};
})
);
const filteredPayments = enrichedPayments.filter(p => p !== null);
// Calculate summary
const summary = {
totalPayments: filteredPayments.length,
totalPaid: filteredPayments.filter((p: any) => p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
totalPending: filteredPayments.filter((p: any) => p.status === 'pending').reduce((sum: number, p: any) => sum + p.amount, 0),
totalRefunded: filteredPayments.filter((p: any) => p.status === 'refunded').reduce((sum: number, p: any) => sum + p.amount, 0),
byProvider: {
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
},
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
refundedCount: filteredPayments.filter((p: any) => p.status === 'refunded').length,
failedCount: filteredPayments.filter((p: any) => p.status === 'failed').length,
};
return c.json({ payments: filteredPayments, summary });
});
export default adminRouter;

652
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,652 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
createToken,
createRefreshToken,
isFirstUser,
getAuthUser,
validatePassword,
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
requireAuth,
} from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const auth = new Hono();
// Rate limiting store (in production, use Redis)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const attempts = loginAttempts.get(email);
if (!attempts) {
return { allowed: true };
}
if (now > attempts.resetAt) {
loginAttempts.delete(email);
return { allowed: true };
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
}
return { allowed: true };
}
function recordFailedAttempt(email: string): void {
const now = Date.now();
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
attempts.count++;
loginAttempts.set(email, attempts);
}
function clearFailedAttempts(email: string): void {
loginAttempts.delete(email);
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
name: z.string().min(2),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
const magicLinkRequestSchema = z.object({
email: z.string().email(),
});
const magicLinkVerifySchema = z.object({
token: z.string(),
});
const passwordResetRequestSchema = z.object({
email: z.string().email(),
});
const passwordResetSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
googleId: z.string().optional(),
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
});
const googleAuthSchema = z.object({
credential: z.string(), // Google ID token
});
// Register
auth.post('/register', zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(data.password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Check if email exists
const existing = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
if (existing) {
// If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
return c.json({
error: 'Email already registered',
canClaim: true,
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
}, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Check if first user (becomes admin)
const firstUser = await isFirstUser();
const hashedPassword = await hashPassword(data.password);
const now = getNow();
const id = generateId();
const newUser = {
id,
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone || null,
role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null,
isClaimed: true,
googleId: null,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role);
const refreshToken = await createRefreshToken(id);
return c.json({
user: {
id,
email: data.email,
name: data.name,
role: newUser.role,
isClaimed: true,
},
token,
refreshToken,
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
}, 201);
});
// Login with email/password
auth.post('/login', zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
const rateLimit = checkRateLimit(data.email);
if (!rateLimit.allowed) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
}, 429);
}
const user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
if (!user) {
recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Check if account is suspended
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
// Check if user has a password set
if (!user.password) {
return c.json({
error: 'No password set for this account',
needsClaim: !user.isClaimed,
message: user.isClaimed
? 'Please use Google login or request a password reset.'
: 'Please claim your account first.'
}, 400);
}
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
const token = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token,
refreshToken,
});
});
// Request magic link login
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
// Create magic link token (expires in 10 minutes)
const token = await createMagicLinkToken(user.id, 'login', 10);
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Your Spanglish Login Link',
html: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${magicLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
});
// Verify magic link and login
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get();
if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
});
// Request password reset
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
// Create reset token (expires in 30 minutes)
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${resetLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
});
// Reset password
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const verification = await verifyMagicLinkToken(token, 'reset_password');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions for security
await invalidateAllUserSessions(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
}
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 24 hours)
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Claim Your Spanglish Account',
html: `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 24 hours.</p>
`,
});
} catch (error) {
console.error('Failed to send claim account email:', error);
}
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
});
// Complete account claim
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
const { token, password, googleId } = c.req.valid('json');
if (!password && !googleId) {
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
}
const verification = await verifyMagicLinkToken(token, 'claim_account');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const now = getNow();
const updates: Record<string, any> = {
isClaimed: true,
accountStatus: 'active',
updatedAt: now,
};
if (password) {
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
updates.password = await hashPassword(password);
}
if (googleId) {
updates.googleId = googleId;
}
await (db as any)
.update(users)
.set(updates)
.where(eq((users as any).id, verification.userId));
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get();
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
message: 'Account claimed successfully!',
});
});
// Google OAuth login/register
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify Google token
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
}
const googleData = await response.json() as {
sub: string;
email: string;
name: string;
email_verified: string;
};
if (googleData.email_verified !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id
let user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
if (!user) {
// Check by google_id
user = await (db as any).select().from(users).where(eq((users as any).googleId, googleId)).get();
}
const now = getNow();
if (user) {
// User exists - link Google account if not already linked
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (!user.googleId) {
await (db as any)
.update(users)
.set({
googleId,
isClaimed: true,
accountStatus: 'active',
updatedAt: now,
})
.where(eq((users as any).id, user.id));
}
// Refresh user data
user = await (db as any).select().from(users).where(eq((users as any).id, user.id)).get();
} else {
// Create new user
const firstUser = await isFirstUser();
const id = generateId();
const newUser = {
id,
email,
password: null,
name,
phone: null,
role: firstUser ? 'admin' : 'user',
languagePreference: null,
isClaimed: true,
googleId,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
} catch (error) {
console.error('Google auth error:', error);
return c.json({ error: 'Failed to authenticate with Google' }, 500);
}
});
// Get current user
auth.get('/me', async (c) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
phone: user.phone,
isClaimed: user.isClaimed,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
accountStatus: user.accountStatus,
createdAt: user.createdAt,
},
});
});
// Change password (authenticated users)
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { currentPassword, newPassword } = c.req.valid('json');
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Verify current password if user has one
if (user.password) {
const validPassword = await verifyPassword(currentPassword, user.password);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
}
const hashedPassword = await hashPassword(newPassword);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password changed successfully' });
});
// Logout (client-side token removal, but we can log the action)
auth.post('/logout', async (c) => {
return c.json({ message: 'Logged out successfully' });
});
export default auth;

View File

@@ -0,0 +1,193 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, contacts, emailSubscribers } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
const contactsRouter = new Hono();
const createContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
const subscribeSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
});
const updateContactSchema = z.object({
status: z.enum(['new', 'read', 'replied']),
});
// Submit contact form (public)
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
const data = c.req.valid('json');
const now = getNow();
const id = generateId();
const newContact = {
id,
name: data.name,
email: data.email,
message: data.message,
status: 'new' as const,
createdAt: now,
};
await (db as any).insert(contacts).values(newContact);
return c.json({ message: 'Message sent successfully' }, 201);
});
// Subscribe to newsletter (public)
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
const data = c.req.valid('json');
// Check if already subscribed
const existing = await (db as any)
.select()
.from(emailSubscribers)
.where(eq((emailSubscribers as any).email, data.email))
.get();
if (existing) {
if (existing.status === 'unsubscribed') {
// Resubscribe
await (db as any)
.update(emailSubscribers)
.set({ status: 'active' })
.where(eq((emailSubscribers as any).id, existing.id));
return c.json({ message: 'Successfully resubscribed' });
}
return c.json({ message: 'Already subscribed' });
}
const now = getNow();
const id = generateId();
const newSubscriber = {
id,
email: data.email,
name: data.name || null,
status: 'active' as const,
createdAt: now,
};
await (db as any).insert(emailSubscribers).values(newSubscriber);
return c.json({ message: 'Successfully subscribed' }, 201);
});
// Unsubscribe from newsletter (public)
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
const { email } = c.req.valid('json');
const existing = await (db as any)
.select()
.from(emailSubscribers)
.where(eq((emailSubscribers as any).email, email))
.get();
if (!existing) {
return c.json({ error: 'Email not found' }, 404);
}
await (db as any)
.update(emailSubscribers)
.set({ status: 'unsubscribed' })
.where(eq((emailSubscribers as any).id, existing.id));
return c.json({ message: 'Successfully unsubscribed' });
});
// Get all contacts (admin)
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const status = c.req.query('status');
let query = (db as any).select().from(contacts);
if (status) {
query = query.where(eq((contacts as any).status, status));
}
const result = await query.orderBy(desc((contacts as any).createdAt)).all();
return c.json({ contacts: result });
});
// Get single contact (admin)
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const contact = await (db as any)
.select()
.from(contacts)
.where(eq((contacts as any).id, id))
.get();
if (!contact) {
return c.json({ error: 'Contact not found' }, 404);
}
return c.json({ contact });
});
// Update contact status (admin)
contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateContactSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const existing = await (db as any)
.select()
.from(contacts)
.where(eq((contacts as any).id, id))
.get();
if (!existing) {
return c.json({ error: 'Contact not found' }, 404);
}
await (db as any)
.update(contacts)
.set({ status: data.status })
.where(eq((contacts as any).id, id));
const updated = await (db as any)
.select()
.from(contacts)
.where(eq((contacts as any).id, id))
.get();
return c.json({ contact: updated });
});
// Delete contact (admin)
contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
await (db as any).delete(contacts).where(eq((contacts as any).id, id));
return c.json({ message: 'Contact deleted successfully' });
});
// Get all subscribers (admin)
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
const status = c.req.query('status');
let query = (db as any).select().from(emailSubscribers);
if (status) {
query = query.where(eq((emailSubscribers as any).status, status));
}
const result = await query.orderBy(desc((emailSubscribers as any).createdAt)).all();
return c.json({ subscribers: result });
});
export default contactsRouter;

View File

@@ -0,0 +1,576 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const dashboard = new Hono();
// Apply authentication to all routes
dashboard.use('*', requireAuth());
// ==================== Profile Routes ====================
const updateProfileSchema = z.object({
name: z.string().min(2).optional(),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
rucNumber: z.string().max(15).optional(),
});
// Get user profile
dashboard.get('/profile', async (c) => {
const user = (c as any).get('user') as AuthUser;
// Get membership duration
const createdDate = new Date(user.createdAt);
const now = new Date();
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
return c.json({
profile: {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone,
languagePreference: user.languagePreference,
rucNumber: user.rucNumber,
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword: !!user.password,
hasGoogleLinked: !!user.googleId,
memberSince: user.createdAt,
membershipDays,
createdAt: user.createdAt,
},
});
});
// Update profile
dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const data = c.req.valid('json');
const now = getNow();
await (db as any)
.update(users)
.set({
...data,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
const updatedUser = await (db as any)
.select()
.from(users)
.where(eq((users as any).id, user.id))
.get();
return c.json({
profile: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
phone: updatedUser.phone,
languagePreference: updatedUser.languagePreference,
rucNumber: updatedUser.rucNumber,
},
message: 'Profile updated successfully',
});
});
// ==================== Tickets Routes ====================
// Get user's tickets
dashboard.get('/tickets', async (c) => {
const user = (c as any).get('user') as AuthUser;
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.orderBy(desc((tickets as any).createdAt))
.all();
// Get event details for each ticket
const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
// Check for invoice
let invoice = null;
if (payment && payment.status === 'paid') {
invoice = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
.get();
}
return {
...ticket,
event: event ? {
id: event.id,
title: event.title,
titleEs: event.titleEs,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
price: event.price,
currency: event.currency,
status: event.status,
bannerUrl: event.bannerUrl,
} : null,
payment: payment ? {
id: payment.id,
provider: payment.provider,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
paidAt: payment.paidAt,
} : null,
invoice: invoice ? {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
pdfUrl: invoice.pdfUrl,
createdAt: invoice.createdAt,
} : null,
};
})
);
return c.json({ tickets: ticketsWithEvents });
});
// Get single ticket detail
dashboard.get('/tickets/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const ticketId = c.req.param('id');
const ticket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).id, ticketId),
eq((tickets as any).userId, user.id)
)
)
.get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
let invoice = null;
if (payment && payment.status === 'paid') {
invoice = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
.get();
}
return c.json({
ticket: {
...ticket,
event,
payment,
invoice,
},
});
});
// ==================== Next Event Route ====================
// Get next upcoming event for user
dashboard.get('/next-event', async (c) => {
const user = (c as any).get('user') as AuthUser;
const now = getNow();
// Get user's tickets for upcoming events
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.all();
if (userTickets.length === 0) {
return c.json({ nextEvent: null });
}
// Find the next upcoming event
let nextEvent = null;
let nextTicket = null;
let nextPayment = null;
for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue;
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (!event) continue;
// Check if event is in the future
if (new Date(event.startDatetime) > new Date()) {
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
nextEvent = event;
nextTicket = ticket;
nextPayment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
.get();
}
}
}
if (!nextEvent) {
return c.json({ nextEvent: null });
}
return c.json({
nextEvent: {
event: nextEvent,
ticket: nextTicket,
payment: nextPayment,
},
});
});
// ==================== Payments & Invoices Routes ====================
// Get payment history
dashboard.get('/payments', async (c) => {
const user = (c as any).get('user') as AuthUser;
// Get all user's tickets first
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.all();
const ticketIds = userTickets.map((t: any) => t.id);
if (ticketIds.length === 0) {
return c.json({ payments: [] });
}
// Get all payments for user's tickets
const allPayments = [];
for (const ticketId of ticketIds) {
const ticketPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.all();
for (const payment of ticketPayments) {
const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
const event = ticket
? await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get()
: null;
let invoice = null;
if (payment.status === 'paid') {
invoice = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
.get();
}
allPayments.push({
...payment,
ticket: ticket ? {
id: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
status: ticket.status,
} : null,
event: event ? {
id: event.id,
title: event.title,
titleEs: event.titleEs,
startDatetime: event.startDatetime,
} : null,
invoice: invoice ? {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
pdfUrl: invoice.pdfUrl,
} : null,
});
}
}
// Sort by createdAt desc
allPayments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return c.json({ payments: allPayments });
});
// Get invoices
dashboard.get('/invoices', async (c) => {
const user = (c as any).get('user') as AuthUser;
const userInvoices = await (db as any)
.select()
.from(invoices)
.where(eq((invoices as any).userId, user.id))
.orderBy(desc((invoices as any).createdAt))
.all();
// Get payment and event details for each invoice
const invoicesWithDetails = await Promise.all(
userInvoices.map(async (invoice: any) => {
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, invoice.paymentId))
.get();
let event = null;
if (payment) {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
if (ticket) {
event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
}
}
return {
...invoice,
event: event ? {
id: event.id,
title: event.title,
titleEs: event.titleEs,
startDatetime: event.startDatetime,
} : null,
};
})
);
return c.json({ invoices: invoicesWithDetails });
});
// ==================== Security Routes ====================
// Get active sessions
dashboard.get('/sessions', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessions = await getUserSessions(user.id);
return c.json({
sessions: sessions.map((s: any) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
lastActiveAt: s.lastActiveAt,
createdAt: s.createdAt,
})),
});
});
// Revoke a specific session
dashboard.delete('/sessions/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessionId = c.req.param('id');
await invalidateSession(sessionId, user.id);
return c.json({ message: 'Session revoked' });
});
// Revoke all sessions (logout everywhere)
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id);
return c.json({ message: 'All sessions revoked. Please log in again.' });
});
// Set password (for users without one)
const setPasswordSchema = z.object({
password: z.string().min(10, 'Password must be at least 10 characters'),
});
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { password } = c.req.valid('json');
// Check if user already has a password
if (user.password) {
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password set successfully' });
});
// Unlink Google account (only if password is set)
dashboard.post('/unlink-google', async (c) => {
const user = (c as any).get('user') as AuthUser;
if (!user.googleId) {
return c.json({ error: 'Google account not linked' }, 400);
}
if (!user.password) {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}
const now = getNow();
await (db as any)
.update(users)
.set({
googleId: null,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Google account unlinked' });
});
// ==================== Dashboard Summary Route ====================
// Get dashboard summary (welcome panel data)
dashboard.get('/summary', async (c) => {
const user = (c as any).get('user') as AuthUser;
const now = new Date();
// Get membership duration
const createdDate = new Date(user.createdAt);
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
// Get ticket count
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, user.id))
.all();
const totalTickets = userTickets.length;
const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length;
const upcomingTickets = [];
for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue;
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (event && new Date(event.startDatetime) > now) {
upcomingTickets.push({ ticket, event });
}
}
// Get pending payments count
const ticketIds = userTickets.map((t: any) => t.id);
let pendingPayments = 0;
for (const ticketId of ticketIds) {
const payment = await (db as any)
.select()
.from(payments)
.where(
and(
eq((payments as any).ticketId, ticketId),
eq((payments as any).status, 'pending_approval')
)
)
.get();
if (payment) pendingPayments++;
}
return c.json({
summary: {
user: {
name: user.name,
email: user.email,
accountStatus: user.accountStatus,
memberSince: user.createdAt,
membershipDays,
},
stats: {
totalTickets,
confirmedTickets,
upcomingEvents: upcomingTickets.length,
pendingPayments,
},
},
});
});
export default dashboard;

View File

@@ -0,0 +1,419 @@
import { Hono } from 'hono';
import { db, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import { nanoid } from 'nanoid';
import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
const emailsRouter = new Hono();
// ==================== Template Routes ====================
// Get all email templates
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
const templates = await (db as any)
.select()
.from(emailTemplates)
.orderBy(desc((emailTemplates as any).createdAt))
.all();
// Parse variables JSON for each template
const parsedTemplates = templates.map((t: any) => ({
...t,
variables: t.variables ? JSON.parse(t.variables) : [],
isSystem: Boolean(t.isSystem),
isActive: Boolean(t.isActive),
}));
return c.json({ templates: parsedTemplates });
});
// Get single email template
emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const template = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
if (!template) {
return c.json({ error: 'Template not found' }, 404);
}
return c.json({
template: {
...template,
variables: template.variables ? JSON.parse(template.variables) : [],
isSystem: Boolean(template.isSystem),
isActive: Boolean(template.isActive),
}
});
});
// Create new email template
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
if (!name || !slug || !subject || !bodyHtml) {
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
}
// Check if slug already exists
const existing = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug))
.get();
if (existing) {
return c.json({ error: 'Template with this slug already exists' }, 400);
}
const now = getNow();
const template = {
id: nanoid(),
name,
slug,
subject,
subjectEs: subjectEs || null,
bodyHtml,
bodyHtmlEs: bodyHtmlEs || null,
bodyText: bodyText || null,
bodyTextEs: bodyTextEs || null,
description: description || null,
variables: variables ? JSON.stringify(variables) : null,
isSystem: 0,
isActive: 1,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(emailTemplates).values(template);
return c.json({
template: {
...template,
variables: variables || [],
isSystem: false,
isActive: true,
},
message: 'Template created successfully'
}, 201);
});
// Update email template
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const body = await c.req.json();
const existing = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
if (!existing) {
return c.json({ error: 'Template not found' }, 404);
}
const updateData: any = { updatedAt: getNow() };
// Only allow updating certain fields for system templates
const systemProtectedFields = ['slug', 'isSystem'];
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
if (!existing.isSystem) {
allowedFields.push('slug');
}
for (const field of allowedFields) {
if (body[field] !== undefined) {
if (field === 'variables') {
updateData[field] = JSON.stringify(body[field]);
} else if (field === 'isActive') {
updateData[field] = body[field] ? 1 : 0;
} else {
updateData[field] = body[field];
}
}
}
await (db as any)
.update(emailTemplates)
.set(updateData)
.where(eq((emailTemplates as any).id, id));
const updated = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
return c.json({
template: {
...updated,
variables: updated.variables ? JSON.parse(updated.variables) : [],
isSystem: Boolean(updated.isSystem),
isActive: Boolean(updated.isActive),
},
message: 'Template updated successfully'
});
});
// Delete email template (only non-system templates)
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const template = await (db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).id, id))
.get();
if (!template) {
return c.json({ error: 'Template not found' }, 404);
}
if (template.isSystem) {
return c.json({ error: 'Cannot delete system templates' }, 400);
}
await (db as any)
.delete(emailTemplates)
.where(eq((emailTemplates as any).id, id));
return c.json({ message: 'Template deleted successfully' });
});
// Get available template variables
emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer']), async (c) => {
const { slug } = c.req.param();
const variables = getTemplateVariables(slug);
return c.json({ variables });
});
// ==================== Email Sending Routes ====================
// Send email using template to event attendees
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
const { eventId } = c.req.param();
const user = (c as any).get('user');
const body = await c.req.json();
const { templateSlug, customVariables, recipientFilter } = body;
if (!templateSlug) {
return c.json({ error: 'Template slug is required' }, 400);
}
const result = await emailService.sendToEventAttendees({
eventId,
templateSlug,
customVariables,
recipientFilter: recipientFilter || 'confirmed',
sentBy: user?.id,
});
return c.json(result);
});
// Send custom email to specific recipients
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
const user = (c as any).get('user');
const body = await c.req.json();
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
if (!to || !subject || !bodyHtml) {
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
}
const result = await emailService.sendCustomEmail({
to,
toName,
subject,
bodyHtml,
bodyText,
eventId,
sentBy: user?.id,
});
return c.json(result);
});
// Preview email (render template without sending)
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
const body = await c.req.json();
const { templateSlug, variables, locale } = body;
if (!templateSlug) {
return c.json({ error: 'Template slug is required' }, 400);
}
const template = await emailService.getTemplate(templateSlug);
if (!template) {
return c.json({ error: 'Template not found' }, 404);
}
const { replaceTemplateVariables, wrapInBaseTemplate } = await import('../lib/emailTemplates.js');
const allVariables = {
...emailService.getCommonVariables(),
lang: locale || 'en',
...variables,
};
const subject = locale === 'es' && template.subjectEs
? template.subjectEs
: template.subject;
const bodyHtml = locale === 'es' && template.bodyHtmlEs
? template.bodyHtmlEs
: template.bodyHtml;
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
return c.json({
subject: finalSubject,
bodyHtml: finalBodyHtml,
});
});
// ==================== Email Logs Routes ====================
// Get email logs
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0');
let query = (db as any).select().from(emailLogs);
const conditions = [];
if (eventId) {
conditions.push(eq((emailLogs as any).eventId, eventId));
}
if (status) {
conditions.push(eq((emailLogs as any).status, status));
}
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
const logs = await query
.orderBy(desc((emailLogs as any).createdAt))
.limit(limit)
.offset(offset)
.all();
// Get total count
let countQuery = (db as any)
.select({ count: sql<number>`count(*)` })
.from(emailLogs);
if (conditions.length > 0) {
countQuery = countQuery.where(and(...conditions));
}
const totalResult = await countQuery.get();
const total = totalResult?.count || 0;
return c.json({
logs,
pagination: {
total,
limit,
offset,
hasMore: offset + logs.length < total,
}
});
});
// Get single email log
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const log = await (db as any)
.select()
.from(emailLogs)
.where(eq((emailLogs as any).id, id))
.get();
if (!log) {
return c.json({ error: 'Email log not found' }, 404);
}
return c.json({ log });
});
// Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
const totalQuery = baseCondition
? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition)
: (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs);
const total = (await totalQuery.get())?.count || 0;
const sentCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'sent'))
: eq((emailLogs as any).status, 'sent');
const sent = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition).get())?.count || 0;
const failedCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'failed'))
: eq((emailLogs as any).status, 'failed');
const failed = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition).get())?.count || 0;
const pendingCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'pending'))
: eq((emailLogs as any).status, 'pending');
const pending = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition).get())?.count || 0;
return c.json({
stats: {
total,
sent,
failed,
pending,
}
});
});
// Seed default templates (admin only)
emailsRouter.post('/seed-templates', requireAuth(['admin']), async (c) => {
await emailService.seedDefaultTemplates();
return c.json({ message: 'Default templates seeded successfully' });
});
// ==================== Configuration Routes ====================
// Get email provider info
emailsRouter.get('/config', requireAuth(['admin']), async (c) => {
const providerInfo = emailService.getProviderInfo();
return c.json(providerInfo);
});
// Test email configuration
emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { to } = body;
if (!to) {
return c.json({ error: 'Recipient email (to) is required' }, 400);
}
const result = await emailService.testConnection(to);
return c.json(result);
});
export default emailsRouter;

View File

@@ -0,0 +1,269 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, events, tickets } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
interface UserContext {
id: string;
email: string;
name: string;
role: string;
}
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Custom validation error handler
const validationHook = (result: any, c: any) => {
if (!result.success) {
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
return c.json({ error: errors }, 400);
}
};
const createEventSchema = z.object({
title: z.string().min(1),
titleEs: z.string().optional().nullable(),
description: z.string().min(1),
descriptionEs: z.string().optional().nullable(),
startDatetime: z.string(),
endDatetime: z.string().optional().nullable(),
location: z.string().min(1),
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
price: z.number().min(0).default(0),
currency: z.string().default('PYG'),
capacity: z.number().min(1).default(50),
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')),
});
const updateEventSchema = createEventSchema.partial();
// Get all events (public)
eventsRouter.get('/', async (c) => {
const status = c.req.query('status');
const upcoming = c.req.query('upcoming');
let query = (db as any).select().from(events);
if (status) {
query = query.where(eq((events as any).status, status));
}
if (upcoming === 'true') {
const now = getNow();
query = query.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
);
}
const result = await query.orderBy(desc((events as any).startDatetime)).all();
// Get ticket counts for each event
const eventsWithCounts = await Promise.all(
result.map(async (event: any) => {
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return {
...event,
bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0),
};
})
);
return c.json({ events: eventsWithCounts });
});
// Get single event (public)
eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const event = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Get ticket count
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return c.json({
event: {
...event,
bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0),
},
});
});
// Get next upcoming event (public)
eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow();
const event = await (db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(1)
.get();
if (!event) {
return c.json({ event: null });
}
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return c.json({
event: {
...event,
bookedCount: ticketCount?.count || 0,
availableSeats: event.capacity - (ticketCount?.count || 0),
},
});
});
// Create event (admin/organizer only)
eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', createEventSchema, validationHook), async (c) => {
const data = c.req.valid('json');
const user = c.get('user');
const now = getNow();
const id = generateId();
const newEvent = {
id,
...data,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(events).values(newEvent);
return c.json({ event: newEvent }, 201);
});
// Update event (admin/organizer only)
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const now = getNow();
await (db as any)
.update(events)
.set({ ...data, updatedAt: now })
.where(eq((events as any).id, id));
const updated = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
return c.json({ event: updated });
});
// Delete event (admin only)
eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
await (db as any).delete(events).where(eq((events as any).id, id));
return c.json({ message: 'Event deleted successfully' });
});
// Get event attendees (admin/organizer only)
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const attendees = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, id))
.all();
return c.json({ attendees });
});
// Duplicate event (admin/organizer only)
eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const now = getNow();
const newId = generateId();
// Create a copy with modified title and draft status
const duplicatedEvent = {
id: newId,
title: `${existing.title} (Copy)`,
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description,
descriptionEs: existing.descriptionEs,
startDatetime: existing.startDatetime,
endDatetime: existing.endDatetime,
location: existing.location,
locationUrl: existing.locationUrl,
price: existing.price,
currency: existing.currency,
capacity: existing.capacity,
status: 'draft',
bannerUrl: existing.bannerUrl,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(events).values(duplicatedEvent);
return c.json({ event: duplicatedEvent, message: 'Event duplicated successfully' }, 201);
});
export default eventsRouter;

View File

@@ -0,0 +1,340 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers)
const activeConnections = new Map<string, Set<(data: any) => void>>();
// Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>();
/**
* LNbits webhook payload structure
*/
interface LNbitsWebhookPayload {
payment_hash: string;
payment_request?: string;
amount: number;
memo?: string;
status: string;
preimage?: string;
extra?: {
ticketId?: string;
eventId?: string;
[key: string]: any;
};
}
/**
* Notify all connected clients for a ticket
*/
function notifyClients(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId);
if (connections) {
connections.forEach(send => {
try {
send(data);
} catch (e) {
// Connection might be closed
}
});
}
}
/**
* Start background payment checking for a ticket
*/
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking
if (activeCheckers.has(ticketId)) {
return;
}
const startTime = Date.now();
const expiryMs = expirySeconds * 1000;
let checkCount = 0;
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
const checkInterval = setInterval(async () => {
checkCount++;
const elapsed = Date.now() - startTime;
// Stop if expired
if (elapsed >= expiryMs) {
console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
notifyClients(ticketId, { type: 'expired', ticketId });
return;
}
try {
const status = await getPaymentStatus(paymentHash);
if (status?.paid) {
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
await handlePaymentComplete(ticketId, paymentHash);
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
}
} catch (error) {
console.error(`Error checking payment for ticket ${ticketId}:`, error);
}
}, 3000); // Check every 3 seconds
activeCheckers.set(ticketId, checkInterval);
}
/**
* Stop background checker for a ticket
*/
function stopBackgroundChecker(ticketId: string) {
const interval = activeCheckers.get(ticketId);
if (interval) {
clearInterval(interval);
activeCheckers.delete(ticketId);
}
}
/**
* LNbits webhook endpoint
* Called by LNbits when a payment is received
*/
lnbitsRouter.post('/webhook', async (c) => {
try {
const payload: LNbitsWebhookPayload = await c.req.json();
console.log('LNbits webhook received:', {
paymentHash: payload.payment_hash,
status: payload.status,
amount: payload.amount,
extra: payload.extra,
});
// Verify the payment is actually complete by checking with LNbits
const isVerified = await verifyWebhookPayment(payload.payment_hash);
if (!isVerified) {
console.warn('LNbits webhook payment not verified:', payload.payment_hash);
return c.json({ received: true, processed: false }, 200);
}
const ticketId = payload.extra?.ticketId;
if (!ticketId) {
console.error('No ticketId in LNbits webhook extra data');
return c.json({ received: true, processed: false }, 200);
}
// Stop background checker since webhook confirmed payment
stopBackgroundChecker(ticketId);
await handlePaymentComplete(ticketId, payload.payment_hash);
// Notify connected clients via SSE
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
return c.json({ received: true, processed: true }, 200);
} catch (error) {
console.error('LNbits webhook error:', error);
return c.json({ error: 'Webhook processing failed' }, 500);
}
});
/**
* Handle successful payment
*/
async function handlePaymentComplete(ticketId: string, paymentHash: string) {
const now = getNow();
// Check if already confirmed to avoid duplicate updates
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (existingTicket?.status === 'confirmed') {
console.log(`Ticket ${ticketId} already confirmed, skipping update`);
return;
}
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticketId));
// Update payment status to paid
await (db as any)
.update(payments)
.set({
status: 'paid',
reference: paymentHash,
paidAt: now,
updatedAt: now,
})
.where(eq((payments as any).ticketId, ticketId));
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
// Get payment for sending receipt
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
// Send confirmation emails asynchronously
Promise.all([
emailService.sendBookingConfirmation(ticketId),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
}
/**
* SSE endpoint for real-time payment status updates
* Frontend connects here to receive instant payment notifications
*/
lnbitsRouter.get('/stream/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId');
// Verify ticket exists
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// If already paid, return immediately
if (ticket.status === 'confirmed') {
return c.json({ type: 'already_paid', ticketId }, 200);
}
// Get payment to start background checker
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
// Start background checker if not already running
if (payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
}
return streamSSE(c, async (stream) => {
// Register this connection
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
}
const sendEvent = (data: any) => {
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
};
activeConnections.get(ticketId)!.add(sendEvent);
// Send initial status
await stream.writeSSE({
data: JSON.stringify({ type: 'connected', ticketId }),
event: 'payment'
});
// Keep connection alive with heartbeat
const heartbeat = setInterval(async () => {
try {
await stream.writeSSE({ data: 'ping', event: 'heartbeat' });
} catch (e) {
clearInterval(heartbeat);
}
}, 15000);
// Clean up on disconnect
stream.onAbort(() => {
clearInterval(heartbeat);
const connections = activeConnections.get(ticketId);
if (connections) {
connections.delete(sendEvent);
if (connections.size === 0) {
activeConnections.delete(ticketId);
}
}
});
// Keep the stream open
while (true) {
await stream.sleep(30000);
}
});
});
/**
* Get payment status for a ticket (fallback polling endpoint)
*/
lnbitsRouter.get('/status/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId');
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
return c.json({
ticketStatus: ticket.status,
paymentStatus: payment?.status || 'unknown',
isPaid: ticket.status === 'confirmed' || payment?.status === 'paid',
});
});
/**
* Manual payment check endpoint
*/
lnbitsRouter.post('/check/:paymentHash', async (c) => {
const paymentHash = c.req.param('paymentHash');
try {
const status = await getPaymentStatus(paymentHash);
if (!status) {
return c.json({ error: 'Payment not found' }, 404);
}
return c.json({
paymentHash,
paid: status.paid,
status: status.status,
});
} catch (error) {
console.error('Error checking payment:', error);
return c.json({ error: 'Failed to check payment status' }, 500);
}
});
export default lnbitsRouter;

148
backend/src/routes/media.ts Normal file
View File

@@ -0,0 +1,148 @@
import { Hono } from 'hono';
import { db, media } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join, extname } from 'path';
const mediaRouter = new Hono();
const UPLOAD_DIR = './uploads';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// Ensure upload directory exists
async function ensureUploadDir() {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
}
// Upload image
mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
try {
const body = await c.req.parseBody();
const file = body['file'] as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large. Maximum size: 5MB' }, 400);
}
await ensureUploadDir();
// Generate unique filename
const id = generateId();
const ext = extname(file.name) || '.jpg';
const filename = `${id}${ext}`;
const filepath = join(UPLOAD_DIR, filename);
// Write file
const arrayBuffer = await file.arrayBuffer();
await writeFile(filepath, Buffer.from(arrayBuffer));
// Get related info from form data
const relatedId = body['relatedId'] as string | undefined;
const relatedType = body['relatedType'] as string | undefined;
// Save to database
const now = getNow();
const mediaRecord = {
id,
fileUrl: `/uploads/${filename}`,
type: 'image' as const,
relatedId: relatedId || null,
relatedType: relatedType || null,
createdAt: now,
};
await (db as any).insert(media).values(mediaRecord);
return c.json({
media: mediaRecord,
url: mediaRecord.fileUrl,
}, 201);
} catch (error) {
console.error('Upload error:', error);
return c.json({ error: 'Failed to upload file' }, 500);
}
});
// Get media by ID
mediaRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const mediaRecord = await (db as any)
.select()
.from(media)
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404);
}
return c.json({ media: mediaRecord });
});
// Delete media
mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const mediaRecord = await (db as any)
.select()
.from(media)
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404);
}
// Delete file from disk
try {
const filepath = join('.', mediaRecord.fileUrl);
if (existsSync(filepath)) {
await unlink(filepath);
}
} catch (error) {
console.error('Failed to delete file:', error);
}
// Delete from database
await (db as any).delete(media).where(eq((media as any).id, id));
return c.json({ message: 'Media deleted successfully' });
});
// List media (admin)
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const relatedType = c.req.query('relatedType');
const relatedId = c.req.query('relatedId');
let query = (db as any).select().from(media);
if (relatedType) {
query = query.where(eq((media as any).relatedType, relatedType));
}
if (relatedId) {
query = query.where(eq((media as any).relatedId, relatedId));
}
const result = await query.all();
return c.json({ media: result });
});
export default mediaRouter;

View File

@@ -0,0 +1,278 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
const paymentOptionsRouter = new Hono();
// Schema for updating global payment options
const updatePaymentOptionsSchema = z.object({
tpagoEnabled: z.boolean().optional(),
tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional(),
bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(),
bankAlias: z.string().optional().nullable(),
bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional(),
cashEnabled: z.boolean().optional(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
});
// Schema for event-level overrides
const updateEventOverridesSchema = z.object({
tpagoEnabled: z.boolean().optional().nullable(),
tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional().nullable(),
bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(),
bankAlias: z.string().optional().nullable(),
bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional().nullable(),
cashEnabled: z.boolean().optional().nullable(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
});
// Get global payment options
paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
const options = await (db as any)
.select()
.from(paymentOptions)
.get();
// If no options exist yet, return defaults
if (!options) {
return c.json({
paymentOptions: {
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
},
});
}
return c.json({ paymentOptions: options });
});
// Update global payment options
paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updatePaymentOptionsSchema), async (c) => {
const data = c.req.valid('json');
const user = (c as any).get('user');
const now = getNow();
// Check if options exist
const existing = await (db as any)
.select()
.from(paymentOptions)
.get();
if (existing) {
// Update existing
await (db as any)
.update(paymentOptions)
.set({
...data,
updatedAt: now,
updatedBy: user.id,
})
.where(eq((paymentOptions as any).id, existing.id));
} else {
// Create new
const id = generateId();
await (db as any).insert(paymentOptions).values({
id,
...data,
updatedAt: now,
updatedBy: user.id,
});
}
const updated = await (db as any)
.select()
.from(paymentOptions)
.get();
return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' });
});
// Get payment options for a specific event (merged with global)
paymentOptionsRouter.get('/event/:eventId', async (c) => {
const eventId = c.req.param('eventId');
// Get the event first to verify it exists
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
.get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Get global options
const globalOptions = await (db as any)
.select()
.from(paymentOptions)
.get();
// Get event overrides
const overrides = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
// Merge global with overrides (override takes precedence if not null)
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
lightningEnabled: true,
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
};
const global = globalOptions || defaults;
// Merge: override values take precedence if they're not null/undefined
const merged = {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
bankName: overrides?.bankName ?? global.bankName,
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
bankAlias: overrides?.bankAlias ?? global.bankAlias,
bankPhone: overrides?.bankPhone ?? global.bankPhone,
bankNotes: overrides?.bankNotes ?? global.bankNotes,
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
lightningEnabled: overrides?.lightningEnabled ?? global.lightningEnabled,
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
};
return c.json({
paymentOptions: merged,
hasOverrides: !!overrides,
});
});
// Get event payment overrides (admin only)
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.param('eventId');
const overrides = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
return c.json({ overrides: overrides || null });
});
// Update event payment overrides
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
const eventId = c.req.param('eventId');
const data = c.req.valid('json');
const now = getNow();
// Verify event exists
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
.get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Check if overrides exist
const existing = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
if (existing) {
await (db as any)
.update(eventPaymentOverrides)
.set({
...data,
updatedAt: now,
})
.where(eq((eventPaymentOverrides as any).id, existing.id));
} else {
const id = generateId();
await (db as any).insert(eventPaymentOverrides).values({
id,
eventId,
...data,
createdAt: now,
updatedAt: now,
});
}
const updated = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
});
// Delete event payment overrides (revert to global)
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.param('eventId');
await (db as any)
.delete(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId));
return c.json({ message: 'Event payment overrides removed' });
});
export default paymentOptionsRouter;

View File

@@ -0,0 +1,431 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, payments, tickets, events } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import emailService from '../lib/email.js';
const paymentsRouter = new Hono();
const updatePaymentSchema = z.object({
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
reference: z.string().optional(),
adminNote: z.string().optional(),
});
const approvePaymentSchema = z.object({
adminNote: z.string().optional(),
});
const rejectPaymentSchema = z.object({
adminNote: z.string().optional(),
});
// Get all payments (admin) - with ticket and event details
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status');
const provider = c.req.query('provider');
const pendingApproval = c.req.query('pendingApproval');
// Get all payments with their associated tickets
let allPayments = await (db as any)
.select()
.from(payments)
.orderBy(desc((payments as any).createdAt))
.all();
// Filter by status
if (status) {
allPayments = allPayments.filter((p: any) => p.status === status);
}
// Filter for pending approval specifically
if (pendingApproval === 'true') {
allPayments = allPayments.filter((p: any) => p.status === 'pending_approval');
}
// Filter by provider
if (provider) {
allPayments = allPayments.filter((p: any) => p.provider === provider);
}
// Enrich with ticket and event data
const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
let event = null;
if (ticket) {
event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
}
return {
...payment,
ticket: ticket ? {
id: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
} : null,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
} : null,
};
})
);
return c.json({ payments: enrichedPayments });
});
// Get payments pending approval (admin dashboard view)
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
const pendingPayments = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).status, 'pending_approval'))
.orderBy(desc((payments as any).userMarkedPaidAt))
.all();
// Enrich with ticket and event data
const enrichedPayments = await Promise.all(
pendingPayments.map(async (payment: any) => {
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
let event = null;
if (ticket) {
event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
}
return {
...payment,
ticket: ticket ? {
id: ticket.id,
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
} : null,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
} : null,
};
})
);
return c.json({ payments: enrichedPayments });
});
// Get payment by ID (admin)
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Get associated ticket
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
.get();
return c.json({ payment: { ...payment, ticket } });
});
// Update payment (admin) - for manual payment confirmation
paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updatePaymentSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const user = (c as any).get('user');
const existing = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!existing) {
return c.json({ error: 'Payment not found' }, 404);
}
const now = getNow();
const updateData: any = { ...data, updatedAt: now };
// If marking as paid, record who approved it and when
if (data.status === 'paid' && existing.status !== 'paid') {
updateData.paidAt = now;
updateData.paidByAdminId = user.id;
}
await (db as any)
.update(payments)
.set(updateData)
.where(eq((payments as any).id, id));
// If payment confirmed, update ticket status and send emails
if (data.status === 'paid') {
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, existing.ticketId));
// Send confirmation emails asynchronously (don't block the response)
Promise.all([
emailService.sendBookingConfirmation(existing.ticketId),
emailService.sendPaymentReceipt(id),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
}
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated });
});
// Approve payment (admin) - specifically for pending_approval payments
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const user = (c as any).get('user');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Can approve pending or pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
const now = getNow();
// Update payment status to paid
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, payment.ticketId));
// Send confirmation emails asynchronously
Promise.all([
emailService.sendBookingConfirmation(payment.ticketId),
emailService.sendPaymentReceipt(id),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Payment approved successfully' });
});
// Reject payment (admin)
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const user = (c as any).get('user');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (!['pending', 'pending_approval'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
}
const now = getNow();
// Update payment status to failed
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Note: We don't cancel the ticket automatically - admin can do that separately if needed
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Payment rejected' });
});
// Update admin note
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const { adminNote } = body;
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
const now = getNow();
await (db as any)
.update(payments)
.set({
adminNote: adminNote || null,
updatedAt: now,
})
.where(eq((payments as any).id, id));
const updated = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
return c.json({ payment: updated, message: 'Note updated' });
});
// Process refund (admin)
paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'paid') {
return c.json({ error: 'Can only refund paid payments' }, 400);
}
const now = getNow();
// Update payment status
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(eq((payments as any).id, id));
// Cancel associated ticket
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, payment.ticketId));
return c.json({ message: 'Refund processed successfully' });
});
// Payment webhook (for Stripe/MercadoPago)
paymentsRouter.post('/webhook', async (c) => {
// This would handle webhook notifications from payment providers
// Implementation depends on which provider is used
const body = await c.req.json();
// Log webhook for debugging
console.log('Payment webhook received:', body);
// TODO: Implement provider-specific webhook handling
// - Verify webhook signature
// - Update payment status
// - Update ticket status
return c.json({ received: true });
});
// Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await (db as any).select().from(payments).all();
const stats = {
total: allPayments.length,
pending: allPayments.filter((p: any) => p.status === 'pending').length,
paid: allPayments.filter((p: any) => p.status === 'paid').length,
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments
.filter((p: any) => p.status === 'paid')
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0),
};
return c.json({ stats });
});
export default paymentsRouter;

View File

@@ -0,0 +1,652 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, tickets, events, users, payments } from '../db/index.js';
import { eq, and, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
const ticketsRouter = new Hono();
const createTicketSchema = z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
phone: z.string().min(6, 'Phone number is required'),
preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
});
const updateTicketSchema = z.object({
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
adminNote: z.string().optional(),
});
const updateNoteSchema = z.object({
note: z.string().max(1000),
});
const adminCreateTicketSchema = z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
autoCheckin: z.boolean().optional().default(false),
adminNote: z.string().max(1000).optional(),
});
// Book a ticket (public)
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const data = c.req.valid('json');
// Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
if (event.status !== 'published') {
return c.json({ error: 'Event is not available for booking' }, 400);
}
// Check capacity
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
eq((tickets as any).status, 'confirmed')
)
)
.get();
if ((ticketCount?.count || 0) >= event.capacity) {
return c.json({ error: 'Event is sold out' }, 400);
}
// Find or create user
let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
const now = getNow();
const fullName = `${data.firstName} ${data.lastName}`.trim();
if (!user) {
const userId = generateId();
user = {
id: userId,
email: data.email,
password: '', // No password for guest bookings
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for duplicate booking
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
.get();
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'You have already booked this event' }, 400);
}
// Create ticket
const ticketId = generateId();
const qrCode = generateTicketCode();
// Cash payments start as pending, card/lightning start as pending until payment confirmed
const ticketStatus = 'pending';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName,
attendeeEmail: data.email,
attendeePhone: data.phone,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
qrCode,
checkinAt: null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create payment record
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: data.paymentMethod,
amount: event.price,
currency: event.currency,
status: 'pending',
reference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// If Lightning payment, create LNbits invoice
let lnbitsInvoice = null;
if (data.paymentMethod === 'lightning' && event.price > 0) {
if (!isLNbitsConfigured()) {
// Delete the ticket and payment we just created
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
return c.json({
error: 'Bitcoin Lightning payments are not available at this time'
}, 400);
}
try {
const apiUrl = process.env.API_URL || 'http://localhost:3001';
// Pass the fiat currency directly to LNbits - it handles conversion automatically
lnbitsInvoice = await createInvoice({
amount: event.price,
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
memo: `Spanglish: ${event.title} - ${fullName}`,
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
expiry: 900, // 15 minutes expiry for faster UX
extra: {
ticketId,
eventId: event.id,
eventTitle: event.title,
attendeeName: fullName,
attendeeEmail: data.email,
},
});
// Update payment with LNbits payment hash reference
await (db as any)
.update(payments)
.set({ reference: lnbitsInvoice.paymentHash })
.where(eq((payments as any).id, paymentId));
(newPayment as any).reference = lnbitsInvoice.paymentHash;
} catch (error: any) {
console.error('Failed to create Lightning invoice:', error);
// Delete the ticket and payment we just created since Lightning payment failed
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
return c.json({
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`
}, 500);
}
}
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
lightningInvoice: lnbitsInvoice ? {
paymentHash: lnbitsInvoice.paymentHash,
paymentRequest: lnbitsInvoice.paymentRequest,
amount: lnbitsInvoice.amount, // Amount in satoshis
fiatAmount: lnbitsInvoice.fiatAmount,
fiatCurrency: lnbitsInvoice.fiatCurrency,
expiry: lnbitsInvoice.expiry,
} : null,
message: 'Booking created successfully',
}, 201);
});
// Get ticket by ID
ticketsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// Get associated event
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
// Get payment
const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get();
return c.json({
ticket: {
...ticket,
event,
payment,
},
});
});
// Update ticket status (admin/organizer)
ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const updates: any = {};
if (data.status) {
updates.status = data.status;
if (data.status === 'checked_in') {
updates.checkinAt = getNow();
}
}
if (Object.keys(updates).length > 0) {
await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id));
}
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated });
});
// Check-in ticket
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status === 'checked_in') {
return c.json({ error: 'Ticket already checked in' }, 400);
}
if (ticket.status !== 'confirmed') {
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
}
await (db as any)
.update(tickets)
.set({ status: 'checked_in', checkinAt: getNow() })
.where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Check-in successful' });
});
// Mark payment as received (for cash payments - admin only)
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const user = (c as any).get('user');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status === 'confirmed') {
return c.json({ error: 'Ticket already confirmed' }, 400);
}
if (ticket.status === 'cancelled') {
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
}
const now = getNow();
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, id));
// Update payment status
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
updatedAt: now,
})
.where(eq((payments as any).ticketId, id));
// Get payment for sending receipt
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, id))
.get();
// Send confirmation emails asynchronously (don't block the response)
Promise.all([
emailService.sendBookingConfirmation(id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err);
});
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Payment marked as received' });
});
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
// This sets status to "pending_approval" and notifies admin
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// Get the payment
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, id))
.get();
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Only allow for manual payment methods
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
}
// Only allow if currently pending
if (payment.status !== 'pending') {
return c.json({ error: 'Payment has already been processed' }, 400);
}
const now = getNow();
// Update payment status to pending_approval
await (db as any)
.update(payments)
.set({
status: 'pending_approval',
userMarkedPaidAt: now,
updatedAt: now,
})
.where(eq((payments as any).id, payment.id));
// Get updated payment
const updatedPayment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).id, payment.id))
.get();
// TODO: Send notification to admin about pending payment approval
return c.json({
payment: updatedPayment,
message: 'Payment marked as sent. Waiting for admin approval.'
});
});
// Cancel ticket
ticketsRouter.post('/:id/cancel', async (c) => {
const id = c.req.param('id');
const user = await getAuthUser(c);
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
// Check authorization (admin or ticket owner)
if (!user || (user.role !== 'admin' && user.id !== ticket.userId)) {
return c.json({ error: 'Unauthorized' }, 403);
}
if (ticket.status === 'cancelled') {
return c.json({ error: 'Ticket already cancelled' }, 400);
}
await (db as any).update(tickets).set({ status: 'cancelled' }).where(eq((tickets as any).id, id));
return c.json({ message: 'Ticket cancelled successfully' });
});
// Remove check-in (reset to confirmed)
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status !== 'checked_in') {
return c.json({ error: 'Ticket is not checked in' }, 400);
}
await (db as any)
.update(tickets)
.set({ status: 'confirmed', checkinAt: null })
.where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Check-in removed successfully' });
});
// Update admin note
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
const id = c.req.param('id');
const { note } = c.req.valid('json');
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
await (db as any)
.update(tickets)
.set({ adminNote: note || null })
.where(eq((tickets as any).id, id));
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
return c.json({ ticket: updated, message: 'Note updated successfully' });
});
// Admin create ticket (at the door)
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
const data = c.req.valid('json');
// Get event
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Check capacity
const ticketCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
.get();
if ((ticketCount?.count || 0) >= event.capacity) {
return c.json({ error: 'Event is at capacity' }, 400);
}
const now = getNow();
// For door sales, email might be empty - use a generated placeholder
const attendeeEmail = data.email && data.email.trim()
? data.email.trim()
: `door-${generateId()}@doorentry.local`;
// Find or create user
let user = await (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get();
const adminFullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
password: '',
name: adminFullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for existing active ticket for this user and event (only if real email provided)
if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) {
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
.get();
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'This person already has a ticket for this event' }, 400);
}
}
// Create ticket
const ticketId = generateId();
const qrCode = generateTicketCode();
// For door sales, mark as confirmed (or checked_in if auto-checkin)
const ticketStatus = data.autoCheckin ? 'checked_in' : 'confirmed';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
qrCode,
checkinAt: data.autoCheckin ? now : null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create payment record (marked as paid for door sales)
const paymentId = generateId();
const adminUser = (c as any).get('user');
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: event.price,
currency: event.currency,
status: 'paid',
reference: 'Door sale',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: data.autoCheckin
? 'Attendee added and checked in successfully'
: 'Attendee added successfully',
}, 201);
});
// Get all tickets (admin)
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
let query = (db as any).select().from(tickets);
const conditions = [];
if (eventId) {
conditions.push(eq((tickets as any).eventId, eventId));
}
if (status) {
conditions.push(eq((tickets as any).status, status));
}
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
const result = await query.all();
return c.json({ tickets: result });
});
export default ticketsRouter;

224
backend/src/routes/users.ts Normal file
View File

@@ -0,0 +1,224 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, users, tickets, events, payments } from '../db/index.js';
import { eq, desc, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
interface UserContext {
id: string;
email: string;
name: string;
role: string;
}
const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
const updateUserSchema = z.object({
name: z.string().min(2).optional(),
phone: z.string().optional(),
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
// Get all users (admin only)
usersRouter.get('/', requireAuth(['admin']), async (c) => {
const role = c.req.query('role');
let query = (db as any).select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
createdAt: (users as any).createdAt,
}).from(users);
if (role) {
query = query.where(eq((users as any).role, role));
}
const result = await query.orderBy(desc((users as any).createdAt)).all();
return c.json({ users: result });
});
// Get user by ID (admin or self)
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
const id = c.req.param('id');
const currentUser = c.get('user');
// Users can only view their own profile unless admin
if (currentUser.role !== 'admin' && currentUser.id !== id) {
return c.json({ error: 'Forbidden' }, 403);
}
const user = await (db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
createdAt: (users as any).createdAt,
})
.from(users)
.where(eq((users as any).id, id))
.get();
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user });
});
// Update user (admin or self)
usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), zValidator('json', updateUserSchema), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
const currentUser = c.get('user');
// Users can only update their own profile unless admin
if (currentUser.role !== 'admin' && currentUser.id !== id) {
return c.json({ error: 'Forbidden' }, 403);
}
// Only admin can change roles
if (data.role && currentUser.role !== 'admin') {
delete data.role;
}
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get();
if (!existing) {
return c.json({ error: 'User not found' }, 404);
}
await (db as any)
.update(users)
.set({ ...data, updatedAt: getNow() })
.where(eq((users as any).id, id));
const updated = await (db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
})
.from(users)
.where(eq((users as any).id, id))
.get();
return c.json({ user: updated });
});
// Get user's ticket history
usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
const id = c.req.param('id');
const currentUser = c.get('user');
// Users can only view their own history unless admin/organizer
if (!['admin', 'organizer'].includes(currentUser.role) && currentUser.id !== id) {
return c.json({ error: 'Forbidden' }, 403);
}
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, id))
.orderBy(desc((tickets as any).createdAt))
.all();
// Get event details for each ticket
const history = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
return {
...ticket,
event,
};
})
);
return c.json({ history });
});
// Delete user (admin only)
usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id');
const currentUser = c.get('user');
// Prevent self-deletion
if (currentUser.id === id) {
return c.json({ error: 'Cannot delete your own account' }, 400);
}
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get();
if (!existing) {
return c.json({ error: 'User not found' }, 404);
}
// Prevent deleting admin users
if (existing.role === 'admin') {
return c.json({ error: 'Cannot delete admin users' }, 400);
}
try {
// Get all tickets for this user
const userTickets = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).userId, id))
.all();
// Delete payments associated with user's tickets
for (const ticket of userTickets) {
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
}
// Delete user's tickets
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
// Delete the user
await (db as any).delete(users).where(eq((users as any).id, id));
return c.json({ message: 'User deleted successfully' });
} catch (error) {
console.error('Error deleting user:', error);
return c.json({ error: 'Failed to delete user. They may have related records.' }, 500);
}
});
// Get user statistics (admin)
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.get();
const adminCount = await (db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
.get();
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
export default usersRouter;