← back to Trademarks Copyright
scripts/daily-drop.ts
98 lines
#!/usr/bin/env tsx
/**
* Daily drop runner — call from cron / launchd.
*
* macOS launchd example (~/Library/LaunchAgents/com.drops.daily.plist):
* runs `tsx /path/to/scripts/daily-drop.ts` every day at 9:00 America/Los_Angeles.
*
* crontab (Linux):
* 0 9 * * * cd /path/to/trademarks-copyright && /usr/local/bin/tsx scripts/daily-drop.ts >> /var/log/drops.log 2>&1
*
* Or hit the HTTP endpoint: curl -X POST "$BASE/api/drops/cron?token=$CRON_TOKEN"
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// launchd does not inherit shell env — load .env.local ourselves.
try {
const raw = readFileSync(resolve(__dirname, "../.env.local"), "utf8");
for (const line of raw.split(/\r?\n/)) {
const m = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
if (!m) continue;
let v = m[2];
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (!(m[1] in process.env)) process.env[m[1]] = v;
}
} catch { /* silent */ }
import { composeDropForDate } from "../src/lib/drops";
import { activeSubscribersFor, renderDropHTML } from "../src/lib/drops";
import { sendEmail, currentBackend } from "../src/lib/email";
import { pool } from "../src/lib/db";
const APP_BASE = process.env.APP_BASE_URL || "http://localhost:9770";
async function main() {
console.log(`[drops] running at ${new Date().toISOString()}`);
const today = new Date();
const composed = await composeDropForDate({ date: today, itemsPerDrop: 10 });
console.log(`[drops] drop ${composed.dropId} "${composed.subject}" items=${composed.items}`);
const subs = await activeSubscribersFor(composed.dropId);
console.log(`[drops] ${subs.length} subscribers to deliver to via ${currentBackend()}`);
let ok = 0, fail = 0;
for (const s of subs) {
const { rows: dRows } = await pool.query(
`INSERT INTO deliveries (drop_id, subscriber_id, delivered_via)
VALUES ($1, $2, 'pending')
ON CONFLICT (drop_id, subscriber_id) DO UPDATE SET delivered_via = EXCLUDED.delivered_via
RETURNING id`,
[composed.dropId, s.id]
);
const deliveryId = dRows[0]?.id;
const { subject, html, text } = await renderDropHTML({
dropId: composed.dropId,
tier: s.tier as "trial" | "standard" | "pro" | "comp",
subscriberToken: s.token,
appBaseUrl: APP_BASE,
deliveryId,
});
const r = await sendEmail({ to: s.email, subject, html, text, unsubscribeToken: s.token });
if (r.ok) {
ok++;
await pool.query(
`UPDATE deliveries SET delivered_via = $1, sent_at = NOW() WHERE id = $2`,
[r.via, deliveryId]
);
await pool.query(
`UPDATE subscribers
SET last_delivered_at = NOW(),
trial_drops_left = CASE WHEN tier='trial' THEN GREATEST(trial_drops_left-1,0) ELSE trial_drops_left END,
status = CASE
WHEN tier='trial' AND GREATEST(trial_drops_left-1,0) = 0 THEN 'trial_expired'
ELSE status
END
WHERE id = $1`, [s.id]
);
} else {
fail++;
await pool.query(
`UPDATE deliveries SET bounced = TRUE, error = $1 WHERE id = $2`,
[r.error ?? "unknown", deliveryId]
);
console.warn(`[drops] fail ${s.email}: ${r.error}`);
}
}
if (ok > 0) {
await pool.query(`UPDATE drops SET status='sent', sent_at=COALESCE(sent_at, NOW()) WHERE id = $1`, [composed.dropId]);
}
console.log(`[drops] done ok=${ok} fail=${fail}`);
await pool.end();
}
main().catch((e) => { console.error("[drops] FATAL", e); process.exit(1); });