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