← back to Trademarks Copyright

scripts/alert-top-scores.ts

236 lines

#!/usr/bin/env tsx
/**
 * Daily alert — top 3 composite-scored items in the catalog.
 * Sends via George (the gmail agent) if GEORGE_ENDPOINT is set,
 * otherwise falls through to the built-in email backends (Resend → SMTP → file).
 *
 * Env:
 *   GEORGE_ENDPOINT   HTTP POST URL for the gmail agent (optional)
 *   GEORGE_AUTH       "Bearer <token>" or similar auth header value (optional)
 *   STEVE_OFFICE_EMAIL  destination address (required unless GEORGE_ENDPOINT accepts no `to`)
 *   APP_BASE_URL      used for absolute links in the HTML
 */

import { pool, query } from "../src/lib/db";
import { sendEmail } from "../src/lib/email";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

// Load .env.local ourselves — launchd does NOT inherit the shell env and tsx
// does not auto-read dotenv files. We only set vars that aren't already set.
try {
  const envPath = resolve(__dirname, "../.env.local");
  const raw = readFileSync(envPath, "utf8");
  for (const line of raw.split(/\r?\n/)) {
    const m = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
    if (!m) continue;
    const k = m[1];
    let v = m[2];
    // Strip surrounding quotes.
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
      v = v.slice(1, -1);
    }
    if (!(k in process.env)) process.env[k] = v;
  }
} catch { /* silent if .env.local missing */ }

function env(k: string, d = ""): string { return process.env[k] ?? d; }
const APP_BASE = env("APP_BASE_URL", "http://localhost:9770");
const TO = env("STEVE_OFFICE_EMAIL");
const GEORGE = env("GEORGE_ENDPOINT");
const GEORGE_AUTH = env("GEORGE_AUTH");

