Compare commits
2 Commits
a5e939221d
...
d44ac949b5
| Author | SHA1 | Date | |
|---|---|---|---|
| d44ac949b5 | |||
|
|
b9f46b02cc |
@@ -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
|
# 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
|
# Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
|
||||||
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, 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
|
||||||
|
|||||||
@@ -437,6 +437,25 @@ async function migrate() {
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Legal settings table for legal page placeholder values
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS legal_settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
company_name TEXT,
|
||||||
|
legal_entity_name TEXT,
|
||||||
|
ruc_number TEXT,
|
||||||
|
company_address TEXT,
|
||||||
|
company_city TEXT,
|
||||||
|
company_country TEXT,
|
||||||
|
support_email TEXT,
|
||||||
|
legal_email TEXT,
|
||||||
|
governing_law TEXT,
|
||||||
|
jurisdiction_city TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
updated_by TEXT REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
} else {
|
} else {
|
||||||
// PostgreSQL migrations
|
// PostgreSQL migrations
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -822,6 +841,25 @@ async function migrate() {
|
|||||||
updated_at TIMESTAMP NOT NULL
|
updated_at TIMESTAMP NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Legal settings table for legal page placeholder values
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS legal_settings (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
company_name VARCHAR(255),
|
||||||
|
legal_entity_name VARCHAR(255),
|
||||||
|
ruc_number VARCHAR(50),
|
||||||
|
company_address TEXT,
|
||||||
|
company_city VARCHAR(100),
|
||||||
|
company_country VARCHAR(100),
|
||||||
|
support_email VARCHAR(255),
|
||||||
|
legal_email VARCHAR(255),
|
||||||
|
governing_law VARCHAR(255),
|
||||||
|
jurisdiction_city VARCHAR(100),
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
updated_by UUID REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Migrations completed successfully!');
|
console.log('Migrations completed successfully!');
|
||||||
|
|||||||
@@ -281,6 +281,23 @@ export const sqliteFaqQuestions = sqliteTable('faq_questions', {
|
|||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legal Settings table for legal page placeholder values
|
||||||
|
export const sqliteLegalSettings = sqliteTable('legal_settings', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
companyName: text('company_name'),
|
||||||
|
legalEntityName: text('legal_entity_name'),
|
||||||
|
rucNumber: text('ruc_number'),
|
||||||
|
companyAddress: text('company_address'),
|
||||||
|
companyCity: text('company_city'),
|
||||||
|
companyCountry: text('company_country'),
|
||||||
|
supportEmail: text('support_email'),
|
||||||
|
legalEmail: text('legal_email'),
|
||||||
|
governingLaw: text('governing_law'),
|
||||||
|
jurisdictionCity: text('jurisdiction_city'),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
// Site Settings table for global website configuration
|
// Site Settings table for global website configuration
|
||||||
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -578,6 +595,23 @@ export const pgFaqQuestions = pgTable('faq_questions', {
|
|||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legal Settings table for legal page placeholder values
|
||||||
|
export const pgLegalSettings = pgTable('legal_settings', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
companyName: varchar('company_name', { length: 255 }),
|
||||||
|
legalEntityName: varchar('legal_entity_name', { length: 255 }),
|
||||||
|
rucNumber: varchar('ruc_number', { length: 50 }),
|
||||||
|
companyAddress: pgText('company_address'),
|
||||||
|
companyCity: varchar('company_city', { length: 100 }),
|
||||||
|
companyCountry: varchar('company_country', { length: 100 }),
|
||||||
|
supportEmail: varchar('support_email', { length: 255 }),
|
||||||
|
legalEmail: varchar('legal_email', { length: 255 }),
|
||||||
|
governingLaw: varchar('governing_law', { length: 255 }),
|
||||||
|
jurisdictionCity: varchar('jurisdiction_city', { length: 100 }),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
// Site Settings table for global website configuration
|
// Site Settings table for global website configuration
|
||||||
export const pgSiteSettings = pgTable('site_settings', {
|
export const pgSiteSettings = pgTable('site_settings', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -623,6 +657,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
|
|||||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||||
|
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
|
||||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
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 LegalPage = typeof sqliteLegalPages.$inferSelect;
|
||||||
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
|
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
|
||||||
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
||||||
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||||
|
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||||
|
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||||
@@ -21,8 +21,10 @@ import paymentOptionsRoutes from './routes/payment-options.js';
|
|||||||
import dashboardRoutes from './routes/dashboard.js';
|
import dashboardRoutes from './routes/dashboard.js';
|
||||||
import siteSettingsRoutes from './routes/site-settings.js';
|
import siteSettingsRoutes from './routes/site-settings.js';
|
||||||
import legalPagesRoutes from './routes/legal-pages.js';
|
import legalPagesRoutes from './routes/legal-pages.js';
|
||||||
|
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||||
import faqRoutes from './routes/faq.js';
|
import faqRoutes from './routes/faq.js';
|
||||||
import emailService from './lib/email.js';
|
import emailService from './lib/email.js';
|
||||||
|
import { initEmailQueue } from './lib/emailQueue.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -1856,6 +1858,7 @@ app.route('/api/payment-options', paymentOptionsRoutes);
|
|||||||
app.route('/api/dashboard', dashboardRoutes);
|
app.route('/api/dashboard', dashboardRoutes);
|
||||||
app.route('/api/site-settings', siteSettingsRoutes);
|
app.route('/api/site-settings', siteSettingsRoutes);
|
||||||
app.route('/api/legal-pages', legalPagesRoutes);
|
app.route('/api/legal-pages', legalPagesRoutes);
|
||||||
|
app.route('/api/legal-settings', legalSettingsRoutes);
|
||||||
app.route('/api/faq', faqRoutes);
|
app.route('/api/faq', faqRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
@@ -1871,6 +1874,9 @@ app.onError((err, c) => {
|
|||||||
|
|
||||||
const port = parseInt(process.env.PORT || '3001');
|
const port = parseInt(process.env.PORT || '3001');
|
||||||
|
|
||||||
|
// Initialize email queue with the email service reference
|
||||||
|
initEmailQueue(emailService);
|
||||||
|
|
||||||
// Initialize email templates on startup
|
// Initialize email templates on startup
|
||||||
emailService.seedDefaultTemplates().catch(err => {
|
emailService.seedDefaultTemplates().catch(err => {
|
||||||
console.error('[Email] Failed to seed templates:', err);
|
console.error('[Email] Failed to seed templates:', err);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
defaultTemplates,
|
defaultTemplates,
|
||||||
type DefaultTemplate
|
type DefaultTemplate
|
||||||
} from './emailTemplates.js';
|
} from './emailTemplates.js';
|
||||||
|
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import type { Transporter } from 'nodemailer';
|
import type { Transporter } from 'nodemailer';
|
||||||
|
|
||||||
@@ -1173,6 +1174,100 @@ export const emailService = {
|
|||||||
errors,
|
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<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 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<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 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)
|
* Send a custom email (not from template)
|
||||||
@@ -1183,10 +1278,11 @@ export const emailService = {
|
|||||||
subject: string;
|
subject: string;
|
||||||
bodyHtml: string;
|
bodyHtml: string;
|
||||||
bodyText?: string;
|
bodyText?: string;
|
||||||
|
replyTo?: string;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
sentBy: string;
|
sentBy?: string | null;
|
||||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
}): 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 = {
|
const allVariables = {
|
||||||
...this.getCommonVariables(),
|
...this.getCommonVariables(),
|
||||||
@@ -1208,7 +1304,7 @@ export const emailService = {
|
|||||||
subject,
|
subject,
|
||||||
bodyHtml: finalBodyHtml,
|
bodyHtml: finalBodyHtml,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
sentBy,
|
sentBy: sentBy || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1218,6 +1314,7 @@ export const emailService = {
|
|||||||
subject,
|
subject,
|
||||||
html: finalBodyHtml,
|
html: finalBodyHtml,
|
||||||
text: bodyText,
|
text: bodyText,
|
||||||
|
replyTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update log
|
// Update log
|
||||||
|
|||||||
194
backend/src/lib/emailQueue.ts
Normal file
194
backend/src/lib/emailQueue.ts
Normal file
@@ -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<string, any>;
|
||||||
|
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<typeof setTimeout> | 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<void> {
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
80
backend/src/lib/legal-placeholders.ts
Normal file
80
backend/src/lib/legal-placeholders.ts
Normal file
@@ -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<string> {
|
||||||
|
if (!content) return content;
|
||||||
|
|
||||||
|
// Fetch legal settings values from DB
|
||||||
|
const settingsValues = await getLegalSettingsValues();
|
||||||
|
|
||||||
|
// Build the full replacement map
|
||||||
|
const replacements: Record<string, string> = { ...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] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,13 +1,37 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
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 { eq, desc } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow } from '../lib/utils.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
import { emailService } from '../lib/email.js';
|
||||||
|
|
||||||
const contactsRouter = new Hono();
|
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, '"')
|
||||||
|
.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({
|
const createContactSchema = z.object({
|
||||||
name: z.string().min(2),
|
name: z.string().min(2),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
|||||||
const now = getNow();
|
const now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
|
|
||||||
|
// Sanitize header-sensitive values to prevent email header injection
|
||||||
|
const sanitizedEmail = sanitizeHeaderValue(data.email);
|
||||||
|
const sanitizedName = sanitizeHeaderValue(data.name);
|
||||||
|
|
||||||
const newContact = {
|
const newContact = {
|
||||||
id,
|
id,
|
||||||
name: data.name,
|
name: sanitizedName,
|
||||||
email: data.email,
|
email: sanitizedEmail,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
status: 'new' as const,
|
status: 'new' as const,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always store the message in admin, regardless of email outcome
|
||||||
await (db as any).insert(contacts).values(newContact);
|
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<any>(
|
||||||
|
(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 = `
|
||||||
|
<p><strong>${safeName}</strong> (${safeEmail}) sent a message:</p>
|
||||||
|
<div style="padding: 16px 20px; background-color: #f8fafc; border-left: 4px solid #3b82f6; margin: 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 1.6;">${safeMessage}</div>
|
||||||
|
<p style="color: #64748b; font-size: 13px;">Reply directly to this email to respond to ${safeName}.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
return c.json({ message: 'Message sent successfully' }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireAuth } from '../lib/auth.js';
|
|||||||
import { getNow, generateId } from '../lib/utils.js';
|
import { getNow, generateId } from '../lib/utils.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||||
|
import { getQueueStatus } from '../lib/emailQueue.js';
|
||||||
|
|
||||||
const emailsRouter = new Hono();
|
const emailsRouter = new Hono();
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
|||||||
|
|
||||||
// ==================== Email Sending Routes ====================
|
// ==================== 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) => {
|
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const { eventId } = c.req.param();
|
const { eventId } = c.req.param();
|
||||||
const user = (c as any).get('user');
|
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);
|
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,
|
eventId,
|
||||||
templateSlug,
|
templateSlug,
|
||||||
customVariables,
|
customVariables,
|
||||||
@@ -411,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json(result);
|
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;
|
export default emailsRouter;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, legalPages } from '../db/index.js';
|
|||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow, generateId } from '../lib/utils.js';
|
import { getNow, generateId } from '../lib/utils.js';
|
||||||
|
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -171,12 +172,15 @@ legalPagesRouter.get('/:slug', async (c) => {
|
|||||||
// Get localized content with fallback
|
// Get localized content with fallback
|
||||||
const { title, contentMarkdown } = getLocalizedContent(page, locale);
|
const { title, contentMarkdown } = getLocalizedContent(page, locale);
|
||||||
|
|
||||||
|
// Replace legal placeholders before returning
|
||||||
|
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
page: {
|
page: {
|
||||||
id: page.id,
|
id: page.id,
|
||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
title,
|
title,
|
||||||
contentMarkdown,
|
contentMarkdown: processedContent,
|
||||||
updatedAt: page.updatedAt,
|
updatedAt: page.updatedAt,
|
||||||
source: 'database',
|
source: 'database',
|
||||||
}
|
}
|
||||||
@@ -195,11 +199,14 @@ legalPagesRouter.get('/:slug', async (c) => {
|
|||||||
? (titles?.es || titles?.en || slug)
|
? (titles?.es || titles?.en || slug)
|
||||||
: (titles?.en || titles?.es || slug);
|
: (titles?.en || titles?.es || slug);
|
||||||
|
|
||||||
|
// Replace legal placeholders in filesystem content too
|
||||||
|
const processedContent = await replaceLegalPlaceholders(content);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
page: {
|
page: {
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
contentMarkdown: content,
|
contentMarkdown: processedContent,
|
||||||
source: 'filesystem',
|
source: 'filesystem',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
146
backend/src/routes/legal-settings.ts
Normal file
146
backend/src/routes/legal-settings.ts
Normal file
@@ -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<any>(
|
||||||
|
(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<Record<string, string>> {
|
||||||
|
const settings = await dbGet<any>(
|
||||||
|
(db as any).select().from(legalSettings).limit(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
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<any>(
|
||||||
|
(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<string, any> = {
|
||||||
|
...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;
|
||||||
@@ -189,7 +189,6 @@ export default function AdminEmailsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||||
templateSlug: composeForm.templateSlug,
|
templateSlug: composeForm.templateSlug,
|
||||||
@@ -197,20 +196,15 @@ export default function AdminEmailsPage() {
|
|||||||
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.success || res.sentCount > 0) {
|
if (res.success) {
|
||||||
toast.success(`Sent ${res.sentCount} emails successfully`);
|
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||||||
if (res.failedCount > 0) {
|
|
||||||
toast.error(`${res.failedCount} emails failed`);
|
|
||||||
}
|
|
||||||
clearDraft();
|
clearDraft();
|
||||||
setShowRecipientPreview(false);
|
setShowRecipientPreview(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to send emails');
|
toast.error(res.error || 'Failed to queue emails');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to send emails');
|
toast.error(error.message || 'Failed to send emails');
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,6 @@ export default function AdminEventDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await emailsApi.sendToEvent(eventId, {
|
const res = await emailsApi.sendToEvent(eventId, {
|
||||||
templateSlug: selectedTemplate,
|
templateSlug: selectedTemplate,
|
||||||
@@ -432,14 +431,12 @@ export default function AdminEventDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(`Email sent to ${res.sentCount} recipients`);
|
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Sent: ${res.sentCount}, Failed: ${res.failedCount}`);
|
toast.error(res.error || 'Failed to queue emails');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to send emails');
|
toast.error(error.message || 'Failed to send emails');
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,46 @@ export default function AdminLegalPagesPage() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Available Placeholders */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-2">
|
||||||
|
{locale === 'es' ? 'Marcadores de posición disponibles:' : 'Available placeholders:'}
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-700 mb-3">
|
||||||
|
{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'
|
||||||
|
}
|
||||||
|
{' '}
|
||||||
|
<a href="/admin/settings" className="underline font-medium hover:text-blue-900">
|
||||||
|
{locale === 'es' ? 'Configuración > Legal' : 'Settings > Legal Settings'}
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<div key={placeholder} className="flex items-center gap-2">
|
||||||
|
<code className="bg-blue-100 text-blue-900 px-1.5 py-0.5 rounded text-xs font-mono whitespace-nowrap">
|
||||||
|
{placeholder}
|
||||||
|
</code>
|
||||||
|
<span className="text-blue-700 text-xs truncate">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -384,7 +384,7 @@ export const emailsApi = {
|
|||||||
customVariables?: Record<string, any>;
|
customVariables?: Record<string, any>;
|
||||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||||
}) =>
|
}) =>
|
||||||
fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
|
fetchApi<{ success: boolean; queuedCount: number; error?: string }>(
|
||||||
`/api/emails/send/event/${eventId}`,
|
`/api/emails/send/event/${eventId}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1008,6 +1008,34 @@ export const siteSettingsApi = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== Legal Settings API ====================
|
||||||
|
|
||||||
|
export interface LegalSettingsData {
|
||||||
|
id?: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
legalEntityName?: string | null;
|
||||||
|
rucNumber?: string | null;
|
||||||
|
companyAddress?: string | null;
|
||||||
|
companyCity?: string | null;
|
||||||
|
companyCountry?: string | null;
|
||||||
|
supportEmail?: string | null;
|
||||||
|
legalEmail?: string | null;
|
||||||
|
governingLaw?: string | null;
|
||||||
|
jurisdictionCity?: string | null;
|
||||||
|
updatedAt?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const legalSettingsApi = {
|
||||||
|
get: () => fetchApi<{ settings: LegalSettingsData }>('/api/legal-settings'),
|
||||||
|
|
||||||
|
update: (data: Partial<LegalSettingsData>) =>
|
||||||
|
fetchApi<{ settings: LegalSettingsData; message: string }>('/api/legal-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// ==================== Legal Pages Types ====================
|
// ==================== Legal Pages Types ====================
|
||||||
|
|
||||||
export interface LegalPage {
|
export interface LegalPage {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'):
|
|||||||
|
|
||||||
// Get a specific legal page content - tries API first, falls back to filesystem
|
// Get a specific legal page content - tries API first, falls back to filesystem
|
||||||
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
// Try to fetch from API with locale parameter
|
// Try to fetch from API with locale parameter
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user