Add full SEO optimization for Spanglish social and language events

- Add comprehensive metadata to root layout with Open Graph, Twitter cards
- Create dynamic sitemap.ts for all pages and events
- Create robots.ts with proper allow/disallow rules
- Add JSON-LD Event structured data to event detail pages
- Add page-specific metadata to events, community, contact, FAQ pages
- Add FAQ structured data schema
- Update footer with local SEO text for Asunción, Paraguay
- Add web manifest for mobile SEO
- Create 404 page with proper noindex
- Optimize image alt text and add lazy loading
- Add NEXT_PUBLIC_SITE_URL env variable
- Add about/ folder to gitignore
This commit is contained in:
root
2026-01-30 21:05:25 +00:00
parent d0ea55dc5b
commit 47ba754f05
40 changed files with 2659 additions and 420 deletions

View File

@@ -216,6 +216,11 @@ async function migrate() {
)
`);
// Add allow_duplicate_bookings column to payment_options if it doesn't exist
try {
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Event payment overrides table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS event_payment_overrides (
@@ -497,6 +502,11 @@ async function migrate() {
)
`);
// Add allow_duplicate_bookings column to payment_options if it doesn't exist
try {
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS event_payment_overrides (
id UUID PRIMARY KEY,

View File

@@ -135,6 +135,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
cashInstructions: text('cash_instructions'),
cashInstructionsEs: text('cash_instructions_es'),
// Booking settings
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
// Metadata
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
@@ -369,6 +371,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
cashInstructions: pgText('cash_instructions'),
cashInstructionsEs: pgText('cash_instructions_es'),
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});

View File

