From b9f46b02cc30491e8a008c08ad4ef8e8402c387e Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 12 Feb 2026 21:03:49 +0000 Subject: [PATCH] Email queue + async sending; legal settings and placeholders - Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR) - Bulk send to event attendees now queues and returns immediately - Frontend shows 'Emails are being sent in the background' - Legal pages, settings, and placeholders updates Co-authored-by: Cursor --- backend/.env.example | 5 + backend/src/db/migrate.ts | 38 + backend/src/db/schema.ts | 39 +- backend/src/index.ts | 6 + backend/src/lib/email.ts | 103 ++- backend/src/lib/emailQueue.ts | 194 ++++ backend/src/lib/legal-placeholders.ts | 80 ++ backend/src/routes/contacts.ts | 87 +- backend/src/routes/emails.ts | 12 +- backend/src/routes/legal-pages.ts | 11 +- backend/src/routes/legal-settings.ts | 146 +++ frontend/src/app/admin/emails/page.tsx | 12 +- frontend/src/app/admin/events/[id]/page.tsx | 7 +- frontend/src/app/admin/legal-pages/page.tsx | 40 + frontend/src/app/admin/settings/page.tsx | 950 +++++++++++++------- frontend/src/lib/api.ts | 30 +- frontend/src/lib/legal.ts | 2 +- 17 files changed, 1410 insertions(+), 352 deletions(-) create mode 100644 backend/src/lib/emailQueue.ts create mode 100644 backend/src/lib/legal-placeholders.ts create mode 100644 backend/src/routes/legal-settings.ts diff --git a/backend/.env.example b/backend/.env.example index 19dfe4e..b244e74 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -67,3 +67,8 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true # SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key # Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587 # Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587 + +# Email Queue Rate Limiting +# Maximum number of emails that can be sent per hour (default: 30) +# If the limit is reached, queued emails will pause and resume automatically +MAX_EMAILS_PER_HOUR=30 diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index c443b50..dff9ad9 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -437,6 +437,25 @@ async function migrate() { updated_at TEXT NOT NULL ) `); + + // Legal settings table for legal page placeholder values + await (db as any).run(sql` + CREATE TABLE IF NOT EXISTS legal_settings ( + id TEXT PRIMARY KEY, + company_name TEXT, + legal_entity_name TEXT, + ruc_number TEXT, + company_address TEXT, + company_city TEXT, + company_country TEXT, + support_email TEXT, + legal_email TEXT, + governing_law TEXT, + jurisdiction_city TEXT, + updated_at TEXT NOT NULL, + updated_by TEXT REFERENCES users(id) + ) + `); } else { // PostgreSQL migrations await (db as any).execute(sql` @@ -822,6 +841,25 @@ async function migrate() { updated_at TIMESTAMP NOT NULL ) `); + + // Legal settings table for legal page placeholder values + await (db as any).execute(sql` + CREATE TABLE IF NOT EXISTS legal_settings ( + id UUID PRIMARY KEY, + company_name VARCHAR(255), + legal_entity_name VARCHAR(255), + ruc_number VARCHAR(50), + company_address TEXT, + company_city VARCHAR(100), + company_country VARCHAR(100), + support_email VARCHAR(255), + legal_email VARCHAR(255), + governing_law VARCHAR(255), + jurisdiction_city VARCHAR(100), + updated_at TIMESTAMP NOT NULL, + updated_by UUID REFERENCES users(id) + ) + `); } console.log('Migrations completed successfully!'); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 2443433..82d7bf1 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -281,6 +281,23 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', { updatedAt: text('updated_at').notNull(), }); +// Legal Settings table for legal page placeholder values +export const sqliteLegalSettings = sqliteTable('legal_settings', { + id: text('id').primaryKey(), + companyName: text('company_name'), + legalEntityName: text('legal_entity_name'), + rucNumber: text('ruc_number'), + companyAddress: text('company_address'), + companyCity: text('company_city'), + companyCountry: text('company_country'), + supportEmail: text('support_email'), + legalEmail: text('legal_email'), + governingLaw: text('governing_law'), + jurisdictionCity: text('jurisdiction_city'), + updatedAt: text('updated_at').notNull(), + updatedBy: text('updated_by').references(() => sqliteUsers.id), +}); + // Site Settings table for global website configuration export const sqliteSiteSettings = sqliteTable('site_settings', { id: text('id').primaryKey(), @@ -578,6 +595,23 @@ export const pgFaqQuestions = pgTable('faq_questions', { updatedAt: timestamp('updated_at').notNull(), }); +// Legal Settings table for legal page placeholder values +export const pgLegalSettings = pgTable('legal_settings', { + id: uuid('id').primaryKey(), + companyName: varchar('company_name', { length: 255 }), + legalEntityName: varchar('legal_entity_name', { length: 255 }), + rucNumber: varchar('ruc_number', { length: 50 }), + companyAddress: pgText('company_address'), + companyCity: varchar('company_city', { length: 100 }), + companyCountry: varchar('company_country', { length: 100 }), + supportEmail: varchar('support_email', { length: 255 }), + legalEmail: varchar('legal_email', { length: 255 }), + governingLaw: varchar('governing_law', { length: 255 }), + jurisdictionCity: varchar('jurisdiction_city', { length: 100 }), + updatedAt: timestamp('updated_at').notNull(), + updatedBy: uuid('updated_by').references(() => pgUsers.id), +}); + // Site Settings table for global website configuration export const pgSiteSettings = pgTable('site_settings', { id: uuid('id').primaryKey(), @@ -623,6 +657,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens; export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions; export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; +export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages; export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions; @@ -657,4 +692,6 @@ export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert; export type LegalPage = typeof sqliteLegalPages.$inferSelect; export type NewLegalPage = typeof sqliteLegalPages.$inferInsert; export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect; -export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert; \ No newline at end of file +export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert; +export type LegalSettings = typeof sqliteLegalSettings.$inferSelect; +export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert; \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 6aa413b..495ef96 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,8 +21,10 @@ import paymentOptionsRoutes from './routes/payment-options.js'; import dashboardRoutes from './routes/dashboard.js'; import siteSettingsRoutes from './routes/site-settings.js'; import legalPagesRoutes from './routes/legal-pages.js'; +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'; const app = new Hono(); @@ -1856,6 +1858,7 @@ app.route('/api/payment-options', paymentOptionsRoutes); app.route('/api/dashboard', dashboardRoutes); app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/legal-pages', legalPagesRoutes); +app.route('/api/legal-settings', legalSettingsRoutes); app.route('/api/faq', faqRoutes); // 404 handler @@ -1871,6 +1874,9 @@ app.onError((err, c) => { 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); diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 7c949ae..39e4c58 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -10,6 +10,7 @@ import { defaultTemplates, type DefaultTemplate } from './emailTemplates.js'; +import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js'; import nodemailer from 'nodemailer'; import type { Transporter } from 'nodemailer'; @@ -1173,6 +1174,100 @@ export const emailService = { errors, }; }, + + /** + * 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. + */ + async queueEventEmails(params: { + eventId: string; + templateSlug: string; + customVariables?: Record; + 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( + (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 this.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(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 this.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: this.formatDate(event.startDatetime, locale, timezone), + eventTime: this.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, + }; + }, /** * Send a custom email (not from template) @@ -1183,10 +1278,11 @@ export const emailService = { subject: string; bodyHtml: string; bodyText?: string; + replyTo?: string; eventId?: string; - sentBy: string; + sentBy?: string | null; }): Promise<{ success: boolean; logId?: string; error?: string }> { - const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params; + const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params; const allVariables = { ...this.getCommonVariables(), @@ -1208,7 +1304,7 @@ export const emailService = { subject, bodyHtml: finalBodyHtml, status: 'pending', - sentBy, + sentBy: sentBy || null, createdAt: now, }); @@ -1218,6 +1314,7 @@ export const emailService = { subject, html: finalBodyHtml, text: bodyText, + replyTo, }); // Update log diff --git a/backend/src/lib/emailQueue.ts b/backend/src/lib/emailQueue.ts new file mode 100644 index 0000000..2621338 --- /dev/null +++ b/backend/src/lib/emailQueue.ts @@ -0,0 +1,194 @@ +// In-memory email queue with rate limiting +// Processes emails asynchronously in the background without blocking the request thread + +import { generateId } from './utils.js'; + +// ==================== Types ==================== + +export interface EmailJob { + id: string; + type: 'template'; + params: TemplateEmailJobParams; + addedAt: number; +} + +export interface TemplateEmailJobParams { + templateSlug: string; + to: string; + toName?: string; + variables: Record; + locale?: string; + eventId?: string; + sentBy?: string; +} + +export interface QueueStatus { + queued: number; + processing: boolean; + sentInLastHour: number; + maxPerHour: number; +} + +// ==================== Queue State ==================== + +const queue: EmailJob[] = []; +const sentTimestamps: number[] = []; +let processing = false; +let processTimer: ReturnType | null = null; + +// Lazy reference to emailService to avoid circular imports +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; +} + +/** + * Initialize the email queue with a reference to the email service. + * Must be called once at startup. + */ +export function initEmailQueue(emailService: any): void { + _emailService = emailService; + console.log('[EmailQueue] Initialized'); +} + +// ==================== Rate Limiting ==================== + +function getMaxPerHour(): number { + return parseInt(process.env.MAX_EMAILS_PER_HOUR || '30', 10); +} + +/** + * Clean up timestamps older than 1 hour + */ +function cleanOldTimestamps(): void { + const oneHourAgo = Date.now() - 3_600_000; + while (sentTimestamps.length > 0 && sentTimestamps[0] <= oneHourAgo) { + sentTimestamps.shift(); + } +} + +// ==================== Queue Operations ==================== + +/** + * Add a single email job to the queue. + * Returns the job ID. + */ +export function enqueueEmail(params: TemplateEmailJobParams): string { + const id = generateId(); + queue.push({ + id, + type: 'template', + params, + addedAt: Date.now(), + }); + scheduleProcessing(); + return id; +} + +/** + * Add multiple email jobs to the queue at once. + * 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(); + } + return ids; +} + +/** + * Get current queue status + */ +export function getQueueStatus(): QueueStatus { + cleanOldTimestamps(); + return { + queued: queue.length, + processing, + sentInLastHour: sentTimestamps.length, + maxPerHour: getMaxPerHour(), + }; +} + +// ==================== Processing ==================== + +function scheduleProcessing(): void { + if (processing) return; + processing = true; + // Start processing on next tick to not block the caller + setImmediate(() => processNext()); +} + +async function processNext(): Promise { + if (queue.length === 0) { + processing = false; + console.log('[EmailQueue] Queue empty. Processing stopped.'); + return; + } + + // Rate limit check + cleanOldTimestamps(); + const maxPerHour = getMaxPerHour(); + + 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 + console.log( + `[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` + + `Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.` + ); + 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()); + console.log( + `[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` + + `Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}` + ); + } catch (error: any) { + 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. + } + + // Small delay between sends to be gentle on the email server + processTimer = setTimeout(() => processNext(), 200); +} + +/** + * Stop processing (for graceful shutdown) + */ +export function stopQueue(): void { + if (processTimer) { + clearTimeout(processTimer); + processTimer = null; + } + processing = false; + console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`); +} diff --git a/backend/src/lib/legal-placeholders.ts b/backend/src/lib/legal-placeholders.ts new file mode 100644 index 0000000..721fe24 --- /dev/null +++ b/backend/src/lib/legal-placeholders.ts @@ -0,0 +1,80 @@ +import { getLegalSettingsValues } from '../routes/legal-settings.js'; + +/** + * Strict whitelist of supported placeholders. + * Only these placeholders will be replaced in legal page content. + * Unknown placeholders remain unchanged. + */ +const SUPPORTED_PLACEHOLDERS = new Set([ + 'COMPANY_NAME', + 'LEGAL_ENTITY_NAME', + 'RUC_NUMBER', + 'COMPANY_ADDRESS', + 'COMPANY_CITY', + 'COMPANY_COUNTRY', + 'SUPPORT_EMAIL', + 'LEGAL_EMAIL', + 'GOVERNING_LAW', + 'JURISDICTION_CITY', + 'CURRENT_YEAR', + 'LAST_UPDATED_DATE', +]); + +/** + * Replace legal placeholders in content using strict whitelist mapping. + * + * Rules: + * - Only supported placeholders are replaced + * - Unknown placeholders remain unchanged + * - Missing values are replaced with empty string + * - No code execution or dynamic evaluation + * - Replacement is pure string substitution + * + * @param content - The markdown/text content containing {{PLACEHOLDER}} tokens + * @param updatedAt - The page's updated_at timestamp (for LAST_UPDATED_DATE) + * @returns Content with placeholders replaced + */ +export async function replaceLegalPlaceholders( + content: string, + updatedAt?: string +): Promise { + if (!content) return content; + + // Fetch legal settings values from DB + const settingsValues = await getLegalSettingsValues(); + + // Build the full replacement map + const replacements: Record = { ...settingsValues }; + + // Dynamic values + replacements['CURRENT_YEAR'] = new Date().getFullYear().toString(); + + if (updatedAt) { + try { + const date = new Date(updatedAt); + if (!isNaN(date.getTime())) { + replacements['LAST_UPDATED_DATE'] = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } else { + replacements['LAST_UPDATED_DATE'] = updatedAt; + } + } catch { + replacements['LAST_UPDATED_DATE'] = updatedAt; + } + } + + // Replace only whitelisted placeholders using a single regex pass + // Matches {{PLACEHOLDER_NAME}} where PLACEHOLDER_NAME is uppercase letters and underscores + return content.replace(/\{\{([A-Z_]+)\}\}/g, (match, placeholderName) => { + // Only replace if the placeholder is in the whitelist + if (!SUPPORTED_PLACEHOLDERS.has(placeholderName)) { + return match; // Unknown placeholder - leave unchanged + } + + // Return the value or empty string if missing + return replacements[placeholderName] ?? ''; + }); +} diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts index 360997b..a86093c 100644 --- a/backend/src/routes/contacts.ts +++ b/backend/src/routes/contacts.ts @@ -1,13 +1,37 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, dbGet, dbAll, contacts, emailSubscribers } from '../db/index.js'; +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 { emailService } from '../lib/email.js'; const contactsRouter = new Hono(); +// ==================== Sanitization Helpers ==================== + +/** + * Sanitize a string to prevent HTML injection + * Escapes HTML special characters + */ +function sanitizeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Sanitize email header values to prevent email header injection + * Strips newlines and carriage returns that could be used to inject headers + */ +function sanitizeHeaderValue(str: string): string { + return str.replace(/[\r\n]/g, '').trim(); +} + const createContactSchema = z.object({ name: z.string().min(2), email: z.string().email(), @@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => { const now = getNow(); const id = generateId(); + // Sanitize header-sensitive values to prevent email header injection + const sanitizedEmail = sanitizeHeaderValue(data.email); + const sanitizedName = sanitizeHeaderValue(data.name); + const newContact = { id, - name: data.name, - email: data.email, + name: sanitizedName, + email: sanitizedEmail, message: data.message, status: 'new' as const, createdAt: now, }; + // Always store the message in admin, regardless of email outcome await (db as any).insert(contacts).values(newContact); + // Send email notification to support email (non-blocking) + try { + // Retrieve support_email from legal_settings + const settings = await dbGet( + (db as any).select().from(legalSettings).limit(1) + ); + + const supportEmail = settings?.supportEmail; + + if (supportEmail) { + const websiteUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; + + // Sanitize all values for HTML display + const safeName = sanitizeHtml(sanitizedName); + const safeEmail = sanitizeHtml(sanitizedEmail); + const safeMessage = sanitizeHtml(data.message); + + const subject = `New Contact Form Message – ${websiteUrl}`; + + const bodyHtml = ` +

