import { pool } from './db'; import { sendMail } from './mailer'; import { renderTemplate, greeting, TemplateKey } from './emailTemplates'; import { getSettings } from './adminSettings'; import { loadStoredTemplate } from './routes/adminEmailTemplates'; // Shown to the customer when they opt in, and stored verbatim against their // consent so the record says what they actually agreed to — the same pattern // the marketing consent already uses. export const FAVORITE_ALERTS_CONSENT_TEXT = 'Email me when an item I have favorited is sold to someone else, so I know it is no longer available.'; export interface FavoriteRecipient { email: string; // Nullable because customers who registered while names were optional have // none — the same reason the greeting needs a fallback at all. first_name: string | null; last_name: string | null; item_name: string; } // Gathered separately from sending because deleting an item cascades its // favorites away: the recipients have to be read *before* the row goes, while // the send has to happen *after*, so nobody is told about a withdrawal that // then failed. export async function collectFavoriteRecipients( itemIds: number[], excludeCustomerId: number | null, onlyUnsold = false ): Promise { if (!itemIds.length) return []; const { rows } = await pool.query( `SELECT c.email, c.first_name, c.last_name, i.name AS item_name FROM favorites f JOIN customers c ON c.id = f.customer_id JOIN items i ON i.id = f.item_id WHERE f.item_id = ANY($1::int[]) AND c.favorite_alerts = true AND c.disabled_at IS NULL AND ($2::int IS NULL OR c.id <> $2::int) AND ($3::boolean = false OR i.status <> 'sold')`, [itemIds, excludeCustomerId, onlyUnsold] ); return rows; } // One message per item per person, never batched: each is about a specific // thing the customer asked to hear about. Sent independently so one bad // address cannot stop the rest — and whatever prompted this has already // happened regardless of whether the mail goes out. async function send(recipients: FavoriteRecipient[], key: TemplateKey): Promise { if (!recipients.length) return; // Loaded once for the batch rather than per recipient: the copy is the same // for everyone, only the item name differs. const stored = await loadStoredTemplate(key); const siteUrl = process.env.PUBLIC_URL ?? ''; const { greetingFormat, greetingFallback } = await getSettings(); for (const recipient of recipients) { const { subject, html } = renderTemplate(key, stored, { greeting: greeting(recipient.first_name, greetingFormat, greetingFallback, recipient.last_name), firstName: recipient.first_name ?? '', lastName: recipient.last_name ?? '', itemName: recipient.item_name, siteUrl }); sendMail(recipient.email, subject, html) .catch(err => console.error('favorite alert failed', err)); } } // Called *after* the sale has been committed, never inside the transaction. // Emailing about a sale that then rolled back would be worse than a late // notification, and the transaction should not be held open for SMTP. // // `buyerId` is excluded: telling customers the item they just bought is no // longer available reads as a bug. export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise { const recipients = await collectFavoriteRecipients(itemIds, buyerId); await send(recipients, 'favoriteSold'); } // Sent when an item is withdrawn from sale rather than sold. Recipients must be // collected before the delete, since the favorites rows cascade with the item. // Async now that the copy is loaded from the database before rendering. It was // previously synchronous in dispatch — the sends were fire-and-forget, but they // were *started* before the caller returned. Leaving it fire-and-forget would // mean the response can beat the mail out of the door, which is a behaviour // change nobody asked for and which the withdrawal test caught. export async function notifyFavoritersOfRemoval(recipients: FavoriteRecipient[]): Promise { await send(recipients, 'favoriteWithdrawn'); }