Use admin timezone for emails and ticket PDFs

- Email: formatDate/formatTime use site timezone from settings
- PDF tickets: date/time formatted in site timezone
- Tickets routes: fetch timezone and pass to PDF generation
This commit is contained in:
Michilis
2026-02-03 18:40:39 +00:00
parent 9090d7bad2
commit 0fd8172e04
3 changed files with 74 additions and 27 deletions

View File

@@ -1,7 +1,7 @@
// Email service for Spanglish platform // Email service for Spanglish platform
// Supports multiple email providers: Resend, SMTP (Nodemailer) // Supports multiple email providers: Resend, SMTP (Nodemailer)
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides, siteSettings } from '../db/index.js';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getNow, generateId } from './utils.js'; import { getNow, generateId } from './utils.js';
import { import {
@@ -324,26 +324,38 @@ export const emailService = {
}, },
/** /**
* Format date for emails * Get the site timezone from settings (cached for performance)
*/ */
formatDate(dateStr: string, locale: string = 'en'): string { async getSiteTimezone(): Promise<string> {
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
return settings?.timezone || 'America/Asuncion';
},
/**
* Format date for emails using site timezone
*/
formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: timezone,
}); });
}, },
/** /**
* Format time for emails * Format time for emails using site timezone
*/ */
formatTime(dateStr: string, locale: string = 'en'): string { formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: timezone,
}); });
}, },
@@ -579,6 +591,9 @@ export const emailService = {
// Calculate total price for multi-ticket bookings // Calculate total price for multi-ticket bookings
const totalPrice = event.price * ticketCount; const totalPrice = event.price * ticketCount;
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
return this.sendTemplateEmail({ return this.sendTemplateEmail({
templateSlug: 'booking-confirmation', templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail, to: ticket.attendeeEmail,
@@ -593,8 +608,8 @@ export const emailService = {
qrCode: ticket.qrCode || '', qrCode: ticket.qrCode || '',
ticketPdfUrl, ticketPdfUrl,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
eventPrice: this.formatCurrency(event.price, event.currency), eventPrice: this.formatCurrency(event.price, event.currency),
@@ -687,6 +702,9 @@ export const emailService = {
? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)` ? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
: this.formatCurrency(totalAmount, payment.currency); : this.formatCurrency(totalAmount, payment.currency);
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
return this.sendTemplateEmail({ return this.sendTemplateEmail({
templateSlug: 'payment-receipt', templateSlug: 'payment-receipt',
to: ticket.attendeeEmail, to: ticket.attendeeEmail,
@@ -697,11 +715,11 @@ export const emailService = {
attendeeName: receiptFullName, attendeeName: receiptFullName,
ticketId: ticket.bookingId || ticket.id, ticketId: ticket.bookingId || ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
paymentAmount: amountDisplay, paymentAmount: amountDisplay,
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider, paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id, paymentReference: payment.reference || payment.id,
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale), paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale, timezone),
}, },
}); });
}, },
@@ -846,14 +864,17 @@ export const emailService = {
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)` ? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: this.formatCurrency(totalPrice, event.currency); : this.formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
// Build variables based on payment method // Build variables based on payment method
const variables: Record<string, any> = { const variables: Record<string, any> = {
attendeeName: attendeeFullName, attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id, ticketId: ticket.bookingId || ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay, paymentAmount: amountDisplay,
@@ -934,6 +955,9 @@ export const emailService = {
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const newBookingUrl = `${frontendUrl}/book/${event.id}`; const newBookingUrl = `${frontendUrl}/book/${event.id}`;
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`); console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
return this.sendTemplateEmail({ return this.sendTemplateEmail({
@@ -947,8 +971,8 @@ export const emailService = {
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
newBookingUrl, newBookingUrl,
@@ -1001,6 +1025,9 @@ export const emailService = {
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] }; return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
} }
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
let sentCount = 0; let sentCount = 0;
let failedCount = 0; let failedCount = 0;
const errors: string[] = []; const errors: string[] = [];
@@ -1023,8 +1050,8 @@ export const emailService = {
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
...customVariables, ...customVariables,

View File

@@ -14,6 +14,7 @@ interface TicketData {
location: string; location: string;
locationUrl?: string; locationUrl?: string;
}; };
timezone?: string;
} }
/** /**
@@ -29,27 +30,29 @@ async function generateQRCode(data: string): Promise<Buffer> {
} }
/** /**
* Format date for display * Format date for display using site timezone
*/ */
function formatDate(dateStr: string): string { function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: timezone,
}); });
} }
/** /**
* Format time for display * Format time for display using site timezone
*/ */
function formatTime(dateStr: string): string { function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleTimeString('en-US', { return date.toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
timeZone: timezone,
}); });
} }
@@ -89,12 +92,13 @@ export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5); doc.moveDown(0.5);
// Date and time // Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333'); doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' }); doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime); const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null; const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' }); doc.text(timeRange, { align: 'center' });
@@ -184,11 +188,13 @@ export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5); doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333'); doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' }); doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime); const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null; const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' }); doc.text(timeRange, { align: 'center' });

View File

@@ -1,7 +1,7 @@
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, tickets, events, users, payments, paymentOptions } from '../db/index.js'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
import { eq, and, sql } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
@@ -378,6 +378,12 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
console.log(`[PDF] Generating PDF with ${confirmedTickets.length} confirmed tickets`); console.log(`[PDF] Generating PDF with ${confirmedTickets.length} confirmed tickets`);
// Get site timezone for proper date/time formatting
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
const timezone = settings?.timezone || 'America/Asuncion';
const ticketsData = confirmedTickets.map((ticket: any) => ({ const ticketsData = confirmedTickets.map((ticket: any) => ({
id: ticket.id, id: ticket.id,
qrCode: ticket.qrCode, qrCode: ticket.qrCode,
@@ -390,6 +396,7 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
location: event.location, location: event.location,
locationUrl: event.locationUrl, locationUrl: event.locationUrl,
}, },
timezone,
})); }));
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
@@ -448,6 +455,12 @@ ticketsRouter.get('/:id/pdf', async (c) => {
} }
try { try {
// Get site timezone for proper date/time formatting
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
const timezone = settings?.timezone || 'America/Asuncion';
const pdfBuffer = await generateTicketPDF({ const pdfBuffer = await generateTicketPDF({
id: ticket.id, id: ticket.id,
qrCode: ticket.qrCode, qrCode: ticket.qrCode,
@@ -460,6 +473,7 @@ ticketsRouter.get('/:id/pdf', async (c) => {
location: event.location, location: event.location,
locationUrl: event.locationUrl, locationUrl: event.locationUrl,
}, },
timezone,
}); });
// Set response headers for PDF download // Set response headers for PDF download