@@ -26,13 +26,30 @@ const app = new Hono();
// Middleware
app.use('*', logger());
// CORS: Only enable in development. In production, nginx handles CORS.
if (process.env.NODE_ENV !== 'production') {
app.use('*', cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3002',
// CORS
// - In production we *typically* rely on nginx to set CORS, but enabling it here
// is a safe fallback (especially for local/proxyless deployments).
// - `FRONTEND_URL` should be set to e.g. https://spanglishcommunity.com in prod.
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const allowedOrigins = new Set<string>([
frontendUrl,
// Common alias (www) for the same site.
frontendUrl.replace('://www.', '://'),
frontendUrl.includes('://') ? frontendUrl.replace('://', '://www.') : frontendUrl,
]);
app.use(
'*',
cors({
origin: (origin) => {
// Non-browser / same-origin requests may omit Origin.
if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null;
},
// We use bearer tokens, but keeping credentials=true matches nginx config.
credentials: true,
}));
}
})
);
// OpenAPI specification
const openApiSpec = {

View File

@@ -1,7 +1,7 @@
// Email service for Spanglish platform
// Supports multiple email providers: Resend, SMTP (Nodemailer)
import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js';
import { db, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { getNow } from './utils.js';
@@ -372,17 +372,17 @@ export const emailService = {
},
/**
* Seed default templates if they don't exist
* Seed default templates if they don't exist, and update system templates with latest content
*/
async seedDefaultTemplates(): Promise<void> {
console.log('[Email] Checking for default templates...');
for (const template of defaultTemplates) {
const existing = await this.getTemplate(template.slug);
const now = getNow();
if (!existing) {
console.log(`[Email] Creating template: ${template.name}`);
const now = getNow();
await (db as any).insert(emailTemplates).values({
id: nanoid(),
@@ -401,6 +401,24 @@ export const emailService = {
createdAt: now,
updatedAt: now,
});
} else if (existing.isSystem) {
// Update system templates with latest content from defaults
console.log(`[Email] Updating system template: ${template.name}`);
await (db as any)
.update(emailTemplates)
.set({
subject: template.subject,
subjectEs: template.subjectEs,
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs,
bodyText: template.bodyText,
bodyTextEs: template.bodyTextEs,
description: template.description,
variables: JSON.stringify(template.variables),
updatedAt: now,
})
.where(eq((emailTemplates as any).slug, template.slug));
}
}
@@ -615,6 +633,159 @@ export const emailService = {
});
},
/**
* Get merged payment configuration for an event (global + overrides)
*/
async getPaymentConfig(eventId: string): Promise<Record<string, any>> {
// Get global options
const globalOptions = await (db as any)
.select()
.from(paymentOptions)
.get();
// Get event overrides
const overrides = await (db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
// Defaults
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
};
const global = globalOptions || defaults;
// Merge: override values take precedence if they're not null/undefined
return {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
bankName: overrides?.bankName ?? global.bankName,
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
bankAlias: overrides?.bankAlias ?? global.bankAlias,
bankPhone: overrides?.bankPhone ?? global.bankPhone,
bankNotes: overrides?.bankNotes ?? global.bankNotes,
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
};
},
/**
* Send payment instructions email (for TPago or Bank Transfer)
* This email is sent immediately after user clicks "Continue to Payment"
*/
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket
const ticket = await (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await (db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
.get();
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get payment
const payment = await (db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
.get();
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for manual payment methods
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
}
// Get merged payment config for this event
const paymentConfig = await this.getPaymentConfig(event.id);
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Generate a payment reference using ticket ID
const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`;
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Determine which template to use
const templateSlug = payment.provider === 'tpago'
? 'payment-instructions-tpago'
: 'payment-instructions-bank-transfer';
// Build variables based on payment method
const variables: Record<string, any> = {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale),
eventTime: this.formatTime(event.startDatetime, locale),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: this.formatCurrency(event.price, event.currency),
paymentReference,
bookingUrl,
};
// Add payment-method specific variables
if (payment.provider === 'tpago') {
variables.tpagoLink = paymentConfig.tpagoLink || '';
} else {
// Bank transfer
variables.bankName = paymentConfig.bankName || '';
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
variables.bankAlias = paymentConfig.bankAlias || '';
variables.bankPhone = paymentConfig.bankPhone || '';
}
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
return this.sendTemplateEmail({
templateSlug,
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables,
});
},
/**
* Send custom email to event attendees
*/

View File

@@ -51,6 +51,16 @@ export const paymentVariables: EmailVariable[] = [
{ name: 'paymentDate', description: 'Payment date', example: 'January 28, 2026' },
];
// Payment instructions variables (for manual payment methods)
export const paymentInstructionsVariables: EmailVariable[] = [
{ name: 'tpagoLink', description: 'TPago payment link', example: 'https://tpago.com.py/...' },
{ name: 'bankName', description: 'Bank name', example: 'Banco Itaú' },
{ name: 'bankAccountHolder', description: 'Account holder name', example: 'Spanglish SRL' },
{ name: 'bankAccountNumber', description: 'Bank account number', example: '1234567890' },
{ name: 'bankAlias', description: 'Bank alias or phone', example: '0981-123-456' },
{ name: 'bankPhone', description: 'Bank phone number', example: '0981-123-456' },
];
// Base HTML wrapper for all emails
export const baseEmailWrapper = `
<!DOCTYPE html>
@@ -641,6 +651,319 @@ El Equipo de Spanglish`,
],
isSystem: true,
},
{
name: 'Payment Instructions - TPago',
slug: 'payment-instructions-tpago',
subject: 'Complete your payment for Spanglish',
subjectEs: 'Completa tu pago para Spanglish',
bodyHtml: `
<h2>You're Almost In! 🎉</h2>
<p>Hi {{attendeeName}},</p>
<p>To complete your booking for:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>📍 Location:</strong> {{eventLocation}}</div>
<div class="event-detail"><strong>💰 Amount:</strong> {{paymentAmount}}</div>
</div>
<p>Please complete your payment using TPago at the link below:</p>
<p style="text-align: center; margin: 24px 0;">
<a href="{{tpagoLink}}" class="btn" style="background-color: #3b82f6;">👉 Pay with Card</a>
</p>
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
If the button doesn't work: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
</p>
<div class="note">
<strong>After completing the payment:</strong><br>
Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us.
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="{{bookingUrl}}" class="btn">✓ I Have Paid</a>
</p>
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>Your spot will be confirmed once we verify the payment.</p>
<p>If you have any questions, just reply to this email.</p>
<p>See you soon,<br>Spanglish</p>
`,
bodyHtmlEs: `
<h2>¡Ya Casi Estás! 🎉</h2>
<p>Hola {{attendeeName}},</p>
<p>Para completar tu reserva para:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>📍 Ubicación:</strong> {{eventLocation}}</div>
<div class="event-detail"><strong>💰 Monto:</strong> {{paymentAmount}}</div>
</div>
<p>Por favor completa tu pago usando TPago en el siguiente enlace:</p>
<p style="text-align: center; margin: 24px 0;">
<a href="{{tpagoLink}}" class="btn" style="background-color: #3b82f6;">👉 Pagar con Tarjeta</a>
</p>
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
Si el botón no funciona: <a href="{{tpagoLink}}" style="color: #3b82f6; word-break: break-all;">{{tpagoLink}}</a>
</p>
<div class="note">
<strong>Después de completar el pago:</strong><br>
Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos.
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="{{bookingUrl}}" class="btn">✓ Ya Pagué</a>
</p>
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>Tu lugar será confirmado una vez que verifiquemos el pago.</p>
<p>Si tienes alguna pregunta, simplemente responde a este email.</p>
<p>¡Nos vemos pronto!<br>Spanglish</p>
`,
bodyText: `You're Almost In!
Hi {{attendeeName}},
To complete your booking for:
{{eventTitle}}
📅 Date: {{eventDate}}
📍 Location: {{eventLocation}}
💰 Amount: {{paymentAmount}}
Please complete your payment using TPago at the link below:
👉 Pay with card: {{tpagoLink}}
After completing the payment, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}}
Your spot will be confirmed once we verify the payment.
If you have any questions, just reply to this email.
See you soon,
Spanglish`,
bodyTextEs: `¡Ya Casi Estás!
Hola {{attendeeName}},
Para completar tu reserva para:
{{eventTitle}}
📅 Fecha: {{eventDate}}
📍 Ubicación: {{eventLocation}}
💰 Monto: {{paymentAmount}}
Por favor completa tu pago usando TPago en el siguiente enlace:
👉 Pagar con tarjeta: {{tpagoLink}}
Después de completar el pago, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}}
Tu lugar será confirmado una vez que verifiquemos el pago.
Si tienes alguna pregunta, simplemente responde a este email.
¡Nos vemos pronto!
Spanglish`,
description: 'Sent when user selects TPago payment and clicks continue to payment',
variables: [
...commonVariables,
...bookingVariables,
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
{ name: 'tpagoLink', description: 'TPago payment link', example: 'https://tpago.com.py/...' },
{ name: 'bookingUrl', description: 'URL to return to payment page', example: 'https://spanglish.com/booking/abc123?step=payment' },
],
isSystem: true,
},
{
name: 'Payment Instructions - Bank Transfer',
slug: 'payment-instructions-bank-transfer',
subject: 'Bank transfer details for your Spanglish booking',
subjectEs: 'Datos de transferencia para tu reserva en Spanglish',
bodyHtml: `
<h2>Thanks for Joining Spanglish! 🙂</h2>
<p>Hi {{attendeeName}},</p>
<p>Here are the bank transfer details for your booking:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>💰 Amount:</strong> {{paymentAmount}}</div>
</div>
<div class="event-card" style="background-color: #ecfdf5; border: 1px solid #10b981;">
<h3 style="color: #059669; margin-top: 0;">🏦 Bank Transfer Details</h3>
{{#if bankName}}
<div class="event-detail"><strong>Bank:</strong> {{bankName}}</div>
{{/if}}
{{#if bankAccountHolder}}
<div class="event-detail"><strong>Account Holder:</strong> {{bankAccountHolder}}</div>
{{/if}}
{{#if bankAccountNumber}}
<div class="event-detail"><strong>Account Number:</strong> <span style="font-family: monospace;">{{bankAccountNumber}}</span></div>
{{/if}}
{{#if bankAlias}}
<div class="event-detail"><strong>Alias:</strong> {{bankAlias}}</div>
{{/if}}
{{#if bankPhone}}
<div class="event-detail"><strong>Phone:</strong> {{bankPhone}}</div>
{{/if}}
<div class="event-detail"><strong>Reference:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div>
</div>
<div class="note">
<strong>After making the transfer:</strong><br>
Return to the website and click <strong>"I have paid"</strong> or click the button below to notify us.
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="{{bookingUrl}}" class="btn">✓ I Have Paid</a>
</p>
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
Or use this link: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>We'll confirm your spot as soon as the payment is received.</p>
<p>If you need help, reply to this email.</p>
<p>See you at the event,<br>Spanglish</p>
`,
bodyHtmlEs: `
<h2>¡Gracias por unirte a Spanglish! 🙂</h2>
<p>Hola {{attendeeName}},</p>
<p>Aquí están los datos de transferencia para tu reserva:</p>
<div class="event-card">
<h3>{{eventTitle}}</h3>
<div class="event-detail"><strong>📅 Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>💰 Monto:</strong> {{paymentAmount}}</div>
</div>
<div class="event-card" style="background-color: #ecfdf5; border: 1px solid #10b981;">
<h3 style="color: #059669; margin-top: 0;">🏦 Datos de Transferencia</h3>
{{#if bankName}}
<div class="event-detail"><strong>Banco:</strong> {{bankName}}</div>
{{/if}}
{{#if bankAccountHolder}}
<div class="event-detail"><strong>Titular:</strong> {{bankAccountHolder}}</div>
{{/if}}
{{#if bankAccountNumber}}
<div class="event-detail"><strong>Nro. Cuenta:</strong> <span style="font-family: monospace;">{{bankAccountNumber}}</span></div>
{{/if}}
{{#if bankAlias}}
<div class="event-detail"><strong>Alias:</strong> {{bankAlias}}</div>
{{/if}}
{{#if bankPhone}}
<div class="event-detail"><strong>Teléfono:</strong> {{bankPhone}}</div>
{{/if}}
<div class="event-detail"><strong>Referencia:</strong> <span style="font-family: monospace; background: #fff; padding: 2px 6px; border-radius: 4px;">{{paymentReference}}</span></div>
</div>
<div class="note">
<strong>Después de realizar la transferencia:</strong><br>
Vuelve al sitio web y haz clic en <strong>"Ya pagué"</strong> o haz clic en el botón de abajo para notificarnos.
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="{{bookingUrl}}" class="btn">✓ Ya Pagué</a>
</p>
<p style="text-align: center; font-size: 12px; color: #6b7280; margin-top: -16px;">
O usa este enlace: <a href="{{bookingUrl}}" style="color: #f59e0b; word-break: break-all;">{{bookingUrl}}</a>
</p>
<p>Confirmaremos tu lugar tan pronto como recibamos el pago.</p>
<p>Si necesitas ayuda, responde a este email.</p>
<p>¡Nos vemos en el evento!<br>Spanglish</p>
`,
bodyText: `Thanks for Joining Spanglish!
Hi {{attendeeName}},
Here are the bank transfer details for your booking:
{{eventTitle}}
📅 Date: {{eventDate}}
💰 Amount: {{paymentAmount}}
Bank Transfer Details:
- Bank: {{bankName}}
- Account Holder: {{bankAccountHolder}}
- Account Number: {{bankAccountNumber}}
- Alias: {{bankAlias}}
- Phone: {{bankPhone}}
- Reference: {{paymentReference}}
After making the transfer, return to the website and click "I have paid" or use this link to notify us:
{{bookingUrl}}
We'll confirm your spot as soon as the payment is received.
If you need help, reply to this email.
See you at the event,
Spanglish`,
bodyTextEs: `¡Gracias por unirte a Spanglish!
Hola {{attendeeName}},
Aquí están los datos de transferencia para tu reserva:
{{eventTitle}}
📅 Fecha: {{eventDate}}
💰 Monto: {{paymentAmount}}
Datos de Transferencia:
- Banco: {{bankName}}
- Titular: {{bankAccountHolder}}
- Nro. Cuenta: {{bankAccountNumber}}
- Alias: {{bankAlias}}
- Teléfono: {{bankPhone}}
- Referencia: {{paymentReference}}
Después de realizar la transferencia, vuelve al sitio web y haz clic en "Ya pagué" o usa este enlace para notificarnos:
{{bookingUrl}}
Confirmaremos tu lugar tan pronto como recibamos el pago.
Si necesitas ayuda, responde a este email.
¡Nos vemos en el evento!
Spanglish`,
description: 'Sent when user selects bank transfer payment and clicks continue to payment',
variables: [
...commonVariables,
...bookingVariables,
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
{ name: 'paymentReference', description: 'Unique payment reference', example: 'SPG-ABC123' },
{ name: 'bankName', description: 'Bank name', example: 'Banco Itaú' },
{ name: 'bankAccountHolder', description: 'Account holder name', example: 'Spanglish SRL' },
{ name: 'bankAccountNumber', description: 'Bank account number', example: '1234567890' },
{ name: 'bankAlias', description: 'Bank alias', example: 'spanglish.py' },
{ name: 'bankPhone', description: 'Bank phone number', example: '0981-123-456' },
{ name: 'bookingUrl', description: 'URL to return to payment page', example: 'https://spanglish.com/booking/abc123?step=payment' },
],
isSystem: true,
},
];
// Helper function to replace template variables

View File

@@ -11,7 +11,8 @@ const mediaRouter = new Hono();
const UPLOAD_DIR = './uploads';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_SIZE =
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
// Ensure upload directory exists
async function ensureUploadDir() {
@@ -37,7 +38,8 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large. Maximum size: 5MB' }, 400);
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
}
await ensureUploadDir();

View File

@@ -26,6 +26,8 @@ const updatePaymentOptionsSchema = z.object({
cashEnabled: z.boolean().optional(),
cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(),
// Booking settings
allowDuplicateBookings: z.boolean().optional(),
});
// Schema for event-level overrides
@@ -75,6 +77,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
cashEnabled: true,
cashInstructions: null,
cashInstructionsEs: null,
allowDuplicateBookings: false,
},
});
}

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, tickets, events, users, payments } from '../db/index.js';
import { db, tickets, events, users, payments, paymentOptions } from '../db/index.js';
import { eq, and, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
@@ -13,9 +13,9 @@ const ticketsRouter = new Hono();
const createTicketSchema = z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().min(2),
lastName: z.string().min(2).optional().or(z.literal('')),
email: z.string().email(),
phone: z.string().min(6, 'Phone number is required'),
phone: z.string().min(6).optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
@@ -76,7 +76,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const now = getNow();
const fullName = `${data.firstName} ${data.lastName}`.trim();
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
if (!user) {
const userId = generateId();
@@ -94,20 +96,29 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
await (db as any).insert(users).values(user);
}
// Check for duplicate booking
const existingTicket = await (db as any)
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
const globalOptions = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
.from(paymentOptions)
.get();
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'You have already booked this event' }, 400);
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
if (!allowDuplicateBookings) {
const existingTicket = await (db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
.get();
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'You have already booked this event' }, 400);
}
}
// Create ticket
@@ -122,9 +133,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
@@ -151,6 +162,20 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
await (db as any).insert(payments).values(newPayment);
// Send payment instructions email for manual payment methods (TPago, Bank Transfer)
if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) {
// Send asynchronously - don't block the response
emailService.sendPaymentInstructions(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending payment instructions email:', err);
});
}
// If Lightning payment, create LNbits invoice
let lnbitsInvoice = null;
if (data.paymentMethod === 'lightning' && event.price > 0) {
@@ -389,6 +414,23 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
}
// Handle idempotency - if already marked as sent or paid, return success with current state
if (payment.status === 'pending_approval') {
return c.json({
payment,
message: 'Payment was already marked as sent. Waiting for admin approval.',
alreadyProcessed: true,
});
}
if (payment.status === 'paid') {
return c.json({
payment,
message: 'Payment has already been confirmed.',
alreadyProcessed: true,
});
}
// Only allow if currently pending
if (payment.status !== 'pending') {
return c.json({ error: 'Payment has already been processed' }, 400);