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:
@@ -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,
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user