← back to Trademarks Copyright
src/lib/drops.ts
390 lines
/**
* Drop composer — picks today's items from the existing tables and writes
* headline/blurb/CTA copy via Qwen. Deterministic where possible; Qwen only for the prose.
*/
import { query } from "./db";
import { qwen, qwenJSON } from "./qwen";
export interface Candidate {
source_kind: "item" | "brand";
source_id: number;
name: string;
one_liner: string;
suggested_cta_url: string;
suggested_cta_text: string;
score: number; // composite rank (higher = better)
tier_gate: "standard" | "pro";
internal: Record<string, unknown>; // extra context for the LLM
}
async function poolExpiredMarks(limit: number): Promise<Candidate[]> {
const { rows } = await query<{
id: number; name: string; kind: string; status: string;
original_owner: string | null; expired_date: string; notes: string | null;
composite_score: string | null; monetization_ideas: unknown;
}>(
`SELECT id, name, kind, status, original_owner, expired_date, notes, composite_score, monetization_ideas
FROM items
WHERE composite_score IS NOT NULL
ORDER BY composite_score DESC NULLS LAST
LIMIT $1`,
[limit]
);
return rows.map((r) => ({
source_kind: "item",
source_id: r.id,
name: r.name,
one_liner: `${r.kind === "copyright" ? "Public-domain work" : "Abandoned US trademark"} — ${r.status}, ${new Date(r.expired_date).getFullYear()}`,
suggested_cta_url: `/item/${r.id}`,
suggested_cta_text: "Open file",
score: Number(r.composite_score ?? 0),
tier_gate: "standard",
internal: {
kind: r.kind,
status: r.status,
owner: r.original_owner,
notes: r.notes,
expired_date: r.expired_date,
monetization_ideas: r.monetization_ideas,
},
}));
}
async function poolBrandCandidates(limit: number): Promise<Candidate[]> {
const { rows } = await query<{
id: number; domain: string; brand_name: string | null; opportunity_label: string | null;
years_in_business: number | null; domain_lifecycle: string | null; abandonment_status: string | null;
days_to_expiry: number | null; notes: string | null;
}>(
`SELECT id, domain, brand_name, opportunity_label, years_in_business,
domain_lifecycle, abandonment_status, notes,
CASE WHEN domain_expires_on IS NOT NULL
THEN (domain_expires_on - CURRENT_DATE) ELSE NULL END AS days_to_expiry
FROM brand_candidates
WHERE opportunity_label IN ('domain_snipe','abandonment_watch')
ORDER BY
CASE opportunity_label
WHEN 'domain_snipe' THEN 0
WHEN 'abandonment_watch' THEN 1
ELSE 2 END,
years_in_business DESC NULLS LAST
LIMIT $1`,
[limit]
);
return rows.map((r) => {
const lbl = r.opportunity_label === "domain_snipe" ? "Domain snipe" : "Abandonment watch";
const one = r.opportunity_label === "domain_snipe"
? `Domain ${r.domain_lifecycle === "expired" ? "is expired" : `expires in ${r.days_to_expiry ?? "?"} days`} — brand dormant`
: `${r.years_in_business ?? "?"} yrs in biz, unregistered, ${r.abandonment_status ?? "?"}`;
return {
source_kind: "brand",
source_id: r.id,
name: r.brand_name || r.domain,
one_liner: `${lbl}: ${one}`,
suggested_cta_url: `https://${r.domain}`,
suggested_cta_text: "Visit site",
score: r.opportunity_label === "domain_snipe" ? 100 : 70,
tier_gate: "standard",
internal: {
domain: r.domain,
label: r.opportunity_label,
years: r.years_in_business,
lifecycle: r.domain_lifecycle,
abandonment: r.abandonment_status,
days_to_expiry: r.days_to_expiry,
notes: r.notes,
},
};
});
}
/** Returns candidates that have NOT yet appeared in any drop. */
export async function freshCandidates(maxPerSource: number): Promise<Candidate[]> {
const [marks, brands] = await Promise.all([
poolExpiredMarks(maxPerSource * 3),
poolBrandCandidates(maxPerSource * 3),
]);
const { rows: used } = await query<{ source_kind: string; source_id: number }>(
`SELECT source_kind, source_id FROM drop_items`
);
const usedKey = new Set(used.map((r) => `${r.source_kind}:${r.source_id}`));
const filtered = [...marks, ...brands]
.filter((c) => !usedKey.has(`${c.source_kind}:${c.source_id}`))
.sort((a, b) => b.score - a.score);
return filtered;
}
async function writeBlurb(c: Candidate, forPro: boolean): Promise<{
headline: string;
blurb: string;
cta_text: string;
pro_extra?: string;
}> {
const prompt = `You are the editor of a daily newsletter about trademark + domain opportunities.
Produce copy for this candidate.
Candidate:
name: ${c.name}
category: ${c.source_kind === "brand" ? "unregistered-brand opportunity" : "expired trademark or public-domain work"}
one-liner: ${c.one_liner}
internal: ${JSON.stringify(c.internal).slice(0, 800)}
Write in a punchy, editorial voice — think Morning Brew meets trademark-lawyer-who-does-stand-up.
No legal advice, no filler.
Return STRICT JSON:
{
"headline": string, // 6-10 words, evocative, start with a capital
"blurb": string, // 2-3 sentences, lead with *why this is on the list today*, end with the most interesting revenue angle
"cta_text": string${forPro ? `,
"pro_extra": string // 2 extra sentences ONLY Pro subscribers see — specific tactical hint like which NICE class to file in, which registrar tends to catch drops for this TLD, etc.` : ""}
}`;
try {
const ans = await qwenJSON<{ headline: string; blurb: string; cta_text: string; pro_extra?: string }>(prompt, 0.5);
return {
headline: (ans.headline || c.name).slice(0, 120),
blurb: (ans.blurb || c.one_liner).slice(0, 600),
cta_text: ans.cta_text || c.suggested_cta_text,
pro_extra: forPro ? ans.pro_extra : undefined,
};
} catch {
return { headline: c.name, blurb: c.one_liner, cta_text: c.suggested_cta_text };
}
}
async function writeSubjectAndIntro(picks: Candidate[]): Promise<{ subject: string; intro: string; outro: string }> {
const summary = picks.map((p, i) => `${i + 1}. ${p.name} — ${p.one_liner}`).join("\n");
const prompt = `Write the subject line, 1-sentence intro, and 1-sentence outro for today's "Drops" newsletter.
Today's picks:
${summary}
Tone: sharp, a little mischievous, zero fluff.
Return STRICT JSON: { "subject": string, "intro": string, "outro": string }`;
try {
const ans = await qwenJSON<{ subject: string; intro: string; outro: string }>(prompt, 0.6);
return {
subject: (ans.subject || "Today's drops").slice(0, 120),
intro: (ans.intro || "").slice(0, 300),
outro: (ans.outro || "").slice(0, 300),
};
} catch {
return { subject: "Today's drops", intro: "Fresh opportunities.", outro: "See you tomorrow." };
}
}
export interface ComposeOptions {
date?: Date;
itemsPerDrop?: number; // cap for the drop itself; the pro_extra gates by tier
force?: boolean; // recompose if a drop for this date already exists
}
export interface ComposedDrop {
dropId: number;
date: string;
subject: string;
items: number;
}
export async function composeDropForDate(opts: ComposeOptions = {}): Promise<ComposedDrop> {
const date = (opts.date ?? new Date()).toISOString().slice(0, 10);
const itemsCap = opts.itemsPerDrop ?? 10;
const existing = await query<{ id: number; status: string }>(
`SELECT id, status FROM drops WHERE drop_date = $1`, [date]
);
if (existing.rows.length && !opts.force) {
return { dropId: existing.rows[0].id, date, subject: "(already exists — use force)", items: 0 };
}
const candidates = await freshCandidates(Math.max(itemsCap * 2, 10));
if (!candidates.length) throw new Error("No fresh candidates — run the hunter or add more items first.");
const picks = candidates.slice(0, itemsCap);
if (existing.rows.length) {
await query(`DELETE FROM drops WHERE drop_date = $1`, [date]);
}
// Fire subject/intro/outro generation in parallel with item blurbs.
const CONCURRENCY = 3;
const blurbPromises: Promise<{ pos: number; pick: Candidate; blurb: Awaited<ReturnType<typeof writeBlurb>> }>[] = [];
const metaPromise = writeSubjectAndIntro(picks);
for (let i = 0; i < picks.length; i += CONCURRENCY) {
const slice = picks.slice(i, i + CONCURRENCY);
const wave = slice.map(async (pick, k) => ({
pos: i + k + 1,
pick,
blurb: await writeBlurb(pick, true),
}));
// Run waves sequentially but each wave runs in parallel.
const done = await Promise.all(wave);
blurbPromises.push(...done.map((d) => Promise.resolve(d)));
}
const [meta, ...settled] = await Promise.all([metaPromise, ...blurbPromises]);
const results = settled;
const { rows: dropRows } = await query<{ id: number }>(
`INSERT INTO drops (drop_date, subject, intro, outro, status)
VALUES ($1, $2, $3, $4, 'draft') RETURNING id`,
[date, meta.subject, meta.intro, meta.outro]
);
const dropId = dropRows[0].id;
for (const r of results) {
await query(
`INSERT INTO drop_items
(drop_id, position, source_kind, source_id, tier_gate, headline, blurb, cta_text, cta_url, pro_extra)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
dropId, r.pos, r.pick.source_kind, r.pick.source_id,
r.pick.tier_gate,
r.blurb.headline, r.blurb.blurb, r.blurb.cta_text, r.pick.suggested_cta_url,
r.blurb.pro_extra ?? null,
]
);
}
return { dropId, date, subject: meta.subject, items: results.length };
}
// ---------- rendering ----------
export interface RenderArgs {
dropId: number;
tier: "trial" | "standard" | "pro" | "comp";
subscriberToken: string;
appBaseUrl: string;
deliveryId?: number; // if set, embed open-tracking pixel
}
export async function renderDropHTML(a: RenderArgs): Promise<{ subject: string; html: string; text: string }> {
const { rows: drops } = await query<{
id: number; subject: string; intro: string | null; outro: string | null; drop_date: string;
}>(`SELECT id, subject, intro, outro, drop_date FROM drops WHERE id = $1`, [a.dropId]);
if (!drops.length) throw new Error("drop not found");
const drop = drops[0];
const { rows: items } = await query<{
position: number; headline: string; blurb: string;
cta_text: string | null; cta_url: string | null; pro_extra: string | null;
}>(`SELECT position, headline, blurb, cta_text, cta_url, pro_extra
FROM drop_items WHERE drop_id = $1 ORDER BY position`, [a.dropId]);
const showPro = a.tier === "pro" || a.tier === "comp";
const maxItems = a.tier === "pro" || a.tier === "comp" ? 999 : a.tier === "standard" ? 3 : 3; // trial = 3
const visible = items.slice(0, maxItems);
const hiddenCount = items.length - visible.length;
const date = new Date(drop.drop_date).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
const itemsHtml = visible.map((it) => {
const pro = showPro && it.pro_extra ? `<p style="margin:8px 0 0; padding:8px 10px; background:#fff8e6; border-left:3px solid #b38f4e; font-size:13px; color:#51391a;"><strong style="color:#b38f4e;">PRO:</strong> ${escapeHtml(it.pro_extra)}</p>` : "";
const cta = it.cta_url ? `<p style="margin:8px 0 0;"><a href="${escapeAttr(makeAbsolute(it.cta_url, a.appBaseUrl))}" style="color:#b38f4e; text-decoration:none; font-weight:600;">${escapeHtml(it.cta_text || "Read more →")}</a></p>` : "";
return `
<div style="padding:16px 20px; border-top:1px solid #e7e2d6;">
<div style="font-size:11px; color:#9e9788; letter-spacing:1px;">#${it.position}</div>
<h3 style="margin:4px 0 6px; font-family:Georgia,serif; font-size:20px; color:#0b0b0c;">${escapeHtml(it.headline)}</h3>
<p style="margin:0; color:#35312a; font-size:15px; line-height:1.55;">${escapeHtml(it.blurb)}</p>
${pro}
${cta}
</div>
`;
}).join("");
const teaser = hiddenCount > 0
? `<div style="padding:24px 20px; text-align:center; border-top:1px solid #e7e2d6; background:#faf7f0;">
<p style="margin:0 0 12px; color:#51391a;"><strong>${hiddenCount} more opportunit${hiddenCount === 1 ? "y is" : "ies are"} in this drop — Pro only.</strong></p>
<a href="${a.appBaseUrl}/drops?upgrade=pro&token=${encodeURIComponent(a.subscriberToken)}" style="display:inline-block; padding:10px 18px; background:#0b0b0c; color:#f7f4ec; text-decoration:none; font-weight:600;">Upgrade to Pro — $99/mo</a>
</div>`
: "";
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">
<meta name="supported-color-schemes" content="light">
<title>${escapeHtml(drop.subject)}</title>
<style>
@media (prefers-color-scheme: dark) {
/* Preserve brand colors even in dark clients. */
body { background-color:#f7f4ec !important; color:#0b0b0c !important; }
}
</style>
</head>
<body style="margin:0; padding:0; background-color:#f7f4ec; font-family:Georgia,'Libre Caslon Text',serif; color:#0b0b0c;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f7f4ec;">
<tr><td align="center" style="padding:30px 10px;">
<table width="620" cellpadding="0" cellspacing="0" style="background:#fff; max-width:620px;">
<tr><td style="padding:24px 20px; background:#0b0b0c; color:#f7f4ec;">
<div style="font-size:11px; letter-spacing:2px; color:#b38f4e;">DROPS · ${date.toUpperCase()}</div>
<h1 style="margin:4px 0 0; font-size:24px; font-family:Georgia,serif;">${escapeHtml(drop.subject)}</h1>
</td></tr>
${drop.intro ? `<tr><td style="padding:18px 20px; font-size:15px; color:#35312a; border-top:1px solid #e7e2d6;"><em>${escapeHtml(drop.intro)}</em></td></tr>` : ""}
<tr><td>${itemsHtml}</td></tr>
${teaser}
${drop.outro ? `<tr><td style="padding:20px; color:#35312a; border-top:1px solid #e7e2d6; font-size:14px;">${escapeHtml(drop.outro)}</td></tr>` : ""}
<tr><td style="padding:16px 20px; background-color:#faf7f0; color:#6b6659; font-size:11px;">
Tier: <strong>${a.tier.toUpperCase()}</strong> · <a href="${a.appBaseUrl}/drops/view/${encodeURIComponent(a.subscriberToken)}" style="color:#6b6659;">Archive</a> · <a href="${a.appBaseUrl}/drops/unsubscribe/${encodeURIComponent(a.subscriberToken)}" style="color:#6b6659;">Unsubscribe</a>
<br><br>
Not legal advice. Before registering any mark or buying any domain, talk to a trademark attorney.
<br><br>
<span style="color:#8a8479;">${escapeHtml(process.env.DROPS_MAILING_ADDRESS || "Drops · [PREVIEW ONLY — real sends are blocked until DROPS_MAILING_ADDRESS is set; CAN-SPAM 16 C.F.R. § 316.5]")}</span>
${a.deliveryId ? `<br><img src="${a.appBaseUrl}/api/drops/pixel/${a.deliveryId}" width="1" height="1" alt="" style="display:block;margin-top:6px;">` : ""}
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
const text = [
`DROPS · ${date}`,
drop.subject,
drop.intro ?? "",
"",
...visible.map((it) => `#${it.position} ${it.headline}\n${it.blurb}${showPro && it.pro_extra ? `\nPRO: ${it.pro_extra}` : ""}\n${it.cta_url ? makeAbsolute(it.cta_url, a.appBaseUrl) : ""}`),
"",
hiddenCount > 0 ? `+ ${hiddenCount} more in Pro — upgrade: ${a.appBaseUrl}/drops?upgrade=pro` : "",
drop.outro ?? "",
"",
`Archive: ${a.appBaseUrl}/drops/view/${a.subscriberToken}`,
`Unsubscribe: ${a.appBaseUrl}/drops/unsubscribe/${a.subscriberToken}`,
].filter(Boolean).join("\n\n");
return { subject: drop.subject, html, text };
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function escapeAttr(s: string): string { return escapeHtml(s); }
function makeAbsolute(url: string, base: string): string {
if (/^https?:/.test(url)) return url;
return `${base.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
}
// ---------- delivery helpers ----------
export async function activeSubscribersFor(dropId: number): Promise<{
id: number; email: string; name: string | null; token: string; tier: string;
}[]> {
const { rows } = await query<{
id: number; email: string; name: string | null; token: string; tier: string;
}>(
`SELECT s.id, s.email, s.name, s.token, s.tier
FROM subscribers s
WHERE s.status = 'active'
AND (s.tier != 'trial' OR s.trial_drops_left > 0)
AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.drop_id = $1 AND d.subscriber_id = s.id)`,
[dropId]
);
return rows;
}