← 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); });