${safeName} (${safeEmail}) sent a message:

+
${safeMessage}
+

Reply directly to this email to respond to ${safeName}.

+ `; + + const bodyText = [ + `${sanitizedName} (${sanitizedEmail}) sent a message:`, + '', + data.message, + '', + `Reply directly to this email to respond to ${sanitizedName}.`, + ].join('\n'); + + const emailResult = await emailService.sendCustomEmail({ + to: supportEmail, + subject, + bodyHtml, + bodyText, + replyTo: sanitizedEmail, + }); + + if (!emailResult.success) { + console.error('[Contact Form] Failed to send email notification:', emailResult.error); + } + } else { + console.warn('[Contact Form] No support email configured in legal settings – skipping email notification'); + } + } catch (emailError: any) { + // Log the error but do NOT break the contact form UX + console.error('[Contact Form] Error sending email notification:', emailError?.message || emailError); + } + return c.json({ message: 'Message sent successfully' }, 201); }); diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index a8235f4..058e2ba 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -5,6 +5,7 @@ import { requireAuth } from '../lib/auth.js'; import { getNow, generateId } from '../lib/utils.js'; import emailService from '../lib/email.js'; import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js'; +import { getQueueStatus } from '../lib/emailQueue.js'; const emailsRouter = new Hono(); @@ -195,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer' // ==================== Email Sending Routes ==================== -// Send email using template to event attendees +// Send email using template to event attendees (non-blocking, queued) emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => { const { eventId } = c.req.param(); const user = (c as any).get('user'); @@ -206,7 +207,8 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a return c.json({ error: 'Template slug is required' }, 400); } - const result = await emailService.sendToEventAttendees({ + // Queue emails for background processing instead of sending synchronously + const result = await emailService.queueEventEmails({ eventId, templateSlug, customVariables, @@ -411,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => { return c.json(result); }); +// Get email queue status +emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => { + const status = getQueueStatus(); + return c.json({ status }); +}); + export default emailsRouter; diff --git a/backend/src/routes/legal-pages.ts b/backend/src/routes/legal-pages.ts index 6e5f0a0..321071f 100644 --- a/backend/src/routes/legal-pages.ts +++ b/backend/src/routes/legal-pages.ts @@ -3,6 +3,7 @@ import { db, dbGet, dbAll, legalPages } from '../db/index.js'; import { eq, desc } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow, generateId } from '../lib/utils.js'; +import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js'; import fs from 'fs'; import path from 'path'; @@ -171,12 +172,15 @@ legalPagesRouter.get('/:slug', async (c) => { // Get localized content with fallback const { title, contentMarkdown } = getLocalizedContent(page, locale); + // Replace legal placeholders before returning + const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt); + return c.json({ page: { id: page.id, slug: page.slug, title, - contentMarkdown, + contentMarkdown: processedContent, updatedAt: page.updatedAt, source: 'database', } @@ -195,11 +199,14 @@ legalPagesRouter.get('/:slug', async (c) => { ? (titles?.es || titles?.en || slug) : (titles?.en || titles?.es || slug); + // Replace legal placeholders in filesystem content too + const processedContent = await replaceLegalPlaceholders(content); + return c.json({ page: { slug, title, - contentMarkdown: content, + contentMarkdown: processedContent, source: 'filesystem', } }); diff --git a/backend/src/routes/legal-settings.ts b/backend/src/routes/legal-settings.ts new file mode 100644 index 0000000..758d3e1 --- /dev/null +++ b/backend/src/routes/legal-settings.ts @@ -0,0 +1,146 @@ +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { db, dbGet, legalSettings } from '../db/index.js'; +import { eq } from 'drizzle-orm'; +import { requireAuth } from '../lib/auth.js'; +import { generateId, getNow } from '../lib/utils.js'; + +interface UserContext { + id: string; + email: string; + name: string; + role: string; +} + +const legalSettingsRouter = new Hono<{ Variables: { user: UserContext } }>(); + +// Validation schema for updating legal settings +const updateLegalSettingsSchema = z.object({ + companyName: z.string().optional().nullable(), + legalEntityName: z.string().optional().nullable(), + rucNumber: z.string().optional().nullable(), + companyAddress: z.string().optional().nullable(), + companyCity: z.string().optional().nullable(), + companyCountry: z.string().optional().nullable(), + supportEmail: z.string().email().optional().nullable().or(z.literal('')), + legalEmail: z.string().email().optional().nullable().or(z.literal('')), + governingLaw: z.string().optional().nullable(), + jurisdictionCity: z.string().optional().nullable(), +}); + +// Get legal settings (admin only) +legalSettingsRouter.get('/', requireAuth(['admin']), async (c) => { + const settings = await dbGet( + (db as any).select().from(legalSettings).limit(1) + ); + + if (!settings) { + // Return empty defaults + return c.json({ + settings: { + companyName: null, + legalEntityName: null, + rucNumber: null, + companyAddress: null, + companyCity: null, + companyCountry: null, + supportEmail: null, + legalEmail: null, + governingLaw: null, + jurisdictionCity: null, + }, + }); + } + + return c.json({ settings }); +}); + +// Internal helper: get legal settings for placeholder replacement (no auth required) +// This is called server-side from legal-pages route, not exposed as HTTP endpoint +export async function getLegalSettingsValues(): Promise> { + const settings = await dbGet( + (db as any).select().from(legalSettings).limit(1) + ); + + if (!settings) { + return {}; + } + + const values: Record = {}; + if (settings.companyName) values['COMPANY_NAME'] = settings.companyName; + if (settings.legalEntityName) values['LEGAL_ENTITY_NAME'] = settings.legalEntityName; + if (settings.rucNumber) values['RUC_NUMBER'] = settings.rucNumber; + if (settings.companyAddress) values['COMPANY_ADDRESS'] = settings.companyAddress; + if (settings.companyCity) values['COMPANY_CITY'] = settings.companyCity; + if (settings.companyCountry) values['COMPANY_COUNTRY'] = settings.companyCountry; + if (settings.supportEmail) values['SUPPORT_EMAIL'] = settings.supportEmail; + if (settings.legalEmail) values['LEGAL_EMAIL'] = settings.legalEmail; + if (settings.governingLaw) values['GOVERNING_LAW'] = settings.governingLaw; + if (settings.jurisdictionCity) values['JURISDICTION_CITY'] = settings.jurisdictionCity; + + return values; +} + +// Update legal settings (admin only) +legalSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateLegalSettingsSchema), async (c) => { + const data = c.req.valid('json'); + const user = c.get('user'); + const now = getNow(); + + // Check if settings exist + const existing = await dbGet( + (db as any).select().from(legalSettings).limit(1) + ); + + if (!existing) { + // Create new settings record + const id = generateId(); + const newSettings = { + id, + companyName: data.companyName || null, + legalEntityName: data.legalEntityName || null, + rucNumber: data.rucNumber || null, + companyAddress: data.companyAddress || null, + companyCity: data.companyCity || null, + companyCountry: data.companyCountry || null, + supportEmail: data.supportEmail || null, + legalEmail: data.legalEmail || null, + governingLaw: data.governingLaw || null, + jurisdictionCity: data.jurisdictionCity || null, + updatedAt: now, + updatedBy: user.id, + }; + + await (db as any).insert(legalSettings).values(newSettings); + + return c.json({ settings: newSettings, message: 'Legal settings created successfully' }, 201); + } + + // Update existing settings + const updateData: Record = { + ...data, + updatedAt: now, + updatedBy: user.id, + }; + + // Normalize empty strings to null + for (const key of Object.keys(updateData)) { + if (updateData[key] === '') { + updateData[key] = null; + } + } + + await (db as any) + .update(legalSettings) + .set(updateData) + .where(eq((legalSettings as any).id, existing.id)); + + const updated = await dbGet( + (db as any).select().from(legalSettings).where(eq((legalSettings as any).id, existing.id)) + ); + + return c.json({ settings: updated, message: 'Legal settings updated successfully' }); +}); + +export default legalSettingsRouter; diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 7535707..6374ad2 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -189,7 +189,6 @@ export default function AdminEmailsPage() { return; } - setSending(true); try { const res = await emailsApi.sendToEvent(composeForm.eventId, { templateSlug: composeForm.templateSlug, @@ -197,20 +196,15 @@ export default function AdminEmailsPage() { customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined, }); - if (res.success || res.sentCount > 0) { - toast.success(`Sent ${res.sentCount} emails successfully`); - if (res.failedCount > 0) { - toast.error(`${res.failedCount} emails failed`); - } + if (res.success) { + toast.success(`${res.queuedCount} email(s) are being sent in the background.`); clearDraft(); setShowRecipientPreview(false); } else { - toast.error('Failed to send emails'); + toast.error(res.error || 'Failed to queue emails'); } } catch (error: any) { toast.error(error.message || 'Failed to send emails'); - } finally { - setSending(false); } }; diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 5bbe25c..1a78d32 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -423,7 +423,6 @@ export default function AdminEventDetailPage() { return; } - setSending(true); try { const res = await emailsApi.sendToEvent(eventId, { templateSlug: selectedTemplate, @@ -432,14 +431,12 @@ export default function AdminEventDetailPage() { }); if (res.success) { - toast.success(`Email sent to ${res.sentCount} recipients`); + toast.success(`${res.queuedCount} email(s) are being sent in the background.`); } else { - toast.error(`Sent: ${res.sentCount}, Failed: ${res.failedCount}`); + toast.error(res.error || 'Failed to queue emails'); } } catch (error: any) { toast.error(error.message || 'Failed to send emails'); - } finally { - setSending(false); } }; diff --git a/frontend/src/app/admin/legal-pages/page.tsx b/frontend/src/app/admin/legal-pages/page.tsx index 82b0dc5..1245072 100644 --- a/frontend/src/app/admin/legal-pages/page.tsx +++ b/frontend/src/app/admin/legal-pages/page.tsx @@ -421,6 +421,46 @@ export default function AdminLegalPagesPage() { + + {/* Available Placeholders */} +
+

+ {locale === 'es' ? 'Marcadores de posición disponibles:' : 'Available placeholders:'} +

+

+ {locale === 'es' + ? 'Puedes usar estos marcadores en el contenido. Se reemplazarán automáticamente con los valores configurados en' + : 'You can use these placeholders in the content. They will be automatically replaced with the values configured in' + } + {' '} + + {locale === 'es' ? 'Configuración > Legal' : 'Settings > Legal Settings'} + . +

+
+ {[ + { placeholder: '{{COMPANY_NAME}}', label: locale === 'es' ? 'Nombre de la empresa' : 'Company name' }, + { placeholder: '{{LEGAL_ENTITY_NAME}}', label: locale === 'es' ? 'Nombre de la entidad legal' : 'Legal entity name' }, + { placeholder: '{{RUC_NUMBER}}', label: locale === 'es' ? 'Número de RUC' : 'RUC number' }, + { placeholder: '{{COMPANY_ADDRESS}}', label: locale === 'es' ? 'Dirección de la empresa' : 'Company address' }, + { placeholder: '{{COMPANY_CITY}}', label: locale === 'es' ? 'Ciudad' : 'City' }, + { placeholder: '{{COMPANY_COUNTRY}}', label: locale === 'es' ? 'País' : 'Country' }, + { placeholder: '{{SUPPORT_EMAIL}}', label: locale === 'es' ? 'Email de soporte' : 'Support email' }, + { placeholder: '{{LEGAL_EMAIL}}', label: locale === 'es' ? 'Email legal' : 'Legal email' }, + { placeholder: '{{GOVERNING_LAW}}', label: locale === 'es' ? 'Ley aplicable' : 'Governing law' }, + { placeholder: '{{JURISDICTION_CITY}}', label: locale === 'es' ? 'Ciudad de jurisdicción' : 'Jurisdiction city' }, + { placeholder: '{{CURRENT_YEAR}}', label: locale === 'es' ? 'Año actual (automático)' : 'Current year (automatic)' }, + { placeholder: '{{LAST_UPDATED_DATE}}', label: locale === 'es' ? 'Fecha de última actualización (automático)' : 'Last updated date (automatic)' }, + ].map(({ placeholder, label }) => ( +
+ + {placeholder} + + {label} +
+ ))} +
+
diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index e3af533..03276d4 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { siteSettingsApi, eventsApi, SiteSettings, TimezoneOption, Event } from '@/lib/api'; +import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; @@ -15,13 +15,18 @@ import { WrenchScrewdriverIcon, CheckCircleIcon, StarIcon, + ScaleIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; +type SettingsTab = 'general' | 'legal'; + export default function AdminSettingsPage() { const { t, locale } = useLanguage(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [savingLegal, setSavingLegal] = useState(false); + const [activeTab, setActiveTab] = useState('general'); const [timezones, setTimezones] = useState([]); const [featuredEvent, setFeaturedEvent] = useState(null); const [clearingFeatured, setClearingFeatured] = useState(false); @@ -43,18 +48,35 @@ export default function AdminSettingsPage() { maintenanceMessageEs: null, }); + const [legalSettings, setLegalSettings] = useState({ + companyName: null, + legalEntityName: null, + rucNumber: null, + companyAddress: null, + companyCity: null, + companyCountry: null, + supportEmail: null, + legalEmail: null, + governingLaw: null, + jurisdictionCity: null, + }); + + const [legalErrors, setLegalErrors] = useState>({}); + useEffect(() => { loadData(); }, []); const loadData = async () => { try { - const [settingsRes, timezonesRes] = await Promise.all([ + const [settingsRes, timezonesRes, legalRes] = await Promise.all([ siteSettingsApi.get(), siteSettingsApi.getTimezones(), + legalSettingsApi.get().catch(() => ({ settings: {} as LegalSettingsData })), ]); setSettings(settingsRes.settings); setTimezones(timezonesRes.timezones); + setLegalSettings(legalRes.settings); // Load featured event details if one is set if (settingsRes.settings.featuredEventId) { @@ -100,10 +122,53 @@ export default function AdminSettingsPage() { } }; + const validateLegalSettings = (): boolean => { + const errors: Record = {}; + + // Validate email formats if provided + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (legalSettings.supportEmail && !emailRegex.test(legalSettings.supportEmail)) { + errors.supportEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address'; + } + if (legalSettings.legalEmail && !emailRegex.test(legalSettings.legalEmail)) { + errors.legalEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address'; + } + + setLegalErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSaveLegal = async () => { + if (!validateLegalSettings()) return; + + setSavingLegal(true); + try { + const response = await legalSettingsApi.update(legalSettings); + setLegalSettings(response.settings); + toast.success(locale === 'es' ? 'Configuración legal guardada' : 'Legal settings saved'); + } catch (error: any) { + toast.error(error.message || 'Failed to save legal settings'); + } finally { + setSavingLegal(false); + } + }; + const updateSetting = (key: K, value: SiteSettings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })); }; + const updateLegalSetting = (key: K, value: LegalSettingsData[K]) => { + setLegalSettings((prev) => ({ ...prev, [key]: value })); + // Clear error for this field when user types + if (legalErrors[key]) { + setLegalErrors((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + } + }; + if (loading) { return (
@@ -112,6 +177,21 @@ export default function AdminSettingsPage() { ); } + const tabs: { id: SettingsTab; label: string; labelEs: string; icon: React.ReactNode }[] = [ + { + id: 'general', + label: 'General Settings', + labelEs: 'Configuración General', + icon: , + }, + { + id: 'legal', + label: 'Legal Settings', + labelEs: 'Configuración Legal', + icon: , + }, + ]; + return (
@@ -126,369 +206,589 @@ export default function AdminSettingsPage() { : 'Configure general website settings'}

- + {activeTab === 'general' && ( + + )} + {activeTab === 'legal' && ( + + )}
-
- {/* Timezone Settings */} - -
-
-
- + {/* Tabs */} +
+ +
+ + {/* General Settings Tab */} + {activeTab === 'general' && ( +
+ {/* Timezone Settings */} + +
+
+
+ +
+
+

+ {locale === 'es' ? 'Zona Horaria' : 'Timezone'} +

+

+ {locale === 'es' + ? 'Zona horaria para mostrar las fechas de eventos' + : 'Timezone used for displaying event dates'} +

+
-
-

- {locale === 'es' ? 'Zona Horaria' : 'Timezone'} -

-

+ +

+ + +

{locale === 'es' - ? 'Zona horaria para mostrar las fechas de eventos' - : 'Timezone used for displaying event dates'} + ? 'Esta zona horaria se usará como referencia para las fechas de eventos.' + : 'This timezone will be used as reference for event dates.'}

+ -
- - -

- {locale === 'es' - ? 'Esta zona horaria se usará como referencia para las fechas de eventos.' - : 'This timezone will be used as reference for event dates.'} -

-
-
-
- - {/* Featured Event */} - -
-
-
- + {/* Featured Event */} + +
+
+
+ +
+
+

+ {locale === 'es' ? 'Evento Destacado' : 'Featured Event'} +

+

+ {locale === 'es' + ? 'El evento destacado aparece en la página de inicio y linktree' + : 'The featured event is displayed on the homepage and linktree'} +

+
-
-

- {locale === 'es' ? 'Evento Destacado' : 'Featured Event'} -

-

- {locale === 'es' - ? 'El evento destacado aparece en la página de inicio y linktree' - : 'The featured event is displayed on the homepage and linktree'} -

-
-
- {featuredEvent ? ( -
-
-
- {featuredEvent.bannerUrl && ( - {featuredEvent.title} - )} -
-

{featuredEvent.title}

-

- {new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - timeZone: 'America/Asuncion', - })} -

-

- {locale === 'es' ? 'Estado:' : 'Status:'} {featuredEvent.status} -

+ {featuredEvent ? ( +
+
+
+ {featuredEvent.bannerUrl && ( + {featuredEvent.title} + )} +
+

{featuredEvent.title}

+

+ {new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'America/Asuncion', + })} +

+

+ {locale === 'es' ? 'Estado:' : 'Status:'} {featuredEvent.status} +

+
+
+
+ + {locale === 'es' ? 'Cambiar' : 'Change'} + +
-
- - {locale === 'es' ? 'Cambiar' : 'Change'} - - +
+ ) : ( +
+

+ {locale === 'es' + ? 'No hay evento destacado. El próximo evento publicado se mostrará automáticamente.' + : 'No featured event set. The next upcoming published event will be shown automatically.'} +

+ + {locale === 'es' ? 'Ir a Eventos para destacar uno' : 'Go to Events to feature one'} + +
+ )} + +

+ {locale === 'es' + ? 'Cuando el evento destacado termine o se despublique, el sistema mostrará automáticamente el próximo evento.' + : 'When the featured event ends or is unpublished, the system will automatically show the next upcoming event.'} +

+
+ + + {/* Site Information */} + +
+
+
+ +
+
+

+ {locale === 'es' ? 'Información del Sitio' : 'Site Information'} +

+

+ {locale === 'es' + ? 'Información básica del sitio web' + : 'Basic website information'} +

+
+
+ +
+
+ updateSetting('siteName', e.target.value)} + placeholder="Spanglish" + /> +
+ +
+
+ +