From 0fd8172e04774141235aee156c1f1405f3668b4a Mon Sep 17 00:00:00 2001 From: Michilis Date: Tue, 3 Feb 2026 18:40:39 +0000 Subject: [PATCH] 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 --- backend/src/lib/email.ts | 57 ++++++++++++++++++++++++++--------- backend/src/lib/pdf.ts | 28 ++++++++++------- backend/src/routes/tickets.ts | 16 +++++++++- 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index bbaa50d..eaa0185 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1,7 +1,7 @@ // Email service for Spanglish platform // 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 { getNow, generateId } from './utils.js'; 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 { + const settings = await dbGet( + (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); return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { weekday: 'long', year: 'numeric', month: 'long', 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); return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { hour: '2-digit', minute: '2-digit', + timeZone: timezone, }); }, @@ -579,6 +591,9 @@ export const emailService = { // Calculate total price for multi-ticket bookings const totalPrice = event.price * ticketCount; + // Get site timezone for proper date/time formatting + const timezone = await this.getSiteTimezone(); + return this.sendTemplateEmail({ templateSlug: 'booking-confirmation', to: ticket.attendeeEmail, @@ -593,8 +608,8 @@ export const emailService = { qrCode: ticket.qrCode || '', ticketPdfUrl, eventTitle, - eventDate: this.formatDate(event.startDatetime, locale), - eventTime: this.formatTime(event.startDatetime, locale), + eventDate: this.formatDate(event.startDatetime, locale, timezone), + eventTime: this.formatTime(event.startDatetime, locale, timezone), eventLocation: event.location, eventLocationUrl: event.locationUrl || '', 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); + // Get site timezone for proper date/time formatting + const timezone = await this.getSiteTimezone(); + return this.sendTemplateEmail({ templateSlug: 'payment-receipt', to: ticket.attendeeEmail, @@ -697,11 +715,11 @@ export const emailService = { attendeeName: receiptFullName, ticketId: ticket.bookingId || ticket.id, eventTitle, - eventDate: this.formatDate(event.startDatetime, locale), + eventDate: this.formatDate(event.startDatetime, locale, timezone), paymentAmount: amountDisplay, paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider, 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); + // Get site timezone for proper date/time formatting + const timezone = await this.getSiteTimezone(); + // Build variables based on payment method const variables: Record = { attendeeName: attendeeFullName, attendeeEmail: ticket.attendeeEmail, ticketId: ticket.bookingId || ticket.id, eventTitle, - eventDate: this.formatDate(event.startDatetime, locale), - eventTime: this.formatTime(event.startDatetime, locale), + eventDate: this.formatDate(event.startDatetime, locale, timezone), + eventTime: this.formatTime(event.startDatetime, locale, timezone), eventLocation: event.location, eventLocationUrl: event.locationUrl || '', paymentAmount: amountDisplay, @@ -934,6 +955,9 @@ export const emailService = { const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; const newBookingUrl = `${frontendUrl}/book/${event.id}`; + // Get site timezone for proper date/time formatting + const timezone = await this.getSiteTimezone(); + console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`); return this.sendTemplateEmail({ @@ -947,8 +971,8 @@ export const emailService = { attendeeEmail: ticket.attendeeEmail, ticketId: ticket.id, eventTitle, - eventDate: this.formatDate(event.startDatetime, locale), - eventTime: this.formatTime(event.startDatetime, locale), + eventDate: this.formatDate(event.startDatetime, locale, timezone), + eventTime: this.formatTime(event.startDatetime, locale, timezone), eventLocation: event.location, eventLocationUrl: event.locationUrl || '', newBookingUrl, @@ -1001,6 +1025,9 @@ export const emailService = { 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 failedCount = 0; const errors: string[] = []; @@ -1023,8 +1050,8 @@ export const emailService = { attendeeEmail: ticket.attendeeEmail, ticketId: ticket.id, eventTitle, - eventDate: this.formatDate(event.startDatetime, locale), - eventTime: this.formatTime(event.startDatetime, locale), + eventDate: this.formatDate(event.startDatetime, locale, timezone), + eventTime: this.formatTime(event.startDatetime, locale, timezone), eventLocation: event.location, eventLocationUrl: event.locationUrl || '', ...customVariables, diff --git a/backend/src/lib/pdf.ts b/backend/src/lib/pdf.ts index c70c499..b25f4c4 100644 --- a/backend/src/lib/pdf.ts +++ b/backend/src/lib/pdf.ts @@ -14,6 +14,7 @@ interface TicketData { location: string; locationUrl?: string; }; + timezone?: string; } /** @@ -29,27 +30,29 @@ async function generateQRCode(data: string): Promise { } /** - * 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); return date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', 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); return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true, + timeZone: timezone, }); } @@ -89,12 +92,13 @@ export async function generateTicketPDF(ticket: TicketData): Promise { doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); 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.text(formatDate(ticket.event.startDatetime), { align: 'center' }); + doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' }); - const startTime = formatTime(ticket.event.startDatetime); - const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null; + const startTime = formatTime(ticket.event.startDatetime, tz); + const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null; const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; 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.moveDown(0.5); + // Date and time (using site timezone) + const tz = ticket.timezone || 'America/Asuncion'; 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 endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null; + const startTime = formatTime(ticket.event.startDatetime, tz); + const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null; const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; doc.text(timeRange, { align: 'center' }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index dcaf5c0..abf55bb 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; 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 { requireAuth, getAuthUser } from '../lib/auth.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`); + // Get site timezone for proper date/time formatting + const settings = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + const timezone = settings?.timezone || 'America/Asuncion'; + const ticketsData = confirmedTickets.map((ticket: any) => ({ id: ticket.id, qrCode: ticket.qrCode, @@ -390,6 +396,7 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => { location: event.location, locationUrl: event.locationUrl, }, + timezone, })); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); @@ -448,6 +455,12 @@ ticketsRouter.get('/:id/pdf', async (c) => { } try { + // Get site timezone for proper date/time formatting + const settings = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + const timezone = settings?.timezone || 'America/Asuncion'; + const pdfBuffer = await generateTicketPDF({ id: ticket.id, qrCode: ticket.qrCode, @@ -460,6 +473,7 @@ ticketsRouter.get('/:id/pdf', async (c) => { location: event.location, locationUrl: event.locationUrl, }, + timezone, }); // Set response headers for PDF download