function escapeHtml(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

function fmtDate(d: string | Date): string {
  return new Date(d).toISOString().slice(0, 10);
}

interface AlertRange { offset: number; limit: number; label: string }

async function renderAlert(range: AlertRange = { offset: 0, limit: 3, label: "Top 3" }): Promise<{ subject: string; html: string; text: string }> {
  const { rows } = await query<{
    id: number; kind: string; name: string; status: string;
    original_owner: string | null; nice_class: string | null; goods_services: string | null;
    expired_date: string; registered_date: string | null; registration_number: string | null;
    source: string; source_url: string | null; notes: string | null;
    distinctiveness_score: number; recognition_score: number;
    domain_available: boolean | null; competing_active_marks: number; monetization_breadth: number;
    composite_score: string; monetization_ideas: { title: string; description: string }[];
  }>(
    `SELECT id, kind, name, status, original_owner, nice_class, goods_services,
            expired_date, registered_date, registration_number,
            source, source_url, notes,
            distinctiveness_score, recognition_score,
            domain_available, competing_active_marks, monetization_breadth,
            composite_score, monetization_ideas
     FROM items
     WHERE composite_score IS NOT NULL
     ORDER BY composite_score DESC
     OFFSET $1 LIMIT $2`,
    [range.offset, range.limit]
  );

  const today = new Date().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" });

  const itemsHtml = rows.map((r, idx) => {
    const globalRank = range.offset + idx + 1;
    const medal = range.offset === 0 ? (["🥇", "🥈", "🥉"][idx] ?? `#${globalRank}`) : `#${globalRank}`;
    const ideas = (r.monetization_ideas || []).map((i) => `<li><strong>${escapeHtml(i.title)}</strong> — ${escapeHtml(i.description)}</li>`).join("");
    return `
      <div style="margin:24px 0; padding:20px; background-color:#faf7f0; border-left:4px solid #b38f4e;">
        <div style="font-size:13px; color:#b38f4e; letter-spacing:1px;">${medal} RANK ${globalRank} · SCORE ${Number(r.composite_score).toFixed(1)}</div>
        <h2 style="margin:6px 0 4px; font-family:Georgia,serif; font-size:22px; color:#0b0b0c;">${escapeHtml(r.name)}</h2>
        <div style="font-size:13px; color:#6b6659;">
          ${escapeHtml(r.kind)} · ${escapeHtml(r.status)} · ${r.registration_number ? `Reg #${escapeHtml(r.registration_number)} · ` : ""}expired ${fmtDate(r.expired_date)}
        </div>

        <table style="width:100%; margin-top:12px; font-size:13px;" cellpadding="0" cellspacing="0">
          <tr><td width="180" style="color:#6b6659; padding:2px 0;">Original owner</td><td>${escapeHtml(r.original_owner ?? "—")}</td></tr>
          ${r.nice_class ? `<tr><td style="color:#6b6659; padding:2px 0;">NICE class</td><td>${escapeHtml(r.nice_class)}</td></tr>` : ""}
          ${r.goods_services ? `<tr><td style="color:#6b6659; padding:2px 0;">Goods/services</td><td>${escapeHtml(r.goods_services)}</td></tr>` : ""}
          <tr><td style="color:#6b6659; padding:2px 0;">Distinctiveness</td><td>${r.distinctiveness_score}</td></tr>
          <tr><td style="color:#6b6659; padding:2px 0;">Recognition</td><td>${r.recognition_score}</td></tr>
          <tr><td style="color:#6b6659; padding:2px 0;">Domain (.com) avail</td><td>${r.domain_available === true ? "yes" : r.domain_available === false ? "no" : "unknown"}</td></tr>
          <tr><td style="color:#6b6659; padding:2px 0;">Competing live marks</td><td>${r.competing_active_marks}</td></tr>
          <tr><td style="color:#6b6659; padding:2px 0;">Monetization breadth</td><td>${r.monetization_breadth}</td></tr>
          <tr><td style="color:#6b6659; padding:2px 0;">Source</td><td>${r.source_url ? `<a href="${escapeHtml(r.source_url)}" style="color:#b38f4e;">${escapeHtml(r.source)}</a>` : escapeHtml(r.source)}</td></tr>
        </table>

        ${r.notes ? `<p style="margin-top:10px; font-size:13px; color:#35312a;"><strong>Notes:</strong> ${escapeHtml(r.notes)}</p>` : ""}

        ${ideas ? `<div style="margin-top:10px;">
          <div style="font-size:12px; color:#6b6659; text-transform:uppercase; letter-spacing:1px;">Monetization angles</div>
          <ul style="margin:6px 0 0 18px; padding:0; font-size:13px; color:#35312a;">${ideas}</ul>
        </div>` : ""}

        <div style="margin-top:12px;">
          <a href="${APP_BASE}/item/${r.id}" style="color:#b38f4e; font-size:13px;">Open in app →</a>
        </div>
      </div>`;
  }).join("");

  const html = `<!DOCTYPE html>
<html><head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="light">
  <title>Top 3 for ${today}</title>
</head>
<body style="margin:0; padding:0; background-color:#f7f4ec; font-family:Georgia,serif; color:#0b0b0c;">
  <table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f7f4ec;">
    <tr><td align="center" style="padding:30px 10px;">
      <table width="680" cellpadding="0" cellspacing="0" style="background-color:#fff; max-width:680px;">
        <tr><td style="padding:24px 24px; background-color:#0b0b0c; color:#f7f4ec;">
          <div style="font-size:11px; letter-spacing:2px; color:#b38f4e;">DAILY ALERT · ${today.toUpperCase()}</div>
          <h1 style="margin:4px 0 0; font-size:26px;">${escapeHtml(range.label)} — ranks ${range.offset + 1}–${range.offset + rows.length}</h1>
        </td></tr>
        <tr><td style="padding:0 24px 24px;">${itemsHtml}</td></tr>
        <tr><td style="padding:16px 24px; background-color:#faf7f0; color:#6b6659; font-size:11px;">
          ${rows.length} items shown · pulled from ${APP_BASE}<br>
          Not legal advice. Abandoned marks can carry common-law rights.
        </td></tr>
      </table>
    </td></tr>
  </table>
</body></html>`;

  const text = [
    `${range.label} for ${today} — ranks ${range.offset + 1}–${range.offset + rows.length}`,
    "",
    ...rows.map((r, idx) => [
      `#${range.offset + idx + 1}  ${r.name}  [score ${Number(r.composite_score).toFixed(1)}]`,
      `  ${r.kind} · ${r.status} · expired ${fmtDate(r.expired_date)}`,
      `  Owner: ${r.original_owner ?? "—"}`,
      r.nice_class ? `  Class: ${r.nice_class}` : "",
      r.goods_services ? `  Covers: ${r.goods_services}` : "",
      `  Distinct=${r.distinctiveness_score} Recog=${r.recognition_score} Domain=${r.domain_available === true ? "yes" : "no"} Competing=${r.competing_active_marks} Monet=${r.monetization_breadth}`,
      r.notes ? `  Notes: ${r.notes}` : "",
      `  App: ${APP_BASE}/item/${r.id}`,
      "",
    ].filter(Boolean).join("\n")),
  ].join("\n");

  return { subject: `${range.label} for ${today} — ranks ${range.offset + 1}–${range.offset + rows.length}`, html, text };
}

