← 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
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); });