Files
redefined-designs/backend/src/favoriteAlerts.ts
T
bermudalamb 2f7268704a feat(admin): make the token lifetimes, cart hold and greeting configurable (#136)
Giving the customer emails a tab of their own (#135) made a family of related holes visible: an admin could edit the wording of every customer email, but not the facts that wording asserted, and two templates could not address the customer at all.

Both token lifetimes were hardcoded twice. VERIFY_TOKEN_TTL_MS sat in routes/customers.ts while the verification body separately said "This link expires in 24 hours", and RESET_TOKEN_TTL_MS sat beside a body separately saying "one hour". The prose was not derived from the constant, it was a second hand-written copy of the same fact — so making the constants configurable without addressing that would have made things worse, not better: the setting moves to two hours and the email keeps confidently promising one. Both are now settings, and both templates state their lifetime through an {{expiresIn}} placeholder rendered from the setting.

The cart reminder gains {{holdDuration}} for the same reason. Per-item deadlines were already inside {{itemList}}, but there was no way to write a sentence about the hold itself without hardcoding a number the cart expiry setting could change underneath the author.

All three durations render through one formatDuration(), so the reset email and the cart reminder say "one hour" the same way rather than in two authors' phrasing. A fractional hour drops to minutes, because "0.5 hours" reads badly and "1.5 hours" reads worse in a sentence a customer is meant to act on.

Every template now offers greeting, firstName and lastName. favoriteSold and favoriteWithdrawn previously offered only itemName and siteUrl and could not address anyone — the query behind them never selected a name, so it does now.

The greeting itself is two settings, a format and a fallback, rather than the wording baked into greeting(). The fallback is separate rather than the format with the name edited out: that editing is guesswork that has to be right every time, and getting it wrong ships "Hi ," to everyone who registered while first names were still optional (#106). Those customers exist, which is why greeting() guarded the case in the first place.

The admin preview renders the durations and the greeting from the live settings rather than from a static sample. The preview exists so an admin sees the email that will be sent, and a sample reading "one hour" while the setting says two is the precise failure these placeholders were added to remove.

Settings are read through a new adminSettings accessor. cart_expiry_hours was previously read by an inline query in two places, each with its own `|| '24'`; with five settings and read sites across three routes and the cron job, a default written twice is a default that will eventually disagree with itself. Values are stored as text, so each definition declares how to read it back — numbers were the only kind until the greeting format arrived.

The new placeholders are available but never required, so every template an admin has already saved keeps rendering and keeps sending.

Also fixes updateAdminSettings announcing success for a save the server refused: it returned the 400 body as though it were the saved settings, so the form reported "Settings saved" either way.

Closes #136
2026-08-23 08:55:53 -05:00

96 lines
4.1 KiB
TypeScript

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<FavoriteRecipient[]> {
if (!itemIds.length) return [];
const { rows } = await pool.query<FavoriteRecipient>(
`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<void> {
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<void> {
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<void> {
await send(recipients, 'favoriteWithdrawn');
}