/** * Promoted-article logic for the BB homepage Industry News feed. * * Three tiers, top-to-bottom: * 1) PREMIUM — explicitly purchased slot in adv.featured_story_slots * (active right now). Sorted by `position` ascending. * 2) CLIENT — article whose title mentions a company that currently has * an active banner campaign on this property, AND was published in * the last 48 hours. Multiple matches sorted by publish date desc. * 3) GENERAL — everything else, publish date desc. * * The output is decorated with `promoted_kind` ('premium' | 'client' | null) * so the renderer can highlight tiers 1 + 2. */ import { createAdminClient } from "./supabase/admin"; const CLIENT_PROMO_WINDOW_HOURS = 48; export type PromotedKind = "premium" | "client" | null; export interface PromotableArticle { title: string; slug: string; date: string; // ISO or parseable; we use Date(article.date) [k: string]: any; } interface AdvertiserName { client_id: string; company_name: string; company_name_lower: string; /** Marketing/brand aliases — short forms that actually appear in * press-release titles. Populated from adv.clients.brand_aliases * because the legal company_name (with Inc/Ltd/Co. suffixes) almost * never appears verbatim in headlines. */ brand_aliases: string[] | null; } interface FeaturedSlot { id: string; article_kind: string; article_id: string; article_slug_cache: string | null; position: number; ends_at: string; starts_at: string; } async function fetchActiveAdvertisers(property: string): Promise { try { const svc = createAdminClient("adv"); const { data } = await svc .from("active_advertisers_by_property") .select("client_id, company_name, company_name_lower, brand_aliases") .eq("property", property); return (data || []) as AdvertiserName[]; } catch { return []; } } async function fetchActiveSlots(property: string): Promise { try { const svc = createAdminClient("adv"); const nowIso = new Date().toISOString(); const { data } = await svc .from("featured_story_slots") .select("id, article_kind, article_id, article_slug_cache, position, ends_at, starts_at") .eq("property", property) .eq("status", "active") .lte("starts_at", nowIso) .gte("ends_at", nowIso) .order("position", { ascending: true }); return (data || []) as FeaturedSlot[]; } catch { return []; } } /** * "Story is about the client" detection * ===================================== * Rule: we only highlight when the story narratively revolves around the * client or their product. A downstream mention ("FileCatalyst Announces * Integration with LiveU", "Stage Precision streamlines tracking with AJA * I/O cards") must NOT promote the partner — the story is about someone * else, the client is just a component. * * Three layered checks against the prefix of the title up to the match * start, in order: * 1. Match starts at position 0 → ALWAYS accept (lead with the client). * 2. Title contains an editorial framing phrase before the match * ("Product Spotlight:", "Review:", "First Look:", etc.) → accept * regardless of where the client name falls. * 3. Immediate prefix ends with a partner/usage phrase * ("with", "using", "powered by", "dealer of", "integration with", * …) → reject; the client is being credited as a vendor/integration, * not the subject. * 4. Default: accept iff the match start is in the first 32 chars. */ const SUBJECT_WINDOW_CHARS = 32; const EDITORIAL_FRAMES = [ "product spotlight:", "spotlight:", "review:", "first look:", "first look at", "hands-on with", "hands on with", "preview:", "profile:", "interview:", "meet ", "inside ", ]; // Partner/usage phrases — when the immediate prefix ends with one of // these, the client is positioned as the SOURCE/COMPONENT, and the real // subject is the company / person / project doing the with-ing. const PARTNER_PREFIXES = [ "with ", "using ", "uses ", "used ", "powered by ", "thanks to ", "integrating ", "integration with ", "integrates ", "featuring ", "support for ", "compatible with ", "adopts ", "selects ", "chooses ", "dealer of ", "stocking dealer of ", "partner of ", "partners with ", "partnership with ", "via ", "runs on ", "running ", "built with ", "built on ", "made with ", "deploys ", "deployed ", "ships with ", "shipped with ", "relies on ", "rely on ", "leverages ", "leveraging ", "supplied by ", "supported by ", ]; function isStoryAboutClient(title: string, matchStart: number): boolean { if (matchStart === 0) return true; const prefix = title.substring(0, matchStart).toLowerCase(); for (const frame of EDITORIAL_FRAMES) { if (prefix.includes(frame)) return true; } for (const p of PARTNER_PREFIXES) { if (prefix.endsWith(p)) return false; } return matchStart < SUBJECT_WINDOW_CHARS; } /** * Return the article's matched advertiser, or null. Matches the title * against the client's legal name PLUS every brand alias (word-boundary, * case-insensitive), filters by `isStoryAboutClient`, and picks the * longest matched substring across all candidates so "Sony Pictures" beats * "Sony" when both could match — and "Magewell Launches…" resolves via the * "Magewell" alias to "Magewell Electronics Co., Ltd." */ function matchAdvertiserInTitle(title: string, advertisers: AdvertiserName[]): AdvertiserName | null { if (!title || advertisers.length === 0) return null; const t = title.toLowerCase(); let best: { adv: AdvertiserName; matchLen: number } | null = null; for (const a of advertisers) { const candidates: string[] = [ a.company_name_lower, ...(a.brand_aliases || []).map((x) => x.toLowerCase()), ].filter((c) => !!c && c.length > 0); for (const c of candidates) { const re = new RegExp(`\\b${c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i"); const m = re.exec(t); if (m && isStoryAboutClient(title, m.index)) { if (!best || c.length > best.matchLen) { best = { adv: a, matchLen: c.length }; } } } } return best?.adv ?? null; } function withinPromoWindow(dateStr: string): boolean { const d = new Date(dateStr); if (isNaN(d.getTime())) return false; const ageHours = (Date.now() - d.getTime()) / 3_600_000; return ageHours >= 0 && ageHours <= CLIENT_PROMO_WINDOW_HOURS; } export interface DecoratedArticle { article: T; promoted_kind: PromotedKind; /** Set only for client-promoted articles — the advertiser name we matched. */ promoted_advertiser?: string; /** Set for premium-promoted — the slot's position number. */ promoted_position?: number; /** ISO timestamp when this promotion expires; null for general */ promoted_until?: string | null; } export async function decorateHomepageFeed( property: string, articles: T[], ): Promise[]> { const [advertisers, slots] = await Promise.all([ fetchActiveAdvertisers(property), fetchActiveSlots(property), ]); // Build slot lookup: article_slug → slot (for wp_imported we slot-key by // slug since wp_imported_posts.id is a uuid but our admin UI typically // pins by slug for readability). const slotBySlug = new Map(); for (const s of slots) { if (s.article_slug_cache) slotBySlug.set(s.article_slug_cache, s); } const decorated: DecoratedArticle[] = articles.map((a) => { // Premium check first const slot = slotBySlug.get(a.slug); if (slot) { return { article: a, promoted_kind: "premium", promoted_position: slot.position, promoted_until: slot.ends_at, }; } // Client check — only stories within 48hrs of publish if (withinPromoWindow(a.date)) { const adv = matchAdvertiserInTitle(a.title, advertisers); if (adv) { const promotedUntil = new Date(new Date(a.date).getTime() + CLIENT_PROMO_WINDOW_HOURS * 3_600_000).toISOString(); return { article: a, promoted_kind: "client", promoted_advertiser: adv.company_name, promoted_until: promotedUntil, }; } } return { article: a, promoted_kind: null, promoted_until: null }; }); // Sort: premium first (by position asc), then client (by date desc), then general (by date desc) function tier(d: DecoratedArticle): number { if (d.promoted_kind === "premium") return 0; if (d.promoted_kind === "client") return 1; return 2; } function dateTs(d: DecoratedArticle): number { const t = new Date(d.article.date).getTime(); return isNaN(t) ? 0 : t; } decorated.sort((a, b) => { const ta = tier(a); const tb = tier(b); if (ta !== tb) return ta - tb; if (ta === 0) return (a.promoted_position ?? 999) - (b.promoted_position ?? 999); return dateTs(b) - dateTs(a); // newest first }); return decorated; }