Refactor monolithic modules and harden booking, email, and auth infrastructure.

Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-25 07:12:59 +00:00
co-authored by Cursor
parent f0e2de2834
commit 613bd7be1d
75 changed files with 7702 additions and 5580 deletions
+3
View File
@@ -12,8 +12,11 @@ const dbType = process.env.DB_TYPE || 'sqlite';
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
if (dbType === 'postgres') {
// Cap connections per instance so that, when running multiple replicas,
// DB_POOL_MAX * replicas stays below the Postgres max_connections limit.
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
max: Number(process.env.DB_POOL_MAX || 10),
});
db = drizzlePg(pool, { schema });
} else {
+24
View File
@@ -432,6 +432,18 @@ async function migrate() {
)
`);
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_queue (
id TEXT PRIMARY KEY,
params TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL,
processed_at TEXT
)
`);
// Site settings table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS site_settings (
@@ -899,6 +911,18 @@ async function migrate() {
)
`);
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_queue (
id UUID PRIMARY KEY,
params TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP
)
`);
// Site settings table
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS site_settings (
+25
View File
@@ -273,6 +273,18 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
updatedAt: text('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const sqliteEmailQueue = sqliteTable('email_queue', {
id: text('id').primaryKey(),
params: text('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: text('status', { enum: ['pending', 'processing', 'sent', 'failed'] }).notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
lastError: text('last_error'),
createdAt: text('created_at').notNull(),
processedAt: text('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const sqliteLegalPages = sqliteTable('legal_pages', {
id: text('id').primaryKey(),
@@ -608,6 +620,18 @@ export const pgEmailSettings = pgTable('email_settings', {
updatedAt: timestamp('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const pgEmailQueue = pgTable('email_queue', {
id: uuid('id').primaryKey(),
params: pgText('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: varchar('status', { length: 20 }).notNull().default('pending'),
attempts: pgInteger('attempts').notNull().default(0),
lastError: pgText('last_error'),
createdAt: timestamp('created_at').notNull(),
processedAt: timestamp('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const pgLegalPages = pgTable('legal_pages', {
id: uuid('id').primaryKey(),
@@ -695,6 +719,7 @@ export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
+31 -6
View File
@@ -25,6 +25,9 @@ import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
import { startBookingCleanup } from './lib/bookingCleanup.js';
import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
const app = new Hono();
@@ -1870,9 +1873,16 @@ app.use('/uploads/*', async (c, next) => {
});
app.use('/uploads/*', serveStatic({ root: './' }));
// Health check
// Health check.
// Always returns 200 so a transient Redis blip does not cause the load balancer
// to pull a node; Redis/subsystem status is reported in the body for monitoring.
app.get('/health', (c) => {
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
redis: describeRedis(),
backends: describeBackends(),
});
});
// API Routes
@@ -1909,15 +1919,30 @@ const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err);
});
// Periodically expire abandoned pending bookings so they stop holding seats.
startBookingCleanup();
// Initialize email templates on startup.
// Guarded by a distributed lock so that, when running multiple replicas, only
// one instance seeds/updates templates per boot instead of all of them racing.
getLock()
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
.then((result) => {
if (result === null) {
console.log('[Email] Template seeding skipped (another instance holds the lock)');
}
})
.catch(err => {
console.error('[Email] Failed to seed templates:', err);
});
console.log(`🚀 Spanglish API server starting on port ${port}`);
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
// Log which backend (memory/redis, local/s3) each subsystem selected.
logSelectedBackends();
serve({
fetch: app.fetch,
port,
+34 -1
View File
@@ -192,11 +192,44 @@ export async function invalidateAllUserSessions(userId: string): Promise<void> {
.where(eq((userSessions as any).userId, userId));
}
// Password validation (min 10 characters per spec)
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
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' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
+36
View File
@@ -0,0 +1,36 @@
// Reports which backend each scalable subsystem is using, for the health
// endpoint and startup logging.
import { isRedisEnabled, isRedisHealthy } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
import { getPubSub } from './stores/pubsub.js';
import { getCache } from './stores/cache.js';
import { getLock } from './stores/lock.js';
import { getStorage } from './storage.js';
export function describeBackends() {
return {
cache: getCache().backend,
rateLimiter: getRateLimiter().backend,
pubsub: getPubSub().backend,
lock: getLock().backend,
storage: getStorage().backend,
};
}
export function describeRedis() {
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
}
/** Log one line per subsystem at startup so the active backend is obvious. */
export function logSelectedBackends(): void {
const b = describeBackends();
const r = describeRedis();
console.log('[startup] Subsystem backends:');
console.log(` redis: ${r.enabled ? 'enabled' : 'disabled (in-memory fallback)'}`);
console.log(` cache: ${b.cache}`);
console.log(` rate limiter: ${b.rateLimiter}`);
console.log(` pub/sub: ${b.pubsub}`);
console.log(` lock: ${b.lock}`);
console.log(` storage: ${b.storage}`);
}
+104
View File
@@ -0,0 +1,104 @@
// Expire stale pending bookings.
//
// When a booking is started, its tickets are created with status 'pending' and
// a 'pending' payment. Pending tickets count toward an event's capacity, so an
// abandoned checkout would otherwise hold those seats forever. This job cancels
// pending tickets whose payment is still 'pending' (i.e. never paid and not
// awaiting admin approval) after a configurable TTL, freeing the seats.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
function getTtlMs(): number {
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
return (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000;
}
/**
* Cancel stale pending bookings. Returns the number of tickets cancelled.
*
* A booking is considered stale when its payment is still 'pending' (not
* 'pending_approval', which means an admin is reviewing a manual transfer) and
* older than PENDING_BOOKING_TTL_MINUTES.
*/
export async function cleanupStalePendingBookings(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
lt((payments as any).createdAt, cutoff)
))
);
if (stale.length === 0) return 0;
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
const paymentIds = stale.map((s) => s.paymentId);
const now = getNow();
let cancelledTickets = 0;
if (ticketIds.length > 0) {
const result: any = await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'pending')
));
cancelledTickets = result?.changes ?? result?.rowCount ?? ticketIds.length;
}
await (db as any)
.update(payments)
.set({ status: 'failed', updatedAt: now })
.where(inArray((payments as any).id, paymentIds));
console.log(
`[BookingCleanup] Expired ${stale.length} stale pending payment(s); ` +
`cancelled ${cancelledTickets} ticket(s).`
);
return cancelledTickets;
}
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic cleanup of stale pending bookings. Each run is guarded by a
* distributed lock so that, across multiple replicas, only one instance does
* the work per interval.
*/
export function startBookingCleanup(): void {
const intervalMs = parseInt(process.env.PENDING_BOOKING_CLEANUP_INTERVAL_MS || '300000', 10); // 5 min
const run = () => {
getLock()
.withLock('cleanup-pending-bookings', Math.min(intervalMs, 60_000), () =>
cleanupStalePendingBookings()
)
.catch((err) =>
console.error('[BookingCleanup] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 30_000).unref?.();
cleanupTimer = setInterval(run, intervalMs);
cleanupTimer.unref?.();
console.log(`[BookingCleanup] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopBookingCleanup(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}
+56 -1421
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
// High-level booking confirmation email sender.
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { sendTemplateEmail } from './templateService.js';
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
/**
* Send booking confirmation email
* Supports multi-ticket bookings - includes all tickets in the booking
*/
export async function sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get all tickets in this booking (if multi-ticket)
let allTickets: any[] = [ticket];
if (ticket.bookingId) {
allTickets = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
const ticketCount = allTickets.length;
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Build attendee list for multi-ticket emails
const attendeeNames = allTickets.map(t =>
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
).join(', ');
// Calculate total price for multi-ticket bookings
const totalPrice = event.price * ticketCount;
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
return sendTemplateEmail({
templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
bookingId: ticket.bookingId || ticket.id,
qrCode: ticket.qrCode || '',
ticketPdfUrl,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
eventPrice: formatCurrency(event.price, event.currency),
// Multi-ticket specific variables
ticketCount: ticketCount.toString(),
totalPrice: formatCurrency(totalPrice, event.currency),
attendeeNames,
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
},
});
}
+101
View File
@@ -0,0 +1,101 @@
// Event-wide bulk email sending via the background queue.
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
import { eq, and } from 'drizzle-orm';
import { enqueueBulkEmails, type TemplateEmailJobParams } from '../emailQueue.js';
import { getTemplate } from './templateService.js';
import { formatDate, formatTime, getSiteTimezone } from './formatting.js';
/**
* Queue emails for event attendees (non-blocking).
* Adds all matching recipients to the background email queue and returns immediately.
* Rate limiting and actual sending is handled by the email queue.
*/
export async function queueEventEmails(params: {
eventId: string;
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
sentBy: string;
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Validate event exists
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
);
if (!event) {
return { success: false, queuedCount: 0, error: 'Event not found' };
}
// Validate template exists
const template = await getTemplate(templateSlug);
if (!template) {
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" 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 dbAll<any>(ticketQuery);
if (eventTickets.length === 0) {
return { success: true, queuedCount: 0, error: 'No recipients found' };
}
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
// Build individual email jobs for the queue
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return {
templateSlug,
to: ticket.attendeeEmail,
toName: fullName,
locale,
eventId: event.id,
sentBy,
variables: {
attendeeName: fullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
...customVariables,
},
};
});
// Enqueue all emails for background processing
enqueueBulkEmails(jobs);
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
return {
success: true,
queuedCount: jobs.length,
};
}
+69
View File
@@ -0,0 +1,69 @@
// Shared formatting helpers and common template variables for emails.
import { db, dbGet, siteSettings } from '../../db/index.js';
import { getCache } from '../stores/cache.js';
/**
* Get common variables for all emails
*/
export function 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',
};
}
/**
* Get the site timezone from settings (cached for performance).
* Cached for a short TTL via the cache abstraction (in-memory or Redis).
*/
export async function getSiteTimezone(): Promise<string> {
const cached = await getCache().get<string>('site:timezone');
if (cached) return cached;
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
const timezone = settings?.timezone || 'America/Asuncion';
await getCache().set('site:timezone', timezone, 60);
return timezone;
}
/**
* Format date for emails using site timezone
*/
export function formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: timezone,
});
}
/**
* Format time for emails using site timezone
*/
export function formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: timezone,
});
}
/**
* Format currency for emails. Kept distinct from lib/utils.ts formatCurrency
* because the email output format ("12.345 PYG" / "$10.00 USD") must not change.
*/
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
}
+474
View File
@@ -0,0 +1,474 @@
// High-level payment-related email senders and payment config resolution.
import { db, dbGet, dbAll, events, tickets, payments, paymentOptions, eventPaymentOverrides } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { sendTemplateEmail } from './templateService.js';
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
/**
* Send payment receipt email
*/
export async function sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment with ticket and event info
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Calculate total amount for multi-ticket bookings
let totalAmount = payment.amount;
let ticketCount = 1;
if (ticket.bookingId) {
// Get all payments for this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
// Sum up all payment amounts for the booking
const bookingPayments = await Promise.all(
bookingTickets.map((t: any) =>
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
)
);
totalAmount = bookingPayments
.filter((p: any) => p)
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
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', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
};
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
: formatCurrency(totalAmount, payment.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
return sendTemplateEmail({
templateSlug: 'payment-receipt',
to: ticket.attendeeEmail,
toName: receiptFullName,
locale,
eventId: event.id,
variables: {
attendeeName: receiptFullName,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
paymentAmount: amountDisplay,
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id,
paymentDate: formatDate(payment.paidAt || payment.createdAt, locale, timezone),
},
});
}
/**
* Get merged payment configuration for an event (global + overrides)
*/
export async function getPaymentConfig(eventId: string): Promise<Record<string, any>> {
// Get global options
const globalOptions = await dbGet<any>(
(db as any)
.select()
.from(paymentOptions)
);
// Get event overrides
const overrides = await dbGet<any>(
(db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
);
// Defaults
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
};
const global = globalOptions || defaults;
// Merge: override values take precedence if they're not null/undefined
return {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
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,
};
}
/**
* Send payment instructions email (for TPago or Bank Transfer)
* This email is sent immediately after user clicks "Continue to Payment"
*/
export async function sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for manual payment methods
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
}
// Get merged payment config for this event
const paymentConfig = await getPaymentConfig(event.id);
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
// Count all tickets in this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate a payment reference using booking ID or ticket ID
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Determine which template to use
const templateSlug = payment.provider === 'tpago'
? 'payment-instructions-tpago'
: 'payment-instructions-bank-transfer';
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
// Build variables based on payment method
const variables: Record<string, any> = {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
paymentReference,
bookingUrl,
};
// Add payment-method specific variables
if (payment.provider === 'tpago') {
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
} else {
// Bank transfer
variables.bankName = paymentConfig.bankName || '';
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
variables.bankAlias = paymentConfig.bankAlias || '';
variables.bankPhone = paymentConfig.bankPhone || '';
}
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug,
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables,
});
}
/**
* Send payment rejection email
* This email is sent when admin rejects a TPago or Bank Transfer payment
*/
export async function sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
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();
// Generate a new booking URL for the event
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug: 'payment-rejected',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
newBookingUrl,
},
});
}
/**
* Send payment reminder email
* This email is sent when admin wants to remind attendee about pending payment
*/
export async function sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for pending/pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
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();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug: 'payment-reminder',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
bookingUrl,
},
});
}
+307
View File
@@ -0,0 +1,307 @@
// Template DB access, seeding, and the core template/custom send + logging logic.
import { db, dbGet, emailTemplates, emailLogs } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { getNow, generateId } from '../utils.js';
import { replaceTemplateVariables, wrapInBaseTemplate, defaultTemplates } from '../emailTemplates.js';
import { sendEmail } from './transport.js';
import { getCommonVariables } from './formatting.js';
/**
* Get a template by slug
*/
export async function getTemplate(slug: string): Promise<any | null> {
const template = await dbGet(
(db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug))
);
return template || null;
}
/**
* Seed default templates if they don't exist, and update system templates with latest content
*/
export async function seedDefaultTemplates(): Promise<void> {
console.log('[Email] Checking for default templates...');
for (const template of defaultTemplates) {
const existing = await getTemplate(template.slug);
const now = getNow();
if (!existing) {
console.log(`[Email] Creating template: ${template.name}`);
await (db as any).insert(emailTemplates).values({
id: generateId(),
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,
});
} else if (existing.isSystem) {
// Update system templates with latest content from defaults
console.log(`[Email] Updating system template: ${template.name}`);
await (db as any)
.update(emailTemplates)
.set({
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),
updatedAt: now,
})
.where(eq((emailTemplates as any).slug, template.slug));
}
}
console.log('[Email] Default templates check complete');
}
/**
* Send an email using a template
*/
export async function 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 getTemplate(templateSlug);
if (!template) {
return { success: false, error: `Template "${templateSlug}" not found` };
}
// Build variables
const allVariables = {
...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, true);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
// Create log entry
const logId = generateId();
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 a custom email (not from template)
*/
export async function sendCustomEmail(params: {
to: string;
toName?: string;
subject: string;
bodyHtml: string;
bodyText?: string;
replyTo?: string;
eventId?: string;
sentBy?: string | null;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
// Strip CR/LF from header-bound values to prevent email header injection
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
const to = stripHeader(rawTo) as string;
const subject = stripHeader(rawSubject) as string;
const replyTo = stripHeader(rawReplyTo);
const allVariables = {
...getCommonVariables(),
subject,
};
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
// Create log entry
const logId = generateId();
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: sentBy || null,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject,
html: finalBodyHtml,
text: bodyText,
replyTo,
});
// 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
};
}
/**
* Resend an email from an existing log entry
*/
export async function resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
}
+308
View File
@@ -0,0 +1,308 @@
// Email transport layer: provider configuration, SMTP setup, and the low-level
// sendEmail router. No template rendering or DB logging happens here.
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
// ==================== Types ====================
export interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
}
export interface SendEmailResult {
success: boolean;
messageId?: string;
error?: string;
}
export 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';
}
/** Provider info for diagnostics endpoints. */
export function 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 };
}
// ==================== 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()}`
};
}
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
function maskEmail(email: string): string {
const [local, domain] = String(email).split('@');
if (!domain) return '***';
const head = local.slice(0, 1);
return `${head}***@${domain}`;
}
/**
* Main send function that routes to the appropriate provider
*/
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const provider = getEmailProvider();
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
switch (provider) {
case 'resend':
return sendWithResend(options);
case 'smtp':
return sendWithSMTP(options);
case 'console':
default:
return sendWithConsole(options);
}
}
/**
* Test email configuration by sending a test email
*/
export async function testConnection(to: string): Promise<SendEmailResult> {
const { provider, configured } = 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!`,
});
}
+161 -50
View File
@@ -1,17 +1,16 @@
// In-memory email queue with rate limiting
// Processes emails asynchronously in the background without blocking the request thread
// Durable email queue with rate limiting.
// Jobs are persisted in the `email_queue` DB table so they survive process
// restarts. Emails are processed asynchronously in the background without
// blocking the request thread.
import { generateId } from './utils.js';
import { eq, and, asc, sql } from 'drizzle-orm';
import { db, dbGet, emailQueue } from '../db/index.js';
import { generateId, getNow } from './utils.js';
import { isRedisEnabled } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
// ==================== Types ====================
export interface EmailJob {
id: string;
type: 'template';
params: TemplateEmailJobParams;
addedAt: number;
}
export interface TemplateEmailJobParams {
templateSlug: string;
to: string;
@@ -22,6 +21,11 @@ export interface TemplateEmailJobParams {
sentBy?: string;
}
interface ClaimedJob {
id: string;
params: TemplateEmailJobParams;
}
export interface QueueStatus {
queued: number;
processing: boolean;
@@ -31,7 +35,8 @@ export interface QueueStatus {
// ==================== Queue State ====================
const queue: EmailJob[] = [];
// Tracks send timestamps for the per-process (non-Redis) sliding-window rate
// limit. The job backlog itself lives in the database, not in memory.
const sentTimestamps: number[] = [];
let processing = false;
let processTimer: ReturnType<typeof setTimeout> | null = null;
@@ -41,7 +46,6 @@ let _emailService: any = null;
function getEmailService() {
if (!_emailService) {
// Dynamic import to avoid circular dependency
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
}
return _emailService;
@@ -50,12 +54,31 @@ function getEmailService() {
/**
* Initialize the email queue with a reference to the email service.
* Must be called once at startup.
*
* Also performs crash recovery: any jobs left in the 'processing' state by a
* previous (crashed) process are reset to 'pending' so they get retried.
*/
export function initEmailQueue(emailService: any): void {
_emailService = emailService;
recoverProcessingJobs()
.then((recovered) => {
if (recovered > 0) {
console.log(`[EmailQueue] Recovered ${recovered} in-flight job(s) after restart`);
}
scheduleProcessing();
})
.catch((err) => console.error('[EmailQueue] Recovery failed:', err?.message || err));
console.log('[EmailQueue] Initialized');
}
async function recoverProcessingJobs(): Promise<number> {
const result: any = await (db as any)
.update(emailQueue)
.set({ status: 'pending' })
.where(eq((emailQueue as any).status, 'processing'));
return result?.changes ?? result?.rowCount ?? 0;
}
// ==================== Rate Limiting ====================
function getMaxPerHour(): number {
@@ -74,19 +97,26 @@ function cleanOldTimestamps(): void {
// ==================== Queue Operations ====================
async function insertJob(id: string, params: TemplateEmailJobParams): Promise<void> {
await (db as any).insert(emailQueue).values({
id,
params: JSON.stringify(params),
status: 'pending',
attempts: 0,
createdAt: getNow(),
});
}
/**
* Add a single email job to the queue.
* Returns the job ID.
* Returns the job ID. Persistence happens asynchronously so the caller is not
* blocked; processing is scheduled once the row is written.
*/
export function enqueueEmail(params: TemplateEmailJobParams): string {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
scheduleProcessing();
insertJob(id, params)
.then(() => scheduleProcessing())
.catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err));
return id;
}
@@ -95,31 +125,32 @@ export function enqueueEmail(params: TemplateEmailJobParams): string {
* Returns array of job IDs.
*/
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
const ids: string[] = [];
for (const params of paramsList) {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
ids.push(id);
}
if (ids.length > 0) {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
}
const ids = paramsList.map(() => generateId());
if (ids.length === 0) return ids;
Promise.all(paramsList.map((params, i) => insertJob(ids[i], params)))
.then(() => {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
})
.catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err));
return ids;
}
/**
* Get current queue status
*/
export function getQueueStatus(): QueueStatus {
export async function getQueueStatus(): Promise<QueueStatus> {
cleanOldTimestamps();
const row = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(emailQueue)
.where(eq((emailQueue as any).status, 'pending'))
);
return {
queued: queue.length,
queued: Number(row?.count || 0),
processing,
sentInLastHour: sentTimestamps.length,
maxPerHour: getMaxPerHour(),
@@ -135,46 +166,125 @@ function scheduleProcessing(): void {
setImmediate(() => processNext());
}
/**
* Atomically claim the oldest pending job by flipping its status to
* 'processing'. Returns null if there is nothing to do. The conditional update
* (WHERE status='pending') guards against two workers claiming the same row.
*/
async function claimNextJob(): Promise<ClaimedJob | null> {
for (let attempt = 0; attempt < 5; attempt++) {
const row = await dbGet<any>(
(db as any)
.select({ id: (emailQueue as any).id, params: (emailQueue as any).params })
.from(emailQueue)
.where(eq((emailQueue as any).status, 'pending'))
.orderBy(asc((emailQueue as any).createdAt))
.limit(1)
);
if (!row) return null;
const result: any = await (db as any)
.update(emailQueue)
.set({ status: 'processing' })
.where(and(
eq((emailQueue as any).id, row.id),
eq((emailQueue as any).status, 'pending')
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected > 0) {
try {
return { id: row.id, params: JSON.parse(row.params) };
} catch {
// Corrupt params: mark failed and move on rather than crash-looping.
await markJob(row.id, 'failed', 'Invalid job params (JSON parse failed)');
continue;
}
}
// Lost the race for this row; try the next pending one.
}
return null;
}
async function markJob(id: string, status: 'sent' | 'failed', error?: string | null): Promise<void> {
const update: any = { status, processedAt: getNow() };
if (status === 'failed') {
update.attempts = sql`${(emailQueue as any).attempts} + 1`;
if (error) update.lastError = error.slice(0, 1000);
}
await (db as any).update(emailQueue).set(update).where(eq((emailQueue as any).id, id));
}
async function releaseJob(id: string): Promise<void> {
await (db as any)
.update(emailQueue)
.set({ status: 'pending' })
.where(eq((emailQueue as any).id, id));
}
async function processNext(): Promise<void> {
if (queue.length === 0) {
let job: ClaimedJob | null;
try {
job = await claimNextJob();
} catch (error: any) {
// Database error while claiming: back off and retry rather than stop.
console.error('[EmailQueue] Failed to claim next job:', error?.message || error);
processTimer = setTimeout(() => processNext(), 5_000);
return;
}
if (!job) {
processing = false;
console.log('[EmailQueue] Queue empty. Processing stopped.');
return;
}
// Rate limit check
// Rate limit check.
// - Without Redis: per-process sliding window.
// - With Redis: a shared hourly counter so the cap applies across all
// instances rather than once per replica.
cleanOldTimestamps();
const maxPerHour = getMaxPerHour();
let waitMs = 0;
if (sentTimestamps.length >= maxPerHour) {
if (isRedisEnabled()) {
const result = await getRateLimiter().consume('email:hourly', maxPerHour, 3_600_000);
if (!result.allowed) {
waitMs = (result.retryAfter ?? 60) * 1000 + 500; // 500ms buffer
}
} else if (sentTimestamps.length >= maxPerHour) {
// Calculate when the oldest timestamp in the window expires
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
}
if (waitMs > 0) {
// Put the claimed job back so it is retried after the cooldown.
await releaseJob(job.id);
console.log(
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
`Pausing for ${Math.ceil(waitMs / 1000)}s.`
);
processTimer = setTimeout(() => processNext(), waitMs);
return;
}
// Dequeue and process
const job = queue.shift()!;
try {
const emailService = getEmailService();
await emailService.sendTemplateEmail(job.params);
sentTimestamps.push(Date.now());
await markJob(job.id, 'sent');
console.log(
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
`Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
);
} catch (error: any) {
await markJob(job.id, 'failed', error?.message || String(error));
console.error(
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
error?.message || error
);
// The sendTemplateEmail method already logs the failure in the email_logs table,
// so we don't need to retry here. The error is logged and we move on.
// The sendTemplateEmail method already logs the failure in the email_logs
// table, so we just record it on the queue row and move on.
}
// Small delay between sends to be gentle on the email server
@@ -182,7 +292,8 @@ async function processNext(): Promise<void> {
}
/**
* Stop processing (for graceful shutdown)
* Stop processing (for graceful shutdown). In-flight and pending jobs remain
* persisted in the database and resume on the next startup.
*/
export function stopQueue(): void {
if (processTimer) {
@@ -190,5 +301,5 @@ export function stopQueue(): void {
processTimer = null;
}
processing = false;
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.');
}
+9 -36
View File
@@ -1,30 +1,15 @@
import { Context } from 'hono';
import { getRateLimiter } from './stores/rateLimiter.js';
/**
* Simple in-memory rate limiter.
* Rate limiting helpers.
*
* Suitable for a single backend instance (the current deployment model). If the
* backend is ever scaled horizontally, replace the in-memory Map with a shared
* store (e.g. Redis) so limits are enforced across instances.
* The actual counting is delegated to a pluggable rate limiter (in-memory by
* default, Redis-backed when REDIS_URL is set) so limits are enforced either
* per instance (single-instance deployments) or across all instances
* (horizontal scaling). See lib/stores/rateLimiter.ts.
*/
interface Bucket {
count: number;
resetAt: number;
}
const buckets = new Map<string, Bucket>();
// Periodically drop expired buckets so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now > bucket.resetAt) buckets.delete(key);
}
}, 60_000);
// Don't keep the process alive just for cleanup.
(cleanup as any).unref?.();
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
export function getClientIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for');
@@ -40,20 +25,8 @@ export function consumeRateLimit(
key: string,
max: number,
windowMs: number
): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now > bucket.resetAt) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true };
}
bucket.count++;
if (bucket.count > max) {
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true };
): Promise<{ allowed: boolean; retryAfter?: number }> {
return getRateLimiter().consume(key, max, windowMs);
}
/**
@@ -63,7 +36,7 @@ export function consumeRateLimit(
export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) {
return async (c: Context, next: () => Promise<void>) => {
const ip = getClientIp(c);
const result = consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
const result = await consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
if (!result.allowed) {
return c.json(
{ error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter },
+93
View File
@@ -0,0 +1,93 @@
// Optional Redis connection manager.
//
// Redis is entirely optional. When REDIS_URL is unset the app runs exactly as
// before with in-memory backends. When set, this module owns a single shared
// command connection plus a dedicated subscriber connection (a connection in
// subscribe mode cannot run normal commands), with auto-reconnect, capped
// backoff, and a health flag that callers and the health endpoint can read.
import Redis from 'ioredis';
let client: Redis | null = null;
let subscriber: Redis | null = null;
let healthy = false;
let initialized = false;
/** Whether Redis is configured via REDIS_URL. */
export function isRedisEnabled(): boolean {
return !!process.env.REDIS_URL;
}
/** Whether the Redis connection is currently usable. */
export function isRedisHealthy(): boolean {
return isRedisEnabled() && healthy;
}
function buildClient(label: string): Redis {
const url = process.env.REDIS_URL as string;
const instance = new Redis(url, {
// Keep the process responsive: fail fast on a per-command basis and let the
// callers degrade to their in-memory fallback rather than hanging.
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: false,
retryStrategy(times) {
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
const delay = Math.min(times * 200, 5000);
return delay;
},
});
instance.on('connect', () => {
console.log(`[redis] (${label}) connecting`);
});
instance.on('ready', () => {
healthy = true;
console.log(`[redis] (${label}) ready`);
});
instance.on('error', (err) => {
healthy = false;
console.error(`[redis] (${label}) error:`, err?.message || err);
});
instance.on('reconnecting', () => {
healthy = false;
console.warn(`[redis] (${label}) reconnecting`);
});
instance.on('end', () => {
healthy = false;
console.warn(`[redis] (${label}) connection closed`);
});
return instance;
}
function ensureInit(): void {
if (initialized || !isRedisEnabled()) return;
initialized = true;
client = buildClient('commands');
subscriber = buildClient('subscriber');
}
/** Shared command connection, or null when Redis is not configured. */
export function getRedis(): Redis | null {
ensureInit();
return client;
}
/** Dedicated subscriber connection, or null when Redis is not configured. */
export function getSubscriber(): Redis | null {
ensureInit();
return subscriber;
}
/** Close connections (used for graceful shutdown). */
export async function closeRedis(): Promise<void> {
const tasks: Promise<unknown>[] = [];
if (client) tasks.push(client.quit().catch(() => undefined));
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
await Promise.all(tasks);
client = null;
subscriber = null;
initialized = false;
healthy = false;
}
+136
View File
@@ -0,0 +1,136 @@
// Media storage abstraction with two implementations:
// - local: writes to the ./uploads directory and serves via /uploads/* (the
// original behavior, and the zero-config default)
// - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are
// shared across instances instead of living on one container's local disk
//
// S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the
// app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg").
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = './uploads';
export interface Storage {
readonly backend: 'local' | 's3';
put(key: string, buffer: Buffer, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
// Public URL to persist as the media record's fileUrl.
publicUrl(key: string): string;
}
/** Whether S3-compatible storage is configured. */
export function isS3Enabled(): boolean {
return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET);
}
/** Extract the storage key (object name) from a stored fileUrl. */
export function keyFromUrl(fileUrl: string): string {
return fileUrl.split('/').pop() || fileUrl;
}
// ==================== Local implementation ====================
class LocalStorage implements Storage {
readonly backend = 'local' as const;
private async ensureDir(): Promise<void> {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
}
async put(key: string, buffer: Buffer): Promise<void> {
await this.ensureDir();
await writeFile(join(UPLOAD_DIR, key), buffer);
}
async delete(key: string): Promise<void> {
const filepath = join(UPLOAD_DIR, key);
if (existsSync(filepath)) {
await unlink(filepath);
}
}
publicUrl(key: string): string {
return `/uploads/${key}`;
}
}
// ==================== S3 implementation ====================
// Imported lazily so the AWS SDK is only loaded when S3 is actually configured.
type S3ClientType = import('@aws-sdk/client-s3').S3Client;
class S3Storage implements Storage {
readonly backend = 's3' as const;
private client: S3ClientType | null = null;
private bucket = process.env.S3_BUCKET as string;
private async getClient(): Promise<S3ClientType> {
if (this.client) return this.client;
const { S3Client } = await import('@aws-sdk/client-s3');
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false';
this.client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
forcePathStyle,
credentials:
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}
: undefined,
});
return this.client;
}
async put(key: string, buffer: Buffer, contentType: string): Promise<void> {
const client = await this.getClient();
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
}
async delete(key: string): Promise<void> {
const client = await this.getClient();
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
})
);
}
publicUrl(key: string): string {
// Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint).
const base = process.env.S3_PUBLIC_URL;
if (base) {
return `${base.replace(/\/$/, '')}/${key}`;
}
// Fall back to a path-style URL against the configured endpoint.
const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, '');
return `${endpoint}/${this.bucket}/${key}`;
}
}
// ==================== Selection ====================
let instance: Storage | null = null;
export function getStorage(): Storage {
if (!instance) {
instance = isS3Enabled() ? new S3Storage() : new LocalStorage();
}
return instance;
}
+105
View File
@@ -0,0 +1,105 @@
// Cache abstraction with two implementations:
// - memory: per-process Map with TTL expiry (single instance)
// - redis: shared GET / SETEX / DEL with JSON values (all instances)
//
// Values are JSON-serialized. Selection happens once based on REDIS_URL. On any
// Redis error the cache behaves as a miss so callers fall back to their source.
import { getRedis, isRedisEnabled } from '../redis.js';
export interface Cache {
readonly backend: 'memory' | 'redis';
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
}
// ==================== Memory implementation ====================
interface Entry {
value: unknown;
expiresAt: number;
}
class MemoryCache implements Cache {
readonly backend = 'memory' as const;
private store = new Map<string, Entry>();
constructor() {
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.store) {
if (now > entry.expiresAt) this.store.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async get<T>(key: string): Promise<T | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
}
async del(key: string): Promise<void> {
this.store.delete(key);
}
}
// ==================== Redis implementation ====================
class RedisCache implements Cache {
readonly backend = 'redis' as const;
async get<T>(key: string): Promise<T | null> {
const redis = getRedis();
if (!redis) return null;
try {
const raw = await redis.get(`cache:${key}`);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch (err: any) {
console.error('[cache] redis get error:', err?.message || err);
return null;
}
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.set(`cache:${key}`, JSON.stringify(value), 'EX', ttlSeconds);
} catch (err: any) {
console.error('[cache] redis set error:', err?.message || err);
}
}
async del(key: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(`cache:${key}`);
} catch (err: any) {
console.error('[cache] redis del error:', err?.message || err);
}
}
}
// ==================== Selection ====================
let instance: Cache | null = null;
export function getCache(): Cache {
if (!instance) {
instance = isRedisEnabled() ? new RedisCache() : new MemoryCache();
}
return instance;
}
+111
View File
@@ -0,0 +1,111 @@
// Distributed lock abstraction with two implementations:
// - memory: per-process key set with TTL (only meaningful within one instance)
// - redis: SET key token NX PX ttl, released with a compare-and-delete Lua
// script so only the holder can release it
//
// Use acquire/release for long-lived ownership (e.g. a background poller) and
// withLock for a one-shot critical section. Selection is based on REDIS_URL.
import { randomUUID } from 'crypto';
import { getRedis, isRedisEnabled } from '../redis.js';
export interface Lock {
readonly backend: 'memory' | 'redis';
// Returns a token when the lock was acquired, or null when already held.
acquire(key: string, ttlMs: number): Promise<string | null>;
release(key: string, token: string): Promise<void>;
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null>;
}
// ==================== Memory implementation ====================
class MemoryLock implements Lock {
readonly backend = 'memory' as const;
private held = new Map<string, { token: string; expiresAt: number }>();
async acquire(key: string, ttlMs: number): Promise<string | null> {
const existing = this.held.get(key);
const now = Date.now();
if (existing && existing.expiresAt > now) {
return null;
}
const token = randomUUID();
this.held.set(key, { token, expiresAt: now + ttlMs });
return token;
}
async release(key: string, token: string): Promise<void> {
const existing = this.held.get(key);
if (existing && existing.token === token) {
this.held.delete(key);
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
if (!token) return null;
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
// ==================== Redis implementation ====================
const RELEASE_SCRIPT =
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
class RedisLock implements Lock {
readonly backend = 'redis' as const;
async acquire(key: string, ttlMs: number): Promise<string | null> {
const redis = getRedis();
if (!redis) {
// Redis configured but unavailable: do not block critical sections.
return randomUUID();
}
const token = randomUUID();
try {
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
return result === 'OK' ? token : null;
} catch (err: any) {
console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
// Fail open so a Redis outage does not deadlock startup or jobs.
return randomUUID();
}
}
async release(key: string, token: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.eval(RELEASE_SCRIPT, 1, `lock:${key}`, token);
} catch (err: any) {
console.error('[lock] redis release error:', err?.message || err);
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
if (!token) return null;
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
// ==================== Selection ====================
let instance: Lock | null = null;
export function getLock(): Lock {
if (!instance) {
instance = isRedisEnabled() ? new RedisLock() : new MemoryLock();
}
return instance;
}
+121
View File
@@ -0,0 +1,121 @@
// Pub/Sub abstraction with two implementations:
// - memory: in-process EventEmitter (single instance only)
// - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches
// subscribers on every instance
//
// Messages are JSON-serialized. Selection happens once based on REDIS_URL.
import { EventEmitter } from 'events';
import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js';
export type PubSubHandler = (message: any) => void;
export interface PubSub {
readonly backend: 'memory' | 'redis';
publish(channel: string, message: any): Promise<void>;
// Returns an unsubscribe function for this specific handler.
subscribe(channel: string, handler: PubSubHandler): Promise<() => void>;
}
// ==================== Memory implementation ====================
class MemoryPubSub implements PubSub {
readonly backend = 'memory' as const;
private emitter = new EventEmitter();
constructor() {
// SSE fan-out can attach many listeners to the same channel; lift the cap.
this.emitter.setMaxListeners(0);
}
async publish(channel: string, message: any): Promise<void> {
this.emitter.emit(channel, message);
}
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
this.emitter.on(channel, handler);
return () => this.emitter.off(channel, handler);
}
}
// ==================== Redis implementation ====================
class RedisPubSub implements PubSub {
readonly backend = 'redis' as const;
// Per-channel handler sets so a single Redis subscription fans out locally.
private handlers = new Map<string, Set<PubSubHandler>>();
private wired = false;
private ensureWired(): void {
if (this.wired) return;
const sub = getSubscriber();
if (!sub) return;
this.wired = true;
sub.on('message', (channel: string, payload: string) => {
const set = this.handlers.get(channel);
if (!set || set.size === 0) return;
let parsed: any = payload;
try {
parsed = JSON.parse(payload);
} catch {
// Leave as raw string if it was not JSON.
}
for (const handler of set) {
try {
handler(parsed);
} catch (err: any) {
console.error('[pubsub] handler error:', err?.message || err);
}
}
});
}
async publish(channel: string, message: any): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.publish(channel, JSON.stringify(message));
} catch (err: any) {
console.error('[pubsub] publish error:', err?.message || err);
}
}
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
this.ensureWired();
const sub = getSubscriber();
if (!sub) return () => undefined;
let set = this.handlers.get(channel);
if (!set) {
set = new Set();
this.handlers.set(channel, set);
try {
await sub.subscribe(channel);
} catch (err: any) {
console.error('[pubsub] subscribe error:', err?.message || err);
}
}
set.add(handler);
return () => {
const current = this.handlers.get(channel);
if (!current) return;
current.delete(handler);
if (current.size === 0) {
this.handlers.delete(channel);
sub.unsubscribe(channel).catch(() => undefined);
}
};
}
}
// ==================== Selection ====================
let instance: PubSub | null = null;
export function getPubSub(): PubSub {
if (!instance) {
instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub();
}
return instance;
}
+98
View File
@@ -0,0 +1,98 @@
// Rate limiter abstraction with two implementations:
// - memory: per-process fixed window (the original behavior)
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
//
// Selection happens once based on REDIS_URL. On any Redis error the limiter
// fails open (allows the request) so a Redis blip never takes the API down.
import { getRedis, isRedisEnabled } from '../redis.js';
export interface RateLimitResult {
allowed: boolean;
retryAfter?: number;
}
export interface RateLimiter {
readonly backend: 'memory' | 'redis';
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
}
// ==================== Memory implementation ====================
interface Bucket {
count: number;
resetAt: number;
}
class MemoryRateLimiter implements RateLimiter {
readonly backend = 'memory' as const;
private buckets = new Map<string, Bucket>();
constructor() {
// Periodically drop expired buckets so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, bucket] of this.buckets) {
if (now > bucket.resetAt) this.buckets.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const now = Date.now();
const bucket = this.buckets.get(key);
if (!bucket || now > bucket.resetAt) {
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true };
}
bucket.count++;
if (bucket.count > max) {
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true };
}
}
// ==================== Redis implementation ====================
class RedisRateLimiter implements RateLimiter {
readonly backend = 'redis' as const;
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const redis = getRedis();
if (!redis) return { allowed: true };
const redisKey = `rl:${key}`;
try {
const count = await redis.incr(redisKey);
if (count === 1) {
// First hit in this window: set the expiry that defines the window.
await redis.pexpire(redisKey, windowMs);
}
if (count > max) {
const ttl = await redis.pttl(redisKey);
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
return { allowed: false, retryAfter };
}
return { allowed: true };
} catch (err: any) {
// Fail open: never block traffic because Redis is unavailable.
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
return { allowed: true };
}
}
}
// ==================== Selection ====================
let instance: RateLimiter | null = null;
export function getRateLimiter(): RateLimiter {
if (!instance) {
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
}
return instance;
}
+10 -19
View File
@@ -6,6 +6,16 @@ import { getNow } from '../lib/utils.js';
const adminRouter = new Hono();
// Escape a value for inclusion in a CSV cell (RFC 4180 quoting).
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
// Dashboard overview stats (admin)
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
@@ -291,16 +301,6 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
})
);
// Generate CSV
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = [
'Ticket ID', 'Full Name', 'Email', 'Phone',
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
@@ -380,15 +380,6 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
});
}
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
const rows = ticketList.map((ticket: any) => ({
+15
View File
@@ -229,6 +229,21 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block
// the login.
if (!String(user.password).startsWith('$argon2')) {
try {
const upgradedHash = await hashPassword(data.password);
await (db as any)
.update(users)
.set({ password: upgradedHash })
.where(eq((users as any).id, user.id));
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
+1 -14
View File
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { generateId, getNow, sanitizeHtml } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
@@ -16,19 +16,6 @@ const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000,
// ==================== Sanitization Helpers ====================
/**
* Sanitize a string to prevent HTML injection
* Escapes HTML special characters
*/
function sanitizeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* Sanitize email header values to prevent email header injection
* Strips newlines and carriage returns that could be used to inject headers
+3 -4
View File
@@ -163,9 +163,8 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', up
const updateData: any = { updatedAt: getNow() };
// Only allow updating certain fields for system templates
const systemProtectedFields = ['slug', 'isSystem'];
// System templates cannot have their slug or isSystem flag changed; only the
// editable fields below are applied.
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
if (!existing.isSystem) {
allowedFields.push('slug');
@@ -486,7 +485,7 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
// Get email queue status
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
const status = getQueueStatus();
const status = await getQueueStatus();
return c.json({ status });
});
-18
View File
@@ -9,24 +9,6 @@ import path from 'path';
const legalPagesRouter = new Hono();
// Helper: Convert plain text to simple markdown
// Preserves paragraphs and line breaks, nothing fancy
function textToMarkdown(text: string): string {
if (!text) return '';
// Split into paragraphs (double newlines)
const paragraphs = text.split(/\n\s*\n/);
// Process each paragraph
const processed = paragraphs.map(para => {
// Replace single newlines with double spaces + newline for markdown line breaks
return para.trim().replace(/\n/g, ' \n');
});
// Join paragraphs with double newlines
return processed.join('\n\n');
}
// Helper: Convert markdown to plain text for editing
function markdownToText(markdown: string): string {
if (!markdown) return '';
+71 -8
View File
@@ -5,15 +5,26 @@ import { eq, and } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
import { getPubSub } from '../lib/stores/pubsub.js';
import { getLock } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers)
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
const channelUnsubs = new Map<string, () => void>();
// Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>();
/** Pub/sub channel that carries payment events for a ticket. */
function paymentChannel(ticketId: string): string {
return `payment:${ticketId}`;
}
/**
* LNbits webhook payload structure
*/
@@ -32,9 +43,21 @@ interface LNbitsWebhookPayload {
}
/**
* Notify all connected clients for a ticket
* Notify every client for a ticket across all instances.
*
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
* mode this is an in-process broadcast; with Redis it reaches whichever
* instance(s) actually hold the SSE socket(s) for this ticket.
*/
async function notifyClients(ticketId: string, data: any) {
await getPubSub().publish(paymentChannel(ticketId), data);
}
/**
* Deliver an event to the SSE sockets held by THIS process for a ticket.
* Invoked by the pub/sub subscription handler.
*/
async function deliverLocal(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId);
if (connections) {
await Promise.all(
@@ -49,17 +72,42 @@ async function notifyClients(ticketId: string, data: any) {
}
}
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
const checkerLockTokens = new Map<string, string>();
/** Release the per-ticket poller lock if this process holds it. */
function releaseCheckerLock(ticketId: string) {
const token = checkerLockTokens.get(ticketId);
if (token) {
checkerLockTokens.delete(ticketId);
void getLock().release(`checker:${ticketId}`, token);
}
}
/**
* Start background payment checking for a ticket
* Start background payment checking for a ticket.
*
* Only one instance should poll LNbits per ticket, so we take a distributed
* lock for the lifetime of the poll. Other instances skip polling and instead
* receive the result via pub/sub. With no Redis configured the lock is a local
* no-op and behavior matches the original single-instance polling.
*/
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking on this instance
if (activeCheckers.has(ticketId)) {
return;
}
const startTime = Date.now();
const expiryMs = expirySeconds * 1000;
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
if (!lockToken) {
// Another instance is already polling this ticket.
return;
}
checkerLockTokens.set(ticketId, lockToken);
const startTime = Date.now();
let checkCount = 0;
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
@@ -73,6 +121,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
await notifyClients(ticketId, { type: 'expired', ticketId });
return;
}
@@ -84,6 +133,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
await handlePaymentComplete(ticketId, paymentHash);
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
@@ -104,6 +154,7 @@ function stopBackgroundChecker(ticketId: string) {
if (interval) {
clearInterval(interval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
}
}
@@ -273,7 +324,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
// Start background checker if not already running (only while still pending)
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
}
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
@@ -291,9 +342,15 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
return;
}
// Register this connection
// Register this connection. The first local connection for a ticket also
// subscribes to the ticket's pub/sub channel so events published by any
// instance (webhook or background checker) are delivered to these sockets.
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
void deliverLocal(ticketId, data);
});
channelUnsubs.set(ticketId, unsub);
}
activeConnections.get(ticketId)!.add(sendEvent);
@@ -317,6 +374,12 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
connections.delete(sendEvent);
if (connections.size === 0) {
activeConnections.delete(ticketId);
// Drop the pub/sub subscription once no local sockets remain.
const unsub = channelUnsubs.get(ticketId);
if (unsub) {
unsub();
channelUnsubs.delete(ticketId);
}
}
}
});
+8 -23
View File
@@ -3,13 +3,10 @@ import { db, dbGet, dbAll, media } from '../db/index.js';
import { eq, and } 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 } from 'path';
import { getStorage, keyFromUrl } from '../lib/storage.js';
const mediaRouter = new Hono();
const UPLOAD_DIR = './uploads';
const MAX_FILE_SIZE =
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
@@ -51,13 +48,6 @@ function detectImageType(buf: Buffer): { mime: string; ext: string } | null {
return null;
}
// 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 {
@@ -83,15 +73,13 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
await ensureUploadDir();
// Generate unique filename using the *detected* extension (ignore client filename)
const id = generateId();
const filename = `${id}${detected.ext}`;
const filepath = join(UPLOAD_DIR, filename);
// Write file
await writeFile(filepath, buffer);
// Persist via the storage backend (local disk or S3-compatible object store).
const storage = getStorage();
await storage.put(filename, buffer, detected.mime);
// Get related info from form data
const relatedId = body['relatedId'] as string | undefined;
@@ -101,7 +89,7 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
const mediaRecord = {
id,
fileUrl: `/uploads/${filename}`,
fileUrl: storage.publicUrl(filename),
type: 'image' as const,
relatedId: relatedId || null,
relatedType: relatedType || null,
@@ -147,12 +135,9 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
return c.json({ error: 'Media not found' }, 404);
}
// Delete file from disk
// Delete the underlying object from the storage backend.
try {
const filepath = join('.', mediaRecord.fileUrl);
if (existsSync(filepath)) {
await unlink(filepath);
}
await getStorage().delete(keyFromUrl(mediaRecord.fileUrl));
} catch (error) {
console.error('Failed to delete file:', error);
}