/**
 * George (DW gmail-agent) speaks {to, subject, body, attachments?} on /api/send-with-attachment.
 * The `body` field is HTML; we include the plain-text version in the attachments[] as a courtesy
 * if needed, but it's optional.
 */
async function sendViaGeorge(to: string, subject: string, html: string, _text: string): Promise<{ ok: boolean; via: string; error?: string; id?: string }> {
  if (!GEORGE) return { ok: false, via: "george-disabled", error: "GEORGE_ENDPOINT unset" };
  if (!to) return { ok: false, via: "george", error: "no recipient" };
  try {
    const r = await fetch(GEORGE, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        ...(GEORGE_AUTH ? { authorization: GEORGE_AUTH } : {}),
      },
      body: JSON.stringify({ to, subject, body: html }),
    });
    const jText = await r.text();
    if (!r.ok) return { ok: false, via: "george", error: `HTTP ${r.status}: ${jText.slice(0, 200)}` };
    try {
      const j = JSON.parse(jText);
      if (j.error) return { ok: false, via: "george", error: String(j.error) };
      return { ok: true, via: "george", id: j.id ?? j.messageId ?? "(no-id)" };
    } catch {
      return { ok: true, via: "george", id: "(non-json ok)" };
    }
  } catch (e) {
    return { ok: false, via: "george", error: (e as Error).message };
  }
}

async function sendOne(range: AlertRange): Promise<{ ok: boolean; via: string; error?: string }> {
  const { subject, html, text } = await renderAlert(range);
  if (GEORGE) {
    const g = await sendViaGeorge(TO, subject, html, text);
    if (g.ok) return g;
    console.warn(`[alert] george fail — ${g.error}`);
  }
  const to = TO || "steve-office@local";
  const r = await sendEmail({ to, subject, html, text });
  return r;
}

async function main() {
  // CLI: `alert-top-scores.ts [offset] [limit] [label]`
  // - no args → single "Top 3" alert (ranks 1-3) — matches the 6am cron behavior
  // - `batch <count>` → send <count> consecutive alerts of 3 items each
  const args = process.argv.slice(2);
  let ranges: AlertRange[];

  if (args[0] === "batch") {
    const count = Math.max(1, Math.min(20, Number(args[1] ?? 6)));
    const perAlert = 3;
    ranges = Array.from({ length: count }, (_, i) => ({
      offset: i * perAlert,
      limit: perAlert,
      label: i === 0 ? "Top 3" : `Next 3 (batch ${i + 1})`,
    }));
  } else if (args.length >= 2) {
    ranges = [{ offset: Number(args[0]), limit: Number(args[1]), label: args[2] ?? "Custom range" }];
  } else {
    ranges = [{ offset: 0, limit: 3, label: "Top 3" }];
  }

  let ok = 0, fail = 0;
  for (let i = 0; i < ranges.length; i++) {
    const r = await sendOne(ranges[i]);
    if (r.ok) { ok++; console.log(`[alert ${i + 1}/${ranges.length}] ranks ${ranges[i].offset + 1}-${ranges[i].offset + ranges[i].limit} — ${r.via}`); }
    else       { fail++; console.error(`[alert ${i + 1}/${ranges.length}] FAIL — ${r.error}`); }
    // 1.2s pause between sends to be polite to George's Gmail API.
    if (i < ranges.length - 1) await new Promise((res) => setTimeout(res, 1200));
  }
  console.log(`[alert] done — ok=${ok} fail=${fail}`);
  await pool.end();
}

main().catch((e) => { console.error("FATAL", e); process.exit(1); });