The backend reads environment variables in a couple of dozen places and validated none of them. A missing or misspelled one was undefined until the first line of code that happened to need it, which could be a long time after the container reported healthy — and several of those failures are silent and customer-visible. DEMO_MODE is the one that mattered most. It was read as "demo unless the value is exactly the string false", so DEMO_MODE=False, DEMO_MODE=0, or any typo meant demo mode stayed on and the shop quietly stopped charging anyone. It is now required and strict: exactly 'true' or 'false', and anything else refuses to start while quoting the value it was given, so the typo is visible in the message rather than inferred. Two requirements are conditional, and that is what makes them expressible at all. PayPal credentials are demanded only when DEMO_MODE=false, because QA runs with none of them on purpose and an unconditional rule would be simply wrong there. PUBLIC_URL is demanded only when SMTP is configured, because its only job is building links in email — an environment that cannot send mail does not need it, and requiring it everywhere would break every existing local setup to prevent nothing. UPLOADS_DIR gets no such reprieve: its fallback is correct inside the container and wrong everywhere else, so inheriting it writes uploads somewhere nobody is looking. Every problem is reported at once rather than one per restart, and the process then exits — the same shape as the container refusing to start on a failed migration rather than serving against a schema it does not match. Warnings are printed but do not stop anything: SMTP absent, the admin gate inactive, or an allowlist missing while mail can be sent. That last one is new and earns its place, since SMTP with no allowlist means the environment can reach real customers, which is what #87 exists to prevent. The admin-gate warning moved here from server.ts, so one place says what this container is and is not configured to do. validateEnv is a pure function of the environment handed to it rather than a reader of process.env, so it is tested exhaustively without booting anything or mutating global state. It is called from server.ts and deliberately not from app.ts: the integration suite imports app directly and would otherwise become a configuration exercise. Its rules are one small function each at module level, because cognitive complexity counts everything declared inside a function and the first version scored 24 against a limit of 15. Verified as a real process, not only in tests. A missing DEMO_MODE, a DEMO_MODE of 'False', real payments with no PayPal credentials, and half-configured SMTP each exit 1 with the problems listed; a valid environment starts and serves. Note the exit codes were checked without a pipe, because $? after `| head` reports head rather than node and had first suggested a clean exit. 141 unit, 169 integration and 94 end-to-end passing, lint unchanged at 0 errors and 8 warnings. Both CI workflows already set all six always-required variables plus DEMO_MODE, so the pipeline is unaffected. Refs #64 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
3.9 KiB
TypeScript
Executable File
92 lines
3.9 KiB
TypeScript
Executable File
import cron from 'node-cron';
|
|
import app from './app';
|
|
import { pool } from './db';
|
|
import { sendMail } from './mailer';
|
|
import { validateEnv } from './envValidation';
|
|
|
|
// Release cart holds whose expiry has passed.
|
|
async function sweepExpiredCarts(): Promise<void> {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
|
|
);
|
|
for (const row of rows) {
|
|
await pool.query(`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [row.item_id]);
|
|
}
|
|
} catch (err) {
|
|
console.error('cart expiry sweep failed:', (err as Error).message);
|
|
}
|
|
}
|
|
|
|
// Remind customers who opted into marketing email about items still held in
|
|
// their cart.
|
|
async function sendCartReminders(): Promise<void> {
|
|
try {
|
|
const { rows } = await pool.query(`
|
|
SELECT c.email, c.name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
|
|
FROM cart_items ci
|
|
JOIN carts ca ON ca.id = ci.cart_id
|
|
JOIN customers c ON c.id = ca.customer_id
|
|
JOIN items i ON i.id = ci.item_id
|
|
WHERE c.marketing_consent = true
|
|
AND (ci.last_reminder_sent_at IS NULL OR ci.last_reminder_sent_at < now() - interval '20 hours')
|
|
AND ci.expires_at > now()
|
|
`);
|
|
|
|
const byEmail = new Map<string, { name: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
|
|
for (const row of rows) {
|
|
if (!byEmail.has(row.email)) byEmail.set(row.email, { name: row.name, items: [] });
|
|
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
|
|
}
|
|
|
|
for (const [email, data] of byEmail) {
|
|
const itemList = data.items.map(i => `<li>${i.name} — reserved until ${i.expiresAt.toLocaleString()}</li>`).join('');
|
|
await sendMail(
|
|
email,
|
|
'Items waiting in your cart',
|
|
`<p>Hi${data.name ? ' ' + data.name : ''},</p>
|
|
<p>You still have items in your cart at Redefined Designs:</p>
|
|
<ul>${itemList}</ul>
|
|
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
|
|
);
|
|
const ids = data.items.map(i => i.cartItemId);
|
|
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
|
|
}
|
|
} catch (err) {
|
|
console.error('daily cart reminder job failed:', (err as Error).message);
|
|
}
|
|
}
|
|
|
|
// Neither scheduler has anything to await these with, so `void` states that the
|
|
// promise is deliberately dropped. That is only safe because both functions
|
|
// catch their own errors above — an escaping rejection would be unhandled, and
|
|
// Node terminates the process on those by default, so a database blip during
|
|
// the sweep would take the container down with it.
|
|
setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
|
|
cron.schedule('0 9 * * *', () => void sendCartReminders());
|
|
|
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
|
|
// Checked at boot rather than left to be discovered by the first request that
|
|
// happens to need a missing value. Every problem is reported at once — fixing a
|
|
// fresh environment one restart at a time is miserable — and anything fatal
|
|
// stops the process, the same way a failed migration does rather than serving
|
|
// against a schema it does not match. The admin-gate warning lives here too now
|
|
// (#63), so there is one place that says what this container is and is not
|
|
// configured to do. See envValidation.ts and #64.
|
|
const { errors, warnings } = validateEnv(process.env);
|
|
|
|
for (const warning of warnings) {
|
|
console.warn(`[config] ${warning}`);
|
|
}
|
|
|
|
if (errors.length) {
|
|
console.error(`[config] refusing to start — ${errors.length} problem(s) with the environment:`);
|
|
for (const error of errors) {
|
|
console.error(`[config] - ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));
|