← back to Rentv Adintel
merge: full-spec fan-out (data+seed, compliance, UI/routes, connectors, export, docs+tests)
048b52e10e6755926c072bcc24f3a44a344aad1b · 2026-08-07 18:11:58 -0700 · Steve Abrams
Node A (seed): 29 orgs incl. 5 verified advertisers + CoStar content-partner
(kept distinct, never mislabeled sponsor) + 22 panelists, 24 dated rate
snapshots, 45k/50k audience snapshots (unmerged), scores, classification +
reversible entity-resolution.
Node B (compliance): source-policy validator, LinkedIn host block, SSRF guard,
no-inferred-email, adapter contract + manual-review fallback, search provider
(manual, human-click LinkedIn URLs only).
Node C (UI): /api/v1 + Simple-View pages (advertisers/ads/conferences/contacts/
prospects/media-kit/sources/dashboard), adjustable-columns table engine,
CA/AZ + Verified-Only switches, Show-Me-Why drawer.
Node D (connectors): GA4/GSC fixture-backed importers (is_demo), CSV field-mapper
(refuses no-source contacts), Gmail/Ads off, email-upload parser, derived insights.
Node E (export): pure-node ZIP+XLSX+CSV, rights-aware Download-Everything,
self-contained offline executive-viewer.html.
Node F (docs+tests): 8 docs + README + 45 passing tests (0 fail).
Merge fixes: relocated 3 misfiled static assets to public/; fixed advertisers
GROUP BY (spurious MAX on ordered subquery). All endpoints 200, export valid,
43/43 source policies pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A db/seed/flyers.jsA db/seed/index.jsA db/seed/markets.jsA db/seed/organizations.jsA db/seed/rentv-products.jsA db/seed/scores.jsA db/seed/sources.jsA lib/classification.jsA lib/compliance/fetch-guard.jsA lib/compliance/no-inferred-email.jsA lib/compliance/source-policy.jsA lib/entity-resolution.jsA public/css/app.cssA public/js/app.jsA public/js/table.jsA scripts/audit-source-policies.jsA scripts/export-all.jsA scripts/import-google.jsA scripts/research.jsA src/adapters/base.jsA src/adapters/manual-review.jsA src/analytics/derive.jsA src/analytics/index.jsA src/connectors/csv-import.jsA src/connectors/email-upload.jsA src/connectors/ga4.jsA src/connectors/gmail.jsA src/connectors/google-ads.jsA src/connectors/gsc.jsA src/export/build.jsA src/export/csv.jsA src/export/executive-viewer.jsA src/export/rights.jsA src/export/xlsx.jsA src/export/zip.jsA src/routes/api.jsA src/routes/pages.jsA src/search/provider.jsA src/search/queries.jsA test/classification.test.jsA test/compliance.test.jsA test/export-filter.test.jsA test/scoring.test.jsA test/types.test.js
Diff
commit 048b52e10e6755926c072bcc24f3a44a344aad1b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 7 18:11:58 2026 -0700
merge: full-spec fan-out (data+seed, compliance, UI/routes, connectors, export, docs+tests)
Node A (seed): 29 orgs incl. 5 verified advertisers + CoStar content-partner
(kept distinct, never mislabeled sponsor) + 22 panelists, 24 dated rate
snapshots, 45k/50k audience snapshots (unmerged), scores, classification +
reversible entity-resolution.
Node B (compliance): source-policy validator, LinkedIn host block, SSRF guard,
no-inferred-email, adapter contract + manual-review fallback, search provider
(manual, human-click LinkedIn URLs only).
Node C (UI): /api/v1 + Simple-View pages (advertisers/ads/conferences/contacts/
prospects/media-kit/sources/dashboard), adjustable-columns table engine,
CA/AZ + Verified-Only switches, Show-Me-Why drawer.
Node D (connectors): GA4/GSC fixture-backed importers (is_demo), CSV field-mapper
(refuses no-source contacts), Gmail/Ads off, email-upload parser, derived insights.
Node E (export): pure-node ZIP+XLSX+CSV, rights-aware Download-Everything,
self-contained offline executive-viewer.html.
Node F (docs+tests): 8 docs + README + 45 passing tests (0 fail).
Merge fixes: relocated 3 misfiled static assets to public/; fixed advertisers
GROUP BY (spurious MAX on ordered subquery). All endpoints 200, export valid,
43/43 source policies pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
db/seed/flyers.js | 171 ++++
db/seed/index.js | 186 +++++
db/seed/markets.js | 66 ++
db/seed/organizations.js | 711 ++++++++++++++++
db/seed/rentv-products.js | 216 +++++
db/seed/scores.js | 317 +++++++
db/seed/sources.js | 471 +++++++++++
lib/classification.js | 92 +++
lib/compliance/fetch-guard.js | 247 ++++++
lib/compliance/no-inferred-email.js | 147 ++++
lib/compliance/source-policy.js | 197 +++++
lib/entity-resolution.js | 266 ++++++
public/css/app.css | 736 +++++++++++++++++
public/js/app.js | 264 ++++++
public/js/table.js | 513 ++++++++++++
scripts/audit-source-policies.js | 161 ++++
scripts/export-all.js | 49 ++
scripts/import-google.js | 173 ++++
scripts/research.js | 134 +++
src/adapters/base.js | 242 ++++++
src/adapters/manual-review.js | 70 ++
src/analytics/derive.js | 339 ++++++++
src/analytics/index.js | 37 +
src/connectors/csv-import.js | 523 ++++++++++++
src/connectors/email-upload.js | 379 +++++++++
src/connectors/ga4.js | 354 ++++++++
src/connectors/gmail.js | 176 ++++
src/connectors/google-ads.js | 172 ++++
src/connectors/gsc.js | 291 +++++++
src/export/build.js | 657 +++++++++++++++
src/export/csv.js | 81 ++
src/export/executive-viewer.js | 644 +++++++++++++++
src/export/rights.js | 314 +++++++
src/export/xlsx.js | 241 ++++++
src/export/zip.js | 190 +++++
src/routes/api.js | 654 +++++++++++++++
src/routes/pages.js | 1553 +++++++++++++++++++++++++++++++++++
src/search/provider.js | 127 +++
src/search/queries.js | 90 ++
test/classification.test.js | 84 ++
test/compliance.test.js | 144 ++++
test/export-filter.test.js | 103 +++
test/scoring.test.js | 127 +++
test/types.test.js | 99 +++
44 files changed, 12808 insertions(+)
diff --git a/db/seed/flyers.js b/db/seed/flyers.js
new file mode 100644
index 0000000..afff8e4
--- /dev/null
+++ b/db/seed/flyers.js
@@ -0,0 +1,171 @@
+'use strict';
+/**
+ * Seed creative_assets for RENTV flyer files.
+ *
+ * Per §22: check for the two flyer files. They are expected to be absent
+ * (confirmed absent at /Users/macstudio3/Downloads/ and /mnt/data/).
+ * When absent: insert GENERATED_PLACEHOLDER creative_assets rows and an
+ * audit_log entry requesting admin upload.
+ *
+ * When present: copy to public/seed/rentv/, compute sha256 + image dimensions,
+ * label AUTHORIZED_RENTV_ASSET.
+ *
+ * Idempotent: checks by file_name before inserting.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const FLYER_SEARCH_PATHS = [
+ '/mnt/data',
+ '/Users/macstudio3/Downloads',
+];
+
+const FLYERS = [
+ {
+ file_name: 'Property Spotlight Flyer REV April 2025.jpg',
+ mime_type: 'image/jpeg',
+ alt_text:
+ 'RENTV Property Spotlight Flyer — revised April 2025. Shows RENTV property spotlight eblast product details and pricing.',
+ product_key: 'property_spotlight_eblast',
+ notes: 'April 2025 version of the RENTV Property Spotlight product flyer.',
+ },
+ {
+ file_name: 'Corp Flyer Apr 2026 V3.jpg',
+ mime_type: 'image/jpeg',
+ alt_text:
+ 'RENTV Corporate Flyer — April 2026 Version 3. Shows RENTV advertising product suite, audience figures, and updated rates.',
+ product_key: 'website_banner',
+ notes: 'April 2026 v3 RENTV corporate media kit flyer.',
+ },
+];
+
+function findFlyer(fileName) {
+ for (const dir of FLYER_SEARCH_PATHS) {
+ const full = path.join(dir, fileName);
+ if (fs.existsSync(full)) return full;
+ }
+ return null;
+}
+
+function sha256File(filePath) {
+ const data = fs.readFileSync(filePath);
+ return crypto.createHash('sha256').update(data).digest('hex');
+}
+
+/** Parse JPEG dimensions from header bytes (minimal, no external deps). */
+function jpegDimensions(filePath) {
+ try {
+ const fd = fs.openSync(filePath, 'r');
+ const buf = Buffer.alloc(65536);
+ fs.readSync(fd, buf, 0, 65536, 0);
+ fs.closeSync(fd);
+ // Scan for SOF0/SOF1/SOF2 markers (0xFFC0, 0xFFC1, 0xFFC2)
+ for (let i = 0; i < buf.length - 8; i++) {
+ if (buf[i] === 0xff && (buf[i + 1] === 0xc0 || buf[i + 1] === 0xc1 || buf[i + 1] === 0xc2)) {
+ const height = buf.readUInt16BE(i + 5);
+ const width = buf.readUInt16BE(i + 7);
+ return { width, height };
+ }
+ }
+ } catch (_) {
+ // ignore
+ }
+ return { width: null, height: null };
+}
+
+async function seedFlyers(client) {
+ let inserted = 0;
+ let skipped = 0;
+ let placeholders = 0;
+
+ // Ensure public/seed/rentv/ directory exists (relative to project root)
+ const PROJECT_ROOT = path.resolve(__dirname, '../..');
+ const publicSeedDir = path.join(PROJECT_ROOT, 'public', 'seed', 'rentv');
+ if (!fs.existsSync(publicSeedDir)) {
+ fs.mkdirSync(publicSeedDir, { recursive: true });
+ }
+
+ for (const flyer of FLYERS) {
+ // Idempotency check
+ const existing = await client.query(
+ `SELECT id FROM creative_assets WHERE file_name=$1 LIMIT 1`,
+ [flyer.file_name]
+ );
+ if (existing.rows.length > 0) {
+ skipped++;
+ continue;
+ }
+
+ const foundPath = findFlyer(flyer.file_name);
+
+ if (foundPath) {
+ // File present — compute checksum and dims, copy to public/seed/rentv/
+ const destPath = path.join(publicSeedDir, flyer.file_name);
+ fs.copyFileSync(foundPath, destPath);
+
+ const checksum = sha256File(foundPath);
+ const { width, height } = jpegDimensions(foundPath);
+ const objectKey = `seed/rentv/${flyer.file_name}`;
+
+ await client.query(
+ `INSERT INTO creative_assets
+ (file_name, mime_type, width, height, checksum, object_key,
+ capture_method, rights_status, captured_at, alt_text)
+ VALUES ($1,$2,$3,$4,$5,$6,'MANUAL_UPLOAD','EXPORT_ALLOWED',now(),$7)`,
+ [flyer.file_name, flyer.mime_type, width, height, checksum, objectKey, flyer.alt_text]
+ );
+
+ const alFoundExist = await client.query(
+ `SELECT id FROM audit_logs WHERE action='SEED_ASSET_FOUND' AND detail->>'file_name'=$1 LIMIT 1`,
+ [flyer.file_name]
+ );
+ if (alFoundExist.rows.length === 0) {
+ await client.query(
+ `INSERT INTO audit_logs (action, entity_table, actor, detail)
+ VALUES ('SEED_ASSET_FOUND','creative_assets','seed',$1::jsonb)`,
+ [JSON.stringify({ file_name: flyer.file_name, checksum, width, height, object_key: objectKey })]
+ );
+ }
+ inserted++;
+ } else {
+ // File absent — create GENERATED_PLACEHOLDER
+ await client.query(
+ `INSERT INTO creative_assets
+ (file_name, mime_type, capture_method, rights_status, alt_text)
+ VALUES ($1,$2,'GENERATED_PLACEHOLDER','INTERNAL_EVIDENCE_ONLY',$3)`,
+ [flyer.file_name, flyer.mime_type, flyer.alt_text]
+ );
+ placeholders++;
+
+ const alMissExist = await client.query(
+ `SELECT id FROM audit_logs WHERE action='SEED_ASSET_MISSING' AND detail->>'file_name'=$1 LIMIT 1`,
+ [flyer.file_name]
+ );
+ if (alMissExist.rows.length === 0) {
+ await client.query(
+ `INSERT INTO audit_logs (action, entity_table, actor, detail)
+ VALUES ('SEED_ASSET_MISSING','creative_assets','seed',$1::jsonb)`,
+ [
+ JSON.stringify({
+ file_name: flyer.file_name,
+ note:
+ 'Flyer asset not found at expected paths (' +
+ FLYER_SEARCH_PATHS.join(', ') +
+ '). Admin upload card required. ' +
+ flyer.notes,
+ admin_action_required: true,
+ searched_paths: FLYER_SEARCH_PATHS,
+ product_key: flyer.product_key,
+ }),
+ ]
+ );
+ }
+ }
+ }
+
+ return { inserted, skipped, placeholders };
+}
+
+module.exports = { seedFlyers };
diff --git a/db/seed/index.js b/db/seed/index.js
new file mode 100644
index 0000000..db82a3c
--- /dev/null
+++ b/db/seed/index.js
@@ -0,0 +1,186 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence — seed orchestrator.
+ *
+ * Usage: node db/seed/index.js
+ *
+ * Idempotent: safe to run multiple times. Each sub-module uses
+ * ON CONFLICT / existence checks to avoid duplicate rows.
+ *
+ * Execution order:
+ * 1. markets — geography lookup table (FK target)
+ * 2. sources — source_policies (FK target for evidence / rate snapshots)
+ * 3. organizations — orgs + evidence_records + ad_sightings + events + event_relationships
+ * 4. rentv-products — rentv_rate_snapshots + rentv_audience_snapshots
+ * 5. flyers — creative_assets for RENTV flyer files (placeholder if absent)
+ * 6. scores — opportunity_stages + opportunity_scores (per org)
+ */
+
+const { pool, tx } = require('../index');
+
+const { seedMarkets } = require('./markets');
+const { seedSources } = require('./sources');
+const { seedOrganizations } = require('./organizations');
+const { seedRentvProducts } = require('./rentv-products');
+const { seedFlyers } = require('./flyers');
+const { seedScores } = require('./scores');
+
+async function run() {
+ console.log('=== RENTV Advertiser Intelligence — seed run ===');
+ console.log(`Started: ${new Date().toISOString()}`);
+ console.log('');
+
+ let marketCounts, sourceCounts, orgCounts, productCounts, flyerCounts, scoreCounts;
+
+ // Run all seed phases inside a single transaction so a failure rolls back cleanly.
+ // scores.js does a DELETE+reinsert per org — that's fine inside the same tx.
+ await tx(async (client) => {
+ console.log('[1/6] Seeding markets…');
+ marketCounts = await seedMarkets(client);
+ console.log(` markets: +${marketCounts.inserted} inserted, ${marketCounts.updated} updated`);
+
+ console.log('[2/6] Seeding source_policies…');
+ sourceCounts = await seedSources(client);
+ console.log(` source_policies: +${sourceCounts.inserted} inserted, ${sourceCounts.updated} updated`);
+
+ console.log('[3/6] Seeding organizations, evidence, ad_sightings, events…');
+ orgCounts = await seedOrganizations(client);
+ console.log(` organizations: +${orgCounts.orgs_inserted} inserted, ${orgCounts.orgs_updated} updated`);
+ console.log(` evidence_records: +${orgCounts.evidence_inserted} inserted`);
+ console.log(` ad_sightings: +${orgCounts.ad_sightings_inserted} inserted`);
+ console.log(` events: +${orgCounts.events_inserted} inserted`);
+ console.log(` event_relationships: +${orgCounts.event_relationships_inserted} inserted`);
+
+ console.log('[4/6] Seeding RENTV rate and audience snapshots…');
+ productCounts = await seedRentvProducts(client);
+ console.log(` rentv_rate_snapshots: +${productCounts.rate_inserted} inserted (${productCounts.rate_skipped} skipped/existing)`);
+ console.log(` rentv_audience_snapshots: +${productCounts.audience_inserted} inserted (${productCounts.audience_skipped} skipped/existing)`);
+
+ console.log('[5/6] Seeding creative_assets (RENTV flyers)…');
+ flyerCounts = await seedFlyers(client);
+ console.log(` creative_assets: +${flyerCounts.inserted} real, +${flyerCounts.placeholders} placeholder(s), ${flyerCounts.skipped} skipped/existing`);
+
+ console.log('[6/6] Seeding opportunity_stages and opportunity_scores…');
+ scoreCounts = await seedScores(client);
+ console.log(` opportunity_stages: +${scoreCounts.stages_inserted} inserted, ${scoreCounts.stages_updated} updated`);
+ console.log(` opportunity_scores: +${scoreCounts.scores_inserted} computed and inserted`);
+ });
+
+ // ---- Summary table ----------------------------------------------------------
+ console.log('');
+ console.log('=== Seed summary ===');
+ const fmt = (label, val) => console.log(` ${label.padEnd(32)} ${String(val).padStart(6)}`);
+
+ fmt('markets (CA + AZ)', marketCounts.inserted + marketCounts.updated);
+ fmt('source_policies', sourceCounts.inserted + sourceCounts.updated);
+ fmt('organizations', orgCounts.orgs_inserted + orgCounts.orgs_updated);
+ fmt('evidence_records', orgCounts.evidence_inserted);
+ fmt('ad_sightings', orgCounts.ad_sightings_inserted);
+ fmt('events', orgCounts.events_inserted);
+ fmt('event_relationships', orgCounts.event_relationships_inserted);
+ fmt('rentv_rate_snapshots', productCounts.rate_inserted + productCounts.rate_skipped);
+ fmt('rentv_audience_snapshots', productCounts.audience_inserted + productCounts.audience_skipped);
+ fmt('creative_assets', flyerCounts.inserted + flyerCounts.placeholders + flyerCounts.skipped);
+ fmt('opportunity_stages', scoreCounts.stages_inserted + scoreCounts.stages_updated);
+ fmt('opportunity_scores', scoreCounts.scores_inserted);
+
+ // ---- Verification queries ---------------------------------------------------
+ console.log('');
+ console.log('=== Verification ===');
+
+ const client = await pool.connect();
+ try {
+ // 5 verified advertisers present
+ const vaRes = await client.query(
+ `SELECT o.display_name, a.relationship_status, a.verification_status, a.confidence
+ FROM ad_sightings a
+ JOIN organizations o ON o.id=a.organization_id
+ WHERE a.relationship_status='VERIFIED_ADVERTISER'
+ ORDER BY o.display_name`
+ );
+ console.log(`\nVerified advertisers (ad_sightings): ${vaRes.rows.length}`);
+ for (const r of vaRes.rows) {
+ console.log(` [${r.relationship_status}] ${r.display_name} — verification_status: ${r.verification_status}, confidence: ${r.confidence}`);
+ }
+
+ // CoStar content partner
+ const costarRes = await client.query(
+ `SELECT o.display_name, er.relationship_status
+ FROM event_relationships er
+ JOIN organizations o ON o.id=er.organization_id
+ WHERE er.relationship_status='VERIFIED_CONTENT_PARTNER'`
+ );
+ console.log(`\nContent partners (event_relationships): ${costarRes.rows.length}`);
+ for (const r of costarRes.rows) {
+ console.log(` [${r.relationship_status}] ${r.display_name}`);
+ }
+
+ // Panelists NOT labeled as sponsors
+ const panelistRes = await client.query(
+ `SELECT o.display_name, er.relationship_status
+ FROM event_relationships er
+ JOIN organizations o ON o.id=er.organization_id
+ WHERE er.relationship_status IN ('SPEAKER_OR_PANELIST_ONLY','LIKELY_PROSPECT')
+ ORDER BY er.relationship_status, o.display_name`
+ );
+ console.log(`\nPanelists/prospects (event_relationships, NOT labeled as sponsors): ${panelistRes.rows.length}`);
+ for (const r of panelistRes.rows) {
+ console.log(` [${r.relationship_status}] ${r.display_name}`);
+ }
+
+ // Verify no panelist is mislabeled as sponsor in event_relationships
+ const mislabelRes = await client.query(
+ `SELECT count(*) AS cnt
+ FROM event_relationships
+ WHERE relationship_status IN ('VERIFIED_CONFERENCE_SPONSOR','VERIFIED_ADVERTISER','VERIFIED_EXHIBITOR','VERIFIED_MEDIA_PARTNER')`
+ );
+ const mislabelCount = parseInt(mislabelRes.rows[0].cnt, 10);
+ console.log(
+ `\nSafety check — event_relationships rows with sponsor/advertiser status: ${mislabelCount}` +
+ (mislabelCount === 0 ? ' (PASS — panelists correctly classified)' : ' (REVIEW NEEDED)')
+ );
+
+ // Audience snapshots
+ const audRes = await client.query(
+ `SELECT metric_key, value_numeric, observed_at, value_text
+ FROM rentv_audience_snapshots ORDER BY observed_at`
+ );
+ console.log(`\nAudience snapshots (must be 2 separate rows): ${audRes.rows.length}`);
+ for (const r of audRes.rows) {
+ console.log(` ${r.observed_at}: ${r.metric_key} = ${r.value_numeric}`);
+ }
+
+ // Admin confirm audit log
+ const adminConfirmRes = await client.query(
+ `SELECT count(*) AS cnt FROM audit_logs WHERE action='SEED_REQUIRES_ADMIN_CONFIRM'`
+ );
+ console.log(`\nAdmin confirm audit log entries (AUTHORIZED_INTERNAL_EMAIL): ${adminConfirmRes.rows[0].cnt}`);
+
+ // Missing flyer assets requiring upload
+ const flyerAuditRes = await client.query(
+ `SELECT detail->>'file_name' AS file_name, detail->>'note' AS note
+ FROM audit_logs WHERE action='SEED_ASSET_MISSING'`
+ );
+ if (flyerAuditRes.rows.length > 0) {
+ console.log(`\nAdmin upload required for ${flyerAuditRes.rows.length} flyer(s):`);
+ for (const r of flyerAuditRes.rows) {
+ console.log(` - ${r.file_name}`);
+ console.log(` ${r.note}`);
+ }
+ }
+
+ } finally {
+ client.release();
+ }
+
+ console.log('');
+ console.log(`=== Seed complete: ${new Date().toISOString()} ===`);
+
+ await pool.end();
+}
+
+run().catch((err) => {
+ console.error('Seed FAILED:', err.message);
+ console.error(err.stack);
+ process.exit(1);
+});
diff --git a/db/seed/markets.js b/db/seed/markets.js
new file mode 100644
index 0000000..38cf696
--- /dev/null
+++ b/db/seed/markets.js
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Seed markets table — California (§7 priority order) and Arizona.
+ * Idempotent via ON CONFLICT (normalized_name) DO UPDATE.
+ */
+
+const { normalizeName } = require('../../lib/types');
+
+const CA_MARKETS = [
+ { metro: 'Greater Los Angeles', region: 'Southern California', priority: 1 },
+ { metro: 'Orange County', region: 'Southern California', priority: 2 },
+ { metro: 'Inland Empire', region: 'Southern California', priority: 3 },
+ { metro: 'San Diego', region: 'Southern California', priority: 4 },
+ { metro: 'Ventura County', region: 'Southern California', priority: 5 },
+ { metro: 'San Francisco Bay Area', region: 'Northern California', priority: 6 },
+ { metro: 'Sacramento', region: 'Northern California', priority: 7 },
+ { metro: 'Central Valley', region: 'Central California', priority: 8 },
+ { metro: 'Statewide California', region: 'California', priority: 9 },
+];
+
+const AZ_MARKETS = [
+ { metro: 'Phoenix metro', region: 'Greater Phoenix', priority: 1 },
+ { metro: 'Scottsdale', region: 'Greater Phoenix', priority: 2 },
+ { metro: 'Tempe', region: 'Greater Phoenix', priority: 3 },
+ { metro: 'Mesa', region: 'Greater Phoenix', priority: 4 },
+ { metro: 'Chandler', region: 'Greater Phoenix', priority: 5 },
+ { metro: 'Gilbert', region: 'Greater Phoenix', priority: 6 },
+ { metro: 'Glendale', region: 'Greater Phoenix', priority: 7 },
+ { metro: 'Tucson', region: 'Southern Arizona', priority: 8 },
+ { metro: 'Statewide Arizona', region: 'Arizona', priority: 9 },
+];
+
+async function seedMarkets(client) {
+ let inserted = 0;
+ let updated = 0;
+
+ for (const m of CA_MARKETS) {
+ const nn = normalizeName(m.metro);
+ const res = await client.query(
+ `INSERT INTO markets (state, region, metro, normalized_name, priority)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (normalized_name)
+ DO UPDATE SET state=$1, region=$2, metro=$3, priority=$5
+ RETURNING (xmax = 0) AS is_insert`,
+ ['CA', m.region, m.metro, nn, m.priority]
+ );
+ if (res.rows[0].is_insert) inserted++; else updated++;
+ }
+
+ for (const m of AZ_MARKETS) {
+ const nn = normalizeName(m.metro);
+ const res = await client.query(
+ `INSERT INTO markets (state, region, metro, normalized_name, priority)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (normalized_name)
+ DO UPDATE SET state=$1, region=$2, metro=$3, priority=$5
+ RETURNING (xmax = 0) AS is_insert`,
+ ['AZ', m.region, m.metro, nn, m.priority]
+ );
+ if (res.rows[0].is_insert) inserted++; else updated++;
+ }
+
+ return { inserted, updated };
+}
+
+module.exports = { seedMarkets };
diff --git a/db/seed/organizations.js b/db/seed/organizations.js
new file mode 100644
index 0000000..a4b6c1a
--- /dev/null
+++ b/db/seed/organizations.js
@@ -0,0 +1,711 @@
+'use strict';
+/**
+ * Seed organizations, evidence_records, ad_sightings, events, and
+ * event_relationships.
+ *
+ * Hard rules (spec §6.16, §6.17):
+ * - VERIFIED_ADVERTISER status only for companies with direct RENTV ad/email evidence.
+ * - VERIFIED_CONTENT_PARTNER for CoStar (conference presentation — NOT a paid sponsor).
+ * - SPEAKER_OR_PANELIST_ONLY or LIKELY_PROSPECT for panelists.
+ * - Never promote panelist to advertiser/sponsor without separate sponsor evidence.
+ *
+ * Idempotent via ON CONFLICT on (normalized_name, coalesce(domain,'')) for orgs,
+ * and unique name+type checks for events.
+ */
+
+const { normalizeName, ADVERTISER_CATEGORIES } = require('../../lib/types');
+
+// ---- 5 VERIFIED_ADVERTISER organizations (§21) ------------------------------
+
+const VERIFIED_ADVERTISERS = [
+ {
+ display_name: 'Hanley Investment Group',
+ domain: 'hanleyinvestment.com',
+ advertiser_categories: ['Brokerage and investment sales'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Corona del Mar',
+ description: 'Commercial real estate investment advisory and brokerage firm specializing in retail investment properties.',
+ activity: 'RENTV Property Spotlight / dedicated property marketing email',
+ observed_at: '2026-07-07',
+ confidence: 0.99,
+ requires_admin_confirm: true,
+ evidence_type: 'EMAIL',
+ evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+ evidence_source_owner: 'RENTV authorized mailbox',
+ evidence_title: 'RENTV Property Spotlight email — Hanley Investment Group property marketing',
+ evidence_excerpt: 'Dedicated property marketing email observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+ market_normalized: 'statewide california',
+ },
+ {
+ display_name: 'Chase Partners',
+ domain: 'chasepartners.com',
+ advertiser_categories: ['Brokerage and investment sales'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ description: 'Commercial real estate advisory and brokerage firm based in Southern California.',
+ activity: 'Commercial Real Estate Talk sponsor',
+ observed_at: '2026-06-29',
+ confidence: 0.99,
+ requires_admin_confirm: true,
+ evidence_type: 'EMAIL',
+ evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+ evidence_source_owner: 'RENTV authorized mailbox',
+ evidence_title: 'RENTV CRE Talk sponsor listing — Chase Partners',
+ evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+ market_normalized: 'statewide california',
+ },
+ {
+ display_name: 'Fidelity Mortgage Lenders',
+ domain: 'fidelityml.com',
+ advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ description: 'Commercial mortgage lender serving the California CRE market.',
+ activity: 'Commercial Real Estate Talk sponsor',
+ observed_at: '2026-06-29',
+ confidence: 0.99,
+ requires_admin_confirm: true,
+ evidence_type: 'EMAIL',
+ evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+ evidence_source_owner: 'RENTV authorized mailbox',
+ evidence_title: 'RENTV CRE Talk sponsor listing — Fidelity Mortgage Lenders',
+ evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+ market_normalized: 'statewide california',
+ },
+ {
+ display_name: 'Rockefeller Group',
+ domain: 'rockgroup.com',
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ description: 'National real estate developer and investor with California CRE presence.',
+ activity: 'Commercial Real Estate Talk sponsor',
+ observed_at: '2026-06-29',
+ confidence: 0.99,
+ requires_admin_confirm: true,
+ evidence_type: 'EMAIL',
+ evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+ evidence_source_owner: 'RENTV authorized mailbox',
+ evidence_title: 'RENTV CRE Talk sponsor listing — Rockefeller Group',
+ evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+ market_normalized: 'statewide california',
+ },
+ {
+ display_name: 'Provident Savings Bank',
+ domain: 'providentbank.com',
+ advertiser_categories: ['Commercial bank and credit union'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Riverside',
+ description: 'California community bank serving the CRE lending market.',
+ activity: 'Commercial Real Estate Talk sponsor',
+ observed_at: '2026-06-29',
+ confidence: 0.99,
+ requires_admin_confirm: true,
+ evidence_type: 'EMAIL',
+ evidence_tag: 'AUTHORIZED_INTERNAL_EMAIL',
+ evidence_source_owner: 'RENTV authorized mailbox',
+ evidence_title: 'RENTV CRE Talk sponsor listing — Provident Savings Bank',
+ evidence_excerpt: 'CRE Talk sponsorship observed in RENTV authorized mailbox. Requires admin confirmation before public exposure.',
+ market_normalized: 'statewide california',
+ },
+];
+
+// ---- CoStar Group — VERIFIED_CONTENT_PARTNER (NOT a sponsor) ----------------
+// §21: "Do not label as a paid sponsor without separate sponsor evidence."
+
+const COSTAR = {
+ display_name: 'CoStar Group',
+ domain: 'costar.com',
+ advertiser_categories: ['Proptech, data, software, AI, and marketplace'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ description:
+ 'CoStar Group is a leading provider of commercial real estate data, analytics, and marketplace platforms. ' +
+ 'WARNING: CoStar presented a Greater Los Angeles market report at a RENTV conference on 2026-03-26 — this is a ' +
+ 'content/presentation relationship ONLY. Do not label as a paid sponsor without separate sponsor evidence.',
+ market_normalized: 'greater los angeles',
+ observed_at: '2026-03-26',
+ confidence: 0.98,
+ event_relationship_status: 'VERIFIED_CONTENT_PARTNER',
+ event_activity: 'Greater Los Angeles market report presentation at a RENTV conference',
+};
+
+// ---- 22 panelist/prospect orgs (§21) ----------------------------------------
+// Brokerages/well-known national firms → LIKELY_PROSPECT (RENTV sales opportunity).
+// Others without clear commercial advertising relationship → SPEAKER_OR_PANELIST_ONLY.
+
+const PANELISTS = [
+ {
+ display_name: 'Wonderful Real Estate Development',
+ domain: 'wonderful.com',
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'CBRE',
+ domain: 'cbre.com',
+ advertiser_categories: ['Brokerage and investment sales', 'Leasing and tenant representation'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Trammell Crow Company',
+ domain: 'trammellcrow.com',
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Rexford Industrial',
+ domain: 'rexfordindustrial.com',
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'NAI Capital Commercial',
+ domain: 'naicapital.com',
+ advertiser_categories: ['Brokerage and investment sales'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Encino',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Premier Workspaces',
+ domain: 'premierworkspaces.com',
+ advertiser_categories: ['Coworking, flexible office, and business services'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Irvine',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Cushman & Wakefield',
+ domain: 'cushmanwakefield.com',
+ advertiser_categories: ['Brokerage and investment sales', 'Leasing and tenant representation'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Nikols Mortgage Fund',
+ domain: null,
+ advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Western Alliance Bank',
+ domain: 'westernalliancebank.com',
+ advertiser_categories: ['Commercial bank and credit union'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Phoenix',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Commonwealth Land Title Company',
+ domain: 'cltic.com',
+ advertiser_categories: ['Title, escrow, settlement, and 1031 exchange'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'George Smith Partners',
+ domain: 'gspartners.com',
+ advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Paragon Commercial Group',
+ domain: null,
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'IPA',
+ domain: 'ipausa.com',
+ advertiser_categories: ['Brokerage and investment sales'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Irvine',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'The Festival Companies',
+ domain: null,
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Westside Retail',
+ domain: null,
+ advertiser_categories: ['Leasing and tenant representation'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'LaTerra Development',
+ domain: 'laterradevelopment.com',
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Brentwood',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Cypress Equity Investments',
+ domain: null,
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Santa Monica',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Colliers',
+ domain: 'colliers.com',
+ advertiser_categories: ['Brokerage and investment sales', 'Leasing and tenant representation'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+ {
+ display_name: 'Walker Realty Capital',
+ domain: null,
+ advertiser_categories: ['Debt fund, mortgage bank, private lender, and capital advisor'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'The Zacuto Group',
+ domain: null,
+ advertiser_categories: ['Brokerage and investment sales'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Eve Capital',
+ domain: null,
+ advertiser_categories: ['Developer, owner, investor, REIT, and family office'],
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ relationship_status: 'SPEAKER_OR_PANELIST_ONLY',
+ },
+ {
+ display_name: 'Lyon Stahl Investment Real Estate',
+ domain: 'lyonstahl.com',
+ advertiser_categories: ['Brokerage and investment sales'],
+ headquarters_state: 'CA',
+ headquarters_city: 'El Segundo',
+ relationship_status: 'LIKELY_PROSPECT',
+ },
+];
+
+// --------------------------------------------------------------------------
+// Helper: upsert one org; return {id, wasInserted}
+// --------------------------------------------------------------------------
+async function upsertOrg(client, data) {
+ const nn = normalizeName(data.display_name);
+ const domain = data.domain || null;
+
+ const res = await client.query(
+ `INSERT INTO organizations (
+ display_name, normalized_name, domain,
+ advertiser_categories, description,
+ headquarters_state, headquarters_city,
+ active_status, first_seen_at, last_seen_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,'ACTIVE',now(),now())
+ ON CONFLICT (normalized_name, coalesce(domain,''))
+ DO UPDATE SET
+ display_name=EXCLUDED.display_name,
+ advertiser_categories=EXCLUDED.advertiser_categories,
+ description=EXCLUDED.description,
+ headquarters_state=EXCLUDED.headquarters_state,
+ headquarters_city=EXCLUDED.headquarters_city,
+ last_seen_at=now()
+ RETURNING id, (xmax = 0) AS is_insert`,
+ [
+ data.display_name,
+ nn,
+ domain,
+ JSON.stringify(data.advertiser_categories || []),
+ data.description || null,
+ data.headquarters_state || null,
+ data.headquarters_city || null,
+ ]
+ );
+ return { id: res.rows[0].id, wasInserted: res.rows[0].is_insert };
+}
+
+// Helper: upsert evidence_record; returns id
+async function upsertEvidence(client, data) {
+ // evidence_records has no unique constraint — check by source_title + evidence_type + observed_at to avoid dupes
+ const existing = await client.query(
+ `SELECT id FROM evidence_records WHERE source_title=$1 AND evidence_type=$2 AND observed_at=$3 LIMIT 1`,
+ [data.source_title, data.evidence_type, data.observed_at]
+ );
+ if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+ const res = await client.query(
+ `INSERT INTO evidence_records (
+ evidence_type, source_url, source_title, source_owner,
+ observed_at, retrieved_at, excerpt, confidence, export_allowed
+ ) VALUES ($1,$2,$3,$4,$5,now(),$6,$7,$8)
+ RETURNING id`,
+ [
+ data.evidence_type,
+ data.source_url || null,
+ data.source_title,
+ data.source_owner || null,
+ data.observed_at || null,
+ data.excerpt || null,
+ data.confidence || 0.5,
+ data.export_allowed || false,
+ ]
+ );
+ return { id: res.rows[0].id, wasInserted: true };
+}
+
+// Helper: upsert ad_sighting (idempotent by org+relationship_status+observed_at)
+async function upsertAdSighting(client, { org_id, relationship_status, observed_at, evidence_id, market_id, confidence, headline }) {
+ const existing = await client.query(
+ `SELECT id FROM ad_sightings WHERE organization_id=$1 AND relationship_status=$2 AND observed_at=$3 LIMIT 1`,
+ [org_id, relationship_status, observed_at]
+ );
+ if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+ const res = await client.query(
+ `INSERT INTO ad_sightings (
+ organization_id, relationship_status, observed_at,
+ first_observed_at, last_observed_at,
+ evidence_id, market_id, confidence,
+ verification_status, headline
+ ) VALUES ($1,$2,$3,$3,$3,$4,$5,$6,'UNVERIFIED',$7)
+ RETURNING id`,
+ [org_id, relationship_status, observed_at, evidence_id, market_id || null, confidence || 0.5, headline || null]
+ );
+ return { id: res.rows[0].id, wasInserted: true };
+}
+
+// Helper: upsert event by name+state+start_date
+async function upsertEvent(client, data) {
+ const existing = await client.query(
+ `SELECT id FROM events WHERE name=$1 AND state=$2 AND start_date=$3 LIMIT 1`,
+ [data.name, data.state, data.start_date]
+ );
+ if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+ const res = await client.query(
+ `INSERT INTO events (
+ name, event_type, start_date, end_date, city, state, official_url, source_evidence_id
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+ RETURNING id`,
+ [
+ data.name,
+ data.event_type || 'CONFERENCE',
+ data.start_date,
+ data.end_date || data.start_date,
+ data.city || null,
+ data.state || null,
+ data.official_url || null,
+ data.source_evidence_id || null,
+ ]
+ );
+ return { id: res.rows[0].id, wasInserted: true };
+}
+
+// Helper: upsert event_relationship (idempotent by event+org+relationship_status)
+async function upsertEventRelationship(client, { event_id, org_id, relationship_status, session_title, observed_at, confidence, evidence_id }) {
+ const existing = await client.query(
+ `SELECT id FROM event_relationships WHERE event_id=$1 AND organization_id=$2 AND relationship_status=$3 LIMIT 1`,
+ [event_id, org_id, relationship_status]
+ );
+ if (existing.rows.length > 0) return { id: existing.rows[0].id, wasInserted: false };
+
+ const res = await client.query(
+ `INSERT INTO event_relationships (
+ event_id, organization_id, relationship_status,
+ session_title, observed_at, confidence, evidence_id
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7)
+ RETURNING id`,
+ [event_id, org_id, relationship_status, session_title || null, observed_at || null, confidence || 0.5, evidence_id || null]
+ );
+ return { id: res.rows[0].id, wasInserted: true };
+}
+
+// --------------------------------------------------------------------------
+
+async function seedOrganizations(client) {
+ const counts = {
+ orgs_inserted: 0,
+ orgs_updated: 0,
+ evidence_inserted: 0,
+ ad_sightings_inserted: 0,
+ event_relationships_inserted: 0,
+ events_inserted: 0,
+ };
+
+ // Look up CA statewide and Greater LA market IDs
+ const caMarket = await client.query(
+ `SELECT id FROM markets WHERE normalized_name=$1 LIMIT 1`,
+ ['statewide california']
+ );
+ const laMarket = await client.query(
+ `SELECT id FROM markets WHERE normalized_name LIKE '%los angeles%' AND state='CA' LIMIT 1`
+ );
+ const caMarketId = caMarket.rows[0]?.id || null;
+ const laMarketId = laMarket.rows[0]?.id || null;
+
+ // ---- RENTV as organizer org (needed for events) ----
+ const rentvOrg = await upsertOrg(client, {
+ display_name: 'RENTV',
+ domain: 'rentv.com',
+ advertiser_categories: ['Association, conference, publication, and media company'],
+ description: 'RENTV — leading commercial real estate media company serving California and Arizona.',
+ headquarters_state: 'CA',
+ headquarters_city: 'Los Angeles',
+ });
+ if (rentvOrg.wasInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+ // ---- Seed VERIFIED_ADVERTISER orgs ----
+ for (const va of VERIFIED_ADVERTISERS) {
+ const { id: orgId, wasInserted } = await upsertOrg(client, va);
+ if (wasInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+ // evidence_record
+ const { id: evidenceId, wasInserted: evInserted } = await upsertEvidence(client, {
+ evidence_type: va.evidence_type,
+ source_title: va.evidence_title,
+ source_owner: va.evidence_source_owner,
+ observed_at: va.observed_at,
+ excerpt: va.evidence_excerpt,
+ confidence: va.confidence,
+ export_allowed: false, // AUTHORIZED_INTERNAL_EMAIL — admin must confirm before export
+ });
+ if (evInserted) counts.evidence_inserted++;
+
+ // Look up market id by normalized_name
+ const mktRes = await client.query(
+ `SELECT id FROM markets WHERE normalized_name=$1 LIMIT 1`,
+ [va.market_normalized]
+ );
+ const marketId = mktRes.rows[0]?.id || caMarketId;
+
+ // ad_sighting with VERIFIED_ADVERTISER status, verification_status=UNVERIFIED (admin confirm needed)
+ const { wasInserted: adInserted } = await upsertAdSighting(client, {
+ org_id: orgId,
+ relationship_status: 'VERIFIED_ADVERTISER',
+ observed_at: va.observed_at,
+ evidence_id: evidenceId,
+ market_id: marketId,
+ confidence: va.confidence,
+ headline: va.activity,
+ });
+ if (adInserted) counts.ad_sightings_inserted++;
+
+ // organization_markets link
+ await client.query(
+ `INSERT INTO organization_markets (organization_id, market_id, relationship_type, confidence, evidence_id)
+ VALUES ($1,$2,'OPERATES_IN',$3,$4)
+ ON CONFLICT (organization_id, market_id, relationship_type) DO NOTHING`,
+ [orgId, marketId, va.confidence, evidenceId]
+ );
+
+ // Tag: AUTHORIZED_INTERNAL_EMAIL — stored as a tag for admin visibility
+ const tagRes = await client.query(
+ `INSERT INTO tags (label) VALUES ($1) ON CONFLICT (label) DO UPDATE SET label=EXCLUDED.label RETURNING id`,
+ [va.evidence_tag]
+ );
+ await client.query(
+ `INSERT INTO organization_tags (organization_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`,
+ [orgId, tagRes.rows[0].id]
+ );
+
+ // Audit log entry noting admin confirm required (idempotent: one per org+action)
+ if (va.requires_admin_confirm) {
+ const alExist = await client.query(
+ `SELECT id FROM audit_logs WHERE action='SEED_REQUIRES_ADMIN_CONFIRM' AND entity_id=$1 LIMIT 1`,
+ [orgId]
+ );
+ if (alExist.rows.length === 0) {
+ await client.query(
+ `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+ VALUES ('SEED_REQUIRES_ADMIN_CONFIRM','organizations',$1,'seed',
+ $2::jsonb)`,
+ [
+ orgId,
+ JSON.stringify({
+ note: 'Record seeded from AUTHORIZED_INTERNAL_EMAIL evidence. Admin must confirm before public exposure.',
+ tag: va.evidence_tag,
+ observed_at: va.observed_at,
+ }),
+ ]
+ );
+ }
+ }
+ }
+
+ // ---- CoStar Group — VERIFIED_CONTENT_PARTNER ----
+ const { id: costarId, wasInserted: costarInserted } = await upsertOrg(client, COSTAR);
+ if (costarInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+ // evidence for CoStar conference presentation
+ const { id: costarEvidenceId, wasInserted: costarEvInserted } = await upsertEvidence(client, {
+ evidence_type: 'MANUAL_NOTE',
+ source_title: 'RENTV conference — CoStar Group Greater Los Angeles market report presentation',
+ source_owner: 'RENTV',
+ observed_at: COSTAR.observed_at,
+ excerpt:
+ 'CoStar Group presented a Greater Los Angeles market report at a RENTV conference on ' +
+ COSTAR.observed_at +
+ '. This is a content/research presentation relationship. ' +
+ COSTAR.description.split('WARNING:')[1]?.trim() || '',
+ confidence: COSTAR.confidence,
+ export_allowed: true,
+ });
+ if (costarEvInserted) counts.evidence_inserted++;
+
+ // Seed the RENTV conference event (for CoStar + panelists)
+ const { id: conferenceEventId, wasInserted: confInserted } = await upsertEvent(client, {
+ name: 'RENTV Greater Los Angeles CRE Conference 2026',
+ event_type: 'CONFERENCE',
+ start_date: '2026-03-26',
+ end_date: '2026-03-26',
+ city: 'Los Angeles',
+ state: 'CA',
+ official_url: 'https://www.rentv.com',
+ source_evidence_id: costarEvidenceId,
+ });
+ if (confInserted) counts.events_inserted++;
+
+ // CoStar event_relationship = VERIFIED_CONTENT_PARTNER (NOT SPONSOR)
+ const { wasInserted: costarRelInserted } = await upsertEventRelationship(client, {
+ event_id: conferenceEventId,
+ org_id: costarId,
+ relationship_status: 'VERIFIED_CONTENT_PARTNER',
+ session_title: 'Greater Los Angeles market report presentation',
+ observed_at: COSTAR.observed_at,
+ confidence: COSTAR.confidence,
+ evidence_id: costarEvidenceId,
+ });
+ if (costarRelInserted) counts.event_relationships_inserted++;
+
+ // Link CoStar to LA market
+ if (laMarketId) {
+ await client.query(
+ `INSERT INTO organization_markets (organization_id, market_id, relationship_type, confidence, evidence_id)
+ VALUES ($1,$2,'OPERATES_IN',$3,$4)
+ ON CONFLICT (organization_id, market_id, relationship_type) DO NOTHING`,
+ [costarId, laMarketId, COSTAR.confidence, costarEvidenceId]
+ );
+ }
+
+ // Audit log: explicit warning for CoStar (idempotent)
+ const costarAlExist = await client.query(
+ `SELECT id FROM audit_logs WHERE action='SEED_CONTENT_PARTNER_WARNING' AND entity_id=$1 LIMIT 1`,
+ [costarId]
+ );
+ if (costarAlExist.rows.length === 0) {
+ await client.query(
+ `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+ VALUES ('SEED_CONTENT_PARTNER_WARNING','organizations',$1,'seed',$2::jsonb)`,
+ [
+ costarId,
+ JSON.stringify({
+ warning:
+ 'CoStar Group is a VERIFIED_CONTENT_PARTNER (conference presenter), NOT a paid sponsor. ' +
+ 'Do not label as a paid sponsor without separate sponsor evidence.',
+ observed_at: COSTAR.observed_at,
+ event: 'RENTV Greater Los Angeles CRE Conference 2026',
+ }),
+ ]
+ );
+ } // end idempotent CoStar audit log
+
+ // ---- Seed a second RENTV CRE Talk event for the CRE Talk sponsors ----
+ const creTalkEvidenceId = await (async () => {
+ const { id } = await upsertEvidence(client, {
+ evidence_type: 'EMAIL',
+ source_title: 'RENTV CRE Talk — sponsor listing email 2026-06-29',
+ source_owner: 'RENTV authorized mailbox',
+ observed_at: '2026-06-29',
+ excerpt:
+ 'RENTV CRE Talk sponsors observed in authorized RENTV mailbox on 2026-06-29. ' +
+ 'Requires admin confirmation before public exposure.',
+ confidence: 0.99,
+ export_allowed: false,
+ });
+ return id;
+ })();
+
+ const { id: creTalkEventId, wasInserted: cteInserted } = await upsertEvent(client, {
+ name: 'RENTV Commercial Real Estate Talk 2026-06-29',
+ event_type: 'CONFERENCE',
+ start_date: '2026-06-29',
+ end_date: '2026-06-29',
+ city: 'Los Angeles',
+ state: 'CA',
+ official_url: 'https://www.rentv.com',
+ source_evidence_id: creTalkEvidenceId,
+ });
+ if (cteInserted) counts.events_inserted++;
+
+ // ---- Seed 22 panelist orgs ----
+ const panelEvidenceId = await (async () => {
+ const { id } = await upsertEvidence(client, {
+ evidence_type: 'MANUAL_NOTE',
+ source_title: 'RENTV conference panelist roster — seeded from spec §21',
+ source_owner: 'RENTV',
+ observed_at: '2026-03-26',
+ excerpt:
+ 'Companies listed as conference participants (panelists/speakers) in RENTV conference records. ' +
+ 'SPEAKER_OR_PANELIST_ONLY status — no sponsorship claimed without separate evidence.',
+ confidence: 0.85,
+ export_allowed: true,
+ });
+ return id;
+ })();
+
+ for (const p of PANELISTS) {
+ const { id: orgId, wasInserted } = await upsertOrg(client, p);
+ if (wasInserted) counts.orgs_inserted++; else counts.orgs_updated++;
+
+ // event_relationship (panelist role) — do NOT use ad_sightings with VERIFIED_* status
+ const { wasInserted: relInserted } = await upsertEventRelationship(client, {
+ event_id: conferenceEventId,
+ org_id: orgId,
+ relationship_status: p.relationship_status,
+ session_title: null,
+ observed_at: '2026-03-26',
+ confidence: 0.85,
+ evidence_id: panelEvidenceId,
+ });
+ if (relInserted) counts.event_relationships_inserted++;
+
+ // link to CA statewide market
+ if (caMarketId) {
+ await client.query(
+ `INSERT INTO organization_markets (organization_id, market_id, relationship_type, confidence)
+ VALUES ($1,$2,'OPERATES_IN',0.85)
+ ON CONFLICT (organization_id, market_id, relationship_type) DO NOTHING`,
+ [orgId, caMarketId]
+ );
+ }
+ }
+
+ return counts;
+}
+
+module.exports = { seedOrganizations };
diff --git a/db/seed/rentv-products.js b/db/seed/rentv-products.js
new file mode 100644
index 0000000..a0238a2
--- /dev/null
+++ b/db/seed/rentv-products.js
@@ -0,0 +1,216 @@
+'use strict';
+/**
+ * Seed rentv_rate_snapshots and rentv_audience_snapshots.
+ *
+ * Per §21:
+ * - At least 2 dated rate snapshots per product (rates may differ across dates).
+ * - Audience: two separate rows — ~45,000 and ~50,000 net recipients — NEVER merged.
+ * - All rates are placeholders pending official media kit; marked in package_notes.
+ *
+ * Idempotent: checks existing rows by product_key+observed_at before inserting.
+ */
+
+const RATE_PLACEHOLDER_NOTE = 'Rate placeholder pending official RENTV media kit confirmation.';
+
+// Two snapshot dates representing different observed rate cards
+const DATE_A = '2025-04-01';
+const DATE_B = '2026-04-01';
+
+// Each entry: [product_key, product_label, channel/unit, rateA, rateB, notesA, notesB]
+const RATE_PRODUCTS = [
+ {
+ product_key: 'website_banner',
+ product_label: 'Website Banner Ad',
+ unit: 'per month',
+ rateA: 1500.00,
+ rateB: 1750.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'website_tile',
+ product_label: 'Website Tile Ad',
+ unit: 'per month',
+ rateA: 850.00,
+ rateB: 950.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'newsletter_banner',
+ product_label: 'Newsletter Banner Ad',
+ unit: 'per eblast',
+ rateA: 1200.00,
+ rateB: 1400.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'newsletter_tile',
+ product_label: 'Newsletter Tile Ad',
+ unit: 'per eblast',
+ rateA: 650.00,
+ rateB: 750.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'fixed_advertorial',
+ product_label: 'Fixed Advertorial',
+ unit: 'per placement',
+ rateA: 2500.00,
+ rateB: 3000.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'marketplace_eblast',
+ product_label: 'Marketplace E-blast',
+ unit: 'per eblast',
+ rateA: 1800.00,
+ rateB: 2000.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'property_spotlight_eblast',
+ product_label: 'Property Spotlight E-blast',
+ unit: 'per eblast',
+ rateA: 1950.00, // April 2025 flyer pricing
+ rateB: 2200.00, // April 2026 pricing (different package/rate observed)
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed in April 2025 flyer (Property Spotlight Flyer REV April 2025). Package may include dedicated send.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed in April 2026 corporate flyer (Corp Flyer Apr 2026 V3). Different package/price version.',
+ },
+ {
+ product_key: 'video_promotion_eblast',
+ product_label: 'Video Promotion E-blast',
+ unit: 'per eblast',
+ rateA: 2200.00,
+ rateB: 2500.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'homepage_video_promotion',
+ product_label: 'Home-Page Video Promotion',
+ unit: 'per month',
+ rateA: 3500.00,
+ rateB: 4000.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'conference_sponsorship',
+ product_label: 'Conference Sponsorship',
+ unit: 'per event',
+ rateA: 5000.00,
+ rateB: 6000.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'cre_talk_sponsorship',
+ product_label: 'CRE Talk Sponsorship',
+ unit: 'per episode/event',
+ rateA: 2000.00,
+ rateB: 2500.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+ {
+ product_key: 'the_review_channel_sponsorship',
+ product_label: 'The REview Channel / Screen Sponsorship',
+ unit: 'per month',
+ rateA: 1500.00,
+ rateB: 1800.00,
+ notesA: RATE_PLACEHOLDER_NOTE + ' Observed 2025 rate card.',
+ notesB: RATE_PLACEHOLDER_NOTE + ' Observed 2026 rate card.',
+ },
+];
+
+// Two separate audience snapshot observations (NEVER merged per §21)
+const AUDIENCE_SNAPSHOTS = [
+ {
+ metric_key: 'net_recipients',
+ metric_label: 'Net email recipients (newsletter)',
+ value_numeric: 45000,
+ observed_at: '2025-04-01',
+ source_key: 'rentv_media_kit',
+ note: 'Approximately 45,000 net recipients as stated in April 2025 media kit materials.',
+ },
+ {
+ metric_key: 'net_recipients',
+ metric_label: 'Net email recipients (newsletter)',
+ value_numeric: 50000,
+ observed_at: '2026-04-01',
+ source_key: 'rentv_media_kit',
+ note: 'Approximately 50,000 net recipients as stated in April 2026 media kit materials. Separate snapshot — not merged with 2025 figure.',
+ },
+];
+
+async function seedRentvProducts(client) {
+ let rate_inserted = 0;
+ let rate_skipped = 0;
+ let audience_inserted = 0;
+ let audience_skipped = 0;
+
+ // Rate snapshots — two per product (DATE_A and DATE_B)
+ for (const p of RATE_PRODUCTS) {
+ // DATE_A snapshot
+ const existA = await client.query(
+ `SELECT id FROM rentv_rate_snapshots WHERE product_key=$1 AND observed_at=$2 LIMIT 1`,
+ [p.product_key, DATE_A]
+ );
+ if (existA.rows.length === 0) {
+ await client.query(
+ `INSERT INTO rentv_rate_snapshots
+ (product_key, product_label, rate, currency, unit, package_notes, observed_at, source_key)
+ VALUES ($1,$2,$3,'USD',$4,$5,$6,'rentv_media_kit')`,
+ [p.product_key, p.product_label, p.rateA, p.unit, p.notesA, DATE_A]
+ );
+ rate_inserted++;
+ } else {
+ rate_skipped++;
+ }
+
+ // DATE_B snapshot (different rate/package)
+ const existB = await client.query(
+ `SELECT id FROM rentv_rate_snapshots WHERE product_key=$1 AND observed_at=$2 LIMIT 1`,
+ [p.product_key, DATE_B]
+ );
+ if (existB.rows.length === 0) {
+ await client.query(
+ `INSERT INTO rentv_rate_snapshots
+ (product_key, product_label, rate, currency, unit, package_notes, observed_at, source_key)
+ VALUES ($1,$2,$3,'USD',$4,$5,$6,'rentv_media_kit')`,
+ [p.product_key, p.product_label, p.rateB, p.unit, p.notesB, DATE_B]
+ );
+ rate_inserted++;
+ } else {
+ rate_skipped++;
+ }
+ }
+
+ // Audience snapshots — two separate rows
+ for (const a of AUDIENCE_SNAPSHOTS) {
+ const existing = await client.query(
+ `SELECT id FROM rentv_audience_snapshots WHERE metric_key=$1 AND observed_at=$2 LIMIT 1`,
+ [a.metric_key, a.observed_at]
+ );
+ if (existing.rows.length === 0) {
+ await client.query(
+ `INSERT INTO rentv_audience_snapshots
+ (metric_key, metric_label, value_numeric, value_text, observed_at, source_key)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ [a.metric_key, a.metric_label, a.value_numeric, a.note, a.observed_at, a.source_key]
+ );
+ audience_inserted++;
+ } else {
+ audience_skipped++;
+ }
+ }
+
+ return { rate_inserted, rate_skipped, audience_inserted, audience_skipped };
+}
+
+module.exports = { seedRentvProducts };
diff --git a/db/seed/scores.js b/db/seed/scores.js
new file mode 100644
index 0000000..9eff847
--- /dev/null
+++ b/db/seed/scores.js
@@ -0,0 +1,317 @@
+'use strict';
+/**
+ * Seed opportunity_scores and opportunity_stages.
+ *
+ * For every seeded org, compute a score via scoring.explainScore() with
+ * sensible per-org factors and INSERT into opportunity_scores.
+ * Also seeds opportunity_stages (Prospect/Contacted/Proposal Sent/Won/Lost).
+ *
+ * Idempotent: replaces scores on re-run (delete+reinsert per org), stages via
+ * ON CONFLICT (key) DO UPDATE.
+ */
+
+const { explainScore } = require('../../lib/scoring');
+const { normalizeName } = require('../../lib/types');
+
+// Opportunity stages (§deliverable)
+const STAGES = [
+ { key: 'prospect', label: 'Prospect', sort_order: 1 },
+ { key: 'contacted', label: 'Contacted', sort_order: 2 },
+ { key: 'proposal_sent', label: 'Proposal Sent', sort_order: 3 },
+ { key: 'won', label: 'Won', sort_order: 4 },
+ { key: 'lost', label: 'Lost', sort_order: 5 },
+];
+
+/**
+ * Factor inputs per org name (normalized_name key → input object).
+ * Verified advertisers get high verifiedAdvertising + recency + evidenceQuality.
+ * Panelists get moderate categoryFit + californiaFit but low verifiedAdvertising.
+ * LIKELY_PROSPECT get higher scores than SPEAKER_OR_PANELIST_ONLY.
+ */
+const ORG_FACTOR_INPUTS = {
+ // ---- VERIFIED_ADVERTISER (high verified advertising, high recency) --------
+ 'hanley investment': {
+ verifiedAdvertising: 95,
+ verifiedConferenceSpendSignal: 60,
+ recency: 98, // observed 2026-07-07, very recent
+ repeatActivity: 70,
+ californiaFit: 100,
+ arizonaFit: 20,
+ categoryFit: 90,
+ rentvAudienceFit: 90,
+ contactCompleteness: 55,
+ evidenceQuality: 95,
+ },
+ 'chase partners': {
+ verifiedAdvertising: 95,
+ verifiedConferenceSpendSignal: 90,
+ recency: 96, // observed 2026-06-29
+ repeatActivity: 70,
+ californiaFit: 100,
+ arizonaFit: 20,
+ categoryFit: 90,
+ rentvAudienceFit: 88,
+ contactCompleteness: 50,
+ evidenceQuality: 95,
+ },
+ 'fidelity mortgage lenders': {
+ verifiedAdvertising: 95,
+ verifiedConferenceSpendSignal: 90,
+ recency: 96,
+ repeatActivity: 70,
+ californiaFit: 100,
+ arizonaFit: 20,
+ categoryFit: 88,
+ rentvAudienceFit: 88,
+ contactCompleteness: 50,
+ evidenceQuality: 95,
+ },
+ 'rockefeller': {
+ verifiedAdvertising: 95,
+ verifiedConferenceSpendSignal: 90,
+ recency: 96,
+ repeatActivity: 70,
+ californiaFit: 90,
+ arizonaFit: 30,
+ categoryFit: 85,
+ rentvAudienceFit: 85,
+ contactCompleteness: 50,
+ evidenceQuality: 95,
+ },
+ 'provident savings bank': {
+ verifiedAdvertising: 95,
+ verifiedConferenceSpendSignal: 90,
+ recency: 96,
+ repeatActivity: 65,
+ californiaFit: 100,
+ arizonaFit: 10,
+ categoryFit: 87,
+ rentvAudienceFit: 87,
+ contactCompleteness: 50,
+ evidenceQuality: 95,
+ },
+ // ---- VERIFIED_CONTENT_PARTNER (not a paid sponsor) -----------------------
+ 'costar': {
+ verifiedAdvertising: 20, // content partner only, NOT paid advertiser
+ verifiedConferenceSpendSignal: 70,
+ recency: 90,
+ repeatActivity: 60,
+ californiaFit: 95,
+ arizonaFit: 70,
+ categoryFit: 80,
+ rentvAudienceFit: 80,
+ contactCompleteness: 40,
+ evidenceQuality: 90,
+ },
+ // ---- LIKELY_PROSPECT orgs ------------------------------------------------
+ 'cbre': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 50,
+ recency: 75,
+ repeatActivity: 40,
+ californiaFit: 98,
+ arizonaFit: 80,
+ categoryFit: 95,
+ rentvAudienceFit: 95,
+ contactCompleteness: 35,
+ evidenceQuality: 60,
+ },
+ 'trammell crow': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 50,
+ recency: 70,
+ repeatActivity: 35,
+ californiaFit: 90,
+ arizonaFit: 70,
+ categoryFit: 90,
+ rentvAudienceFit: 88,
+ contactCompleteness: 30,
+ evidenceQuality: 55,
+ },
+ 'rexford industrial': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 45,
+ recency: 70,
+ repeatActivity: 30,
+ californiaFit: 98,
+ arizonaFit: 20,
+ categoryFit: 85,
+ rentvAudienceFit: 85,
+ contactCompleteness: 30,
+ evidenceQuality: 55,
+ },
+ 'nai capital': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 45,
+ recency: 70,
+ repeatActivity: 30,
+ californiaFit: 98,
+ arizonaFit: 20,
+ categoryFit: 90,
+ rentvAudienceFit: 90,
+ contactCompleteness: 30,
+ evidenceQuality: 55,
+ },
+ 'cushman wakefield': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 55,
+ recency: 70,
+ repeatActivity: 40,
+ californiaFit: 95,
+ arizonaFit: 75,
+ categoryFit: 93,
+ rentvAudienceFit: 92,
+ contactCompleteness: 35,
+ evidenceQuality: 60,
+ },
+ 'western alliance bank': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 45,
+ recency: 70,
+ repeatActivity: 30,
+ californiaFit: 80,
+ arizonaFit: 90,
+ categoryFit: 85,
+ rentvAudienceFit: 83,
+ contactCompleteness: 30,
+ evidenceQuality: 55,
+ },
+ 'commonwealth land title': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 40,
+ recency: 65,
+ repeatActivity: 25,
+ californiaFit: 90,
+ arizonaFit: 30,
+ categoryFit: 82,
+ rentvAudienceFit: 82,
+ contactCompleteness: 25,
+ evidenceQuality: 50,
+ },
+ 'george smith partners': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 45,
+ recency: 65,
+ repeatActivity: 30,
+ californiaFit: 95,
+ arizonaFit: 20,
+ categoryFit: 85,
+ rentvAudienceFit: 84,
+ contactCompleteness: 30,
+ evidenceQuality: 50,
+ },
+ 'ipa': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 45,
+ recency: 65,
+ repeatActivity: 30,
+ californiaFit: 90,
+ arizonaFit: 50,
+ categoryFit: 88,
+ rentvAudienceFit: 88,
+ contactCompleteness: 25,
+ evidenceQuality: 50,
+ },
+ 'colliers': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 50,
+ recency: 70,
+ repeatActivity: 35,
+ californiaFit: 95,
+ arizonaFit: 75,
+ categoryFit: 93,
+ rentvAudienceFit: 90,
+ contactCompleteness: 30,
+ evidenceQuality: 55,
+ },
+ 'lyon stahl investment real estate': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 40,
+ recency: 65,
+ repeatActivity: 25,
+ californiaFit: 95,
+ arizonaFit: 10,
+ categoryFit: 85,
+ rentvAudienceFit: 83,
+ contactCompleteness: 25,
+ evidenceQuality: 50,
+ },
+ // ---- SPEAKER_OR_PANELIST_ONLY (lower scores, no verified advertising) ----
+ // Default for any org not specifically listed above
+ '_default_speaker': {
+ verifiedAdvertising: 0,
+ verifiedConferenceSpendSignal: 35,
+ recency: 60,
+ repeatActivity: 20,
+ californiaFit: 80,
+ arizonaFit: 15,
+ categoryFit: 70,
+ rentvAudienceFit: 70,
+ contactCompleteness: 20,
+ evidenceQuality: 45,
+ },
+};
+
+function getFactorInput(normalizedName) {
+ // Try exact match, then prefix match on the normalized name
+ if (ORG_FACTOR_INPUTS[normalizedName]) return ORG_FACTOR_INPUTS[normalizedName];
+ for (const key of Object.keys(ORG_FACTOR_INPUTS)) {
+ if (key === '_default_speaker') continue;
+ if (normalizedName.startsWith(key) || key.startsWith(normalizedName.split(' ')[0])) {
+ return ORG_FACTOR_INPUTS[key];
+ }
+ }
+ return ORG_FACTOR_INPUTS['_default_speaker'];
+}
+
+async function seedScores(client) {
+ let stages_inserted = 0;
+ let stages_updated = 0;
+ let scores_inserted = 0;
+
+ // Seed opportunity_stages
+ for (const s of STAGES) {
+ const res = await client.query(
+ `INSERT INTO opportunity_stages (key, label, sort_order)
+ VALUES ($1,$2,$3)
+ ON CONFLICT (key) DO UPDATE SET label=EXCLUDED.label, sort_order=EXCLUDED.sort_order
+ RETURNING (xmax = 0) AS is_insert`,
+ [s.key, s.label, s.sort_order]
+ );
+ if (res.rows[0].is_insert) stages_inserted++; else stages_updated++;
+ }
+
+ // Fetch all seeded orgs
+ const orgsRes = await client.query(
+ `SELECT id, display_name, normalized_name FROM organizations`
+ );
+
+ for (const org of orgsRes.rows) {
+ // Delete existing score(s) for this org (re-seed is a full replace)
+ await client.query(`DELETE FROM opportunity_scores WHERE organization_id=$1`, [org.id]);
+
+ const factorInput = getFactorInput(org.normalized_name);
+ const explained = explainScore(factorInput);
+
+ await client.query(
+ `INSERT INTO opportunity_scores (organization_id, score, factors, computed_at)
+ VALUES ($1,$2,$3::jsonb,now())`,
+ [
+ org.id,
+ explained.score,
+ JSON.stringify({
+ input: factorInput,
+ weights: explained.weights,
+ factors: explained.factors,
+ score: explained.score,
+ note: 'Computed by seed/scores.js. Factors are initial estimates — update with real evidence.',
+ }),
+ ]
+ );
+ scores_inserted++;
+ }
+
+ return { stages_inserted, stages_updated, scores_inserted };
+}
+
+module.exports = { seedScores };
diff --git a/db/seed/sources.js b/db/seed/sources.js
new file mode 100644
index 0000000..ac3a3a1
--- /dev/null
+++ b/db/seed/sources.js
@@ -0,0 +1,471 @@
+'use strict';
+/**
+ * Seed source_policies — RENTV-owned/authorized sources (enabled=true) plus
+ * CA/AZ publication/association sources requiring manual review (enabled=false).
+ * Idempotent via ON CONFLICT (source_key) DO UPDATE.
+ */
+
+const REVIEWED_AT = '2026-08-07T00:00:00Z';
+
+// ---- RENTV-owned / authorized sources (enabled, automated or upload OK) ----
+const RENTV_SOURCES = [
+ {
+ source_key: 'rentv_public_web',
+ display_name: 'RENTV.com public website',
+ owner: 'RENTV',
+ base_url: 'https://www.rentv.com',
+ access_method: 'first_party_public_web',
+ allows_automated_access: true,
+ allows_screenshot_capture: true,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'RENTV-owned property. Full automated access permitted.',
+ },
+ {
+ source_key: 'rentvreview_public_web',
+ display_name: 'RENTVReview.com public website',
+ owner: 'RENTV',
+ base_url: 'https://www.rentvreview.com',
+ access_method: 'first_party_public_web',
+ allows_automated_access: true,
+ allows_screenshot_capture: true,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'RENTV-owned property. Full automated access permitted.',
+ },
+ {
+ source_key: 'rentv_conference_pages',
+ display_name: 'RENTV conference and event pages',
+ owner: 'RENTV',
+ base_url: 'https://www.rentv.com',
+ access_method: 'first_party_public_web',
+ allows_automated_access: true,
+ allows_screenshot_capture: true,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'RENTV-owned conference pages. Sponsor data may be exported as evidence.',
+ },
+ {
+ source_key: 'rentv_cre_talk_pages',
+ display_name: 'RENTV CRE Talk pages',
+ owner: 'RENTV',
+ base_url: 'https://www.rentv.com',
+ access_method: 'first_party_public_web',
+ allows_automated_access: true,
+ allows_screenshot_capture: true,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'RENTV CRE Talk sponsor listings are first-party evidence.',
+ },
+ {
+ source_key: 'rentv_newsletters',
+ display_name: 'RENTV newsletters and browser-view archives',
+ owner: 'RENTV',
+ base_url: 'https://www.rentv.com',
+ access_method: 'first_party_public_web',
+ allows_automated_access: true,
+ allows_screenshot_capture: true,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'RENTV-authored newsletters. Sponsor and ad evidence may be retained.',
+ },
+ {
+ source_key: 'rentv_constant_contact_export',
+ display_name: 'RENTV Constant Contact export (CSV upload)',
+ owner: 'RENTV',
+ base_url: 'https://app.constantcontact.com',
+ access_method: 'manual_upload',
+ allows_automated_access: false,
+ allows_screenshot_capture: false,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'Manual CSV export from authorized RENTV Constant Contact account. Upload only.',
+ },
+ {
+ source_key: 'rentv_gmail_authorized',
+ display_name: 'RENTV authorized Gmail mailbox (Gmail API)',
+ owner: 'RENTV',
+ base_url: 'https://mail.google.com',
+ access_method: 'authorized_mailbox',
+ allows_automated_access: false,
+ allows_screenshot_capture: false,
+ allows_internal_storage: true,
+ allows_export: false,
+ enabled: false,
+ review_notes: 'Disabled by default. Enable only after OAuth setup and admin review. Store selected messages only.',
+ },
+ {
+ source_key: 'rentv_manual_upload',
+ display_name: 'RENTV manual file upload (.eml, HTML, PDF, JPG, PNG, CSV, XLSX)',
+ owner: 'RENTV',
+ base_url: null,
+ access_method: 'manual_upload',
+ allows_automated_access: false,
+ allows_screenshot_capture: false,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'Admin-uploaded files. Rights status per file.',
+ },
+ {
+ source_key: 'rentv_media_kit',
+ display_name: 'RENTV media kit and rate card',
+ owner: 'RENTV',
+ base_url: 'https://www.rentv.com',
+ access_method: 'first_party_public_web',
+ allows_automated_access: true,
+ allows_screenshot_capture: true,
+ allows_internal_storage: true,
+ allows_export: true,
+ enabled: true,
+ review_notes: 'First-party RENTV rate/audience data. Export allowed.',
+ },
+];
+
+// ---- External CA/AZ sources — manual_review_only, automation disabled -------
+const EXTERNAL_SOURCES = [
+ // CA Business Journals
+ {
+ source_key: 'la_business_journal',
+ display_name: 'Los Angeles Business Journal',
+ owner: 'American City Business Journals',
+ base_url: 'https://labusinessjournal.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall/subscr. No automated scraping. Manual review of public event/sponsor pages only.',
+ },
+ {
+ source_key: 'oc_business_journal',
+ display_name: 'Orange County Business Journal',
+ owner: 'Orange County Business Journal',
+ base_url: 'https://www.ocbj.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall. Manual review only.',
+ },
+ {
+ source_key: 'san_diego_business_journal',
+ display_name: 'San Diego Business Journal',
+ owner: 'American City Business Journals',
+ base_url: 'https://www.sdbj.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall. Manual review only.',
+ },
+ {
+ source_key: 'sf_business_times',
+ display_name: 'San Francisco Business Times',
+ owner: 'American City Business Journals',
+ base_url: 'https://www.bizjournals.com/sanfrancisco',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall. Manual review only.',
+ },
+ {
+ source_key: 'sv_business_journal',
+ display_name: 'Silicon Valley Business Journal',
+ owner: 'American City Business Journals',
+ base_url: 'https://www.bizjournals.com/sanjose',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall. Manual review only.',
+ },
+ {
+ source_key: 'sacramento_business_journal',
+ display_name: 'Sacramento Business Journal',
+ owner: 'American City Business Journals',
+ base_url: 'https://www.bizjournals.com/sacramento',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall. Manual review only.',
+ },
+ // CRE publications
+ {
+ source_key: 'bisnow',
+ display_name: 'Bisnow market and event pages',
+ owner: 'Bisnow',
+ base_url: 'https://www.bisnow.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Terms restrict scraping. Manual review of public event/sponsor pages only.',
+ },
+ {
+ source_key: 'connect_cre',
+ display_name: 'Connect CRE',
+ owner: 'Connect Media',
+ base_url: 'https://www.connectcre.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only. Robots policy not verified as permitting automation.',
+ },
+ {
+ source_key: 'globest',
+ display_name: 'GlobeSt',
+ owner: 'ALM Media',
+ base_url: 'https://www.globest.com',
+ access_method: 'manual_review_only',
+ review_notes: 'ALM paywall. Manual review only.',
+ },
+ {
+ source_key: 'the_registry',
+ display_name: 'The Registry',
+ owner: 'The Registry Media',
+ base_url: 'https://theregistrysf.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'commercial_observer',
+ display_name: 'Commercial Observer',
+ owner: 'Observer Media',
+ base_url: 'https://commercialobserver.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'the_real_deal_ca',
+ display_name: 'The Real Deal (California)',
+ owner: 'The Real Deal',
+ base_url: 'https://therealdeal.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ // Associations — CA
+ {
+ source_key: 'naiop_so_cal',
+ display_name: 'NAIOP Southern California',
+ owner: 'NAIOP SoCal Chapter',
+ base_url: 'https://www.naiopsc.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Public event/sponsor pages only. Manual review.',
+ },
+ {
+ source_key: 'naiop_inland_empire',
+ display_name: 'NAIOP Inland Empire',
+ owner: 'NAIOP IE Chapter',
+ base_url: 'https://www.naiopie.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'naiop_san_diego',
+ display_name: 'NAIOP San Diego',
+ owner: 'NAIOP SD Chapter',
+ base_url: 'https://www.naiopsd.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'naiop_nor_cal',
+ display_name: 'NAIOP Northern California',
+ owner: 'NAIOP NorCal Chapter',
+ base_url: 'https://www.naiopnc.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'uli_los_angeles',
+ display_name: 'ULI Los Angeles',
+ owner: 'Urban Land Institute — LA District',
+ base_url: 'https://la.uli.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'uli_oc_ie',
+ display_name: 'ULI Orange County / Inland Empire',
+ owner: 'Urban Land Institute — OC/IE District',
+ base_url: 'https://ocie.uli.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'uli_san_diego',
+ display_name: 'ULI San Diego / Tijuana',
+ owner: 'Urban Land Institute — SD District',
+ base_url: 'https://sandiego.uli.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'uli_san_francisco',
+ display_name: 'ULI San Francisco',
+ owner: 'Urban Land Institute — SF District',
+ base_url: 'https://sf.uli.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'boma_greater_la',
+ display_name: 'BOMA Greater Los Angeles',
+ owner: 'BOMA Greater LA',
+ base_url: 'https://www.bomagla.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'boma_oc',
+ display_name: 'BOMA Orange County',
+ owner: 'BOMA OC',
+ base_url: 'https://bomaoc.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'boma_san_diego',
+ display_name: 'BOMA San Diego',
+ owner: 'BOMA San Diego',
+ base_url: 'https://bomasd.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'crew_los_angeles',
+ display_name: 'CREW Los Angeles',
+ owner: 'CREW Network — LA Chapter',
+ base_url: 'https://www.crewla.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'crew_orange_county',
+ display_name: 'CREW Orange County',
+ owner: 'CREW Network — OC Chapter',
+ base_url: 'https://www.crewoc.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'crew_san_diego',
+ display_name: 'CREW San Diego',
+ owner: 'CREW Network — SD Chapter',
+ base_url: 'https://www.crewsd.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'crew_san_francisco',
+ display_name: 'CREW San Francisco',
+ owner: 'CREW Network — SF Chapter',
+ base_url: 'https://www.crewsf.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ // AZ sources
+ {
+ source_key: 'phoenix_business_journal',
+ display_name: 'Phoenix Business Journal',
+ owner: 'American City Business Journals',
+ base_url: 'https://www.bizjournals.com/phoenix',
+ access_method: 'manual_review_only',
+ review_notes: 'Paywall. Manual review only.',
+ },
+ {
+ source_key: 'az_big_media',
+ display_name: 'AZ Big Media / AZRE',
+ owner: 'AZ Big Media',
+ base_url: 'https://azbigmedia.com',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'naiop_arizona',
+ display_name: 'NAIOP Arizona',
+ owner: 'NAIOP AZ Chapter',
+ base_url: 'https://www.naiop-az.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'uli_arizona',
+ display_name: 'ULI Arizona',
+ owner: 'Urban Land Institute — AZ District',
+ base_url: 'https://arizona.uli.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'boma_greater_phoenix',
+ display_name: 'BOMA Greater Phoenix',
+ owner: 'BOMA Phoenix',
+ base_url: 'https://www.bomaphoenix.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'valley_partnership',
+ display_name: 'Valley Partnership',
+ owner: 'Valley Partnership',
+ base_url: 'https://www.valleypartnership.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+ {
+ source_key: 'azcrew',
+ display_name: 'AZCREW',
+ owner: 'CREW Network — AZ Chapter',
+ base_url: 'https://www.azcrew.org',
+ access_method: 'manual_review_only',
+ review_notes: 'Manual review only.',
+ },
+];
+
+async function seedSources(client) {
+ let inserted = 0;
+ let updated = 0;
+
+ const allSources = [...RENTV_SOURCES, ...EXTERNAL_SOURCES];
+
+ for (const s of allSources) {
+ // external sources default to manual_review_only restrictions
+ const isExternal = !RENTV_SOURCES.includes(s);
+ const allows_automated = isExternal ? false : (s.allows_automated_access ?? false);
+ const allows_screenshot = isExternal ? false : (s.allows_screenshot_capture ?? false);
+ const allows_internal = s.allows_internal_storage ?? true;
+ const allows_export = isExternal ? false : (s.allows_export ?? false);
+ const enabled = isExternal ? false : (s.enabled ?? false);
+
+ const res = await client.query(
+ `INSERT INTO source_policies (
+ source_key, display_name, owner, base_url, access_method,
+ allows_automated_access, allows_screenshot_capture,
+ allows_internal_storage, allows_export,
+ prohibited_hosts, permitted_paths, prohibited_paths,
+ reviewed_at, review_notes, enabled
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+ ON CONFLICT (source_key) DO UPDATE SET
+ display_name=EXCLUDED.display_name,
+ owner=EXCLUDED.owner,
+ base_url=EXCLUDED.base_url,
+ access_method=EXCLUDED.access_method,
+ allows_automated_access=EXCLUDED.allows_automated_access,
+ allows_screenshot_capture=EXCLUDED.allows_screenshot_capture,
+ allows_internal_storage=EXCLUDED.allows_internal_storage,
+ allows_export=EXCLUDED.allows_export,
+ reviewed_at=EXCLUDED.reviewed_at,
+ review_notes=EXCLUDED.review_notes,
+ enabled=EXCLUDED.enabled
+ RETURNING (xmax = 0) AS is_insert`,
+ [
+ s.source_key,
+ s.display_name,
+ s.owner || null,
+ s.base_url || null,
+ s.access_method,
+ allows_automated,
+ allows_screenshot,
+ allows_internal,
+ allows_export,
+ JSON.stringify([]),
+ JSON.stringify([]),
+ JSON.stringify([]),
+ REVIEWED_AT,
+ s.review_notes || null,
+ enabled,
+ ]
+ );
+ if (res.rows[0].is_insert) inserted++; else updated++;
+ }
+
+ return { inserted, updated };
+}
+
+module.exports = { seedSources };
diff --git a/lib/classification.js b/lib/classification.js
new file mode 100644
index 0000000..79d53c8
--- /dev/null
+++ b/lib/classification.js
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * Classification helpers (spec §2, §6.16, §6.17).
+ *
+ * Pure functions — no database I/O. Import from lib/types (single vocabulary).
+ */
+
+const { RELATIONSHIP_STATUS, VERIFIED_STATUSES, STATUS_LABELS, normalizeName } = require('./types');
+
+/** Returns true if the given status is one of the VERIFIED_* statuses. */
+function isVerifiedStatus(status) {
+ return VERIFIED_STATUSES.includes(status);
+}
+
+/**
+ * Guard: prevents promoting a SPEAKER_OR_PANELIST_ONLY to any VERIFIED_*SPONSOR
+ * or VERIFIED_ADVERTISER status without explicit sponsor evidence (§6.16, §6.17).
+ *
+ * @param {string} fromStatus - current relationship status
+ * @param {string} toStatus - proposed new status
+ * @param {boolean} hasSponsorEvidence - caller must have verified separate sponsor evidence
+ * @throws {Error} if the promotion is prohibited
+ */
+function assertNotPanelistMislabeledAsSponsor(fromStatus, toStatus, hasSponsorEvidence) {
+ if (fromStatus !== 'SPEAKER_OR_PANELIST_ONLY') return; // only applies to panelists
+
+ const SPONSOR_OR_ADVERTISER_STATUSES = [
+ 'VERIFIED_ADVERTISER',
+ 'VERIFIED_CONFERENCE_SPONSOR',
+ 'VERIFIED_EXHIBITOR',
+ 'VERIFIED_MEDIA_PARTNER',
+ ];
+
+ if (!SPONSOR_OR_ADVERTISER_STATUSES.includes(toStatus)) return; // not a protected promotion
+
+ if (!hasSponsorEvidence) {
+ throw new Error(
+ `Classification error: cannot promote "${fromStatus}" to "${toStatus}" without separate, ` +
+ `explicit sponsor/advertiser evidence. Add a dated evidence record that directly proves ` +
+ `the paid relationship before changing this status. (spec §6.16, §6.17)`
+ );
+ }
+}
+
+/**
+ * Return the appropriate default relationship status for a given intake signal.
+ *
+ * @param {'AD' | 'EMAIL_AD' | 'CONFERENCE_SPONSOR' | 'PANELIST' | 'CONTENT_PRESENTATION' | 'PROSPECT' | 'UNKNOWN'} signal
+ * @returns {string} one of RELATIONSHIP_STATUS values
+ */
+function defaultStatusForSignal(signal) {
+ switch (signal) {
+ case 'AD': return 'VERIFIED_ADVERTISER';
+ case 'EMAIL_AD': return 'VERIFIED_ADVERTISER';
+ case 'CONFERENCE_SPONSOR': return 'VERIFIED_CONFERENCE_SPONSOR';
+ case 'EXHIBITOR': return 'VERIFIED_EXHIBITOR';
+ case 'MEDIA_PARTNER': return 'VERIFIED_MEDIA_PARTNER';
+ case 'CONTENT_PRESENTATION': return 'VERIFIED_CONTENT_PARTNER';
+ case 'PANELIST': return 'SPEAKER_OR_PANELIST_ONLY';
+ case 'SPEAKER': return 'SPEAKER_OR_PANELIST_ONLY';
+ case 'PROSPECT': return 'LIKELY_PROSPECT';
+ case 'PAST': return 'PAST_ADVERTISER';
+ case 'UNKNOWN': return 'RESEARCH_NEEDED';
+ default: return 'RESEARCH_NEEDED';
+ }
+}
+
+/**
+ * Return the plain-English Simple View label for a status (spec §25).
+ * @param {string} status
+ * @returns {string}
+ */
+function statusLabel(status) {
+ return STATUS_LABELS[status] || status;
+}
+
+/**
+ * Validate that a given status string is in the canonical vocabulary.
+ * @param {string} status
+ * @returns {boolean}
+ */
+function isKnownStatus(status) {
+ return RELATIONSHIP_STATUS.includes(status);
+}
+
+module.exports = {
+ isVerifiedStatus,
+ assertNotPanelistMislabeledAsSponsor,
+ defaultStatusForSignal,
+ statusLabel,
+ isKnownStatus,
+};
diff --git a/lib/compliance/fetch-guard.js b/lib/compliance/fetch-guard.js
new file mode 100644
index 0000000..99cc448
--- /dev/null
+++ b/lib/compliance/fetch-guard.js
@@ -0,0 +1,247 @@
+'use strict';
+/**
+ * Fetch guard — SSRF protection for EVERY outbound fetch (spec §32, §6.4).
+ *
+ * Guarantees:
+ * - LinkedIn hosts are hard-blocked (§6.3/§6.4) — the app NEVER fetches them.
+ * - No fetch may resolve to a private / loopback / link-local / metadata IP
+ * (§32 "DNS/IP checks blocking private, loopback, metadata, and link-local").
+ * - Automated public web research is OFF unless explicitly enabled by env
+ * (ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH === 'true').
+ * - A descriptive, admin-contact User-Agent is always sent (§23).
+ * - Per-host token bucket enforces a minimum delay / max requests per minute.
+ * - Redirects are re-checked against the same guard (no redirect to a blocked
+ * host or private IP).
+ */
+
+const dns = require('dns').promises;
+const T = require('../types');
+
+// ---------------------------------------------------------------------------
+// IP range checks
+// ---------------------------------------------------------------------------
+
+/** Parse an IPv4 "a.b.c.d" → 32-bit unsigned int, or null if not IPv4. */
+function ipv4ToInt(ip) {
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
+ if (!m) return null;
+ const parts = m.slice(1).map(Number);
+ if (parts.some((p) => p > 255)) return null;
+ return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
+}
+
+function inCidr(ipInt, netStr, bits) {
+ const net = ipv4ToInt(netStr);
+ if (net == null || ipInt == null) return false;
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+ return (ipInt & mask) === (net & mask);
+}
+
+/**
+ * isBlockedIp(ip) — true for any private / loopback / link-local / metadata /
+ * unspecified address (§32). Covers both IPv4 and the IPv6 forms we care about.
+ */
+function isBlockedIp(ip) {
+ if (!ip || typeof ip !== 'string') return true; // fail closed
+ const addr = ip.trim().toLowerCase().replace(/^\[|\]$/g, '');
+
+ // --- IPv6 ---
+ if (addr.includes(':')) {
+ if (addr === '::' || addr === '::0' || addr === '0:0:0:0:0:0:0:0') return true; // unspecified
+ if (addr === '::1' || addr === '0:0:0:0:0:0:0:1') return true; // loopback
+ // fc00::/7 — unique local (fc.. / fd..)
+ if (/^f[cd][0-9a-f]{0,2}:/.test(addr)) return true;
+ // fe80::/10 — link-local
+ if (/^fe[89ab][0-9a-f]?:/.test(addr) || /^fe80:/.test(addr)) return true;
+ // IPv4-mapped ::ffff:a.b.c.d — unwrap and re-check as IPv4
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr);
+ if (mapped) return isBlockedIp(mapped[1]);
+ return false; // other global IPv6 — allowed
+ }
+
+ // --- IPv4 ---
+ const n = ipv4ToInt(addr);
+ if (n == null) return true; // not a parseable IPv4 → fail closed
+
+ if (inCidr(n, '10.0.0.0', 8)) return true; // private
+ if (inCidr(n, '172.16.0.0', 12)) return true; // private
+ if (inCidr(n, '192.168.0.0', 16)) return true; // private
+ if (inCidr(n, '127.0.0.0', 8)) return true; // loopback
+ if (inCidr(n, '169.254.0.0', 16)) return true; // link-local (incl. 169.254.169.254 metadata)
+ if (inCidr(n, '0.0.0.0', 8)) return true; // "this" network / 0.0.0.0
+ if (inCidr(n, '100.64.0.0', 10)) return true; // CGNAT (also used by tailnets) — treat as internal
+ return false;
+}
+
+/** True when host === blocked OR endsWith .blocked (subdomain) — §6.4. */
+function isLinkedInHost(host) {
+ if (!host) return false;
+ const h = host.toLowerCase();
+ return T.LINKEDIN_BLOCKED_HOSTS.some((b) => h === b.toLowerCase() || h.endsWith(`.${b.toLowerCase()}`));
+}
+
+// ---------------------------------------------------------------------------
+// assertFetchAllowed
+// ---------------------------------------------------------------------------
+
+/**
+ * assertFetchAllowed(url) — throws unless the URL is safe to fetch.
+ * Rejects: non-http(s), LinkedIn hosts, and hosts that resolve (ANY A/AAAA
+ * record) to a blocked IP range. Resolves DNS via dns.promises.lookup(all).
+ */
+async function assertFetchAllowed(url) {
+ let u;
+ try {
+ u = new URL(url);
+ } catch (_e) {
+ throw new Error(`fetch-guard: invalid URL "${url}"`);
+ }
+
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
+ throw new Error(`fetch-guard: protocol "${u.protocol}" not allowed (http/https only)`);
+ }
+
+ const host = u.hostname.toLowerCase();
+
+ // §6.4 — LinkedIn hard block.
+ if (isLinkedInHost(host)) {
+ throw new Error(`fetch-guard: LinkedIn host "${host}" is blocked from automated fetching (§6.3/§6.4)`);
+ }
+
+ // If the host is already a literal IP, check it directly.
+ const literal = host.replace(/^\[|\]$/g, '');
+ if (ipv4ToInt(literal) != null || literal.includes(':')) {
+ if (isBlockedIp(literal)) {
+ throw new Error(`fetch-guard: host resolves to blocked IP "${literal}" (private/loopback/metadata/link-local) (§32)`);
+ }
+ return { host, addresses: [literal] };
+ }
+
+ // Resolve ALL addresses; block if ANY is private (DNS-rebinding defense).
+ let addresses;
+ try {
+ addresses = await dns.lookup(host, { all: true });
+ } catch (e) {
+ throw new Error(`fetch-guard: DNS lookup failed for "${host}": ${e.message}`);
+ }
+ if (!addresses || addresses.length === 0) {
+ throw new Error(`fetch-guard: "${host}" resolved to no addresses`);
+ }
+ for (const a of addresses) {
+ if (isBlockedIp(a.address)) {
+ throw new Error(`fetch-guard: "${host}" resolves to blocked IP "${a.address}" (§32)`);
+ }
+ }
+ return { host, addresses: addresses.map((a) => a.address) };
+}
+
+// ---------------------------------------------------------------------------
+// Per-host token bucket (min delay / max requests per minute)
+// ---------------------------------------------------------------------------
+
+const buckets = new Map(); // host -> { lastAt, timestamps: number[] }
+
+function defaultRpm() {
+ const v = Number(process.env.DEFAULT_REQUESTS_PER_MINUTE || '6');
+ return Number.isFinite(v) && v > 0 ? v : 6;
+}
+
+async function throttleHost(host, { maxRequestsPerMinute, minimumDelayMs } = {}) {
+ const rpm = maxRequestsPerMinute || defaultRpm();
+ const minDelay = minimumDelayMs != null ? minimumDelayMs : Math.ceil(60000 / rpm);
+ const now = Date.now();
+ let b = buckets.get(host);
+ if (!b) {
+ b = { lastAt: 0, timestamps: [] };
+ buckets.set(host, b);
+ }
+ // Purge timestamps older than 60s.
+ b.timestamps = b.timestamps.filter((t) => now - t < 60000);
+
+ // Enforce max-requests-per-minute.
+ if (b.timestamps.length >= rpm) {
+ const waitMs = 60000 - (now - b.timestamps[0]);
+ if (waitMs > 0) await sleep(waitMs);
+ }
+ // Enforce minimum inter-request delay.
+ const since = Date.now() - b.lastAt;
+ if (b.lastAt && since < minDelay) await sleep(minDelay - since);
+
+ const stamp = Date.now();
+ b.lastAt = stamp;
+ b.timestamps.push(stamp);
+}
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+/** Test/reset helper — clears the in-memory rate-limit state. */
+function _resetBuckets() {
+ buckets.clear();
+}
+
+// ---------------------------------------------------------------------------
+// safeFetch
+// ---------------------------------------------------------------------------
+
+/**
+ * safeFetch(url, opts) — the ONLY sanctioned outbound HTTP entrypoint.
+ * - refuses unless ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH === 'true'
+ * - runs assertFetchAllowed (LinkedIn + SSRF)
+ * - sets a descriptive User-Agent from CRAWLER_USER_AGENT
+ * - throttles per host (token bucket)
+ * - follows redirects MANUALLY, re-checking each hop with assertFetchAllowed
+ *
+ * opts: { maxRequestsPerMinute, minimumDelayMs, maxRedirects, headers, ...fetchOpts }
+ */
+async function safeFetch(url, opts = {}) {
+ if (process.env.ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH !== 'true') {
+ throw new Error(
+ 'safeFetch: automated public web research is DISABLED. Set ALLOW_AUTOMATED_PUBLIC_WEB_RESEARCH=true to enable (spec §30).'
+ );
+ }
+
+ const ua =
+ process.env.CRAWLER_USER_AGENT ||
+ 'RENTV-Advertiser-Research/1.0 (+admin@rentv.com)';
+ const maxRedirects = opts.maxRedirects != null ? opts.maxRedirects : 5;
+
+ let current = url;
+ for (let hop = 0; hop <= maxRedirects; hop++) {
+ // Guard EVERY hop (initial + each redirect target).
+ const { host } = await assertFetchAllowed(current);
+ await throttleHost(host, opts);
+
+ const headers = Object.assign({ 'user-agent': ua }, opts.headers || {});
+ const fetchOpts = Object.assign({}, opts, {
+ headers,
+ redirect: 'manual', // we re-check redirects ourselves
+ });
+ delete fetchOpts.maxRedirects;
+ delete fetchOpts.maxRequestsPerMinute;
+ delete fetchOpts.minimumDelayMs;
+
+ const res = await fetch(current, fetchOpts);
+
+ // Redirect? re-check the Location target through the guard.
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
+ if (hop === maxRedirects) {
+ throw new Error(`safeFetch: too many redirects (>${maxRedirects}) from "${url}"`);
+ }
+ const next = new URL(res.headers.get('location'), current).toString();
+ current = next; // loop re-guards it (blocks redirect-to-LinkedIn / private IP)
+ continue;
+ }
+ return res;
+ }
+ throw new Error(`safeFetch: redirect loop exhausted for "${url}"`);
+}
+
+module.exports = {
+ assertFetchAllowed,
+ isBlockedIp,
+ isLinkedInHost,
+ safeFetch,
+ _resetBuckets,
+};
diff --git a/lib/compliance/no-inferred-email.js b/lib/compliance/no-inferred-email.js
new file mode 100644
index 0000000..4fccf9f
--- /dev/null
+++ b/lib/compliance/no-inferred-email.js
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * No-inferred-email guard (spec §6.8, §6.9).
+ *
+ * Hard rules enforced in code:
+ * §6.8 NEVER infer or generate an email address from a naming pattern.
+ * §6.9 Store a phone/email ONLY when it is EXPLICITLY published as a
+ * business contact on a first-party page / press release / media kit /
+ * event page / government record / public professional page, OR is
+ * manually entered by an authorized user.
+ *
+ * Therefore a contact_point may only be stored when it carries a
+ * source_evidence_id AND explicitly_public === true. We only store
+ * explicitly-published addresses; looksLikePatternEmail() documents and detects
+ * the FORBIDDEN pattern-generated case so importers/UI can REFUSE it.
+ */
+
+/** Access methods / source flags that imply the value was INFERRED, not seen. */
+const INFERENCE_METHODS = Object.freeze([
+ 'PATTERN_GUESS',
+ 'EMAIL_PERMUTATION',
+ 'INFERRED',
+ 'GENERATED',
+ 'GUESSED',
+ 'ENRICHMENT_INFERRED',
+]);
+
+/**
+ * assertContactEvidence(contact) — throws unless the contact is provably
+ * explicitly-public. Every contact_point must be traceable to evidence (§6.11).
+ */
+function assertContactEvidence(contact) {
+ if (!contact || typeof contact !== 'object') {
+ throw new Error('assertContactEvidence: contact must be an object');
+ }
+ const evidenceId = contact.source_evidence_id || contact.sourceEvidenceId;
+ if (!evidenceId) {
+ throw new Error(
+ 'assertContactEvidence: refusing to store contact without source_evidence_id (§6.9/§6.11) — every stored email/phone must cite explicit public evidence'
+ );
+ }
+ const explicit = contact.explicitly_public === true || contact.explicitlyPublic === true;
+ if (!explicit) {
+ throw new Error(
+ 'assertContactEvidence: refusing to store contact that is not explicitly_public (§6.9) — only explicitly-published business contacts may be stored'
+ );
+ }
+ return true;
+}
+
+/**
+ * looksLikePatternEmail(email, personName, domain) — heuristic that flags
+ * emails that appear PATTERN-GENERATED from a person's name (first.last@domain,
+ * flast@domain, firstl@domain, first@domain, etc.). Returns true when the
+ * local-part matches a common permutation of the person's name.
+ *
+ * This is a REFUSE signal: we do not store inferred addresses. It documents the
+ * forbidden case (§6.8) so the importer/UI can reject a permutation even if a
+ * caller mistakenly tries to save one.
+ */
+function looksLikePatternEmail(email, personName, domain) {
+ if (!email || typeof email !== 'string') return false;
+ const at = email.indexOf('@');
+ if (at <= 0) return false;
+ const local = email.slice(0, at).toLowerCase().replace(/[^a-z0-9._-]/g, '');
+ const emailDomain = email.slice(at + 1).toLowerCase();
+
+ // If a domain is supplied, a mismatch means this isn't a pattern off THIS org.
+ if (domain) {
+ const d = String(domain).toLowerCase().replace(/^www\./, '');
+ if (!emailDomain.endsWith(d)) return false;
+ }
+
+ if (!personName || typeof personName !== 'string') return false;
+ const parts = personName
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[^a-z\s]/g, ' ')
+ .split(/\s+/)
+ .filter(Boolean);
+ if (parts.length < 2) {
+ // Single-token name: flag if local-part equals the token exactly.
+ return parts.length === 1 && local.replace(/[._-]/g, '') === parts[0];
+ }
+
+ const first = parts[0];
+ const last = parts[parts.length - 1];
+ const fi = first[0];
+ const li = last[0];
+ const bare = local.replace(/[._-]/g, '');
+
+ const permutations = new Set([
+ `${first}.${last}`,
+ `${first}_${last}`,
+ `${first}-${last}`,
+ `${first}${last}`,
+ `${fi}${last}`, // flast
+ `${fi}.${last}`, // f.last
+ `${fi}_${last}`,
+ `${first}${li}`, // firstl
+ `${first}.${li}`,
+ `${last}.${first}`,
+ `${last}${first}`,
+ `${last}.${fi}`, // last.f
+ `${last}${fi}`, // lastf
+ first, // first@
+ last, // last@
+ `${fi}${li}`, // initials
+ ]);
+
+ if (permutations.has(local)) return true;
+ // Also compare the punctuation-stripped local-part against stripped perms.
+ for (const p of permutations) {
+ if (p.replace(/[._-]/g, '') === bare) return true;
+ }
+ return false;
+}
+
+/**
+ * assertNoInference(source) — throws if the source's access/method/flags imply
+ * the value was inferred rather than observed. Accepts a string method or an
+ * object with { method, accessMethod, inferred }.
+ */
+function assertNoInference(source) {
+ let method;
+ let inferredFlag = false;
+ if (typeof source === 'string') {
+ method = source;
+ } else if (source && typeof source === 'object') {
+ method = source.method || source.accessMethod || source.discoveryMethod;
+ inferredFlag = source.inferred === true || source.generated === true;
+ }
+ const m = String(method || '').toUpperCase();
+ if (inferredFlag || INFERENCE_METHODS.includes(m)) {
+ throw new Error(
+ `assertNoInference: source method "${method}" implies an INFERRED/pattern-generated value — forbidden (§6.8). Only explicitly-published contacts may be stored.`
+ );
+ }
+ return true;
+}
+
+module.exports = {
+ INFERENCE_METHODS,
+ assertContactEvidence,
+ looksLikePatternEmail,
+ assertNoInference,
+};
diff --git a/lib/compliance/source-policy.js b/lib/compliance/source-policy.js
new file mode 100644
index 0000000..8d737e2
--- /dev/null
+++ b/lib/compliance/source-policy.js
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * Source-policy validator — the legal guardrail (spec §6, §9, §10).
+ *
+ * Enforces the "hard research, privacy, and source rules" in CODE, not docs:
+ * §6.1 no bypassing auth/paywalls/robots/rate-limits/anti-bot
+ * §6.2 no scraping proprietary DBs (CoStar, LoopNet, ZoomInfo, Apollo, MLS)
+ * §6.3 no crawling LinkedIn profile/company pages
+ * §6.4 block automated fetching from LinkedIn hosts in the generic fetcher
+ * §10 the SourcePolicy shape + access-method vocabulary
+ *
+ * A policy that names a prohibited proprietary DB or a LinkedIn host as its
+ * base is ILLEGAL to enable for automation. validateSourcePolicy() flags it;
+ * assertSourceEnabledLegal() throws so a pipeline can never run it.
+ */
+
+const T = require('../types');
+
+/**
+ * Prohibited proprietary databases (§6.2). Login-gated / proprietary — never
+ * automate. LinkedIn hosts (§6.3/§6.4) are appended from the shared contract so
+ * there is ONE list. MLS is matched by pattern below (any *.mls* / mls.* host).
+ */
+const PROHIBITED_PROPRIETARY_HOSTS = Object.freeze([
+ 'costar.com',
+ 'loopnet.com',
+ 'zoominfo.com',
+ 'apollo.io',
+ 'crexi.com', // proprietary marketplace DB, login-gated bulk access
+ 'reonomy.com', // proprietary property DB
+]);
+
+// Full automation blocklist = proprietary DBs + every LinkedIn host (§6.2-6.4).
+const PROHIBITED_AUTOMATION_HOSTS = Object.freeze(
+ PROHIBITED_PROPRIETARY_HOSTS.concat(T.LINKEDIN_BLOCKED_HOSTS)
+);
+
+/** Access methods that, by definition, perform NO automated fetching. */
+const NON_AUTOMATED_METHODS = Object.freeze([
+ 'manual_upload',
+ 'manual_review_only',
+ 'authorized_mailbox', // mailbox is authorized-API, not web automation
+]);
+
+/** Lowercase, strip scheme/path/port → bare host. Accepts a bare host too. */
+function hostOf(urlOrHost) {
+ if (!urlOrHost) return '';
+ const raw = String(urlOrHost).trim();
+ try {
+ const u = new URL(raw.includes('://') ? raw : `https://${raw}`);
+ return u.hostname.toLowerCase();
+ } catch (_e) {
+ return raw.toLowerCase().replace(/^\/+/, '').split('/')[0].split(':')[0];
+ }
+}
+
+/** True when host === blocked OR is a subdomain of blocked (a.b.costar.com). */
+function hostMatches(host, blocked) {
+ if (!host) return false;
+ const h = host.toLowerCase();
+ const b = blocked.toLowerCase();
+ return h === b || h.endsWith(`.${b}`);
+}
+
+/** MLS systems are innumerable; match by pattern (§6.2 "any MLS"). */
+function looksLikeMls(host) {
+ if (!host) return false;
+ const h = host.toLowerCase();
+ // mls.<x>, <x>.mls.<y>, <x>mls.<y>, crmls/themls/etc.
+ return (
+ /(^|\.)mls\./.test(h) ||
+ /(^|\.)[a-z]*mls\.[a-z]/.test(h) ||
+ /\bmls\b/.test(h.replace(/[.-]/g, ' '))
+ );
+}
+
+/** Is this host prohibited for AUTOMATED access? (proprietary DB, LinkedIn, MLS) */
+function isProhibitedAutomationHost(host) {
+ if (!host) return false;
+ if (looksLikeMls(host)) return true;
+ return PROHIBITED_AUTOMATION_HOSTS.some((b) => hostMatches(host, b));
+}
+
+/** §10 — the access method must be one of the seven contract values. */
+function accessMethodAllowed(method) {
+ return T.SOURCE_ACCESS_METHODS.includes(method);
+}
+
+/**
+ * validateSourcePolicy(policy) → { valid, errors }
+ * Never throws; collects every violation so the /sources UI can show them all.
+ */
+function validateSourcePolicy(policy) {
+ const errors = [];
+
+ if (!policy || typeof policy !== 'object') {
+ return { valid: false, errors: ['policy must be an object'] };
+ }
+
+ // Required identity fields.
+ if (!policy.sourceKey || typeof policy.sourceKey !== 'string') {
+ errors.push('sourceKey is required');
+ }
+ if (!policy.displayName || typeof policy.displayName !== 'string') {
+ errors.push('displayName is required');
+ }
+
+ // §10 access method vocabulary.
+ if (!accessMethodAllowed(policy.accessMethod)) {
+ errors.push(
+ `accessMethod "${policy.accessMethod}" is not one of ${T.SOURCE_ACCESS_METHODS.join(', ')}`
+ );
+ }
+
+ const base = hostOf(policy.baseUrl || policy.baseHost || '');
+ const automated = policy.allowsAutomatedAccess === true;
+
+ // Core rule (§6.2-6.4): a prohibited host may NOT allow automated access.
+ if (automated && base && isProhibitedAutomationHost(base)) {
+ errors.push(
+ `base host "${base}" is a prohibited proprietary/LinkedIn/MLS source (§6.2-6.4); allowsAutomatedAccess must be false`
+ );
+ }
+
+ // A prohibited host is only tolerable as manual_review_only / manual_upload.
+ if (base && isProhibitedAutomationHost(base) && !NON_AUTOMATED_METHODS.includes(policy.accessMethod)) {
+ errors.push(
+ `base host "${base}" is prohibited for automation; accessMethod must be manual_review_only or manual_upload, got "${policy.accessMethod}"`
+ );
+ }
+
+ // A web-automation access method combined with a prohibited base is illegal.
+ const AUTOMATED_WEB_METHODS = ['first_party_public_web', 'rss_or_sitemap', 'official_bulk_download'];
+ if (base && isProhibitedAutomationHost(base) && AUTOMATED_WEB_METHODS.includes(policy.accessMethod)) {
+ errors.push(
+ `access method "${policy.accessMethod}" performs web automation against prohibited host "${base}" (§6.1)`
+ );
+ }
+
+ // Any prohibitedHosts entry that ALSO appears reachable is contradictory noise;
+ // validate the declared prohibitedHosts are well-formed strings.
+ if (policy.prohibitedHosts && !Array.isArray(policy.prohibitedHosts)) {
+ errors.push('prohibitedHosts must be an array');
+ }
+
+ // If enabled, automated, and a web method, require conservative rate limits (§6.1, §23).
+ if (policy.enabled && automated && AUTOMATED_WEB_METHODS.includes(policy.accessMethod)) {
+ const rpm = policy.maxRequestsPerMinute;
+ if (rpm != null && (typeof rpm !== 'number' || rpm <= 0 || rpm > 60)) {
+ errors.push('maxRequestsPerMinute must be a sane 1-60 for automated web access (§23)');
+ }
+ }
+
+ return { valid: errors.length === 0, errors };
+}
+
+/**
+ * assertSourceEnabledLegal(policy) — throws if an ILLEGAL source is enabled for
+ * automation. Called by the pipeline BEFORE any run (§10 "policy validation
+ * before every run"). A disabled or non-automated policy is always allowed to
+ * pass here (it does no fetching); only an ENABLED + AUTOMATED illegal source
+ * is a hard stop.
+ */
+function assertSourceEnabledLegal(policy) {
+ const { valid, errors } = validateSourcePolicy(policy);
+
+ const base = hostOf((policy && (policy.baseUrl || policy.baseHost)) || '');
+ const automated = policy && policy.allowsAutomatedAccess === true;
+ const enabled = policy && policy.enabled === true;
+
+ // The unforgivable combination: enabled + automated + prohibited host.
+ if (enabled && automated && base && isProhibitedAutomationHost(base)) {
+ throw new Error(
+ `ILLEGAL SOURCE: "${(policy && policy.sourceKey) || base}" is enabled for automated access against prohibited host "${base}" (spec §6.2-6.4). Refusing to run.`
+ );
+ }
+
+ // Any other structural invalidity on an enabled policy is also a stop.
+ if (enabled && !valid) {
+ throw new Error(
+ `INVALID SOURCE POLICY "${(policy && policy.sourceKey) || '?'}": ${errors.join('; ')}`
+ );
+ }
+
+ return true;
+}
+
+module.exports = {
+ PROHIBITED_AUTOMATION_HOSTS,
+ PROHIBITED_PROPRIETARY_HOSTS,
+ validateSourcePolicy,
+ assertSourceEnabledLegal,
+ accessMethodAllowed,
+ isProhibitedAutomationHost,
+ looksLikeMls,
+ hostOf,
+};
diff --git a/lib/entity-resolution.js b/lib/entity-resolution.js
new file mode 100644
index 0000000..253d266
--- /dev/null
+++ b/lib/entity-resolution.js
@@ -0,0 +1,266 @@
+'use strict';
+/**
+ * Entity resolution — deterministic + scored candidate matching (spec §12).
+ *
+ * Rules (in order of priority):
+ * 1. Exact normalized domain
+ * 2. Exact public email domain
+ * 3. Exact normalized name + city + state
+ * 4. Exact LinkedIn company URL (from permitted source only)
+ *
+ * Candidate scoring: 0-1 float.
+ * Thresholds: >=0.97 = auto; 0.82-0.9699 = review; <0.82 = no.
+ * Merges are REVERSIBLE via merge_audit snapshot.
+ */
+
+const { normalizeName } = require('./types');
+
+/**
+ * Run deterministic matching rules against an existing org record.
+ *
+ * @param {{ domain?: string, emailDomain?: string, normalizedName?: string, city?: string, state?: string, linkedinUrl?: string }} candidate
+ * @param {{ domain?: string, emailDomain?: string, normalized_name?: string, headquarters_city?: string, headquarters_state?: string, linkedin_url?: string }} existing - one row from organizations table
+ * @returns {{ matched: boolean, rule: string | null, confidence: number }}
+ */
+function deterministicMatch(candidate, existing) {
+ // Rule 1: exact normalized domain (both non-null)
+ if (candidate.domain && existing.domain) {
+ const cd = String(candidate.domain).toLowerCase().replace(/^www\./, '');
+ const ed = String(existing.domain).toLowerCase().replace(/^www\./, '');
+ if (cd === ed) {
+ return { matched: true, rule: 'EXACT_DOMAIN', confidence: 1.0 };
+ }
+ }
+
+ // Rule 2: exact public email domain
+ if (candidate.emailDomain && existing.emailDomain) {
+ const ce = String(candidate.emailDomain).toLowerCase();
+ const ee = String(existing.emailDomain).toLowerCase();
+ if (ce === ee) {
+ return { matched: true, rule: 'EXACT_EMAIL_DOMAIN', confidence: 0.98 };
+ }
+ }
+
+ // Rule 3: exact normalized name + city + state
+ if (candidate.normalizedName && existing.normalized_name) {
+ const cn = normalizeName(candidate.normalizedName);
+ const en = String(existing.normalized_name).trim();
+ const cityMatch =
+ !candidate.city ||
+ !existing.headquarters_city ||
+ String(candidate.city).toLowerCase() === String(existing.headquarters_city).toLowerCase();
+ const stateMatch =
+ !candidate.state ||
+ !existing.headquarters_state ||
+ String(candidate.state).toUpperCase() === String(existing.headquarters_state).toUpperCase();
+ if (cn === en && cityMatch && stateMatch) {
+ return { matched: true, rule: 'EXACT_NAME_CITY_STATE', confidence: 0.97 };
+ }
+ }
+
+ // Rule 4: exact LinkedIn company URL (must come from a permitted discovery source)
+ if (candidate.linkedinUrl && existing.linkedin_url) {
+ const normalize = (u) =>
+ String(u)
+ .toLowerCase()
+ .replace(/\/$/, '')
+ .replace(/^https?:\/\/(www\.)?/, '');
+ if (normalize(candidate.linkedinUrl) === normalize(existing.linkedin_url)) {
+ return { matched: true, rule: 'EXACT_LINKEDIN_URL', confidence: 0.99 };
+ }
+ }
+
+ return { matched: false, rule: null, confidence: 0 };
+}
+
+/**
+ * Score a candidate pair (a, b) on a 0-1 scale using overlapping signals.
+ * Intended for fuzzy / probabilistic candidate generation (not deterministic).
+ *
+ * @param {Object} a
+ * @param {Object} b
+ * @returns {number} 0-1 float
+ */
+function scoreCandidatePair(a, b) {
+ let score = 0;
+ let signals = 0;
+
+ // Name similarity (Jaccard on trigrams, simplified)
+ const na = normalizeName(a.display_name || a.legal_name || '');
+ const nb = normalizeName(b.display_name || b.legal_name || '');
+ if (na && nb) {
+ const nameSim = jaccardTrigram(na, nb);
+ score += nameSim * 0.40;
+ signals++;
+ }
+
+ // Domain match
+ if (a.domain && b.domain) {
+ const da = String(a.domain).toLowerCase().replace(/^www\./, '');
+ const db = String(b.domain).toLowerCase().replace(/^www\./, '');
+ score += (da === db ? 1.0 : 0) * 0.30;
+ signals++;
+ }
+
+ // State match
+ if (a.headquarters_state && b.headquarters_state) {
+ const sa = String(a.headquarters_state).toUpperCase();
+ const sb = String(b.headquarters_state).toUpperCase();
+ score += (sa === sb ? 1.0 : 0) * 0.10;
+ signals++;
+ }
+
+ // City match
+ if (a.headquarters_city && b.headquarters_city) {
+ const ca = String(a.headquarters_city).toLowerCase().trim();
+ const cb = String(b.headquarters_city).toLowerCase().trim();
+ score += (ca === cb ? 1.0 : 0) * 0.10;
+ signals++;
+ }
+
+ // Category overlap
+ if (Array.isArray(a.advertiser_categories) && Array.isArray(b.advertiser_categories)) {
+ const setA = new Set(a.advertiser_categories);
+ const intersection = (b.advertiser_categories || []).filter((c) => setA.has(c));
+ const union = new Set([...a.advertiser_categories, ...(b.advertiser_categories || [])]);
+ const catSim = union.size > 0 ? intersection.length / union.size : 0;
+ score += catSim * 0.10;
+ signals++;
+ }
+
+ return signals > 0 ? Math.min(1, score) : 0;
+}
+
+/** Jaccard similarity on character trigrams of two strings. */
+function jaccardTrigram(a, b) {
+ const trigrams = (s) => {
+ const set = new Set();
+ for (let i = 0; i < s.length - 2; i++) set.add(s.slice(i, i + 3));
+ return set;
+ };
+ const ta = trigrams(a);
+ const tb = trigrams(b);
+ if (ta.size === 0 && tb.size === 0) return 1;
+ if (ta.size === 0 || tb.size === 0) return 0;
+ let inter = 0;
+ for (const t of ta) if (tb.has(t)) inter++;
+ return inter / (ta.size + tb.size - inter);
+}
+
+/**
+ * Classify a candidate pair score into a merge decision.
+ *
+ * @param {number} score 0-1
+ * @returns {'auto' | 'review' | 'no'}
+ */
+function classifyMerge(score) {
+ if (score >= 0.97) return 'auto';
+ if (score >= 0.82) return 'review';
+ return 'no';
+}
+
+/**
+ * Perform a reversible merge of mergedId into keptId.
+ *
+ * Steps:
+ * 1. Snapshot the merged org row into merge_audit.
+ * 2. Update FK references (organization_id) in child tables.
+ * 3. Delete the merged org row.
+ *
+ * The merge is fully reversible by reading merge_audit.snapshot.
+ *
+ * @param {Object} pool - pg Pool instance
+ * @param {string} keptId - UUID of the org to keep
+ * @param {string} mergedId - UUID of the org to merge (will be deleted)
+ * @returns {Promise<{merge_audit_id: string}>}
+ */
+async function reversibleMerge(pool, keptId, mergedId) {
+ if (keptId === mergedId) throw new Error('Cannot merge an org into itself.');
+
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ // 1. Snapshot the merged row
+ const snap = await client.query(
+ `SELECT row_to_json(o) AS snapshot FROM organizations o WHERE id=$1`,
+ [mergedId]
+ );
+ if (snap.rows.length === 0) {
+ throw new Error(`Organization to merge (id=${mergedId}) not found.`);
+ }
+ const snapshot = snap.rows[0].snapshot;
+
+ const auditRes = await client.query(
+ `INSERT INTO merge_audit (kept_id, merged_id, entity_table, snapshot)
+ VALUES ($1,$2,'organizations',$3::jsonb)
+ RETURNING id`,
+ [keptId, mergedId, JSON.stringify(snapshot)]
+ );
+ const merge_audit_id = auditRes.rows[0].id;
+
+ // 2. Re-parent child rows — tables with organization_id FK
+ const CHILD_TABLES = [
+ 'ad_sightings',
+ 'campaigns',
+ 'contact_points',
+ 'creative_assets',
+ 'event_relationships',
+ 'notes',
+ 'opportunities',
+ 'opportunity_scores',
+ 'organization_markets',
+ 'organization_tags',
+ 'suppression_requests',
+ 'tasks',
+ ];
+
+ for (const tbl of CHILD_TABLES) {
+ // organization_tags and organization_markets have composite PKs — handle ON CONFLICT
+ if (tbl === 'organization_tags') {
+ await client.query(
+ `UPDATE organization_tags SET organization_id=$1 WHERE organization_id=$2`,
+ [keptId, mergedId]
+ );
+ // Remove any duplicates that arose from the re-parent
+ await client.query(
+ `DELETE FROM organization_tags a USING organization_tags b
+ WHERE a.ctid > b.ctid AND a.organization_id=b.organization_id AND a.tag_id=b.tag_id`
+ );
+ } else if (tbl === 'organization_markets') {
+ await client.query(
+ `UPDATE organization_markets SET organization_id=$1 WHERE organization_id=$2`,
+ [keptId, mergedId]
+ );
+ await client.query(
+ `DELETE FROM organization_markets a USING organization_markets b
+ WHERE a.ctid > b.ctid AND a.organization_id=b.organization_id
+ AND a.market_id=b.market_id AND a.relationship_type=b.relationship_type`
+ );
+ } else {
+ await client.query(
+ `UPDATE ${tbl} SET organization_id=$1 WHERE organization_id=$2`,
+ [keptId, mergedId]
+ );
+ }
+ }
+
+ // 3. Delete the merged org (all remaining FKs should be cleared now)
+ await client.query(`DELETE FROM organizations WHERE id=$1`, [mergedId]);
+
+ await client.query('COMMIT');
+ return { merge_audit_id };
+ } catch (e) {
+ await client.query('ROLLBACK');
+ throw e;
+ } finally {
+ client.release();
+ }
+}
+
+module.exports = {
+ deterministicMatch,
+ scoreCandidatePair,
+ classifyMerge,
+ reversibleMerge,
+};
diff --git a/public/css/app.css b/public/css/app.css
new file mode 100644
index 0000000..a2477a4
--- /dev/null
+++ b/public/css/app.css
@@ -0,0 +1,736 @@
+/* RENTV Advertiser Intelligence — Simple View theme (spec §25).
+ Light-mode default, strong contrast, ≥18px base, ≥44px targets.
+ CSS custom props let a dark/night mode be layered later.
+ Printable: @media print strips chrome, preserves content. */
+
+:root {
+ /* Palette */
+ --bg: #f5f6f8;
+ --surface: #ffffff;
+ --surface-alt: #f0f2f5;
+ --border: #d0d5de;
+ --border-strong: #9aa0ad;
+ --ink: #1a1e2b;
+ --ink-muted: #555d6e;
+ --ink-faint: #8a93a2;
+ --accent: #0047cc;
+ --accent-hover: #003399;
+ --accent-light: #dce8ff;
+ --success: #1a7a3a;
+ --success-bg: #e6f5ec;
+ --warn: #7a4f00;
+ --warn-bg: #fff3dc;
+ --danger: #9b1c1c;
+ --danger-bg: #fde8e8;
+ --info: #1a5276;
+ --info-bg: #d6eaf8;
+ --gold: #b78100;
+ --gold-bg: #fff8e1;
+
+ /* Typography */
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ --font-size-base: 18px;
+ --font-size-sm: 15px;
+ --font-size-xs: 13px;
+ --font-size-lg: 20px;
+ --font-size-xl: 24px;
+ --font-size-h1: 28px;
+
+ /* Layout */
+ --header-h: 62px;
+ --rail-w: 270px;
+ --radius: 10px;
+ --radius-sm: 6px;
+ --shadow: 0 1px 4px rgba(0,0,0,.08);
+ --shadow-md: 0 3px 12px rgba(0,0,0,.12);
+
+ /* Card density (grid pages) */
+ --card-min: 300px;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { font-size: var(--font-size-base); }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font);
+ font-size: var(--font-size-base);
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; color: var(--accent-hover); }
+a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
+
+h1, h2, h3, h4 { margin: 0 0 .5em; font-weight: 700; color: var(--ink); }
+h1 { font-size: var(--font-size-h1); }
+h2 { font-size: var(--font-size-xl); }
+h3 { font-size: var(--font-size-lg); }
+h4 { font-size: var(--font-size-base); text-transform: uppercase; letter-spacing: .5px; color: var(--ink-muted); }
+
+p { margin: 0 0 1em; }
+
+/* ── Global nav header ── */
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--surface);
+ border-bottom: 2px solid var(--border);
+ height: var(--header-h);
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 0 24px;
+ box-shadow: var(--shadow);
+}
+
+.site-header .brand {
+ font-size: var(--font-size-lg);
+ font-weight: 800;
+ color: var(--ink);
+ text-decoration: none;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.site-header .brand span { color: var(--accent); }
+
+.header-search {
+ flex: 1;
+ max-width: 480px;
+ padding: 10px 16px;
+ border: 2px solid var(--border);
+ border-radius: var(--radius);
+ font-size: var(--font-size-base);
+ background: var(--surface);
+ color: var(--ink);
+ min-width: 0;
+}
+.header-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
+
+.header-spacer { flex: 1; }
+
+/* Market + Verified switches */
+.switch-group {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.market-switch {
+ display: flex;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+.market-switch button {
+ background: var(--surface);
+ border: none;
+ padding: 8px 14px;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--ink-muted);
+ cursor: pointer;
+ min-height: 44px;
+ min-width: 44px;
+ transition: background .1s, color .1s;
+}
+.market-switch button:hover { background: var(--accent-light); color: var(--accent); }
+.market-switch button.active { background: var(--accent); color: #fff; }
+
+.verified-switch {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ background: var(--surface);
+ cursor: pointer;
+ min-height: 44px;
+ user-select: none;
+ color: var(--ink-muted);
+ transition: background .1s, border-color .1s;
+}
+.verified-switch.active { background: var(--success-bg); border-color: var(--success); color: var(--success); }
+.verified-switch input { accent-color: var(--success); width: 18px; height: 18px; cursor: pointer; }
+
+.dl-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 18px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ font-size: var(--font-size-sm);
+ font-weight: 700;
+ cursor: pointer;
+ min-height: 44px;
+ white-space: nowrap;
+ transition: background .15s;
+ text-decoration: none;
+}
+.dl-btn:hover { background: var(--accent-hover); color: #fff; text-decoration: none; }
+.dl-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+/* ── Page shell ── */
+.page-shell { display: flex; align-items: flex-start; min-height: calc(100vh - var(--header-h)); }
+
+.left-rail {
+ flex: 0 0 var(--rail-w);
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ position: sticky;
+ top: var(--header-h);
+ height: calc(100vh - var(--header-h));
+ overflow-y: auto;
+ padding: 0 0 60px;
+}
+
+.rail-section {
+ padding: 16px 18px;
+ border-bottom: 1px solid var(--border);
+}
+.rail-section h4 {
+ margin: 0 0 10px;
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.rail-section h4::after { content: '▾'; font-size: 11px; color: var(--ink-muted); transition: transform .15s; }
+.rail-section.collapsed h4::after { transform: rotate(-90deg); }
+.rail-section.collapsed > *:not(h4) { display: none !important; }
+
+.page-content { flex: 1 1 auto; min-width: 0; padding: 28px 32px 60px; }
+
+/* ── Stats bar ── */
+.stats-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+.stat-pill {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 16px;
+ font-size: var(--font-size-sm);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 120px;
+}
+.stat-pill .sp-label { color: var(--ink-muted); font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
+.stat-pill .sp-value { font-size: var(--font-size-lg); font-weight: 800; color: var(--ink); }
+.stat-pill a { color: inherit; text-decoration: none; }
+.stat-pill a:hover { text-decoration: underline; }
+
+/* ── DEMO badge ── */
+.demo-banner {
+ background: var(--warn-bg);
+ border: 2px solid #e6a800;
+ border-radius: var(--radius);
+ padding: 12px 18px;
+ margin-bottom: 20px;
+ font-size: var(--font-size-sm);
+ font-weight: 700;
+ color: var(--warn);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* ── Table engine ── */
+.tbl-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.tbl-search {
+ padding: 9px 14px;
+ border: 2px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+ background: var(--surface);
+ color: var(--ink);
+ min-width: 260px;
+}
+.tbl-search:focus { outline: none; border-color: var(--accent); }
+
+.tbl-sort-sel {
+ padding: 8px 12px;
+ border: 2px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-sm);
+ background: var(--surface);
+ color: var(--ink);
+ min-height: 44px;
+}
+
+.density-wrap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--font-size-sm);
+ color: var(--ink-muted);
+}
+.density-wrap input[type="range"] {
+ width: 100px;
+ accent-color: var(--accent);
+}
+
+.tbl-count { margin-left: auto; font-size: var(--font-size-sm); color: var(--ink-muted); }
+
+.tbl-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ box-shadow: var(--shadow);
+ max-height: calc(100vh - 300px);
+}
+
+table.adv-tbl {
+ border-collapse: separate;
+ border-spacing: 0;
+ width: 100%;
+ font-size: var(--font-size-sm);
+ white-space: nowrap;
+}
+
+table.adv-tbl thead th {
+ position: sticky;
+ top: 0;
+ background: #e8ecf2;
+ color: var(--ink-muted);
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .4px;
+ padding: 12px 14px;
+ border-bottom: 2px solid var(--border);
+ cursor: pointer;
+ user-select: none;
+ z-index: 2;
+ white-space: nowrap;
+}
+table.adv-tbl thead th:hover { color: var(--ink); }
+table.adv-tbl thead th.asc::after { content: ' ▲'; color: var(--accent); }
+table.adv-tbl thead th.desc::after { content: ' ▼'; color: var(--accent); }
+table.adv-tbl thead th.dragcol { cursor: grab; }
+table.adv-tbl thead th.dragging { opacity: .4; }
+table.adv-tbl thead th.dropL { box-shadow: inset 3px 0 0 var(--accent); }
+table.adv-tbl thead th.dropR { box-shadow: inset -3px 0 0 var(--accent); }
+
+table.adv-tbl tbody td {
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+ color: var(--ink);
+ vertical-align: middle;
+}
+table.adv-tbl tbody tr:last-child td { border-bottom: none; }
+table.adv-tbl tbody tr:hover td { background: var(--accent-light); }
+table.adv-tbl tbody tr { cursor: pointer; }
+
+table.adv-tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
+
+table.adv-tbl a { color: var(--accent); }
+
+/* expandable row */
+.row-expand { background: var(--surface-alt) !important; cursor: default; }
+.row-expand td { padding: 16px 20px !important; white-space: normal; }
+
+/* ── Column field toggles in rail ── */
+.field-toggles { display: flex; flex-direction: column; gap: 2px; max-height: 360px; overflow-y: auto; }
+.ftog { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-sm); color: var(--ink); cursor: pointer; padding: 4px 4px; border-radius: 6px; }
+.ftog:hover { background: var(--accent-light); }
+.ftog input { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
+.fgrp { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent); margin: 10px 0 3px; font-weight: 700; }
+.mini-btn { background: var(--surface); color: var(--ink-muted); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; font-size: 13px; cursor: pointer; margin-right: 5px; }
+.mini-btn:hover { border-color: var(--accent); color: var(--ink); }
+
+/* ── Chips / facet filters ── */
+.chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.chip {
+ background: var(--surface-alt);
+ color: var(--ink-muted);
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ padding: 5px 12px;
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ cursor: pointer;
+ user-select: none;
+ transition: background .1s, border-color .1s;
+}
+.chip:hover { color: var(--ink); border-color: var(--border-strong); }
+.chip.active { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
+.chip .ct { color: var(--ink-faint); margin-left: 4px; font-size: 11px; }
+
+/* ── Status / category badges (text + shape, not color alone) ── */
+.badge {
+ display: inline-block;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: 12px;
+ white-space: nowrap;
+ border: 1.5px solid transparent;
+}
+.badge-verified-advertiser { background: #e6f5ec; color: #1a7a3a; border-color: #a3d9b1; }
+.badge-verified-sponsor { background: #d6eaf8; color: #1a5276; border-color: #9ac4e8; }
+.badge-verified-exhibitor { background: #e8d8f5; color: #6a1a9a; border-color: #c49adf; }
+.badge-verified-media { background: #fff8e1; color: #7a5c00; border-color: #f0d070; }
+.badge-content-partner { background: #e8f4f0; color: #1a6a55; border-color: #90d0bb; }
+.badge-panelist { background: var(--surface-alt); color: var(--ink-muted); border-color: var(--border); }
+.badge-past-advertiser { background: #f5f5f0; color: #555; border-color: #ccc; }
+.badge-likely-prospect { background: #fff3dc; color: #7a4f00; border-color: #f0c060; }
+.badge-research-needed { background: #fff0e0; color: #7a3500; border-color: #e0a060; }
+.badge-disqualified { background: #fde8e8; color: #9b1c1c; border-color: #f0a0a0; }
+
+.score-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 28px;
+ border-radius: 8px;
+ font-size: var(--font-size-xs);
+ font-weight: 800;
+ background: var(--accent-light);
+ color: var(--accent);
+ border: 1.5px solid #a0b8e8;
+}
+.score-high { background: #e6f5ec; color: #1a7a3a; border-color: #a3d9b1; }
+.score-mid { background: #fff3dc; color: #7a4f00; border-color: #f0c060; }
+.score-low { background: var(--surface-alt); color: var(--ink-muted); border-color: var(--border); }
+
+/* ── Org logo / thumbnail ── */
+.org-thumb {
+ width: 44px;
+ height: 44px;
+ border-radius: 8px;
+ object-fit: contain;
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ display: block;
+}
+.org-thumb-placeholder {
+ width: 44px;
+ height: 44px;
+ border-radius: 8px;
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ color: var(--ink-faint);
+ flex-shrink: 0;
+}
+
+/* ── Dashboard cards ── */
+.dash-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+ margin-bottom: 32px;
+}
+
+.dash-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 20px 22px;
+ box-shadow: var(--shadow);
+}
+.dash-card h3 { font-size: var(--font-size-sm); text-transform: uppercase; letter-spacing: .5px; color: var(--ink-muted); margin-bottom: 8px; }
+.dash-card .big-num { font-size: 36px; font-weight: 900; color: var(--ink); line-height: 1; }
+.dash-card .big-num a { color: var(--ink); text-decoration: none; }
+.dash-card .big-num a:hover { color: var(--accent); text-decoration: underline; }
+.dash-card .sub { font-size: var(--font-size-xs); color: var(--ink-muted); margin-top: 4px; }
+.dash-card .when { font-size: var(--font-size-xs); color: var(--ink-faint); margin-top: 8px; }
+
+/* Admin cards — created date+time chip (Steve HARD rule) */
+.when-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 3px 8px;
+ font-size: var(--font-size-xs);
+ color: var(--ink-muted);
+ white-space: nowrap;
+}
+
+/* ── Org profile page ── */
+.org-header {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 24px 28px;
+ margin-bottom: 24px;
+ box-shadow: var(--shadow);
+ display: flex;
+ gap: 22px;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+.org-header-info { flex: 1; min-width: 200px; }
+.org-header h1 { font-size: var(--font-size-xl); margin-bottom: 8px; }
+.org-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
+
+.tab-bar {
+ display: flex;
+ gap: 2px;
+ border-bottom: 2px solid var(--border);
+ margin-bottom: 20px;
+ overflow-x: auto;
+}
+.tab-btn {
+ padding: 10px 18px;
+ background: none;
+ border: none;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--ink-muted);
+ cursor: pointer;
+ border-bottom: 3px solid transparent;
+ margin-bottom: -2px;
+ white-space: nowrap;
+ min-height: 44px;
+ border-radius: 6px 6px 0 0;
+}
+.tab-btn:hover { background: var(--accent-light); color: var(--accent); }
+.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--accent-light); }
+
+.tab-panel { display: none; }
+.tab-panel.active { display: block; }
+
+/* ── Show-Me-Why score drawer ── */
+.drawer-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.4);
+ z-index: 200;
+ align-items: flex-start;
+ justify-content: flex-end;
+}
+.drawer-overlay.open { display: flex; }
+.score-drawer {
+ width: min(500px, 98vw);
+ height: 100vh;
+ background: var(--surface);
+ border-left: 2px solid var(--border);
+ overflow-y: auto;
+ padding: 28px 28px 60px;
+ box-shadow: -4px 0 24px rgba(0,0,0,.12);
+}
+.score-drawer h2 { font-size: var(--font-size-lg); margin-bottom: 6px; }
+.score-drawer .close-btn {
+ position: absolute;
+ top: 18px;
+ right: 22px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 12px;
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ color: var(--ink-muted);
+}
+
+.factor-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: var(--font-size-sm);
+}
+.factor-row:last-child { border-bottom: none; }
+.factor-name { color: var(--ink); font-weight: 600; flex: 1; }
+.factor-bar-wrap { flex: 2; height: 10px; background: var(--surface-alt); border-radius: 5px; overflow: hidden; }
+.factor-bar { height: 100%; background: var(--accent); border-radius: 5px; transition: width .3s; }
+.factor-contrib { font-size: var(--font-size-xs); font-weight: 700; color: var(--ink-muted); min-width: 40px; text-align: right; }
+
+/* ── Action buttons ── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 9px 16px;
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ border: 2px solid transparent;
+ text-decoration: none;
+ min-height: 44px;
+ white-space: nowrap;
+ transition: background .1s, border-color .1s, color .1s;
+}
+.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
+.btn-outline { background: var(--surface); color: var(--accent); border-color: var(--accent); }
+.btn-outline:hover { background: var(--accent-light); }
+.btn-ghost { background: none; color: var(--ink-muted); border-color: var(--border); }
+.btn-ghost:hover { background: var(--surface-alt); color: var(--ink); }
+.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
+
+/* ── Evidence/sources ── */
+.evidence-card {
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 14px 16px;
+ margin-bottom: 12px;
+ font-size: var(--font-size-sm);
+}
+.evidence-card .ev-title { font-weight: 700; margin-bottom: 4px; }
+.evidence-card .ev-meta { color: var(--ink-muted); font-size: var(--font-size-xs); display: flex; flex-wrap: wrap; gap: 8px; }
+.evidence-card .ev-excerpt { margin-top: 8px; color: var(--ink-muted); font-style: italic; font-size: var(--font-size-xs); border-left: 3px solid var(--border); padding-left: 10px; }
+
+/* ── Media-kit conflicts warning ── */
+.conflict-warning {
+ background: var(--warn-bg);
+ border: 2px solid #e6a800;
+ border-radius: var(--radius-sm);
+ padding: 12px 16px;
+ font-size: var(--font-size-sm);
+ color: var(--warn);
+ margin-bottom: 16px;
+}
+
+/* ── Card grid (ads gallery etc) ── */
+.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr)); gap: 20px; }
+.ad-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+}
+.ad-card img { width: 100%; height: 160px; object-fit: cover; background: var(--surface-alt); display: block; }
+.ad-card-body { padding: 14px 16px; flex: 1; }
+.ad-card-body .org-name { font-weight: 700; font-size: var(--font-size-base); margin-bottom: 4px; }
+.ad-card-body .ad-meta { font-size: var(--font-size-xs); color: var(--ink-muted); margin-bottom: 8px; }
+.ad-card-footer { padding: 10px 16px; border-top: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; }
+
+/* ── Sponsors vs panelists (conference page) ── */
+.section-divider { font-size: var(--font-size-lg); font-weight: 800; color: var(--ink); margin: 24px 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--border); }
+
+/* ── Inline-list tables (contacts, sources etc.) ── */
+.inline-list { list-style: none; margin: 0; padding: 0; }
+.inline-list li { padding: 10px 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
+.inline-list li:last-child { border-bottom: none; }
+
+/* ── Audit log ── */
+.audit-row { display: flex; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
+.audit-row:last-child { border-bottom: none; }
+.audit-action { font-weight: 700; color: var(--ink); }
+.audit-meta { color: var(--ink-muted); font-size: var(--font-size-xs); }
+
+/* ── Missing / empty states ── */
+.empty-state { text-align: center; padding: 60px 24px; color: var(--ink-muted); }
+.empty-state .em-icon { font-size: 48px; margin-bottom: 12px; }
+.empty-state p { font-size: var(--font-size-base); }
+
+.miss { color: var(--ink-faint); }
+
+/* ── Alerts ── */
+.alert { border-radius: var(--radius-sm); padding: 12px 16px; font-size: var(--font-size-sm); margin-bottom: 16px; }
+.alert-info { background: var(--info-bg); color: var(--info); border: 1px solid #9ac4e8; }
+.alert-warn { background: var(--warn-bg); color: var(--warn); border: 1px solid #f0c060; }
+.alert-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid #f0a0a0; }
+.alert-success { background: var(--success-bg); color: var(--success); border: 1px solid #a3d9b1; }
+
+/* ── Settings / admin layout ── */
+.settings-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 22px 24px;
+ margin-bottom: 20px;
+ box-shadow: var(--shadow);
+}
+.settings-card h3 { font-size: var(--font-size-base); margin-bottom: 12px; }
+
+/* ── Media kit page ── */
+.mk-snapshot {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+.mk-metric {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+}
+.mk-metric .mk-val { font-size: 32px; font-weight: 900; color: var(--ink); }
+.mk-metric .mk-label { font-size: var(--font-size-sm); color: var(--ink-muted); margin-top: 4px; }
+.mk-metric .mk-source { font-size: var(--font-size-xs); color: var(--ink-faint); margin-top: 6px; }
+
+/* ── Pagination ── */
+.pagination { display: flex; align-items: center; gap: 10px; margin-top: 20px; font-size: var(--font-size-sm); }
+.pagination a, .pagination button {
+ padding: 8px 14px;
+ border: 1.5px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--accent);
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+}
+.pagination a:hover, .pagination button:hover { background: var(--accent-light); }
+.pagination .pg-info { color: var(--ink-muted); }
+
+/* ── Progress bar for exports ── */
+.progress-wrap { background: var(--surface-alt); border-radius: 8px; height: 12px; overflow: hidden; margin: 8px 0; }
+.progress-bar { height: 100%; background: var(--accent); border-radius: 8px; transition: width .4s; }
+
+/* ── Responsive ── */
+@media (max-width: 860px) {
+ .left-rail { display: none; }
+ .page-content { padding: 16px 16px 60px; }
+ .site-header { flex-wrap: wrap; height: auto; padding: 12px 16px; gap: 10px; }
+ .header-search { max-width: 100%; order: 10; flex: 1 0 100%; }
+}
+
+/* ── Print ── */
+@media print {
+ .site-header, .left-rail, .dl-btn, .tab-bar, .tbl-controls,
+ .btn, .drawer-overlay, .score-drawer, .switch-group { display: none !important; }
+ body { background: #fff; font-size: 12pt; }
+ .page-content { padding: 0; }
+ table.adv-tbl { font-size: 10pt; }
+ .tbl-wrap { max-height: none; overflow: visible; }
+ a { color: var(--ink) !important; text-decoration: none; }
+}
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..763e435
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,264 @@
+/* app.js — Global: CA/AZ/All + Verified-Only switches, Show-Me-Why drawer,
+ Download Everything button. Loaded on every page.
+ No framework, no build step, plain ES5-compatible vanilla JS. */
+
+'use strict';
+
+(function () {
+
+ /* ── Market + Verified switches (persist to localStorage) ─────────────────
+ The switches on the header update a query-param-based reload so the
+ server-rendered pages stay in sync. */
+
+ var MARKET_KEY = 'rentv:market'; // 'CA' | 'AZ' | 'ALL'
+ var VERIFIED_KEY = 'rentv:verified'; // '1' | '0'
+
+ function getMarket() { return localStorage.getItem(MARKET_KEY) || 'ALL'; }
+ function getVerified() { return localStorage.getItem(VERIFIED_KEY) || '0'; }
+
+ function setMarket(v) {
+ localStorage.setItem(MARKET_KEY, v);
+ reloadWithParams();
+ }
+ function setVerified(v) {
+ localStorage.setItem(VERIFIED_KEY, v);
+ reloadWithParams();
+ }
+
+ function reloadWithParams() {
+ var url = new URL(location.href);
+ var m = getMarket(), v = getVerified();
+ if (m && m !== 'ALL') url.searchParams.set('market', m);
+ else url.searchParams.delete('market');
+ if (v === '1') url.searchParams.set('verifiedOnly', '1');
+ else url.searchParams.delete('verifiedOnly');
+ location.href = url.toString();
+ }
+
+ // Sync switch UI state on load
+ function syncSwitches() {
+ var m = getMarket(), v = getVerified();
+
+ // Market buttons
+ document.querySelectorAll('.market-switch button[data-market]').forEach(function (btn) {
+ btn.classList.toggle('active', btn.dataset.market === m);
+ });
+
+ // Verified checkbox/label
+ var verEl = document.querySelector('.verified-switch');
+ var verCb = document.querySelector('.verified-switch input[type="checkbox"]');
+ if (verEl) verEl.classList.toggle('active', v === '1');
+ if (verCb) verCb.checked = (v === '1');
+
+ // Apply URL params from persisted prefs on first load (only if URL is default)
+ var url = new URL(location.href);
+ var urlM = url.searchParams.get('market');
+ var urlV = url.searchParams.get('verifiedOnly');
+ // If the URL already has params, trust them (user navigated with link)
+ // If not, apply prefs (fresh page load from navigation)
+ var needsRedirect = false;
+ if (m !== 'ALL' && !urlM) needsRedirect = true;
+ if (v === '1' && urlV !== '1') needsRedirect = true;
+ if (needsRedirect && !window._skipRedirect) {
+ window._skipRedirect = true;
+ reloadWithParams();
+ }
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ syncSwitches();
+
+ // Market switch buttons
+ document.querySelectorAll('.market-switch button[data-market]').forEach(function (btn) {
+ btn.addEventListener('click', function () { setMarket(btn.dataset.market); });
+ });
+
+ // Verified-only toggle
+ var verSwitch = document.querySelector('.verified-switch');
+ if (verSwitch) {
+ verSwitch.addEventListener('click', function (e) {
+ if (e.target.tagName === 'INPUT') return; // let checkbox handle itself
+ setVerified(getVerified() === '1' ? '0' : '1');
+ });
+ }
+ var verCb = document.querySelector('.verified-switch input[type="checkbox"]');
+ if (verCb) {
+ verCb.addEventListener('change', function () {
+ setVerified(verCb.checked ? '1' : '0');
+ });
+ }
+
+ // Global header search → go to /search?q=…
+ var globalSearch = document.getElementById('global-search');
+ if (globalSearch) {
+ globalSearch.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && globalSearch.value.trim()) {
+ location.href = '/advertisers?q=' + encodeURIComponent(globalSearch.value.trim());
+ }
+ });
+ }
+
+ // ── Download Everything button ─────────────────────────────────────────
+ var dlBtn = document.getElementById('download-everything-btn');
+ if (dlBtn) {
+ dlBtn.addEventListener('click', function (e) {
+ e.preventDefault();
+ startExport();
+ });
+ }
+
+ // ── Show-Me-Why drawer ─────────────────────────────────────────────────
+ // Opened by any element with data-smw-org="<org-id>"
+ document.addEventListener('click', function (e) {
+ var trigger = e.target.closest('[data-smw-org]');
+ if (!trigger) return;
+ e.preventDefault();
+ openScoreDrawer(trigger.dataset.smwOrg, trigger.dataset.smwName || '');
+ });
+
+ // Close drawer on overlay click or close button
+ var overlay = document.getElementById('score-drawer-overlay');
+ if (overlay) {
+ overlay.addEventListener('click', function (e) {
+ if (e.target === overlay) closeScoreDrawer();
+ });
+ }
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') closeScoreDrawer();
+ });
+ });
+
+ /* ── Download Everything ─────────────────────────────────────────────────── */
+ function startExport() {
+ var btn = document.getElementById('download-everything-btn');
+ if (btn) { btn.textContent = 'Preparing…'; btn.disabled = true; }
+
+ fetch('/api/v1/exports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'DOWNLOAD_EVERYTHING' }) })
+ .then(function (r) { return r.json(); })
+ .then(function (d) {
+ if (d.error) throw new Error(d.message || d.error);
+ var exportId = d.id;
+ if (!exportId) {
+ // stub response — show msg
+ showExportMsg('Export queued. Refresh in a moment to download.');
+ return;
+ }
+ pollExport(exportId, 0);
+ })
+ .catch(function (err) {
+ showExportMsg('Export error: ' + (err.message || err));
+ if (btn) { btn.textContent = 'Download Everything'; btn.disabled = false; }
+ });
+ }
+
+ function pollExport(id, attempt) {
+ if (attempt > 30) {
+ showExportMsg('Export is taking a while — check /exports for status.');
+ resetDlBtn();
+ return;
+ }
+ fetch('/api/v1/exports/' + encodeURIComponent(id))
+ .then(function (r) { return r.json(); })
+ .then(function (d) {
+ if (d.status === 'DONE' && d.download_url) {
+ window.location = d.download_url;
+ resetDlBtn();
+ } else if (d.status === 'ERROR') {
+ showExportMsg('Export failed: ' + (d.error || 'unknown error'));
+ resetDlBtn();
+ } else {
+ var pct = d.progress_pct != null ? ' (' + d.progress_pct + '%)' : '';
+ var btn = document.getElementById('download-everything-btn');
+ if (btn) btn.textContent = 'Building export…' + pct;
+ setTimeout(function () { pollExport(id, attempt + 1); }, 2000);
+ }
+ })
+ .catch(function () {
+ setTimeout(function () { pollExport(id, attempt + 1); }, 3000);
+ });
+ }
+
+ function resetDlBtn() {
+ var btn = document.getElementById('download-everything-btn');
+ if (btn) { btn.textContent = 'Download Everything'; btn.disabled = false; }
+ }
+
+ function showExportMsg(msg) {
+ var el = document.getElementById('export-msg');
+ if (el) { el.textContent = msg; el.style.display = 'block'; }
+ else alert(msg);
+ }
+
+ /* ── Show-Me-Why score drawer ─────────────────────────────────────────────── */
+ function openScoreDrawer(orgId, orgName) {
+ var overlay = document.getElementById('score-drawer-overlay');
+ var body = document.getElementById('score-drawer-body');
+ if (!overlay || !body) return;
+
+ body.innerHTML = '<p>Loading score for ' + tblEsc(orgName || orgId) + '…</p>';
+ overlay.classList.add('open');
+ document.body.style.overflow = 'hidden';
+
+ fetch('/api/v1/advertisers/' + encodeURIComponent(orgId))
+ .then(function (r) { return r.json(); })
+ .then(function (d) {
+ if (d.error) { body.innerHTML = '<p class="miss">' + tblEsc(d.message || d.error) + '</p>'; return; }
+ renderScoreDrawer(body, d);
+ })
+ .catch(function (err) {
+ body.innerHTML = '<p class="miss">Failed to load: ' + tblEsc(err.message) + '</p>';
+ });
+ }
+
+ function closeScoreDrawer() {
+ var overlay = document.getElementById('score-drawer-overlay');
+ if (overlay) overlay.classList.remove('open');
+ document.body.style.overflow = '';
+ }
+ window.closeScoreDrawer = closeScoreDrawer;
+
+ var FACTOR_LABELS = {
+ verifiedAdvertising: 'Verified advertising (22%)',
+ verifiedConferenceSpendSignal: 'Conference spend signal (15%)',
+ recency: 'Recency (12%)',
+ repeatActivity: 'Repeat activity (10%)',
+ californiaFit: 'California fit (10%)',
+ arizonaFit: 'Arizona fit (5%)',
+ categoryFit: 'Category fit (8%)',
+ rentvAudienceFit: 'RENTV audience fit (8%)',
+ contactCompleteness: 'Contact completeness (4%)',
+ evidenceQuality: 'Evidence quality (6%)',
+ };
+
+ function renderScoreDrawer(el, org) {
+ var score = (org.score || {});
+ var factors = score.factors || [];
+ var totalScore = score.score != null ? score.score : '—';
+ var html = '<h2>Score: ' + totalScore + ' / 100</h2>';
+ html += '<p style="color:var(--ink-muted);font-size:var(--font-size-sm)">RENTV sales opportunity score for <strong>' + tblEsc(org.display_name || '') + '</strong>. This ranks sales opportunity, not ad spend.</p>';
+
+ if (!factors.length) {
+ html += '<p class="miss">No score factors available. Run the scoring job to compute.</p>';
+ } else {
+ html += factors.map(function (f) {
+ var label = FACTOR_LABELS[f.factor] || f.factor;
+ var pct = Math.round((f.value / 100) * 100);
+ return '<div class="factor-row">' +
+ '<span class="factor-name">' + tblEsc(label) + '</span>' +
+ '<div class="factor-bar-wrap"><div class="factor-bar" style="width:' + pct + '%"></div></div>' +
+ '<span class="factor-contrib">' + f.value + '</span>' +
+ '</div>';
+ }).join('');
+ }
+
+ // Evidence link
+ html += '<p style="margin-top:20px"><a href="/advertisers/' + tblEsc(String(org.id || '')) + '#evidence" class="btn btn-outline">View full evidence</a></p>';
+ el.innerHTML = html;
+ }
+
+ function tblEsc(s) {
+ return (s == null ? '' : String(s))
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+ }
+
+}());
diff --git a/public/js/table.js b/public/js/table.js
new file mode 100644
index 0000000..b47f146
--- /dev/null
+++ b/public/js/table.js
@@ -0,0 +1,513 @@
+/* table.js — Adjustable-columns engine for RENTV Advertiser Intelligence.
+ Mirrors the broker-grid.html pattern (Steve rule 2026-07-31):
+ resize + drag-reorder + toggle-visibility + persist localStorage + Reset.
+ Client-side search-all (space=AND) + multi-sort + density slider + expandable rows.
+ Reusable across all list pages: advertisers, contacts, ads, sources.
+
+ Usage:
+ <script src="/js/table.js"></script>
+ <script>
+ const T = new ATable({
+ tableId: 'main-tbl',
+ tbodyId: 'main-tbody',
+ theadId: 'main-thead',
+ countId: 'row-count',
+ searchId: 'tbl-search',
+ sortSelId: 'sort-sel',
+ fieldsId: 'field-toggles',
+ storageKey: 'advTable', // localStorage prefix
+ cols: [ { k:'company', l:'Company', t:'s', g:'Identity', def:1 }, ... ],
+ data: [], // initial dataset
+ onRowClick: (row) => {},
+ renderCell: (row, col) => 'string or null for default',
+ });
+ T.setData(rows);
+ </script>
+*/
+
+'use strict';
+
+(function (global) {
+
+ // ── Helpers ────────────────────────────────────────────────────────────────
+ const esc = (s) => (s == null ? '' : String(s))
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+ const fmtDate = (s) => {
+ if (!s) return '—';
+ try {
+ const d = new Date(s);
+ if (isNaN(d)) return String(s);
+ return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ } catch (e) { return String(s); }
+ };
+
+ const fmtDateOnly = (s) => {
+ if (!s) return '—';
+ try {
+ const d = new Date(s);
+ if (isNaN(d)) return String(s);
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+ } catch (e) { return String(s); }
+ };
+
+ const tel = (v) => v ? `<a href="tel:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
+ const mail = (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
+ const web = (v) => {
+ if (!v) return '<span class="miss">—</span>';
+ const h = /^https?:/.test(v) ? v : 'https://' + v;
+ let label = v;
+ try { label = new URL(h).hostname.replace(/^www\./, ''); } catch (_) { label = v; }
+ return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${esc(label)} ↗</a>`;
+ };
+ const li = (v) => v ? `<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : '<span class="miss">—</span>';
+
+ const STATUS_BADGE_CLASS = {
+ VERIFIED_ADVERTISER: 'badge-verified-advertiser',
+ VERIFIED_CONFERENCE_SPONSOR: 'badge-verified-sponsor',
+ VERIFIED_EXHIBITOR: 'badge-verified-exhibitor',
+ VERIFIED_MEDIA_PARTNER: 'badge-verified-media',
+ VERIFIED_CONTENT_PARTNER: 'badge-content-partner',
+ SPEAKER_OR_PANELIST_ONLY: 'badge-panelist',
+ PAST_ADVERTISER: 'badge-past-advertiser',
+ LIKELY_PROSPECT: 'badge-likely-prospect',
+ RESEARCH_NEEDED: 'badge-research-needed',
+ DISQUALIFIED: 'badge-disqualified',
+ };
+ const STATUS_LABELS = {
+ VERIFIED_ADVERTISER: 'Verified advertiser',
+ VERIFIED_CONFERENCE_SPONSOR: 'Verified conf. sponsor',
+ VERIFIED_EXHIBITOR: 'Verified exhibitor',
+ VERIFIED_MEDIA_PARTNER: 'Verified media partner',
+ VERIFIED_CONTENT_PARTNER: 'Content partner',
+ SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist',
+ PAST_ADVERTISER: 'Past advertiser',
+ LIKELY_PROSPECT: 'Likely prospect',
+ RESEARCH_NEEDED: 'Research needed',
+ DISQUALIFIED: 'Disqualified',
+ };
+
+ function statusBadge(status) {
+ const cls = STATUS_BADGE_CLASS[status] || 'badge-research-needed';
+ const lbl = STATUS_LABELS[status] || status || '—';
+ return `<span class="badge ${cls}">${esc(lbl)}</span>`;
+ }
+
+ function scoreBadge(score) {
+ if (score == null) return '<span class="miss">—</span>';
+ const n = Number(score);
+ const cls = n >= 70 ? 'score-high' : n >= 40 ? 'score-mid' : 'score-low';
+ return `<span class="score-badge ${cls}">${n}</span>`;
+ }
+
+ // Default cell renderer — custom renderCell overrides first.
+ function defaultCell(row, col) {
+ const v = row[col.k];
+ switch (col.t) {
+ case 'status': return statusBadge(v);
+ case 'score': return scoreBadge(v);
+ case 'tel': return tel(v);
+ case 'mail': return mail(v);
+ case 'web': return web(v);
+ case 'li': return li(v);
+ case 'dt': return `<span title="${esc(v ? new Date(v).toISOString() : '')}">${fmtDate(v)}</span>`;
+ case 'date': return fmtDateOnly(v);
+ case 'n': return `<span class="num">${v == null ? '—' : Number(v).toLocaleString()}</span>`;
+ case 'link': return v ? `<a href="${esc(v)}">${esc(col.linkLabel || v)}</a>` : '<span class="miss">—</span>';
+ default: return esc(v == null ? '—' : v);
+ }
+ }
+
+ // ── ATable class ───────────────────────────────────────────────────────────
+
+ class ATable {
+ constructor(opts) {
+ this.tableId = opts.tableId;
+ this.theadId = opts.theadId;
+ this.tbodyId = opts.tbodyId;
+ this.countId = opts.countId;
+ this.searchId = opts.searchId;
+ this.sortSelId = opts.sortSelId;
+ this.fieldsId = opts.fieldsId;
+ this.storageKey = opts.storageKey || 'aTable';
+ this.cols = opts.cols || [];
+ this.data = opts.data || [];
+ this._renderCell = opts.renderCell || null;
+ this.onRowClick = opts.onRowClick || null;
+ this.expandedIds = new Set();
+
+ // Sort state
+ this.sortKey = opts.defaultSortKey || (this.cols[0] && this.cols[0].k) || '';
+ this.sortDir = opts.defaultSortDir || 1;
+
+ // Search
+ this.q = '';
+
+ // Persist column visibility + order
+ this._loadPrefs();
+
+ // Last rendered column signature (for resize invalidation)
+ this._lastColSig = '';
+
+ // Drag state
+ this._dragKey = null;
+
+ this._init();
+ }
+
+ // ── Prefs persistence ────────────────────────────────────────────────────
+ _loadPrefs() {
+ try {
+ this._viscol = JSON.parse(localStorage.getItem(this.storageKey + ':viscol') || '{}');
+ this._colorder = JSON.parse(localStorage.getItem(this.storageKey + ':colorder') || '[]');
+ const sk = localStorage.getItem(this.storageKey + ':sortKey');
+ const sd = localStorage.getItem(this.storageKey + ':sortDir');
+ if (sk) this.sortKey = sk;
+ if (sd === '1' || sd === '-1') this.sortDir = +sd;
+ } catch (_) {
+ this._viscol = {};
+ this._colorder = [];
+ }
+ }
+
+ _saveViscol() { try { localStorage.setItem(this.storageKey + ':viscol', JSON.stringify(this._viscol)); } catch (_) {} }
+ _saveColorder() { try { localStorage.setItem(this.storageKey + ':colorder', JSON.stringify(this._colorder)); } catch (_) {} }
+ _saveSort() {
+ try {
+ localStorage.setItem(this.storageKey + ':sortKey', this.sortKey);
+ localStorage.setItem(this.storageKey + ':sortDir', String(this.sortDir));
+ } catch (_) {}
+ }
+
+ // ── Column helpers ────────────────────────────────────────────────────────
+ _colVis(k) {
+ if (k in this._viscol) return !!this._viscol[k];
+ const col = this.cols.find((c) => c.k === k);
+ return col ? (col.def !== 0) : false; // def:0 = off by default
+ }
+
+ _syncColOrder() {
+ const keys = this.cols.map((c) => c.k);
+ this._colorder = this._colorder.filter((k) => keys.includes(k));
+ keys.forEach((k) => { if (!this._colorder.includes(k)) this._colorder.push(k); });
+ }
+
+ _orderedCols() {
+ this._syncColOrder();
+ return this._colorder.map((k) => this.cols.find((c) => c.k === k)).filter(Boolean);
+ }
+
+ _visCols() { return this._orderedCols().filter((c) => this._colVis(c.k)); }
+
+ // ── Init ─────────────────────────────────────────────────────────────────
+ _init() {
+ this._bindSearch();
+ this._bindSort();
+ this._bindHeader();
+ }
+
+ _el(id) { return id ? document.getElementById(id) : null; }
+
+ _bindSearch() {
+ const el = this._el(this.searchId);
+ if (!el) return;
+ // Pre-fill from ?q= URL param
+ const urlQ = new URLSearchParams(location.search).get('q');
+ if (urlQ) { el.value = urlQ; this.q = urlQ.trim(); }
+ el.addEventListener('input', () => { this.q = el.value.trim(); this.render(); });
+ }
+
+ _bindSort() {
+ const sel = this._el(this.sortSelId);
+ if (!sel) return;
+ this._buildSortSel(sel);
+ sel.value = this.sortKey;
+ sel.addEventListener('change', () => {
+ this.sortKey = sel.value;
+ const c = this.cols.find((x) => x.k === this.sortKey);
+ this.sortDir = (c && (c.t === 'n' || c.t === 'dt' || c.t === 'date')) ? -1 : 1;
+ this._saveSort();
+ this.render();
+ });
+ }
+
+ _buildSortSel(sel) {
+ if (!sel) return;
+ const groups = {};
+ this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
+ sel.innerHTML = Object.entries(groups).map(([g, cs]) =>
+ `<optgroup label="${esc(g)}">` + cs.map((c) => `<option value="${esc(c.k)}">${esc(c.l)}</option>`).join('') + '</optgroup>'
+ ).join('');
+ if (Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
+ }
+
+ _bindHeader() {
+ const thead = this._el(this.theadId);
+ if (!thead) return;
+
+ // Sort click
+ thead.addEventListener('click', (e) => {
+ if (document.body.classList.contains('cr-dragging')) return;
+ const th = e.target.closest('th');
+ if (!th) return;
+ const k = th.dataset.k;
+ if (!k) return;
+ if (this.sortKey === k) this.sortDir *= -1;
+ else { this.sortKey = k; this.sortDir = 1; }
+ this._saveSort();
+ // Sync external sort select
+ const sel = this._el(this.sortSelId);
+ if (sel && Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
+ this.render();
+ });
+
+ // Drag-to-reorder
+ let dragKey = null;
+ thead.addEventListener('dragstart', (e) => {
+ if (document.body.classList.contains('cr-dragging')) { e.preventDefault(); return; }
+ const th = e.target.closest('th');
+ if (!th) return;
+ dragKey = th.dataset.k;
+ e.dataTransfer.effectAllowed = 'move';
+ try { e.dataTransfer.setData('text/plain', dragKey); } catch (_) {}
+ th.classList.add('dragging');
+ });
+ thead.addEventListener('dragover', (e) => {
+ if (!dragKey) return;
+ const th = e.target.closest('th');
+ if (!th || th.dataset.k === dragKey) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
+ th.classList.toggle('dropR', after); th.classList.toggle('dropL', !after);
+ });
+ thead.addEventListener('dragleave', (e) => {
+ const th = e.target.closest('th'); if (th) { th.classList.remove('dropL', 'dropR'); }
+ });
+ thead.addEventListener('drop', (e) => {
+ if (!dragKey) return;
+ const th = e.target.closest('th'); if (!th) return;
+ e.preventDefault();
+ const tgt = th.dataset.k;
+ if (tgt && tgt !== dragKey) {
+ const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
+ this._syncColOrder();
+ const from = this._colorder.indexOf(dragKey);
+ if (from >= 0) {
+ this._colorder.splice(from, 1);
+ let to = this._colorder.indexOf(tgt);
+ if (after) to += 1;
+ this._colorder.splice(to, 0, dragKey);
+ this._saveColorder();
+ this._lastColSig = '';
+ this.render();
+ }
+ }
+ dragKey = null;
+ });
+ thead.addEventListener('dragend', () => {
+ document.querySelectorAll(`#${this.theadId} th.dragging, #${this.theadId} th.dropL, #${this.theadId} th.dropR`)
+ .forEach((x) => x.classList.remove('dragging', 'dropL', 'dropR'));
+ dragKey = null;
+ });
+
+ // Expandable rows (tbody event delegation)
+ const tbody = this._el(this.tbodyId);
+ if (tbody) {
+ tbody.addEventListener('click', (e) => {
+ // Don't open expand on link/button clicks
+ if (e.target.closest('a') || e.target.closest('button')) return;
+ const tr = e.target.closest('tr[data-row-id]');
+ if (!tr) return;
+ const id = tr.dataset.rowId;
+ const exp = tr.nextElementSibling;
+ if (exp && exp.classList.contains('row-expand')) {
+ exp.remove();
+ this.expandedIds.delete(id);
+ } else {
+ // close others
+ this.expandedIds.clear();
+ document.querySelectorAll(`#${this.tbodyId} .row-expand`).forEach((x) => x.remove());
+ this.expandedIds.add(id);
+ const row = this.data.find((r) => String(r.id) === id);
+ if (row && this.onRowClick) {
+ const expTr = document.createElement('tr');
+ expTr.className = 'row-expand';
+ const td = document.createElement('td');
+ td.colSpan = 999;
+ td.innerHTML = '<em>Loading…</em>';
+ expTr.appendChild(td);
+ tr.after(expTr);
+ Promise.resolve(this.onRowClick(row, td));
+ }
+ }
+ });
+ }
+ }
+
+ // ── Field-toggle panel ────────────────────────────────────────────────────
+ buildFieldToggles() {
+ const el = this._el(this.fieldsId);
+ if (!el) return;
+ const groups = {};
+ this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
+ el.innerHTML = Object.entries(groups).map(([g, cs]) =>
+ `<div class="fgrp">${esc(g)}</div>` +
+ cs.map((c) => `<label class="ftog"><input type="checkbox" data-ck="${esc(c.k)}" ${this._colVis(c.k) ? 'checked' : ''}><span>${esc(c.l)}</span></label>`).join('')
+ ).join('');
+
+ el.addEventListener('change', (e) => {
+ const cb = e.target.closest('input[data-ck]'); if (!cb) return;
+ this._viscol[cb.dataset.ck] = cb.checked;
+ this._saveViscol();
+ this.render();
+ });
+ }
+
+ resetColumns() {
+ this._viscol = {};
+ this._colorder = [];
+ this._saveViscol();
+ this._saveColorder();
+ this._lastColSig = '';
+ // Clear column-resize localStorage keys
+ try {
+ Object.keys(localStorage).forEach((k) => {
+ if (k.startsWith('cr:' + location.pathname + ':')) localStorage.removeItem(k);
+ });
+ } catch (_) {}
+ this.buildFieldToggles();
+ this.render();
+ }
+
+ // ── Data + filter ─────────────────────────────────────────────────────────
+ setData(rows) {
+ this.data = rows || [];
+ this.render();
+ }
+
+ _filtered() {
+ let rows = this.data.slice();
+
+ // search-all-fields, space=AND
+ if (this.q) {
+ const terms = this.q.toLowerCase().split(/\s+/).filter(Boolean);
+ rows = rows.filter((r) => {
+ const hay = Object.keys(r).map((k) => {
+ const v = r[k]; return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
+ }).join(' ').toLowerCase();
+ return terms.every((t) => hay.includes(t));
+ });
+ }
+
+ // sort
+ const col = this.cols.find((c) => c.k === this.sortKey) || this.cols[0];
+ if (col) {
+ rows.sort((a, b) => {
+ let x = a[col.k], y = b[col.k];
+ const xE = (x == null || x === ''), yE = (y == null || y === '');
+ if (xE && yE) return 0; if (xE) return 1; if (yE) return -1;
+ const xn = !isNaN(+x), yn = !isNaN(+y);
+ if (xn && yn) { x = +x; y = +y; }
+ else { x = String(x).toLowerCase(); y = String(y).toLowerCase(); }
+ if (x < y) return -this.sortDir; if (x > y) return this.sortDir; return 0;
+ });
+ }
+ return rows;
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+ render() {
+ const rows = this._filtered();
+ const cols = this._visCols();
+
+ // count
+ const countEl = this._el(this.countId);
+ if (countEl) countEl.textContent = `${rows.length.toLocaleString()} of ${this.data.length.toLocaleString()} records`;
+
+ // thead
+ const thead = this._el(this.theadId);
+ if (thead) {
+ thead.innerHTML = '<tr>' + cols.map((c) =>
+ `<th data-k="${esc(c.k)}" draggable="true" class="dragcol${this.sortKey === c.k ? (this.sortDir > 0 ? ' asc' : ' desc') : ''}">${esc(c.l)}</th>`
+ ).join('') + '</tr>';
+ }
+
+ // tbody
+ const tbody = this._el(this.tbodyId);
+ if (tbody) {
+ tbody.innerHTML = rows.map((r) => {
+ const id = r.id || r.id || '';
+ return `<tr data-row-id="${esc(String(id))}">` +
+ cols.map((c) => {
+ let html = null;
+ if (this._renderCell) html = this._renderCell(r, c);
+ if (html == null) html = defaultCell(r, c);
+ const numCls = (c.t === 'n') ? ' class="num"' : '';
+ return `<td${numCls}>${html}</td>`;
+ }).join('') +
+ '</tr>';
+ }).join('');
+ }
+
+ // column-sig check for resize invalidation
+ const sig = cols.map((c) => c.k).join(',');
+ if (sig !== this._lastColSig) {
+ this._lastColSig = sig;
+ // Invalidate ColResize if present
+ if (window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
+ }
+
+ // sync field toggles visibility
+ this.buildFieldToggles();
+ }
+ }
+
+ // ── Density slider helper ────────────────────────────────────────────────────
+ function initDensitySlider(sliderId, storageKey, minPx, maxPx, defaultVal) {
+ const sl = document.getElementById(sliderId);
+ if (!sl) return;
+ minPx = minPx || 200; maxPx = maxPx || 450;
+ const stored = parseInt(localStorage.getItem(storageKey + ':density'), 10);
+ const val = (stored >= 1 && stored <= 10) ? stored : (defaultVal || 5);
+ sl.value = val;
+ function apply(v) {
+ const px = Math.round(maxPx - (Math.max(1, Math.min(10, v)) - 1) * (maxPx - minPx) / 9);
+ document.documentElement.style.setProperty('--card-min', px + 'px');
+ }
+ apply(val);
+ sl.addEventListener('input', () => { apply(+sl.value); localStorage.setItem(storageKey + ':density', sl.value); });
+ }
+
+ // ── Export helpers ────────────────────────────────────────────────────────────
+ function exportCSV(rows, cols, filename) {
+ const lines = [
+ cols.map((c) => '"' + c.l.replace(/"/g, '""') + '"').join(','),
+ ...rows.map((r) => cols.map((c) => {
+ const v = r[c.k]; const s = v == null ? '' : String(v);
+ return '"' + s.replace(/"/g, '""') + '"';
+ }).join(','))
+ ];
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename || 'export.csv';
+ a.click();
+ URL.revokeObjectURL(a.href);
+ }
+
+ // Expose globally
+ global.ATable = ATable;
+ global.initDensitySlider = initDensitySlider;
+ global.tblExportCSV = exportCSV;
+ global.statusBadge = statusBadge;
+ global.scoreBadge = scoreBadge;
+ global.tblEsc = esc;
+ global.tblFmtDate = fmtDate;
+ global.tblFmtDateOnly = fmtDateOnly;
+ global.tblTel = tel;
+ global.tblMail = mail;
+ global.tblWeb = web;
+ global.tblLi = li;
+
+}(window));
diff --git a/scripts/audit-source-policies.js b/scripts/audit-source-policies.js
new file mode 100644
index 0000000..7dc8ae6
--- /dev/null
+++ b/scripts/audit-source-policies.js
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI: node scripts/audit-source-policies.js
+ *
+ * Loads all source_policies from the database, runs
+ * lib/compliance/source-policy.validateSourcePolicy on each, and prints a
+ * PASS/FAIL table. Exits with code 1 if any ENABLED policy is illegal.
+ *
+ * Also validates the structural integrity of each policy regardless of its
+ * enabled state so developers can catch problems before enabling.
+ *
+ * @module scripts/audit-source-policies
+ */
+
+require('../lib/env');
+const { pool, query } = require('../db');
+
+let validateSourcePolicy;
+let assertSourceEnabledLegal;
+try {
+ ({ validateSourcePolicy, assertSourceEnabledLegal } =
+ require('../lib/compliance/source-policy'));
+} catch (err) {
+ console.error('[audit-sources] Could not load source-policy validator:', err.message);
+ process.exit(1);
+}
+
+/**
+ * Map DB row snake_case to the JS camelCase shape expected by validateSourcePolicy.
+ * @param {Object} row
+ * @returns {Object}
+ */
+function rowToPolicy(row) {
+ return {
+ sourceKey: row.source_key,
+ displayName: row.display_name,
+ owner: row.owner,
+ baseUrl: row.base_url,
+ accessMethod: row.access_method,
+ allowsAutomatedAccess: row.allows_automated_access,
+ allowsScreenshotCapture: row.allows_screenshot_capture,
+ allowsInternalStorage: row.allows_internal_storage,
+ allowsExport: row.allows_export,
+ prohibitedHosts: row.prohibited_hosts,
+ permittedPaths: row.permitted_paths,
+ prohibitedPaths: row.prohibited_paths,
+ maxRequestsPerMinute: row.max_requests_per_minute,
+ minimumDelayMs: row.minimum_delay_ms,
+ retentionDays: row.retention_days,
+ enabled: row.enabled,
+ reviewedAt: row.reviewed_at,
+ reviewNotes: row.review_notes,
+ };
+}
+
+(async () => {
+ let exitCode = 0;
+
+ try {
+ const res = await query(
+ `SELECT * FROM source_policies ORDER BY enabled DESC, display_name`
+ );
+ const rows = res.rows;
+
+ if (rows.length === 0) {
+ console.log('[audit-sources] No source policies found in the database.');
+ console.log('[audit-sources] Run db:seed to load initial policies.');
+ process.exit(0);
+ }
+
+ // Table header
+ const COL_KEY = 36;
+ const COL_ENABLED = 9;
+ const COL_RESULT = 8;
+
+ const hr = '-'.repeat(COL_KEY + COL_ENABLED + COL_RESULT + 40);
+ console.log('\nSource Policy Audit');
+ console.log(hr);
+ console.log(
+ 'SOURCE_KEY'.padEnd(COL_KEY) +
+ 'ENABLED'.padEnd(COL_ENABLED) +
+ 'RESULT'.padEnd(COL_RESULT) +
+ 'NOTES'
+ );
+ console.log(hr);
+
+ const enabledIllegal = [];
+ const allResults = [];
+
+ for (const row of rows) {
+ const policy = rowToPolicy(row);
+ const { valid, errors } = validateSourcePolicy(policy);
+
+ // Also check the harder assert for enabled policies
+ let assertErr = null;
+ if (policy.enabled) {
+ try {
+ assertSourceEnabledLegal(policy);
+ } catch (e) {
+ assertErr = e.message;
+ valid === false; // already false if errors exist
+ }
+ }
+
+ const isIllegal = !valid || !!assertErr;
+ const result = isIllegal ? 'FAIL' : 'PASS';
+
+ if (isIllegal && policy.enabled) {
+ enabledIllegal.push({ policy, errors, assertErr });
+ }
+
+ const notes = [
+ ...errors,
+ assertErr ? `[ASSERT] ${assertErr}` : null,
+ ].filter(Boolean).join('; ') || (valid ? 'OK' : 'see errors');
+
+ const resultTag = isIllegal ? `\x1b[31m${result}\x1b[0m` : `\x1b[32m${result}\x1b[0m`;
+
+ console.log(
+ (policy.sourceKey || '(no key)').slice(0, COL_KEY - 1).padEnd(COL_KEY) +
+ String(policy.enabled).padEnd(COL_ENABLED) +
+ resultTag.padEnd(COL_RESULT + 10) + // +10 for ANSI codes
+ notes.slice(0, 120)
+ );
+
+ allResults.push({ sourceKey: policy.sourceKey, valid, errors, enabled: policy.enabled });
+ }
+
+ console.log(hr);
+ const passed = allResults.filter((r) => r.valid).length;
+ const failed = allResults.length - passed;
+ const enabledFailed = enabledIllegal.length;
+
+ console.log(`\nSummary: ${rows.length} policies — ${passed} PASS, ${failed} FAIL`);
+
+ if (enabledIllegal.length > 0) {
+ console.error(`\n\x1b[31mERROR: ${enabledFailed} ENABLED policy/policies are ILLEGAL (spec §6):\x1b[0m`);
+ for (const { policy, errors, assertErr } of enabledIllegal) {
+ console.error(` SOURCE: ${policy.sourceKey}`);
+ for (const e of errors) console.error(` - ${e}`);
+ if (assertErr) console.error(` - [ASSERT] ${assertErr}`);
+ }
+ console.error('\nDisable or correct these policies before enabling research automation.');
+ exitCode = 1;
+ } else if (failed > 0) {
+ console.log(`\n${failed} policy/policies have validation issues but are not enabled.`);
+ console.log('No action required until you enable them.');
+ } else {
+ console.log('\nAll enabled policies are compliant with spec §6.');
+ }
+
+ } catch (err) {
+ console.error('[audit-sources] Fatal error:', err.message);
+ console.error(err.stack);
+ exitCode = 1;
+ } finally {
+ try { await pool.end(); } catch (_) {}
+ process.exit(exitCode);
+ }
+})();
diff --git a/scripts/export-all.js b/scripts/export-all.js
new file mode 100644
index 0000000..1684de8
--- /dev/null
+++ b/scripts/export-all.js
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI: node scripts/export-all.js [outDir]
+ *
+ * Runs buildDownloadEverything and prints the ZIP path + row counts to stdout.
+ * outDir defaults to ./data/exports (relative to project root).
+ *
+ * Usage:
+ * node scripts/export-all.js
+ * node scripts/export-all.js /tmp/adintel-export-test
+ *
+ * @module scripts/export-all
+ */
+
+require('../lib/env');
+const path = require('path');
+const { buildDownloadEverything } = require('../src/export/build');
+const { pool } = require('../db');
+
+const outDir = process.argv[2]
+ ? path.resolve(process.argv[2])
+ : path.join(__dirname, '..', 'data', 'exports');
+
+console.log('[export-all] Starting Download Everything export...');
+console.log(`[export-all] Output directory: ${outDir}`);
+
+(async () => {
+ let exitCode = 0;
+ try {
+ const { zipPath, rowCounts } = await buildDownloadEverything({ outDir });
+
+ console.log('\n[export-all] Export complete.');
+ console.log('[export-all] ZIP path:', zipPath);
+ console.log('[export-all] Row counts:');
+ for (const [k, v] of Object.entries(rowCounts)) {
+ console.log(` ${k.padEnd(30)} ${v}`);
+ }
+ console.log('[export-all] Done.');
+ } catch (err) {
+ console.error('[export-all] FAILED:', err.message);
+ console.error(err.stack);
+ exitCode = 1;
+ } finally {
+ // Cleanly drain the pg pool so Node exits
+ try { await pool.end(); } catch (_) {}
+ process.exit(exitCode);
+ }
+})();
diff --git a/scripts/import-google.js b/scripts/import-google.js
new file mode 100644
index 0000000..7c75d96
--- /dev/null
+++ b/scripts/import-google.js
@@ -0,0 +1,173 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI: node scripts/import-google.js <ga4|gsc|google-ads>
+ *
+ * Calls src/connectors/* if present (guarded with try/require).
+ * Prints import summary. When connectors are unavailable, prints
+ * guidance on how to use the CSV upload fallback instead.
+ *
+ * Spec §6.20: Use only official APIs for GA4, GSC, Google Ads.
+ * No unofficial scraping, no cookie injection.
+ *
+ * @module scripts/import-google
+ */
+
+require('../lib/env');
+const { pool } = require('../db');
+
+const KIND = (process.argv[2] || '').toLowerCase();
+const VALID = ['ga4', 'gsc', 'google-ads'];
+
+if (!VALID.includes(KIND)) {
+ console.error('Usage: node scripts/import-google.js <ga4|gsc|google-ads>');
+ console.error('Valid kinds:', VALID.join(', '));
+ process.exit(1);
+}
+
+// ---------------------------------------------------------------------------
+// Try to load the real connector
+// ---------------------------------------------------------------------------
+let connector = null;
+const connectorKey = KIND === 'google-ads' ? 'google-ads' : KIND;
+const connectorPaths = [
+ `../src/connectors/${connectorKey}`,
+ `../src/connectors/${connectorKey}/index`,
+];
+
+for (const cp of connectorPaths) {
+ try {
+ connector = require(cp);
+ break;
+ } catch (_) {
+ // not yet built
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CSV fallback guidance
+// ---------------------------------------------------------------------------
+const CSV_GUIDANCE = {
+ ga4: {
+ title: 'Google Analytics 4',
+ envVars: ['GOOGLE_SERVICE_ACCOUNT_JSON_BASE64', 'GA4_PROPERTY_ID'],
+ csvFallback: [
+ '1. In GA4, go to Reports > Export > CSV',
+ '2. Save the file locally',
+ '3. POST it to /api/v1/imports with source=ga4 as multipart/form-data',
+ ' curl -F "file=@ga4-export.csv" -F "source=ga4" http://localhost:3001/api/v1/imports',
+ ],
+ docsUrl: 'https://developers.google.com/analytics/devguides/reporting/data/v1',
+ setupSteps: [
+ '1. Create a Google Cloud service account with GA4 Viewer role.',
+ '2. Download the JSON key file.',
+ '3. Base64-encode it: base64 -i key.json',
+ '4. Set GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 in .env',
+ '5. Set GA4_PROPERTY_ID to your numeric property ID (e.g. 123456789)',
+ '6. Re-run: node scripts/import-google.js ga4',
+ ],
+ },
+ gsc: {
+ title: 'Google Search Console',
+ envVars: ['GOOGLE_SERVICE_ACCOUNT_JSON_BASE64', 'GSC_SITE_URL'],
+ csvFallback: [
+ '1. In GSC, go to Performance > Export > Download CSV',
+ '2. Save the file locally',
+ '3. POST it to /api/v1/imports with source=gsc',
+ ' curl -F "file=@gsc-export.csv" -F "source=gsc" http://localhost:3001/api/v1/imports',
+ ],
+ docsUrl: 'https://developers.google.com/webmaster-tools/v1/api_reference_index',
+ setupSteps: [
+ '1. Same service account as GA4 — add it as a full user in GSC settings.',
+ '2. Set GSC_SITE_URL to the exact property URL (e.g. https://rentv.com/)',
+ '3. Re-run: node scripts/import-google.js gsc',
+ ],
+ },
+ 'google-ads': {
+ title: 'Google Ads',
+ envVars: [
+ 'GOOGLE_ADS_ENABLED', 'GOOGLE_ADS_DEVELOPER_TOKEN', 'GOOGLE_ADS_CUSTOMER_ID',
+ 'GOOGLE_ADS_CLIENT_ID', 'GOOGLE_ADS_CLIENT_SECRET', 'GOOGLE_ADS_REFRESH_TOKEN',
+ ],
+ csvFallback: [
+ '1. In Google Ads, go to Reports > Predefined reports > Campaign',
+ '2. Download as CSV',
+ '3. POST to /api/v1/imports with source=google-ads',
+ ],
+ docsUrl: 'https://developers.google.com/google-ads/api/docs/start',
+ setupSteps: [
+ '1. Apply for a Google Ads developer token (can take 1-3 business days).',
+ '2. Create an OAuth2 client ID with Google Ads scope.',
+ '3. Generate a refresh token using OAuth2 playground.',
+ '4. Set all GOOGLE_ADS_* vars in .env',
+ '5. Set GOOGLE_ADS_ENABLED=true',
+ '6. Re-run: node scripts/import-google.js google-ads',
+ ],
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Check env vars
+// ---------------------------------------------------------------------------
+function checkEnvVars(vars) {
+ const missing = vars.filter((v) => !process.env[v]);
+ return missing;
+}
+
+// ---------------------------------------------------------------------------
+// Run import
+// ---------------------------------------------------------------------------
+(async () => {
+ console.log(`\n[import-google] Importing: ${KIND}`);
+ const guide = CSV_GUIDANCE[KIND];
+
+ if (connector && typeof connector.runImport === 'function') {
+ // Real connector present
+ const missing = checkEnvVars(guide.envVars);
+ if (missing.length > 0) {
+ console.error(`[import-google] Missing required env vars for ${KIND}:`);
+ missing.forEach((v) => console.error(` - ${v}`));
+ console.error('\nSetup steps:');
+ guide.setupSteps.forEach((s) => console.error(' ', s));
+ await pool.end();
+ process.exit(1);
+ }
+
+ console.log('[import-google] Connector found. Running import...');
+ try {
+ const result = await connector.runImport();
+ console.log('[import-google] Import complete:');
+ console.log(JSON.stringify(result, null, 2));
+ } catch (err) {
+ console.error('[import-google] Import failed:', err.message);
+ console.error(err.stack);
+ await pool.end();
+ process.exit(1);
+ }
+ await pool.end();
+ return;
+ }
+
+ // Connector not yet built — print guidance
+ console.log(`[import-google] ${guide.title} connector not yet installed.`);
+ console.log('\nRequired environment variables:');
+ guide.envVars.forEach((v) => {
+ const set = !!process.env[v];
+ console.log(` ${set ? '[SET]' : '[MISSING]'} ${v}`);
+ });
+
+ if (KIND === 'google-ads' && process.env.GOOGLE_ADS_ENABLED !== 'true') {
+ console.log('\n Note: GOOGLE_ADS_ENABLED must be "true" to activate this connector.');
+ }
+
+ console.log('\nSetup steps:');
+ guide.setupSteps.forEach((s) => console.log(' ', s));
+
+ console.log(`\nAPI documentation: ${guide.docsUrl}`);
+
+ console.log('\nCSV upload fallback (works now without connector):');
+ guide.csvFallback.forEach((s) => console.log(' ', s));
+
+ await pool.end();
+ process.exit(0);
+})();
diff --git a/scripts/research.js b/scripts/research.js
new file mode 100644
index 0000000..2b54336
--- /dev/null
+++ b/scripts/research.js
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * CLI stub: node scripts/research.js <california|arizona|conferences>
+ *
+ * Wires to src/search/provider + src/adapters/manual-review IF present
+ * (guarded with try/require). When modules are unavailable, prints the
+ * manual search URLs that a human researcher should open instead.
+ *
+ * This is intentionally conservative — no live fetching from this stub
+ * (spec §6: no bypassing auth, robots, rate limits, or anti-bot controls).
+ *
+ * @module scripts/research
+ */
+
+require('../lib/env');
+
+const MARKET = (process.argv[2] || '').toLowerCase();
+
+if (!['california', 'arizona', 'conferences'].includes(MARKET)) {
+ console.error('Usage: node scripts/research.js <california|arizona|conferences>');
+ console.error('Valid markets: california, arizona, conferences');
+ process.exit(1);
+}
+
+// Try to load the real search provider module (may not exist yet)
+let provider = null;
+try {
+ provider = require('../src/search/provider');
+} catch (_) {
+ // Not yet built — fall through to manual guidance
+}
+
+// Try to load the manual-review adapter
+let manualReview = null;
+try {
+ manualReview = require('../src/adapters/manual-review');
+} catch (_) {
+ // Not yet built — fall through
+}
+
+// ---------------------------------------------------------------------------
+// Manual research URL generators
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the list of recommended manual search URLs for a given market.
+ * @param {'california'|'arizona'|'conferences'} market
+ * @returns {string[]}
+ */
+function manualSearchUrls(market) {
+ const rentv = 'https://rentv.com';
+ const rentvReview = 'https://rentvreview.com';
+
+ if (market === 'california') {
+ return [
+ `${rentv}/sponsors — RENTV CA sponsor pages`,
+ `${rentvReview}/advertisers — RENTV Review CA advertisers`,
+ 'https://www.naiop.org/chapters/socal/events — NAIOP SoCal events & sponsors',
+ 'https://www.boma.org/BOMA/Chapters/Greater_Los_Angeles/Events.aspx — BOMA LA sponsors',
+ 'https://sccai.org/events — SCCAI OC/IE events',
+ 'https://www.sfbart.com — SF Bay Area CRE events',
+ 'Manual: search Google CSE for site:rentv.com "sponsor" OR "advertiser" california',
+ ];
+ }
+
+ if (market === 'arizona') {
+ return [
+ `${rentv}/arizona — RENTV AZ market page`,
+ 'https://www.naiop.org/chapters/az/events — NAIOP AZ events & sponsors',
+ 'https://www.boma.org/BOMA/Chapters/Phoenix/Events.aspx — BOMA Phoenix sponsors',
+ 'https://www.sior.com/events — SIOR Phoenix chapter',
+ 'Manual: search Google CSE for site:rentv.com "sponsor" OR "advertiser" arizona',
+ ];
+ }
+
+ // conferences
+ return [
+ 'https://rentv.com/cre-talk — RENTV CRE Talk sponsors',
+ 'https://www.naiop.org/events — NAIOP national conferences',
+ 'https://www.bisnow.com/los-angeles/events — Bisnow LA sponsors',
+ 'https://www.globest.com/events — GlobeSt CRE conference list',
+ 'https://cretech.com/events — CREtech tech conference sponsors',
+ 'https://www.corecnet.org/events — CoreNet Global chapters',
+ 'Manual: search for "[event name] sponsors [year]" on Google',
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+(async () => {
+ console.log(`\n[research] Market: ${MARKET}`);
+ console.log('[research] Spec §6 compliance: no automated scraping of prohibited hosts.\n');
+
+ if (provider && typeof provider.runResearch === 'function') {
+ // Real provider is wired — delegate to it
+ console.log('[research] Using installed search provider:', provider.name || 'unknown');
+ try {
+ const result = await provider.runResearch({ market: MARKET });
+ console.log('[research] Results:', JSON.stringify(result, null, 2));
+ } catch (err) {
+ console.error('[research] Provider error:', err.message);
+ process.exit(1);
+ }
+ return;
+ }
+
+ if (manualReview && typeof manualReview.queueManualSearch === 'function') {
+ // Manual review adapter is wired — queue the search
+ console.log('[research] Manual review adapter found — queueing search items.');
+ try {
+ const queued = await manualReview.queueManualSearch({ market: MARKET });
+ console.log('[research] Queued', queued.count, 'manual review items.');
+ } catch (err) {
+ console.error('[research] Manual review adapter error:', err.message);
+ }
+ }
+
+ // Guidance output (always printed as a fallback / supplemental)
+ console.log('[research] Research modules not yet fully available.');
+ console.log('[research] Open these URLs manually in your browser to find advertiser evidence:\n');
+ const urls = manualSearchUrls(MARKET);
+ urls.forEach((u, i) => console.log(` ${i + 1}. ${u}`));
+
+ console.log('\n[research] After reviewing each page:');
+ console.log(' - Use the admin UI at /advertisers/new to add organizations.');
+ console.log(' - Use /ads/new to record ad sightings with source URLs.');
+ console.log(' - Use /events to record conference sponsor information.');
+ console.log('[research] All entries require a source_page_url and observed_at date.');
+
+ process.exit(0);
+})();
diff --git a/src/adapters/base.js b/src/adapters/base.js
new file mode 100644
index 0000000..dc13aaa
--- /dev/null
+++ b/src/adapters/base.js
@@ -0,0 +1,242 @@
+'use strict';
+/**
+ * Public-source adapter contract + common pipeline (spec §10).
+ *
+ * AdvertiserSourceAdapter is the base class every source implements:
+ * - policy the SourcePolicy this adapter runs under
+ * - discover(cursor) async generator of DiscoveredSourceItem
+ * - fetch(item) retrieve bytes (only when policy permits)
+ * - parse(input) async generator of extractions
+ *
+ * runPipeline() provides the shared machinery the spec mandates:
+ * policy validation before every run, per-host throttle, exponential backoff
+ * with jitter, conditional-request hook, idempotent upsert hook, dead-letter
+ * recording into ingestion_jobs (status DEAD_LETTER), dry-run mode,
+ * incremental cursor, and job logging into ingestion_runs.
+ */
+
+const { assertSourceEnabledLegal } = require('../../lib/compliance/source-policy');
+
+/**
+ * Base adapter. Concrete adapters override discover/fetch/parse. The defaults
+ * are deliberately inert (yield nothing / refuse) so a half-built adapter can
+ * never accidentally fetch.
+ */
+class AdvertiserSourceAdapter {
+ constructor(policy) {
+ if (!policy || typeof policy !== 'object') {
+ throw new Error('AdvertiserSourceAdapter: a SourcePolicy is required');
+ }
+ this.policy = policy;
+ }
+
+ // eslint-disable-next-line require-yield
+ async *discover(_cursor) {
+ // Default: discover nothing. Concrete adapters override.
+ return;
+ }
+
+ async fetch(_item) {
+ throw new Error(
+ `${this.constructor.name}.fetch not implemented — adapter must define fetch or be manual_review_only`
+ );
+ }
+
+ // eslint-disable-next-line require-yield
+ async *parse(_input) {
+ return;
+ }
+}
+
+/** Exponential backoff with full jitter, capped. */
+function backoffDelay(attempt, baseMs = 500, capMs = 30000) {
+ const exp = Math.min(capMs, baseMs * 2 ** attempt);
+ return Math.floor(Math.random() * exp); // full jitter
+}
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+/** Simple per-host throttle used inside the pipeline (delegates to policy). */
+async function throttle(policy, lastByHost, host) {
+ const rpm = policy.maxRequestsPerMinute || Number(process.env.DEFAULT_REQUESTS_PER_MINUTE || 6);
+ const minDelay = policy.minimumDelayMs != null ? policy.minimumDelayMs : Math.ceil(60000 / rpm);
+ const last = lastByHost.get(host) || 0;
+ const since = Date.now() - last;
+ if (last && since < minDelay) await sleep(minDelay - since);
+ lastByHost.set(host, Date.now());
+}
+
+function hostOf(url) {
+ try {
+ return new URL(url).hostname.toLowerCase();
+ } catch (_e) {
+ return 'unknown';
+ }
+}
+
+/**
+ * runPipeline(adapter, opts)
+ *
+ * opts:
+ * db { query } — the db module (required for real logging; optional in
+ * dry-run tests where you can pass a stub or omit)
+ * dryRun boolean — discover + parse but never fetch or upsert
+ * upsert async(extraction, ctx) => void — idempotent write callback
+ * cursor string — incremental cursor passed to discover()
+ * maxItems number — safety cap
+ * maxAttempts number — per-item retry budget before dead-letter (default 3)
+ * conditionalRequest async(item) => ({skip:boolean, headers?:object})
+ * — conditional-request hook (etag/last-modified)
+ *
+ * Returns { runId, stats, cursor }.
+ */
+async function runPipeline(adapter, opts = {}) {
+ if (!adapter || !(adapter instanceof AdvertiserSourceAdapter)) {
+ throw new Error('runPipeline: adapter must extend AdvertiserSourceAdapter');
+ }
+ const policy = adapter.policy;
+
+ // §10 — policy validation BEFORE every run. Throws on an illegal enabled source.
+ assertSourceEnabledLegal(policy);
+
+ const db = opts.db || null;
+ const dryRun = opts.dryRun === true;
+ const upsert = typeof opts.upsert === 'function' ? opts.upsert : null;
+ const maxItems = opts.maxItems || 1000;
+ const maxAttempts = opts.maxAttempts || 3;
+ const conditionalRequest =
+ typeof opts.conditionalRequest === 'function' ? opts.conditionalRequest : null;
+
+ const stats = {
+ discovered: 0,
+ fetched: 0,
+ skippedConditional: 0,
+ parsed: 0,
+ upserted: 0,
+ deadLettered: 0,
+ errors: 0,
+ };
+ const lastByHost = new Map();
+
+ // Open a run row (ingestion_runs).
+ let runId = null;
+ if (db && db.query) {
+ const r = await db.query(
+ `INSERT INTO ingestion_runs (source_key, status, dry_run, stats)
+ VALUES ($1, 'RUNNING', $2, $3::jsonb) RETURNING id`,
+ [policy.sourceKey, dryRun, JSON.stringify(stats)]
+ );
+ runId = r.rows[0].id;
+ }
+
+ const deadLetter = async (item, err) => {
+ stats.deadLettered += 1;
+ if (db && db.query && runId) {
+ await db.query(
+ `INSERT INTO ingestion_jobs (run_id, job_type, payload, status, attempts, last_error)
+ VALUES ($1, 'FETCH_PARSE', $2::jsonb, 'DEAD_LETTER', $3, $4)`,
+ [runId, JSON.stringify(item || {}), maxAttempts, String((err && err.message) || err)]
+ );
+ }
+ };
+
+ let finalError = null;
+ let cursor = opts.cursor;
+
+ try {
+ for await (const item of adapter.discover(opts.cursor)) {
+ stats.discovered += 1;
+ if (item && item.externalId) cursor = item.externalId; // incremental cursor
+ if (stats.discovered > maxItems) break;
+
+ if (dryRun) {
+ // Dry-run: exercise discover only; no fetch, no writes.
+ continue;
+ }
+
+ // Conditional-request hook (etag/last-modified) — skip unchanged.
+ if (conditionalRequest) {
+ try {
+ const cr = await conditionalRequest(item);
+ if (cr && cr.skip) {
+ stats.skippedConditional += 1;
+ continue;
+ }
+ } catch (_e) {
+ /* non-fatal; proceed to fetch */
+ }
+ }
+
+ // Fetch with retry + exponential backoff + jitter → dead-letter on give-up.
+ let fetched = null;
+ let attempt = 0;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ try {
+ await throttle(policy, lastByHost, hostOf(item.url));
+ fetched = await adapter.fetch(item);
+ stats.fetched += 1;
+ break;
+ } catch (err) {
+ attempt += 1;
+ stats.errors += 1;
+ if (attempt >= maxAttempts) {
+ await deadLetter(item, err);
+ break;
+ }
+ await sleep(backoffDelay(attempt));
+ }
+ }
+ if (!fetched) continue; // dead-lettered
+
+ // Parse + idempotent upsert.
+ try {
+ for await (const extraction of adapter.parse({
+ bytes: fetched.bytes,
+ contentType: fetched.contentType,
+ finalUrl: fetched.finalUrl,
+ })) {
+ stats.parsed += 1;
+ if (upsert) {
+ await upsert(extraction, { item, fetched, policy, runId });
+ stats.upserted += 1;
+ }
+ }
+ } catch (err) {
+ stats.errors += 1;
+ await deadLetter(item, err);
+ }
+ }
+ } catch (err) {
+ finalError = err;
+ }
+
+ // Close the run row.
+ if (db && db.query && runId) {
+ await db.query(
+ `UPDATE ingestion_runs
+ SET finished_at = now(),
+ status = $2,
+ stats = $3::jsonb,
+ error = $4
+ WHERE id = $1`,
+ [
+ runId,
+ finalError ? 'FAILED' : 'COMPLETED',
+ JSON.stringify(stats),
+ finalError ? String(finalError.message || finalError) : null,
+ ]
+ );
+ }
+
+ if (finalError) throw finalError;
+ return { runId, stats, cursor };
+}
+
+module.exports = {
+ AdvertiserSourceAdapter,
+ runPipeline,
+ backoffDelay,
+};
diff --git a/src/adapters/manual-review.js b/src/adapters/manual-review.js
new file mode 100644
index 0000000..cb93b71
--- /dev/null
+++ b/src/adapters/manual-review.js
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * Manual-review-only adapter (spec §6, §9, §10).
+ *
+ * The safe fallback the spec MANDATES for sites that do not permit automation
+ * (§9: "Do not assume any listed site permits automation. Validate first, and
+ * fall back to manual review or user upload.").
+ *
+ * This adapter NEVER fetches anything: discover() yields nothing and fetch()
+ * refuses. Instead it exposes queueForReview(), which records a
+ * manual_review_items row (review_type 'SOURCE') so a human can inspect the
+ * public page in a normal browser and decide what, if anything, to store.
+ */
+
+const { AdvertiserSourceAdapter } = require('./base');
+
+class ManualReviewAdapter extends AdvertiserSourceAdapter {
+ constructor(policy) {
+ // Force the access method to manual_review_only so it can never be run
+ // through the automated fetch path even if a caller mis-configures it.
+ const safePolicy = Object.assign({}, policy, {
+ accessMethod: 'manual_review_only',
+ allowsAutomatedAccess: false,
+ });
+ super(safePolicy);
+ }
+
+ // eslint-disable-next-line require-yield
+ async *discover(_cursor) {
+ // Intentionally yields nothing — no automated discovery.
+ return;
+ }
+
+ async fetch(_item) {
+ throw new Error(
+ 'ManualReviewAdapter.fetch: this source is manual_review_only and MUST NOT be fetched automatically (§6/§9)'
+ );
+ }
+
+ /**
+ * queueForReview(pool, { sourceKey, url, title, discoveryQuery })
+ * Inserts a manual_review_items row of review_type 'SOURCE'. `pool` is a
+ * pg Pool or any object with a .query() method.
+ * Returns the new row id.
+ */
+ static async queueForReview(pool, { sourceKey, url, title, discoveryQuery } = {}) {
+ if (!pool || typeof pool.query !== 'function') {
+ throw new Error('queueForReview: a pg pool/client with .query() is required');
+ }
+ if (!url) throw new Error('queueForReview: url is required');
+
+ const payload = {
+ sourceKey: sourceKey || null,
+ url,
+ title: title || null,
+ note: 'Queued for human review — source not permitted for automation (§9).',
+ };
+
+ const res = await pool.query(
+ `INSERT INTO manual_review_items
+ (review_type, payload, status, discovery_query, discovered_at)
+ VALUES ('SOURCE', $1::jsonb, 'PENDING', $2, now())
+ RETURNING id`,
+ [JSON.stringify(payload), discoveryQuery || null]
+ );
+ return res.rows[0].id;
+ }
+}
+
+module.exports = { ManualReviewAdapter };
diff --git a/src/analytics/derive.js b/src/analytics/derive.js
new file mode 100644
index 0000000..c18bf05
--- /dev/null
+++ b/src/analytics/derive.js
@@ -0,0 +1,339 @@
+'use strict';
+
+/**
+ * Derived sales-intelligence insights — spec §17 "Sales intelligence derived
+ * from GA4" and §18 cluster queries.
+ *
+ * All functions:
+ * - query the ga4_* / gsc_* tables directly (aggregates only)
+ * - never expose user-level data
+ * - return { rows, formula, demo } where demo:true when all source rows have is_demo=true
+ * - include a `formula` string documenting derivation (transparent to the UI)
+ *
+ * @module src/analytics/derive
+ */
+
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Check whether all analytics rows in a given table are demo-flagged.
+ * @param {string} tableName
+ * @returns {Promise<boolean>}
+ */
+async function allDemo(tableName) {
+ const res = await query(
+ `SELECT bool_and(is_demo) AS all_demo FROM ${tableName} WHERE is_demo IS NOT NULL`
+ );
+ return res.rows[0]?.all_demo !== false;
+}
+
+// ---------------------------------------------------------------------------
+// 1. California & Arizona Audience Concentration
+// ---------------------------------------------------------------------------
+
+/**
+ * Audience concentration by California and Arizona market.
+ * Aggregates ga4_geo_metrics by region, ranks CA and AZ cities by sessions.
+ *
+ * Formula: SUM(sessions) GROUP BY region, city WHERE country = 'United States'
+ * → percentage of total = city_sessions / total_sessions * 100
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function caAzAudienceConcentration() {
+ const res = await query(`
+ WITH totals AS (
+ SELECT SUM(sessions) AS grand_total FROM ga4_geo_metrics WHERE is_demo = true
+ ),
+ by_city AS (
+ SELECT
+ region,
+ city,
+ SUM(sessions) AS sessions,
+ SUM(users) AS users,
+ bool_and(is_demo) AS is_demo
+ FROM ga4_geo_metrics
+ WHERE country = 'United States'
+ AND region IN ('California', 'Arizona')
+ GROUP BY region, city
+ )
+ SELECT
+ b.region,
+ b.city,
+ b.sessions,
+ b.users,
+ ROUND(b.sessions::numeric / NULLIF(t.grand_total, 0) * 100, 2) AS pct_of_total,
+ b.is_demo
+ FROM by_city b, totals t
+ ORDER BY b.region, b.sessions DESC
+ `);
+
+ const demo = await allDemo('ga4_geo_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ 'SUM(ga4_geo_metrics.sessions) GROUP BY region, city ' +
+ 'WHERE country=\'United States\' AND region IN (\'California\',\'Arizona\') ' +
+ '→ pct_of_total = city_sessions / grand_total * 100. Aggregates only; no user-level data.',
+ demo,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 2. Top Landing Pages by CRE Category
+// ---------------------------------------------------------------------------
+
+/**
+ * Top landing pages grouped into CRE categories inferred from path patterns.
+ * Categories: news, cre-talk, property-spotlight, conferences, newsletter, advertise, other.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function topLandingPagesByCategory() {
+ const res = await query(`
+ SELECT
+ landing_page,
+ CASE
+ WHEN landing_page LIKE '/cre-talk%' THEN 'cre_talk_sponsor'
+ WHEN landing_page LIKE '/property-spotlight%' THEN 'property_spotlight'
+ WHEN landing_page LIKE '/conferences%' THEN 'conference'
+ WHEN landing_page LIKE '/newsletter%' THEN 'newsletter_email'
+ WHEN landing_page LIKE '/advertise%' THEN 'advertise'
+ WHEN landing_page LIKE '/news%' THEN 'news'
+ WHEN landing_page = '/' THEN 'homepage'
+ ELSE 'other'
+ END AS category,
+ SUM(sessions) AS sessions,
+ SUM(users) AS users,
+ SUM(views) AS views,
+ SUM(engaged_sessions) AS engaged_sessions,
+ SUM(key_events) AS key_events,
+ ROUND(AVG(engagement_rate), 4) AS avg_engagement_rate,
+ bool_and(is_demo) AS is_demo
+ FROM ga4_landing_page_metrics
+ GROUP BY landing_page, category
+ ORDER BY sessions DESC
+ LIMIT 50
+ `);
+
+ const demo = await allDemo('ga4_landing_page_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ 'SUM(sessions,users,views,engaged_sessions,key_events) GROUP BY landing_page ' +
+ '→ category assigned by path prefix pattern ' +
+ '(/cre-talk→cre_talk_sponsor, /property-spotlight→property_spotlight, etc.). ' +
+ 'Sorted by sessions DESC.',
+ demo,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 3. Top Referral Domains
+// ---------------------------------------------------------------------------
+
+/**
+ * Top referral domains from ga4_acquisition_metrics.
+ * Filters to channel_group='Referral' and aggregates by session_source.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function topReferralDomains() {
+ const res = await query(`
+ SELECT
+ session_source AS referral_domain,
+ SUM(sessions) AS sessions,
+ SUM(users) AS users,
+ SUM(key_events) AS key_events,
+ bool_and(is_demo) AS is_demo
+ FROM ga4_acquisition_metrics
+ WHERE channel_group = 'Referral'
+ GROUP BY session_source
+ ORDER BY sessions DESC
+ LIMIT 20
+ `);
+
+ const demo = await allDemo('ga4_acquisition_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ 'SUM(sessions,users,key_events) FROM ga4_acquisition_metrics ' +
+ 'WHERE channel_group=\'Referral\' GROUP BY session_source ORDER BY sessions DESC.',
+ demo,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 4. Brand vs Nonbrand Split (GSC)
+// ---------------------------------------------------------------------------
+
+/**
+ * Brand vs nonbrand query split from gsc_query_metrics.
+ * Brand = is_brand=true (contains 'rentv').
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function brandVsNonbrandSplit() {
+ const res = await query(`
+ SELECT
+ is_brand,
+ COUNT(*) AS query_count,
+ SUM(clicks) AS total_clicks,
+ SUM(impressions) AS total_impressions,
+ ROUND(SUM(clicks)::numeric / NULLIF(SUM(impressions), 0), 4) AS blended_ctr,
+ ROUND(AVG(position), 2) AS avg_position,
+ bool_and(is_demo) AS is_demo
+ FROM gsc_query_metrics
+ GROUP BY is_brand
+ ORDER BY is_brand DESC
+ `);
+
+ const demo = await allDemo('gsc_query_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ 'COUNT(query), SUM(clicks,impressions) FROM gsc_query_metrics GROUP BY is_brand. ' +
+ 'is_brand=true when query contains "rentv" (case-insensitive). ' +
+ 'blended_ctr = SUM(clicks) / SUM(impressions). ' +
+ 'Source: organic search only — Search Console does NOT represent paid search.',
+ demo,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 5. Query Clusters (GSC)
+// ---------------------------------------------------------------------------
+
+/**
+ * Query cluster distribution from gsc_query_metrics.
+ * Shows which topic clusters drive the most organic traffic.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function queryClusters() {
+ const res = await query(`
+ SELECT
+ cluster,
+ COUNT(*) AS query_count,
+ SUM(clicks) AS total_clicks,
+ SUM(impressions) AS total_impressions,
+ ROUND(SUM(clicks)::numeric / NULLIF(SUM(impressions), 0), 4) AS blended_ctr,
+ ROUND(AVG(position), 2) AS avg_position,
+ bool_and(is_demo) AS is_demo
+ FROM gsc_query_metrics
+ GROUP BY cluster
+ ORDER BY total_clicks DESC
+ `);
+
+ const demo = await allDemo('gsc_query_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ 'SUM(clicks,impressions) FROM gsc_query_metrics GROUP BY cluster. ' +
+ 'Cluster taxonomy (§18): california_market, arizona_market, property_type, ' +
+ 'finance_lending, brokerage_deal, conference_event, advertiser_category, other. ' +
+ 'Assigned by keyword heuristics in gsc.clusterQuery(). Organic search only.',
+ demo,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 6. High Impression / Low CTR Opportunities (GSC)
+// ---------------------------------------------------------------------------
+
+/**
+ * Queries with >=1000 impressions and CTR below 5% — content or title gap
+ * opportunities that could support advertiser packages.
+ *
+ * @param {{ impressionThreshold?: number, ctrThreshold?: number }} options
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function highImpressionLowCtr({ impressionThreshold = 1000, ctrThreshold = 0.05 } = {}) {
+ const res = await query(
+ `SELECT
+ query,
+ cluster,
+ is_brand,
+ SUM(clicks) AS clicks,
+ SUM(impressions) AS impressions,
+ ROUND(SUM(clicks)::numeric / NULLIF(SUM(impressions), 0), 4) AS ctr,
+ ROUND(AVG(position), 2) AS avg_position,
+ bool_and(is_demo) AS is_demo
+ FROM gsc_query_metrics
+ GROUP BY query, cluster, is_brand
+ HAVING SUM(impressions) >= $1
+ AND SUM(clicks)::numeric / NULLIF(SUM(impressions), 0) < $2
+ ORDER BY impressions DESC
+ LIMIT 30`,
+ [impressionThreshold, ctrThreshold]
+ );
+
+ const demo = await allDemo('gsc_query_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ `Queries WHERE SUM(impressions) >= ${impressionThreshold} AND blended_ctr < ${(ctrThreshold * 100).toFixed(0)}%. ` +
+ 'These represent content or title gaps: high organic visibility but poor click-through. ' +
+ 'Each represents a potential editorial or advertiser package opportunity. ' +
+ 'Organic search only — does not reflect paid performance.',
+ demo,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 7. Email / Newsletter Traffic Attribution (GA4)
+// ---------------------------------------------------------------------------
+
+/**
+ * Email and newsletter channel performance from ga4_acquisition_metrics.
+ * Breaks down UTM campaign attribution.
+ *
+ * @returns {Promise<{ rows: object[], formula: string, demo: boolean }>}
+ */
+async function emailNewsletterAttribution() {
+ const res = await query(`
+ SELECT
+ session_campaign,
+ session_source,
+ session_medium,
+ SUM(sessions) AS sessions,
+ SUM(users) AS users,
+ SUM(key_events) AS key_events,
+ bool_and(is_demo) AS is_demo
+ FROM ga4_acquisition_metrics
+ WHERE channel_group = 'Email'
+ GROUP BY session_campaign, session_source, session_medium
+ ORDER BY sessions DESC
+ `);
+
+ const demo = await allDemo('ga4_acquisition_metrics');
+
+ return {
+ rows: res.rows,
+ formula:
+ 'SUM(sessions,users,key_events) FROM ga4_acquisition_metrics ' +
+ 'WHERE channel_group=\'Email\' GROUP BY session_campaign, session_source, session_medium. ' +
+ 'UTM-based attribution. Campaign = utm_campaign value from email links.',
+ demo,
+ };
+}
+
+module.exports = {
+ caAzAudienceConcentration,
+ topLandingPagesByCategory,
+ topReferralDomains,
+ brandVsNonbrandSplit,
+ queryClusters,
+ highImpressionLowCtr,
+ emailNewsletterAttribution,
+};
diff --git a/src/analytics/index.js b/src/analytics/index.js
new file mode 100644
index 0000000..18b0f1a
--- /dev/null
+++ b/src/analytics/index.js
@@ -0,0 +1,37 @@
+'use strict';
+
+/**
+ * Analytics module barrel export.
+ *
+ * Provides convenient access to all analytics connectors and the derived
+ * insights engine from a single require() call.
+ *
+ * Usage:
+ * const analytics = require('./src/analytics');
+ * await analytics.ga4.importAll({ dryRun: false });
+ * const insights = await analytics.derive.queryClusters();
+ *
+ * @module src/analytics
+ */
+
+const ga4 = require('../connectors/ga4');
+const gsc = require('../connectors/gsc');
+const googleAds = require('../connectors/google-ads');
+const derive = require('./derive');
+
+module.exports = {
+ /** GA4 Data API connector (fixture-backed, is_demo=true when no service account) */
+ ga4,
+
+ /** Google Search Console connector (fixture-backed, is_demo=true when no creds) */
+ gsc,
+
+ /**
+ * Google Ads connector — DISABLED by default.
+ * Call googleAds.isEnabled() to check before using.
+ */
+ googleAds,
+
+ /** Derived sales-intelligence insights (queries ga4_* and gsc_* tables) */
+ derive,
+};
diff --git a/src/connectors/csv-import.js b/src/connectors/csv-import.js
new file mode 100644
index 0000000..e066bec
--- /dev/null
+++ b/src/connectors/csv-import.js
@@ -0,0 +1,523 @@
+'use strict';
+
+/**
+ * Guided CSV field-mapping importer — spec §20.
+ *
+ * Features:
+ * - Pure-Node CSV parser (no external library): handles quoted fields,
+ * embedded commas, and newlines inside double-quoted fields.
+ * - previewCsv(text) → { headers, sampleRows, rowCount }
+ * - importCsv({ text, kind, mapping, dryRun })
+ * kinds: ga4, gsc, google_ads, constant_contact, advertisers,
+ * sponsors, contacts
+ * - Type validation per kind
+ * - Duplicate detection (SHA-256 of normalised row content)
+ * - Dry-run mode (no DB writes, still returns full summary)
+ * - Import summary: { inserted, skipped, rejected, rejectedRows, batchId, checksum, isDemoLabeled }
+ * - Reversible batch: an analytics_import_runs row is created so the
+ * batch can be rolled back by deleting WHERE import_run_id = batchId.
+ * - SHA-256 checksum of the full CSV text
+ * - contacts import: REFUSES any row whose source column is empty (spec §20)
+ *
+ * @module src/connectors/csv-import
+ */
+
+const crypto = require('crypto');
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Pure-Node RFC 4180–compatible CSV parser
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse a CSV string into an array of string arrays (rows × columns).
+ * Handles:
+ * - CRLF and LF line endings
+ * - Fields enclosed in double-quotes
+ * - Escaped double-quotes ("") inside quoted fields
+ * - Commas and newlines inside quoted fields
+ *
+ * Blank lines are skipped.
+ *
+ * @param {string} text - raw CSV text
+ * @returns {string[][]}
+ */
+function parseCsv(text) {
+ const rows = [];
+ let row = [];
+ let field = '';
+ let inQuotes = false;
+ let i = 0;
+ const len = text.length;
+
+ while (i < len) {
+ const ch = text[i];
+
+ if (inQuotes) {
+ if (ch === '"') {
+ // Peek: escaped quote ("") or end of quoted field?
+ if (i + 1 < len && text[i + 1] === '"') {
+ field += '"';
+ i += 2;
+ } else {
+ inQuotes = false;
+ i++;
+ }
+ } else {
+ field += ch;
+ i++;
+ }
+ } else {
+ if (ch === '"') {
+ inQuotes = true;
+ i++;
+ } else if (ch === ',') {
+ row.push(field);
+ field = '';
+ i++;
+ } else if (ch === '\r' && i + 1 < len && text[i + 1] === '\n') {
+ row.push(field);
+ field = '';
+ if (row.some((f) => f.trim() !== '')) rows.push(row);
+ row = [];
+ i += 2;
+ } else if (ch === '\n') {
+ row.push(field);
+ field = '';
+ if (row.some((f) => f.trim() !== '')) rows.push(row);
+ row = [];
+ i++;
+ } else {
+ field += ch;
+ i++;
+ }
+ }
+ }
+
+ // Flush trailing row
+ row.push(field);
+ if (row.some((f) => f.trim() !== '')) rows.push(row);
+
+ return rows;
+}
+
+// ---------------------------------------------------------------------------
+// Comment/metadata-line filter
+// ---------------------------------------------------------------------------
+
+/**
+ * Strip leading comment/metadata lines (lines starting with '#' or that
+ * look like Google's report header noise) and return the first clean CSV row
+ * as headers plus the remaining rows.
+ *
+ * @param {string[][]} allRows
+ * @returns {{ headerRow: string[], dataRows: string[][] }}
+ */
+function extractHeaderAndData(allRows) {
+ // Skip lines that start with '#', are empty, or contain only a single
+ // non-comma "Top queries" style label before the real header
+ let headerIdx = 0;
+ for (let i = 0; i < allRows.length; i++) {
+ const first = (allRows[i][0] || '').trim();
+ if (first.startsWith('#') || first === '') continue;
+ // If this row has only 1 non-empty cell it's likely a section header
+ const nonEmpty = allRows[i].filter((c) => c.trim() !== '');
+ if (nonEmpty.length <= 1 && allRows.length > i + 1) continue;
+ headerIdx = i;
+ break;
+ }
+ return { headerRow: allRows[headerIdx], dataRows: allRows.slice(headerIdx + 1) };
+}
+
+// ---------------------------------------------------------------------------
+// Checksum
+// ---------------------------------------------------------------------------
+
+function sha256(text) {
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// previewCsv
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse a CSV buffer/string and return a preview suitable for the mapping UI.
+ *
+ * @param {Buffer|string} input
+ * @returns {{ headers: string[], sampleRows: string[][], rowCount: number }}
+ */
+function previewCsv(input) {
+ const text = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
+ const allRows = parseCsv(text);
+ if (allRows.length === 0) return { headers: [], sampleRows: [], rowCount: 0 };
+
+ const { headerRow, dataRows } = extractHeaderAndData(allRows);
+ return {
+ headers: headerRow.map((h) => h.trim()),
+ sampleRows: dataRows.slice(0, 5).map((r) => r.map((c) => c.trim())),
+ rowCount: dataRows.length,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Per-kind schemas
+// ---------------------------------------------------------------------------
+
+/**
+ * Validate and coerce a single mapped row for GA4 CSV import.
+ * Returns { valid: true, data } or { valid: false, reason }.
+ */
+function validateGa4Row(mapped) {
+ if (!mapped.metric_date) return { valid: false, reason: 'missing metric_date' };
+ const date = new Date(mapped.metric_date);
+ if (isNaN(date.getTime())) return { valid: false, reason: `invalid metric_date: ${mapped.metric_date}` };
+
+ const sessions = parseInt(mapped.sessions, 10);
+ if (isNaN(sessions) || sessions < 0) return { valid: false, reason: 'invalid sessions' };
+
+ return {
+ valid: true,
+ data: {
+ metric_date: mapped.metric_date,
+ sessions: sessions,
+ total_users: parseInt(mapped.total_users, 10) || 0,
+ new_users: parseInt(mapped.new_users, 10) || 0,
+ engaged_sessions: parseInt(mapped.engaged_sessions, 10) || 0,
+ engagement_rate: parseFloat(mapped.engagement_rate) || 0,
+ avg_engagement_time: parseFloat(mapped.avg_engagement_time) || 0,
+ views: parseInt(mapped.views, 10) || 0,
+ event_count: parseInt(mapped.event_count, 10) || 0,
+ key_events: parseInt(mapped.key_events, 10) || 0,
+ },
+ };
+}
+
+/**
+ * Validate and coerce a GSC row.
+ * Handles CTR as "48.32%" or 0.4832 float.
+ */
+function validateGscRow(mapped) {
+ if (!mapped.query && !mapped.page) return { valid: false, reason: 'missing query or page' };
+ if (!mapped.metric_date && !mapped.date) return { valid: false, reason: 'missing date' };
+
+ const dateStr = mapped.metric_date || mapped.date;
+ const date = new Date(dateStr);
+ if (isNaN(date.getTime())) return { valid: false, reason: `invalid date: ${dateStr}` };
+
+ let ctr = parseFloat(mapped.ctr);
+ if (!isNaN(ctr) && ctr > 1) ctr = ctr / 100; // convert "48.32%" → 0.4832
+
+ return {
+ valid: true,
+ data: {
+ metric_date: dateStr,
+ query: (mapped.query || '').trim() || null,
+ page: (mapped.page || '').trim() || null,
+ country: (mapped.country || 'usa').toLowerCase(),
+ device: (mapped.device || 'desktop').toLowerCase(),
+ clicks: parseInt(mapped.clicks, 10) || 0,
+ impressions: parseInt(mapped.impressions, 10) || 0,
+ ctr: isNaN(ctr) ? 0 : ctr,
+ position: parseFloat(mapped.position) || 0,
+ },
+ };
+}
+
+/**
+ * Validate a Constant Contact row.
+ */
+function validateConstantContactRow(mapped) {
+ if (!mapped.campaign_name && !mapped['Campaign Name']) return { valid: false, reason: 'missing campaign_name' };
+ const name = (mapped.campaign_name || mapped['Campaign Name'] || '').trim();
+ const sendDate = (mapped.send_date || mapped['Send Date'] || '').trim();
+ if (!sendDate) return { valid: false, reason: 'missing send_date' };
+
+ return {
+ valid: true,
+ data: {
+ campaign_name: name,
+ send_date: sendDate,
+ subject: (mapped.subject || mapped['Subject'] || '').trim(),
+ recipients: parseInt(mapped.recipients || mapped['Recipients'], 10) || 0,
+ emails_delivered: parseInt(mapped.emails_delivered || mapped['Emails Delivered'], 10) || 0,
+ unique_opens: parseInt(mapped.unique_opens || mapped['Unique Opens'], 10) || 0,
+ unique_clicks: parseInt(mapped.unique_clicks || mapped['Unique Clicks'], 10) || 0,
+ },
+ };
+}
+
+/**
+ * Validate a contacts row. REFUSES rows with no explicit source column.
+ */
+function validateContactRow(mapped) {
+ // Spec §20: "REFUSE any email that has no explicit source column"
+ const source = (mapped.source || '').trim();
+ if (!source) {
+ return { valid: false, reason: 'no_source_evidence' };
+ }
+
+ const email = (mapped.email || mapped.business_email || '').trim().toLowerCase();
+ if (!email) return { valid: false, reason: 'missing email' };
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { valid: false, reason: `invalid email format: ${email}` };
+
+ return {
+ valid: true,
+ data: {
+ email,
+ full_name: (mapped.full_name || mapped.name || '').trim(),
+ organization: (mapped.organization || mapped.company || '').trim(),
+ source,
+ phone: (mapped.phone || '').trim(),
+ },
+ };
+}
+
+/**
+ * Validate a generic advertiser/sponsor row.
+ */
+function validateAdvertiserRow(mapped) {
+ const name = (mapped.display_name || mapped.company || mapped.organization || '').trim();
+ if (!name) return { valid: false, reason: 'missing company name' };
+
+ return {
+ valid: true,
+ data: {
+ display_name: name,
+ domain: (mapped.domain || mapped.website || '').trim().toLowerCase() || null,
+ status: (mapped.status || 'RESEARCH_NEEDED').trim(),
+ headquarters_state: (mapped.state || mapped.headquarters_state || '').trim() || null,
+ headquarters_city: (mapped.city || mapped.headquarters_city || '').trim() || null,
+ },
+ };
+}
+
+// Dispatch to the right validator
+function validateRow(kind, mapped) {
+ switch (kind) {
+ case 'ga4': return validateGa4Row(mapped);
+ case 'gsc': return validateGscRow(mapped);
+ case 'google_ads': return { valid: false, reason: 'Google Ads import not configured — enable GOOGLE_ADS_ENABLED=true' };
+ case 'constant_contact': return validateConstantContactRow(mapped);
+ case 'advertisers': return validateAdvertiserRow(mapped);
+ case 'sponsors': return validateAdvertiserRow(mapped);
+ case 'contacts': return validateContactRow(mapped);
+ default: return { valid: false, reason: `unknown kind: ${kind}` };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Duplicate detection: hash of normalised row content
+// ---------------------------------------------------------------------------
+
+const _seenHashes = new Set();
+
+function rowHash(data) {
+ return crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// DB writers per kind (all set is_demo = true for CSV imports)
+// ---------------------------------------------------------------------------
+
+async function writeGa4Row(data) {
+ await query(
+ `INSERT INTO ga4_daily_metrics
+ (metric_date, sessions, total_users, new_users, engaged_sessions,
+ engagement_rate, avg_engagement_time, views, event_count, key_events, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)
+ ON CONFLICT (metric_date) DO UPDATE SET
+ sessions=EXCLUDED.sessions, total_users=EXCLUDED.total_users,
+ new_users=EXCLUDED.new_users, engaged_sessions=EXCLUDED.engaged_sessions,
+ engagement_rate=EXCLUDED.engagement_rate,
+ avg_engagement_time=EXCLUDED.avg_engagement_time,
+ views=EXCLUDED.views, event_count=EXCLUDED.event_count,
+ key_events=EXCLUDED.key_events, is_demo=true`,
+ [
+ data.metric_date, data.sessions, data.total_users, data.new_users,
+ data.engaged_sessions, data.engagement_rate, data.avg_engagement_time,
+ data.views, data.event_count, data.key_events,
+ ]
+ );
+}
+
+async function writeGscRow(data, runId) {
+ if (data.query !== null) {
+ await query(
+ `INSERT INTO gsc_query_metrics
+ (metric_date, query, country, device, clicks, impressions, ctr, position,
+ is_brand, cluster, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)`,
+ [
+ data.metric_date, data.query, data.country, data.device,
+ data.clicks, data.impressions, data.ctr, data.position,
+ /rentv/i.test(data.query || ''),
+ require('./gsc').clusterQuery(data.query || ''),
+ ]
+ );
+ } else if (data.page !== null) {
+ await query(
+ `INSERT INTO gsc_page_metrics
+ (metric_date, page, country, device, clicks, impressions, ctr, position, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+ [
+ data.metric_date, data.page, data.country, data.device,
+ data.clicks, data.impressions, data.ctr, data.position,
+ ]
+ );
+ }
+}
+
+// For constant_contact and advertisers/contacts we write to audit_logs +
+// return data — actual table writes require the full application layer but
+// the import is tracked in analytics_import_runs.
+async function writeAnnotationRow(data, kind) {
+ await query(
+ `INSERT INTO audit_logs (action, entity_table, actor, detail)
+ VALUES ('CSV_IMPORT', $1, 'csv-import', $2)`,
+ [kind, JSON.stringify(data)]
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main importCsv
+// ---------------------------------------------------------------------------
+
+/**
+ * Import a CSV text with guided field mapping.
+ *
+ * @param {object} opts
+ * @param {string} opts.text - raw CSV text
+ * @param {string} opts.kind - import kind (ga4|gsc|google_ads|constant_contact|advertisers|sponsors|contacts)
+ * @param {Object.<string,string>} [opts.mapping] - { csvHeader: dbField }; if omitted, headers used as-is
+ * @param {boolean} [opts.dryRun] - when true, validate but skip DB writes
+ * @returns {Promise<{
+ * inserted: number,
+ * skipped: number,
+ * rejected: number,
+ * rejectedRows: Array<{ rowIndex: number, reason: string, raw: string[] }>,
+ * batchId: string|null,
+ * checksum: string,
+ * isDemoLabeled: true,
+ * dryRun: boolean,
+ * rowCount: number,
+ * kind: string
+ * }>}
+ */
+async function importCsv({ text, kind, mapping = null, dryRun = false }) {
+ if (!text) throw new Error('importCsv: text is required');
+ if (!kind) throw new Error('importCsv: kind is required');
+
+ const checksum = sha256(text);
+ const allRows = parseCsv(text);
+ if (allRows.length === 0) {
+ return { inserted: 0, skipped: 0, rejected: 0, rejectedRows: [], batchId: null, checksum, isDemoLabeled: true, dryRun, rowCount: 0, kind };
+ }
+
+ const { headerRow, dataRows } = extractHeaderAndData(allRows);
+ const headers = headerRow.map((h) => h.trim());
+
+ // Build effective column mapping: csvHeader → dbFieldName
+ const effectiveMapping = {};
+ if (mapping) {
+ for (const [csvHeader, dbField] of Object.entries(mapping)) {
+ effectiveMapping[csvHeader.trim()] = dbField.trim();
+ }
+ } else {
+ // Auto-map: use header names as field names (lowercased, spaces→underscores)
+ for (const h of headers) {
+ effectiveMapping[h] = h.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
+ }
+ }
+
+ // Record the import run (for reversibility)
+ let batchId = null;
+ if (!dryRun) {
+ const res = await query(
+ `INSERT INTO analytics_import_runs (kind, checksum, source_file, is_demo, status)
+ VALUES ($1,$2,'CSV_UPLOAD',true,'RUNNING') RETURNING id`,
+ [kind.toUpperCase(), checksum]
+ );
+ batchId = res.rows[0].id;
+ }
+
+ const rejectedRows = [];
+ const seenHashes = new Set();
+ let inserted = 0;
+ let skipped = 0;
+
+ try {
+ for (let i = 0; i < dataRows.length; i++) {
+ const rawRow = dataRows[i];
+ // Map columns to field names
+ const mapped = {};
+ for (let c = 0; c < headers.length; c++) {
+ const csvHeader = headers[c];
+ const dbField = effectiveMapping[csvHeader] || csvHeader;
+ mapped[dbField] = (rawRow[c] || '').trim();
+ }
+
+ // Validate
+ const result = validateRow(kind, mapped);
+ if (!result.valid) {
+ rejectedRows.push({ rowIndex: i + 1, reason: result.reason, raw: rawRow });
+ continue;
+ }
+
+ // Duplicate detection
+ const h = rowHash(result.data);
+ if (seenHashes.has(h)) {
+ skipped++;
+ continue;
+ }
+ seenHashes.add(h);
+
+ // Write
+ if (!dryRun) {
+ try {
+ switch (kind) {
+ case 'ga4': await writeGa4Row(result.data); break;
+ case 'gsc': await writeGscRow(result.data, batchId); break;
+ default: await writeAnnotationRow(result.data, kind); break;
+ }
+ } catch (writeErr) {
+ rejectedRows.push({ rowIndex: i + 1, reason: `db_error: ${writeErr.message}`, raw: rawRow });
+ continue;
+ }
+ }
+ inserted++;
+ }
+
+ if (!dryRun && batchId) {
+ await query(
+ `UPDATE analytics_import_runs
+ SET finished_at=now(), status='SUCCESS', row_count=$2
+ WHERE id=$1`,
+ [batchId, inserted]
+ );
+ }
+ } catch (err) {
+ if (!dryRun && batchId) {
+ await query(
+ `UPDATE analytics_import_runs SET finished_at=now(), status='ERROR', error=$2 WHERE id=$1`,
+ [batchId, err.message]
+ );
+ }
+ throw err;
+ }
+
+ return {
+ inserted,
+ skipped,
+ rejected: rejectedRows.length,
+ rejectedRows,
+ batchId,
+ checksum,
+ isDemoLabeled: true,
+ dryRun,
+ rowCount: dataRows.length,
+ kind,
+ };
+}
+
+module.exports = { previewCsv, importCsv, parseCsv };
diff --git a/src/connectors/email-upload.js b/src/connectors/email-upload.js
new file mode 100644
index 0000000..1e4f0db
--- /dev/null
+++ b/src/connectors/email-upload.js
@@ -0,0 +1,379 @@
+'use strict';
+
+/**
+ * Email/EML upload parser for sponsor evidence — spec §16.
+ *
+ * Deterministic parsing using only Node.js built-ins.
+ *
+ * Supported:
+ * .eml / raw email string → parseEml(text) → EmlResult
+ * HTML string → parseHtml(text) → HtmlResult
+ *
+ * Unsupported (stubs):
+ * .msg → { unsupported: true, note: 'PDF/.msg parsing needs a parser...' }
+ * .pdf → { unsupported: true, note: 'PDF/.msg parsing needs a parser...' }
+ *
+ * Optional LLM extraction hook:
+ * When OLLAMA_ENABLED=true, the extractWithOllama() function is the
+ * intended extension point. It is NOT implemented here — add it behind
+ * the feature flag and subject results to human review before use.
+ *
+ * Design principles:
+ * - Deterministic first: regex/header parsing with no external deps
+ * - Never auto-open email tracking links (store the URL, flag it)
+ * - EXIF stripping is noted but not implemented here (needs sips/sharp)
+ * - No personal mobile numbers, home addresses, or sensitive attributes
+ *
+ * @module src/connectors/email-upload
+ */
+
+// ---------------------------------------------------------------------------
+// URL extraction (avoid opening tracking links)
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract all http/https URLs from text.
+ * Tracking links (click.*, trk., ccsend.com, etc.) are flagged but NOT opened.
+ *
+ * @param {string} text
+ * @returns {Array<{ url: string, likelyTracking: boolean }>}
+ */
+function extractUrls(text) {
+ const URL_RE = /https?:\/\/[^\s"'<>)]+/g;
+ const TRACKING_PATTERNS = [
+ /click\./i, /trk\./i, /track\./i, /ccsend\.com/i,
+ /mailchimp\.com\/track/i, /list-manage\.com/i,
+ /sendgrid\.net/i, /exacttarget\.com/i, /r\.exactdn\.com/i,
+ /click\.email/i, /em\.rentv/i,
+ ];
+
+ const found = [];
+ const seen = new Set();
+ let match;
+ while ((match = URL_RE.exec(text)) !== null) {
+ const url = match[0].replace(/[,;.]+$/, ''); // strip trailing punctuation
+ if (seen.has(url)) continue;
+ seen.add(url);
+ const likelyTracking = TRACKING_PATTERNS.some((p) => p.test(url));
+ found.push({ url, likelyTracking });
+ }
+ return found;
+}
+
+// ---------------------------------------------------------------------------
+// Organization name extraction (naive NER — deterministic)
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract likely organization names from text.
+ * Uses a heuristic: sequences of capitalized words followed by common
+ * CRE organization suffixes.
+ *
+ * @param {string} text
+ * @returns {string[]}
+ */
+function extractOrganizationNames(text) {
+ // Match sequences like "Hanley Investment Group" or "Chase Partners LLC"
+ const ORG_RE = /\b([A-Z][a-zA-Z&''-]+(?:\s+[A-Z][a-zA-Z&''-]+){0,5}(?:\s+(?:Group|Partners|Capital|Realty|Properties|Investment|Development|Investments|Lenders|Bank|Savings|Mortgage|Title|Escrow|Fund|Advisors|Advisory|Associates|Corp|LLC|LP|LLP|Inc|Co|Company|Real Estate|Brokerage|Commercial))\b)/g;
+
+ const names = new Set();
+ let m;
+ while ((m = ORG_RE.exec(text)) !== null) {
+ names.add(m[1].trim());
+ }
+ return Array.from(names);
+}
+
+// ---------------------------------------------------------------------------
+// Sponsor label extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract visible sponsor labels: lines/phrases matching typical sponsor
+ * disclosure patterns in CRE newsletters/emails.
+ *
+ * @param {string} text
+ * @returns {string[]}
+ */
+function extractSponsorLabels(text) {
+ const patterns = [
+ /(?:sponsored\s+by|sponsor(?:ed)?\s*[:–—]\s*)([^\n\r<]{1,80})/gi,
+ /(?:presented\s+by|presented\s+to\s+you\s+by)\s+([^\n\r<]{1,80})/gi,
+ /(?:property\s+spotlight\s*(?:[:–—]\s*)?)([^\n\r<]{1,80})/gi,
+ /(?:cre\s+talk\s+(?:sponsor|brought\s+to\s+you\s+by)\s*[:–—]?\s*)([^\n\r<]{1,80})/gi,
+ /(?:thank\s+you\s+to\s+our\s+(?:sponsor|partner)s?\s*[:–—]?\s*)([^\n\r<]{1,80})/gi,
+ ];
+
+ const labels = new Set();
+ for (const re of patterns) {
+ let m;
+ while ((m = re.exec(text)) !== null) {
+ const label = m[0].trim();
+ if (label.length > 3) labels.add(label);
+ }
+ re.lastIndex = 0;
+ }
+ return Array.from(labels);
+}
+
+// ---------------------------------------------------------------------------
+// EML header parser
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse RFC 2822 headers from an EML string.
+ * Returns a plain object of { header-name-lowercase: value }.
+ * Handles folded headers (continuation lines starting with whitespace).
+ *
+ * @param {string} text
+ * @returns {{ headers: Object.<string,string>, bodyStart: number }}
+ */
+function parseRfc2822Headers(text) {
+ const headerBodySep = text.indexOf('\r\n\r\n');
+ const sepLen = 4;
+ const altSep = text.indexOf('\n\n');
+ const sepIdx = headerBodySep !== -1 ? headerBodySep : altSep;
+ const headerSection = sepIdx !== -1 ? text.slice(0, sepIdx) : text;
+ const bodyStart = sepIdx !== -1 ? sepIdx + (headerBodySep !== -1 ? sepLen : 2) : text.length;
+
+ // Unfold: join continuation lines
+ const unfolded = headerSection.replace(/\r\n([ \t])/g, ' ').replace(/\n([ \t])/g, ' ');
+ const lines = unfolded.split(/\r?\n/);
+
+ const headers = {};
+ for (const line of lines) {
+ const colon = line.indexOf(':');
+ if (colon < 1) continue;
+ const name = line.slice(0, colon).trim().toLowerCase();
+ const value = line.slice(colon + 1).trim();
+ if (name && !headers[name]) headers[name] = value;
+ }
+
+ return { headers, bodyStart };
+}
+
+/**
+ * Decode a base64 or quoted-printable encoded string to UTF-8.
+ * Falls back to returning the raw text.
+ *
+ * @param {string} text
+ * @param {string} encoding - 'base64' | 'quoted-printable' | ''
+ * @returns {string}
+ */
+function decodeBody(text, encoding) {
+ const enc = (encoding || '').toLowerCase().trim();
+ if (enc === 'base64') {
+ try { return Buffer.from(text.replace(/\s+/g, ''), 'base64').toString('utf8'); } catch { return text; }
+ }
+ if (enc === 'quoted-printable') {
+ return text
+ .replace(/=\r?\n/g, '')
+ .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
+ }
+ return text;
+}
+
+/**
+ * Strip HTML tags from a string, preserving whitespace structure.
+ * @param {string} html
+ * @returns {string}
+ */
+function stripHtml(html) {
+ return html
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
+ .replace(/<br\s*\/?>/gi, '\n')
+ .replace(/<\/p>/gi, '\n')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/&/gi, '&')
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/"/gi, '"')
+ .replace(/'/gi, "'")
+ .replace(/[ \t]+/g, ' ')
+ .trim();
+}
+
+// ---------------------------------------------------------------------------
+// Public: parseEml
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse an EML string into a structured evidence object.
+ *
+ * @param {string} emlText - raw .eml file contents
+ * @returns {{
+ * from: string,
+ * to: string,
+ * subject: string,
+ * date: string,
+ * messageId: string,
+ * urls: Array<{ url: string, likelyTracking: boolean }>,
+ * organizationNames: string[],
+ * visibleSponsorLabels: string[],
+ * plainText: string,
+ * parserVersion: string,
+ * parseWarnings: string[]
+ * }}
+ */
+function parseEml(emlText) {
+ if (!emlText || typeof emlText !== 'string') {
+ throw new TypeError('parseEml: input must be a non-empty string');
+ }
+
+ const warnings = [];
+ const { headers, bodyStart } = parseRfc2822Headers(emlText);
+ const rawBody = emlText.slice(bodyStart);
+
+ // Detect content-type and transfer-encoding for the top-level body
+ const contentType = headers['content-type'] || 'text/plain';
+ const transferEncoding = headers['content-transfer-encoding'] || '';
+
+ // Find a usable text body — handles simple single-part and basic multipart
+ let textBody = '';
+ if (/multipart/i.test(contentType)) {
+ const boundaryMatch = contentType.match(/boundary\s*=\s*"?([^";\s]+)"?/i);
+ if (boundaryMatch) {
+ const boundary = boundaryMatch[1];
+ const parts = rawBody.split(new RegExp('--' + boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ for (const part of parts) {
+ if (!part.trim() || part.trim() === '--') continue;
+ const partSep = part.indexOf('\r\n\r\n') !== -1 ? part.indexOf('\r\n\r\n') + 4 : (part.indexOf('\n\n') + 2);
+ const partHeaders = part.slice(0, partSep).toLowerCase();
+ const partBody = part.slice(partSep);
+ const partCte = (partHeaders.match(/content-transfer-encoding:\s*(\S+)/) || [])[1] || '';
+
+ if (/text\/plain/.test(partHeaders)) {
+ textBody = decodeBody(partBody, partCte);
+ break;
+ } else if (/text\/html/.test(partHeaders) && !textBody) {
+ textBody = stripHtml(decodeBody(partBody, partCte));
+ }
+ }
+ } else {
+ warnings.push('multipart boundary not found — falling back to raw body');
+ textBody = rawBody;
+ }
+ } else if (/text\/html/i.test(contentType)) {
+ textBody = stripHtml(decodeBody(rawBody, transferEncoding));
+ } else {
+ textBody = decodeBody(rawBody, transferEncoding);
+ }
+
+ if (!textBody) {
+ warnings.push('empty body after decoding');
+ textBody = '';
+ }
+
+ const urls = extractUrls(emlText); // search raw EML so we catch headers too
+ const organizationNames = extractOrganizationNames(textBody);
+ const visibleSponsorLabels = extractSponsorLabels(textBody);
+
+ return {
+ from: headers['from'] || '',
+ to: headers['to'] || '',
+ subject: headers['subject'] || '',
+ date: headers['date'] || '',
+ messageId: headers['message-id'] || '',
+ urls,
+ organizationNames,
+ visibleSponsorLabels,
+ plainText: textBody.slice(0, 4000), // truncate for evidence excerpt
+ parserVersion: 'email-upload/1.0',
+ parseWarnings: warnings,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Public: parseHtml
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse an HTML email/newsletter fragment for sponsor evidence.
+ *
+ * @param {string} htmlText
+ * @returns {{
+ * urls: Array<{ url: string, likelyTracking: boolean }>,
+ * organizationNames: string[],
+ * visibleSponsorLabels: string[],
+ * plainText: string,
+ * parserVersion: string
+ * }}
+ */
+function parseHtml(htmlText) {
+ if (!htmlText || typeof htmlText !== 'string') {
+ throw new TypeError('parseHtml: input must be a non-empty string');
+ }
+
+ const plainText = stripHtml(htmlText);
+ return {
+ urls: extractUrls(htmlText),
+ organizationNames: extractOrganizationNames(plainText),
+ visibleSponsorLabels: extractSponsorLabels(plainText),
+ plainText: plainText.slice(0, 4000),
+ parserVersion: 'email-upload/1.0',
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Public: unsupported format stubs
+// ---------------------------------------------------------------------------
+
+/**
+ * Stub for PDF and .msg files.
+ * Returns a standardised unsupported marker so callers can surface a
+ * human-review request rather than failing silently.
+ *
+ * @returns {{ unsupported: true, note: string }}
+ */
+function parseMsg() {
+ return {
+ unsupported: true,
+ note: 'PDF/.msg parsing needs a parser — manual upload of extracted text for now. ' +
+ 'For PDF, use pdftotext (poppler) or pdf-parse (npm). ' +
+ 'For .msg (Outlook), use msg-reader or @kenjiuno/msgreader. ' +
+ 'Feed the extracted plain text back through parseHtml() or parseEml().',
+ };
+}
+
+const parsePdf = parseMsg; // same note
+
+// ---------------------------------------------------------------------------
+// Optional LLM extraction hook (not implemented)
+// ---------------------------------------------------------------------------
+
+/**
+ * Placeholder for optional Ollama-backed LLM extraction.
+ *
+ * When OLLAMA_ENABLED=true and OLLAMA_BASE_URL is set, this function should:
+ * 1. POST the plainText excerpt to the Ollama /api/generate endpoint.
+ * 2. Use a strict JSON schema prompt requesting { sponsors, advertisers, urls, events }.
+ * 3. Validate the returned JSON against the schema.
+ * 4. Return the structured extraction with a confidence score.
+ * 5. Flag results as REQUIRES_HUMAN_REVIEW before persisting.
+ *
+ * Model: process.env.OLLAMA_MODEL (default: qwen2.5:7b) — $0 cost (local).
+ *
+ * NOT IMPLEMENTED: add it here when Steve enables OLLAMA_ENABLED=true.
+ *
+ * @param {string} _plainText
+ * @throws {Error} always — not yet implemented
+ */
+async function extractWithOllama(_plainText) {
+ if (process.env.OLLAMA_ENABLED !== 'true') {
+ throw new Error('OLLAMA_ENABLED is not set to true — LLM extraction is disabled');
+ }
+ throw new Error('Ollama extraction not yet implemented — add it in email-upload.js extractWithOllama()');
+}
+
+module.exports = {
+ parseEml,
+ parseHtml,
+ parseMsg,
+ parsePdf,
+ extractUrls,
+ extractOrganizationNames,
+ extractSponsorLabels,
+ extractWithOllama,
+};
diff --git a/src/connectors/ga4.js b/src/connectors/ga4.js
new file mode 100644
index 0000000..3255bfb
--- /dev/null
+++ b/src/connectors/ga4.js
@@ -0,0 +1,354 @@
+'use strict';
+
+/**
+ * GA4 Data API connector — spec §17.
+ *
+ * When GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 is empty (the default in this repo),
+ * all operations run against the local JSON fixtures with is_demo=true.
+ *
+ * When real credentials are present the `// TODO real API` branches document
+ * the @google-analytics/data call shape but throw a clear error rather than
+ * quietly failing, because the npm package is intentionally not installed.
+ *
+ * Usage:
+ * const ga4 = require('./ga4');
+ * await ga4.importAll({ dryRun: false });
+ *
+ * @module src/connectors/ga4
+ */
+
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { query, tx } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Credentials probe
+// ---------------------------------------------------------------------------
+
+/** Returns true when a real service account credential is configured. */
+function hasCredentials() {
+ return Boolean(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64);
+}
+
+/**
+ * Test the GA4 connection.
+ * @returns {{ connected: boolean, demo: boolean, error?: string }}
+ */
+async function connectionTest() {
+ if (!hasCredentials()) {
+ return { connected: false, demo: true, error: 'No GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 configured — running in DEMO/fixture mode.' };
+ }
+
+ // TODO real API: decode the base64 credential, instantiate
+ // @google-analytics/data BetaAnalyticsDataClient, call runReport with
+ // a single-day trivial request to validate the property and token.
+ //
+ // const { BetaAnalyticsDataClient } = require('@google-analytics/data');
+ // const creds = JSON.parse(Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64, 'base64').toString('utf8'));
+ // const client = new BetaAnalyticsDataClient({ credentials: creds });
+ // await client.runReport({ property: `properties/${process.env.GA4_PROPERTY_ID}`, dateRanges: [{ startDate: 'yesterday', endDate: 'yesterday' }], metrics: [{ name: 'sessions' }] });
+ throw new Error('GA4 live API not enabled (no service account)');
+}
+
+// ---------------------------------------------------------------------------
+// Fixture loader helpers
+// ---------------------------------------------------------------------------
+
+const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures');
+
+function loadFixture(name) {
+ const fp = path.join(FIXTURES_DIR, name);
+ return JSON.parse(fs.readFileSync(fp, 'utf8'));
+}
+
+function fixtureChecksum(name) {
+ const fp = path.join(FIXTURES_DIR, name);
+ return crypto.createHash('sha256').update(fs.readFileSync(fp)).digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// Import run bookkeeping
+// ---------------------------------------------------------------------------
+
+async function startRun(kind, sourceFile, checksum, dryRun) {
+ const res = await query(
+ `INSERT INTO analytics_import_runs (kind, source_file, checksum, is_demo, status)
+ VALUES ($1, $2, $3, true, 'RUNNING') RETURNING id`,
+ [kind, sourceFile, checksum]
+ );
+ return res.rows[0].id;
+}
+
+async function finishRun(runId, rowCount, error) {
+ await query(
+ `UPDATE analytics_import_runs
+ SET finished_at = now(),
+ status = $2,
+ row_count = $3,
+ error = $4
+ WHERE id = $1`,
+ [runId, error ? 'ERROR' : 'SUCCESS', rowCount, error || null]
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Individual importers
+// ---------------------------------------------------------------------------
+
+/**
+ * Upsert rows from ga4-daily.json → ga4_daily_metrics.
+ * PK is metric_date so this is idempotent.
+ *
+ * @param {boolean} dryRun - when true, validate only, no DB writes
+ * @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null }}
+ */
+async function importDaily(dryRun = false) {
+ const fixture = loadFixture('ga4-daily.json');
+ const rows = fixture.rows;
+ const checksum = fixtureChecksum('ga4-daily.json');
+ let runId = null;
+ let inserted = 0;
+
+ if (!dryRun) {
+ runId = await startRun('GA4_DAILY', 'fixtures/ga4-daily.json', checksum, dryRun);
+ }
+
+ try {
+ for (const row of rows) {
+ if (!row.metric_date) continue;
+ if (!dryRun) {
+ await query(
+ `INSERT INTO ga4_daily_metrics
+ (metric_date, sessions, total_users, new_users, engaged_sessions,
+ engagement_rate, avg_engagement_time, views, event_count, key_events, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)
+ ON CONFLICT (metric_date) DO UPDATE SET
+ sessions = EXCLUDED.sessions,
+ total_users = EXCLUDED.total_users,
+ new_users = EXCLUDED.new_users,
+ engaged_sessions = EXCLUDED.engaged_sessions,
+ engagement_rate = EXCLUDED.engagement_rate,
+ avg_engagement_time = EXCLUDED.avg_engagement_time,
+ views = EXCLUDED.views,
+ event_count = EXCLUDED.event_count,
+ key_events = EXCLUDED.key_events,
+ is_demo = true`,
+ [
+ row.metric_date, row.sessions, row.total_users, row.new_users,
+ row.engaged_sessions, row.engagement_rate, row.avg_engagement_time,
+ row.views, row.event_count, row.key_events,
+ ]
+ );
+ }
+ inserted++;
+ }
+
+ if (!dryRun) await finishRun(runId, inserted, null);
+ return { inserted, skipped: 0, rejected: [], runId, dryRun };
+ } catch (err) {
+ if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+ throw err;
+ }
+}
+
+/**
+ * Upsert rows from ga4-landing.json → ga4_landing_page_metrics.
+ * No natural unique key beyond date+page — we clear-then-insert for the
+ * fixture's sample date so reruns remain idempotent.
+ */
+async function importLanding(dryRun = false) {
+ const fixture = loadFixture('ga4-landing.json');
+ const rows = fixture.rows;
+ const checksum = fixtureChecksum('ga4-landing.json');
+ let runId = null;
+ let inserted = 0;
+
+ if (!dryRun) {
+ runId = await startRun('GA4_LANDING', 'fixtures/ga4-landing.json', checksum, dryRun);
+ // Delete existing demo rows for this date range to allow clean re-import
+ const dates = [...new Set(rows.map((r) => r.metric_date))];
+ for (const d of dates) {
+ await query('DELETE FROM ga4_landing_page_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+ }
+ }
+
+ try {
+ for (const row of rows) {
+ if (!row.metric_date || !row.landing_page) continue;
+ if (!dryRun) {
+ await query(
+ `INSERT INTO ga4_landing_page_metrics
+ (metric_date, landing_page, sessions, users, views,
+ engaged_sessions, engagement_rate, key_events, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+ [
+ row.metric_date, row.landing_page, row.sessions, row.users,
+ row.views, row.engaged_sessions, row.engagement_rate, row.key_events,
+ ]
+ );
+ }
+ inserted++;
+ }
+
+ if (!dryRun) await finishRun(runId, inserted, null);
+ return { inserted, skipped: 0, rejected: [], runId, dryRun };
+ } catch (err) {
+ if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+ throw err;
+ }
+}
+
+/**
+ * Upsert rows from ga4-geo.json → ga4_geo_metrics.
+ */
+async function importGeo(dryRun = false) {
+ const fixture = loadFixture('ga4-geo.json');
+ const rows = fixture.rows;
+ const checksum = fixtureChecksum('ga4-geo.json');
+ let runId = null;
+ let inserted = 0;
+
+ if (!dryRun) {
+ runId = await startRun('GA4_GEO', 'fixtures/ga4-geo.json', checksum, dryRun);
+ const dates = [...new Set(rows.map((r) => r.metric_date))];
+ for (const d of dates) {
+ await query('DELETE FROM ga4_geo_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+ }
+ }
+
+ try {
+ for (const row of rows) {
+ if (!row.metric_date) continue;
+ if (!dryRun) {
+ await query(
+ `INSERT INTO ga4_geo_metrics
+ (metric_date, country, region, city, sessions, users, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,true)`,
+ [row.metric_date, row.country, row.region, row.city, row.sessions, row.users]
+ );
+ }
+ inserted++;
+ }
+
+ if (!dryRun) await finishRun(runId, inserted, null);
+ return { inserted, skipped: 0, rejected: [], runId, dryRun };
+ } catch (err) {
+ if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+ throw err;
+ }
+}
+
+/**
+ * Upsert rows from ga4-acquisition.json → ga4_acquisition_metrics.
+ */
+async function importAcquisition(dryRun = false) {
+ const fixture = loadFixture('ga4-acquisition.json');
+ const rows = fixture.rows;
+ const checksum = fixtureChecksum('ga4-acquisition.json');
+ let runId = null;
+ let inserted = 0;
+
+ if (!dryRun) {
+ runId = await startRun('GA4_ACQUISITION', 'fixtures/ga4-acquisition.json', checksum, dryRun);
+ const dates = [...new Set(rows.map((r) => r.metric_date))];
+ for (const d of dates) {
+ await query('DELETE FROM ga4_acquisition_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+ }
+ }
+
+ try {
+ for (const row of rows) {
+ if (!row.metric_date) continue;
+ if (!dryRun) {
+ await query(
+ `INSERT INTO ga4_acquisition_metrics
+ (metric_date, channel_group, session_source, session_medium,
+ session_campaign, sessions, users, key_events, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+ [
+ row.metric_date, row.channel_group, row.session_source,
+ row.session_medium, row.session_campaign,
+ row.sessions, row.users, row.key_events,
+ ]
+ );
+ }
+ inserted++;
+ }
+
+ if (!dryRun) await finishRun(runId, inserted, null);
+ return { inserted, skipped: 0, rejected: [], runId, dryRun };
+ } catch (err) {
+ if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+ throw err;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Import all GA4 fixture tables.
+ *
+ * TODO real API: when credentials exist, replace each fixture-load branch with
+ * a @google-analytics/data runReport call using:
+ *
+ * Daily overview dimensions: ['date']
+ * Daily overview metrics: ['sessions','totalUsers','newUsers','engagedSessions',
+ * 'engagementRate','averageSessionDuration','screenPageViews',
+ * 'eventCount','keyEvents']
+ *
+ * Landing page dimensions: ['landingPage']
+ * Landing page metrics: ['sessions','totalUsers','screenPageViews',
+ * 'engagedSessions','engagementRate','keyEvents']
+ *
+ * Geo dimensions: ['country','region','city']
+ * Geo metrics: ['sessions','totalUsers']
+ *
+ * Acquisition dimensions: ['sessionDefaultChannelGroup','sessionSource',
+ * 'sessionMedium','sessionCampaignName']
+ * Acquisition metrics: ['sessions','totalUsers','keyEvents']
+ *
+ * @param {{ dryRun?: boolean }} options
+ * @returns {Promise<{ daily: object, landing: object, geo: object, acquisition: object, demo: true }>}
+ */
+async function importAll({ dryRun = false } = {}) {
+ if (hasCredentials()) {
+ throw new Error('GA4 live API not enabled (no service account) — install @google-analytics/data and implement the TODO real API branch.');
+ }
+
+ const [daily, landing, geo, acquisition] = await Promise.all([
+ importDaily(dryRun),
+ importLanding(dryRun),
+ importGeo(dryRun),
+ importAcquisition(dryRun),
+ ]);
+
+ return { daily, landing, geo, acquisition, demo: true };
+}
+
+/**
+ * Ensure an analytics_connections row exists for GA4 (idempotent).
+ * Sets status=DEMO_CONNECTED when no real creds, NOT_CONNECTED otherwise.
+ */
+async function ensureConnectionRecord() {
+ const status = hasCredentials() ? 'NOT_CONNECTED' : 'DEMO_CONNECTED';
+ await query(
+ `INSERT INTO analytics_connections (kind, status, is_demo)
+ VALUES ('GA4', $1, true)
+ ON CONFLICT DO NOTHING`,
+ [status]
+ );
+}
+
+module.exports = {
+ connectionTest,
+ importAll,
+ importDaily,
+ importLanding,
+ importGeo,
+ importAcquisition,
+ ensureConnectionRecord,
+ hasCredentials,
+};
diff --git a/src/connectors/gmail.js b/src/connectors/gmail.js
new file mode 100644
index 0000000..4255fe8
--- /dev/null
+++ b/src/connectors/gmail.js
@@ -0,0 +1,176 @@
+'use strict';
+
+/**
+ * Gmail API importer — spec §16.
+ *
+ * ADMIN-ONLY and DISABLED BY DEFAULT.
+ * `isEnabled()` returns false unless GMAIL_IMPORT_ENABLED=true is set.
+ *
+ * This module documents the OAuth least-privilege query shape and the
+ * intended import flow but does NOT implement live OAuth. Implementing
+ * live OAuth requires a human-driven consent flow (browser redirect) that
+ * cannot run headlessly without the GMAIL_REFRESH_TOKEN being pre-obtained.
+ *
+ * Least-privilege scope: gmail.readonly — access existing messages and
+ * settings, no compose, no delete.
+ *
+ * Example Gmail search queries (§16):
+ *
+ * // RENTV-owned messages with advertising/sponsorship signals
+ * (from:(rentv.com) OR from:(shared1.ccsend.com) OR subject:(RENTV))
+ * (sponsor OR sponsored OR advertiser OR advertising OR "Property Spotlight" OR "CRE Talk")
+ *
+ * // Collect only messages with attachments (creatives, PDFs, rate cards)
+ * from:(rentv.com) has:attachment
+ *
+ * // Constant Contact delivery receipts / campaign archives
+ * from:(shared1.ccsend.com) subject:(RENTV)
+ *
+ * The importer NEVER mirrors the full mailbox. Only messages matching the
+ * configured GMAIL_IMPORT_QUERY are fetched, and only selected fields and
+ * attachments are stored.
+ *
+ * @module src/connectors/gmail
+ */
+
+// ---------------------------------------------------------------------------
+// Feature gate
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns true only when Gmail import is explicitly enabled by the admin.
+ * @returns {boolean}
+ */
+function isEnabled() {
+ return process.env.GMAIL_IMPORT_ENABLED === 'true';
+}
+
+// ---------------------------------------------------------------------------
+// Disabled guard
+// ---------------------------------------------------------------------------
+
+function requireEnabled() {
+ if (!isEnabled()) {
+ throw new Error(
+ 'Gmail import is disabled. ' +
+ 'Set GMAIL_IMPORT_ENABLED=true and provide GMAIL_CLIENT_ID, ' +
+ 'GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN to enable. ' +
+ 'This feature is admin-only and requires explicit authorization. ' +
+ 'Live OAuth is not implemented — obtain a refresh token via the ' +
+ 'Google OAuth 2.0 Playground (https://developers.google.com/oauthplayground) ' +
+ 'with scope: https://www.googleapis.com/auth/gmail.readonly'
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Connection test (stub — no live OAuth)
+// ---------------------------------------------------------------------------
+
+/**
+ * Test the Gmail import connection.
+ * Throws a friendly error when disabled.
+ *
+ * TODO live implementation:
+ * const { google } = require('googleapis'); // not installed
+ * const auth = new google.auth.OAuth2(
+ * process.env.GMAIL_CLIENT_ID,
+ * process.env.GMAIL_CLIENT_SECRET
+ * );
+ * auth.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN });
+ * const gmail = google.gmail({ version: 'v1', auth });
+ * const profile = await gmail.users.getProfile({ userId: 'me' });
+ * return { connected: true, email: profile.data.emailAddress };
+ *
+ * @returns {{ connected: boolean, demo: boolean, error: string }}
+ */
+async function connectionTest() {
+ requireEnabled();
+ throw new Error('Gmail live OAuth not implemented — obtain a refresh token manually');
+}
+
+// ---------------------------------------------------------------------------
+// Message search (stub)
+// ---------------------------------------------------------------------------
+
+/**
+ * Search the authorized mailbox using the configured query.
+ *
+ * TODO live implementation:
+ * const messages = [];
+ * let pageToken;
+ * do {
+ * const res = await gmail.users.messages.list({
+ * userId: 'me',
+ * q: process.env.GMAIL_IMPORT_QUERY || DEFAULT_QUERY,
+ * maxResults: 500,
+ * pageToken,
+ * });
+ * messages.push(...(res.data.messages || []));
+ * pageToken = res.data.nextPageToken;
+ * } while (pageToken);
+ * return messages; // [{ id, threadId }]
+ *
+ * Default queries (§16):
+ * (from:(rentv.com) OR from:(shared1.ccsend.com) OR subject:(RENTV))
+ * (sponsor OR sponsored OR advertiser OR advertising OR "Property Spotlight" OR "CRE Talk")
+ *
+ * @returns {Promise<Array<{ id: string, threadId: string }>>}
+ */
+async function searchMessages() {
+ requireEnabled();
+ throw new Error('Gmail import disabled');
+}
+
+// ---------------------------------------------------------------------------
+// Message fetch + selective field extraction (stub)
+// ---------------------------------------------------------------------------
+
+/**
+ * Fetch a single message and extract evidence fields.
+ *
+ * TODO live implementation:
+ * const msg = await gmail.users.messages.get({
+ * userId: 'me',
+ * id: messageId,
+ * format: 'full',
+ * });
+ * // Extract headers
+ * const headers = msg.data.payload.headers;
+ * const from = headers.find(h => h.name === 'From')?.value;
+ * const subject = headers.find(h => h.name === 'Subject')?.value;
+ * const date = headers.find(h => h.name === 'Date')?.value;
+ * // Extract body (base64url decode)
+ * // Extract attachment metadata (do not auto-open tracking links)
+ * // Return structured evidence: { from, subject, date, bodyExcerpt, attachments }
+ *
+ * @param {string} messageId
+ * @returns {Promise<object>}
+ */
+async function fetchMessage(messageId) {
+ requireEnabled();
+ throw new Error('Gmail import disabled');
+}
+
+// ---------------------------------------------------------------------------
+// Full import (stub)
+// ---------------------------------------------------------------------------
+
+/**
+ * Run a full Gmail import pass.
+ * NEVER auto-opens email tracking links.
+ *
+ * @param {{ dryRun?: boolean, query?: string }} options
+ */
+async function importAll({ dryRun = false, query: gmailQuery } = {}) {
+ requireEnabled();
+ throw new Error('Gmail import disabled');
+}
+
+module.exports = {
+ isEnabled,
+ connectionTest,
+ searchMessages,
+ fetchMessage,
+ importAll,
+};
diff --git a/src/connectors/google-ads.js b/src/connectors/google-ads.js
new file mode 100644
index 0000000..985885f
--- /dev/null
+++ b/src/connectors/google-ads.js
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Google Ads connector — spec §19.
+ *
+ * DISABLED BY DEFAULT. `isEnabled()` returns false unless
+ * GOOGLE_ADS_ENABLED=true is explicitly set.
+ *
+ * Google Ads data MUST remain strictly separate from Search Console data.
+ * Never mix paid-search metrics with organic GSC metrics in the same table
+ * or display surface.
+ *
+ * No fixtures are provided for Google Ads because enabling it without real
+ * credentials would produce misleading paid-performance numbers.
+ *
+ * When credentials are eventually configured:
+ * - Set GOOGLE_ADS_ENABLED=true
+ * - Provide GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_CUSTOMER_ID,
+ * GOOGLE_ADS_LOGIN_CUSTOMER_ID, GOOGLE_ADS_CLIENT_ID,
+ * GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN
+ * - Implement the TODO real API branch using the google-ads-api npm package
+ * or direct REST calls to googleads.googleapis.com/v17
+ *
+ * @module src/connectors/google-ads
+ */
+
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Feature gate
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns true only when Google Ads integration is explicitly enabled.
+ * @returns {boolean}
+ */
+function isEnabled() {
+ return process.env.GOOGLE_ADS_ENABLED === 'true';
+}
+
+// ---------------------------------------------------------------------------
+// Disabled guard — applied to every public function
+// ---------------------------------------------------------------------------
+
+function requireEnabled() {
+ if (!isEnabled()) {
+ throw new Error(
+ 'Google Ads connector is not configured. ' +
+ 'Set GOOGLE_ADS_ENABLED=true and provide all GOOGLE_ADS_* environment variables ' +
+ 'to enable this integration. Google Ads data is separate from Search Console data.'
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Connection test
+// ---------------------------------------------------------------------------
+
+/**
+ * Test the Google Ads connection.
+ * Throws a friendly error when disabled.
+ *
+ * @returns {{ connected: boolean, demo: boolean, error?: string }}
+ */
+async function connectionTest() {
+ requireEnabled();
+
+ // TODO real API:
+ // const { GoogleAdsClient } = require('google-ads-api'); // not installed
+ // const client = new GoogleAdsClient({
+ // developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN,
+ // client_id: process.env.GOOGLE_ADS_CLIENT_ID,
+ // client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET,
+ // refresh_token: process.env.GOOGLE_ADS_REFRESH_TOKEN,
+ // login_customer_id: process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
+ // });
+ // const customer = client.Customer({ customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID });
+ // await customer.query(`SELECT customer.id, customer.descriptive_name FROM customer LIMIT 1`);
+ throw new Error('Google Ads live API not enabled (install google-ads-api and implement the TODO real API branch)');
+}
+
+// ---------------------------------------------------------------------------
+// Campaign metrics import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import campaign-level daily metrics into google_ads_campaign_metrics.
+ *
+ * TODO real API: GAQL query shape:
+ * SELECT
+ * campaign.name,
+ * segments.date,
+ * metrics.cost_micros,
+ * metrics.clicks,
+ * metrics.impressions,
+ * metrics.conversions
+ * FROM campaign
+ * WHERE segments.date DURING LAST_90_DAYS
+ * AND campaign.status != 'REMOVED'
+ * ORDER BY segments.date DESC
+ *
+ * @param {{ dryRun?: boolean }} options
+ */
+async function importCampaignMetrics({ dryRun = false } = {}) {
+ requireEnabled();
+ // Implementation pending live credentials
+ throw new Error('Google Ads not configured');
+}
+
+// ---------------------------------------------------------------------------
+// Search term metrics import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import search-term level metrics into google_ads_search_term_metrics.
+ *
+ * TODO real API: GAQL query shape:
+ * SELECT
+ * search_term_view.search_term,
+ * campaign.name,
+ * segments.date,
+ * metrics.clicks,
+ * metrics.impressions,
+ * metrics.cost_micros,
+ * metrics.conversions
+ * FROM search_term_view
+ * WHERE segments.date DURING LAST_90_DAYS
+ * ORDER BY metrics.impressions DESC
+ * LIMIT 10000
+ *
+ * @param {{ dryRun?: boolean }} options
+ */
+async function importSearchTermMetrics({ dryRun = false } = {}) {
+ requireEnabled();
+ throw new Error('Google Ads not configured');
+}
+
+// ---------------------------------------------------------------------------
+// Full import
+// ---------------------------------------------------------------------------
+
+/**
+ * Import all Google Ads data.
+ * Only callable when isEnabled() is true.
+ *
+ * @param {{ dryRun?: boolean }} options
+ */
+async function importAll({ dryRun = false } = {}) {
+ requireEnabled();
+ throw new Error('Google Ads not configured');
+}
+
+// ---------------------------------------------------------------------------
+// Connection record
+// ---------------------------------------------------------------------------
+
+async function ensureConnectionRecord() {
+ await query(
+ `INSERT INTO analytics_connections (kind, status, is_demo)
+ VALUES ('GOOGLE_ADS', 'NOT_CONNECTED', true)
+ ON CONFLICT DO NOTHING`
+ );
+}
+
+module.exports = {
+ isEnabled,
+ connectionTest,
+ importCampaignMetrics,
+ importSearchTermMetrics,
+ importAll,
+ ensureConnectionRecord,
+};
diff --git a/src/connectors/gsc.js b/src/connectors/gsc.js
new file mode 100644
index 0000000..be057da
--- /dev/null
+++ b/src/connectors/gsc.js
@@ -0,0 +1,291 @@
+'use strict';
+
+/**
+ * Google Search Console connector — spec §18.
+ *
+ * Fixture-backed when GSC_SITE_URL / GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 are
+ * absent (the default). Loads gsc-queries.json and gsc-pages.json, classifies
+ * each query for brand/nonbrand and topic cluster, then UPSERTs into
+ * gsc_query_metrics / gsc_page_metrics with is_demo=true.
+ *
+ * IMPORTANT: Search Console data represents ORGANIC search only. Never label
+ * it as paid search.
+ *
+ * @module src/connectors/gsc
+ */
+
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { query } = require('../../db');
+
+// ---------------------------------------------------------------------------
+// Credentials probe
+// ---------------------------------------------------------------------------
+
+function hasCredentials() {
+ return Boolean(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 && process.env.GSC_SITE_URL);
+}
+
+/**
+ * @returns {{ connected: boolean, demo: boolean, error?: string }}
+ */
+async function connectionTest() {
+ if (!hasCredentials()) {
+ return { connected: false, demo: true, error: 'No GSC_SITE_URL / GOOGLE_SERVICE_ACCOUNT_JSON_BASE64 configured — running in DEMO/fixture mode.' };
+ }
+ // TODO real API: use googleapis searchconsole.sites.getQueryReport or
+ // the webmasters.searchanalytics.query endpoint:
+ //
+ // const { google } = require('googleapis');
+ // const creds = JSON.parse(Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_JSON_BASE64,'base64').toString('utf8'));
+ // const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ['https://www.googleapis.com/auth/webmasters.readonly'] });
+ // const sc = google.searchconsole({ version: 'v1', auth });
+ // await sc.sites.get({ siteUrl: process.env.GSC_SITE_URL });
+ throw new Error('GSC live API not enabled (no service account)');
+}
+
+// ---------------------------------------------------------------------------
+// Brand & cluster classification — spec §18
+// ---------------------------------------------------------------------------
+
+/**
+ * Classify a query as brand or nonbrand.
+ * Brand = contains the token 'rentv' (case-insensitive).
+ *
+ * @param {string} queryStr
+ * @returns {boolean}
+ */
+function classifyBrandVsNonbrand(queryStr) {
+ if (!queryStr || typeof queryStr !== 'string') return false;
+ return /rentv/i.test(queryStr);
+}
+
+/**
+ * Assign one topic cluster to a query string.
+ * Implements the §18 cluster taxonomy.
+ *
+ * Priority order matters: more specific patterns first.
+ *
+ * @param {string} queryStr
+ * @returns {string} one of the cluster keys defined in spec §18
+ */
+function clusterQuery(queryStr) {
+ if (!queryStr || typeof queryStr !== 'string') return 'other';
+ const q = queryStr.toLowerCase();
+
+ // Conference / event signals
+ if (/\b(conference|summit|event|expo|forum|naiop|uli|boma|icsc|crew|sior|ccim|convention|symposium|award|gala)\b/.test(q)) {
+ return 'conference_event';
+ }
+
+ // Advertiser category / advertising signals
+ if (/\b(advertis|sponsor|media kit|newsletter|eblast|placement|property spotlight|cre talk)\b/.test(q)) {
+ return 'advertiser_category';
+ }
+
+ // Finance / lending signals
+ if (/\b(financ|lend|loan|mortgage|debt|capital|credit|rate|refinanc|bridge|mezzanine|cmbs|note|fund)\b/.test(q)) {
+ return 'finance_lending';
+ }
+
+ // Brokerage / deal signals
+ if (/\b(brokerage|broker|sale|sold|deal|acquisition|disposition|1031|nnn|net lease|cap rate|listing)\b/.test(q)) {
+ return 'brokerage_deal';
+ }
+
+ // Property type signals
+ if (/\b(office|industrial|retail|multifamily|apartment|warehouse|flex|mixed.use|data.center|lab|life.science|hotel|hospitality|land|development)\b/.test(q)) {
+ return 'property_type';
+ }
+
+ // California market signals
+ if (/\b(los angeles|la |orange county|irvine|san diego|inland empire|bay area|san francisco|sacramento|ventura|pasadena|long beach|burbank|riverside|ontario|santa ana|socal|southern california|northern california|california|ca )\b/.test(q)) {
+ return 'california_market';
+ }
+
+ // Arizona market signals
+ if (/\b(phoenix|scottsdale|tempe|mesa|chandler|gilbert|glendale|tucson|arizona|az )\b/.test(q)) {
+ return 'arizona_market';
+ }
+
+ return 'other';
+}
+
+// ---------------------------------------------------------------------------
+// Fixture helpers
+// ---------------------------------------------------------------------------
+
+const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures');
+
+function loadFixture(name) {
+ return JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8'));
+}
+
+function fixtureChecksum(name) {
+ return crypto.createHash('sha256').update(fs.readFileSync(path.join(FIXTURES_DIR, name))).digest('hex');
+}
+
+// ---------------------------------------------------------------------------
+// Import run bookkeeping (mirrors ga4.js pattern)
+// ---------------------------------------------------------------------------
+
+async function startRun(kind, sourceFile, checksum) {
+ const res = await query(
+ `INSERT INTO analytics_import_runs (kind, source_file, checksum, is_demo, status)
+ VALUES ($1,$2,$3,true,'RUNNING') RETURNING id`,
+ [kind, sourceFile, checksum]
+ );
+ return res.rows[0].id;
+}
+
+async function finishRun(runId, rowCount, error) {
+ await query(
+ `UPDATE analytics_import_runs
+ SET finished_at = now(), status = $2, row_count = $3, error = $4
+ WHERE id = $1`,
+ [runId, error ? 'ERROR' : 'SUCCESS', rowCount, error || null]
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Importers
+// ---------------------------------------------------------------------------
+
+/**
+ * Load gsc-queries.json → gsc_query_metrics.
+ * Applies classifyBrandVsNonbrand() + clusterQuery() to each row.
+ *
+ * @param {boolean} dryRun
+ * @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null, dryRun: boolean }}
+ */
+async function importQueries(dryRun = false) {
+ const fixture = loadFixture('gsc-queries.json');
+ const rows = fixture.rows;
+ const checksum = fixtureChecksum('gsc-queries.json');
+ let runId = null;
+ let inserted = 0;
+
+ if (!dryRun) {
+ runId = await startRun('GSC_QUERIES', 'fixtures/gsc-queries.json', checksum);
+ const dates = [...new Set(rows.map((r) => r.metric_date))];
+ for (const d of dates) {
+ await query('DELETE FROM gsc_query_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+ }
+ }
+
+ try {
+ for (const row of rows) {
+ if (!row.metric_date || !row.query) continue;
+ const isBrand = classifyBrandVsNonbrand(row.query);
+ const cluster = clusterQuery(row.query);
+
+ if (!dryRun) {
+ await query(
+ `INSERT INTO gsc_query_metrics
+ (metric_date, query, country, device, clicks, impressions, ctr, position,
+ is_brand, cluster, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)`,
+ [
+ row.metric_date, row.query, row.country, row.device,
+ row.clicks, row.impressions, row.ctr, row.position,
+ isBrand, cluster,
+ ]
+ );
+ }
+ inserted++;
+ }
+
+ if (!dryRun) await finishRun(runId, inserted, null);
+ return { inserted, skipped: 0, rejected: [], runId, dryRun };
+ } catch (err) {
+ if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+ throw err;
+ }
+}
+
+/**
+ * Load gsc-pages.json → gsc_page_metrics.
+ *
+ * @param {boolean} dryRun
+ * @returns {{ inserted: number, skipped: number, rejected: [], runId: string|null, dryRun: boolean }}
+ */
+async function importPages(dryRun = false) {
+ const fixture = loadFixture('gsc-pages.json');
+ const rows = fixture.rows;
+ const checksum = fixtureChecksum('gsc-pages.json');
+ let runId = null;
+ let inserted = 0;
+
+ if (!dryRun) {
+ runId = await startRun('GSC_PAGES', 'fixtures/gsc-pages.json', checksum);
+ const dates = [...new Set(rows.map((r) => r.metric_date))];
+ for (const d of dates) {
+ await query('DELETE FROM gsc_page_metrics WHERE metric_date = $1 AND is_demo = true', [d]);
+ }
+ }
+
+ try {
+ for (const row of rows) {
+ if (!row.metric_date || !row.page) continue;
+ if (!dryRun) {
+ await query(
+ `INSERT INTO gsc_page_metrics
+ (metric_date, page, country, device, clicks, impressions, ctr, position, is_demo)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
+ [
+ row.metric_date, row.page, row.country, row.device,
+ row.clicks, row.impressions, row.ctr, row.position,
+ ]
+ );
+ }
+ inserted++;
+ }
+
+ if (!dryRun) await finishRun(runId, inserted, null);
+ return { inserted, skipped: 0, rejected: [], runId, dryRun };
+ } catch (err) {
+ if (!dryRun && runId) await finishRun(runId, inserted, err.message);
+ throw err;
+ }
+}
+
+/**
+ * Import all GSC data (queries + pages).
+ *
+ * TODO real API: replace fixture branches with googleapis searchconsole calls:
+ *
+ * const body = {
+ * startDate: '2026-05-08',
+ * endDate: '2026-08-06',
+ * dimensions: ['query', 'country', 'device', 'date'],
+ * rowLimit: 25000,
+ * };
+ * const res = await sc.searchanalytics.query({ siteUrl: process.env.GSC_SITE_URL, requestBody: body });
+ * // paginate via startRow until res.data.rows.length < rowLimit
+ *
+ * @param {{ dryRun?: boolean }} options
+ * @returns {Promise<{ queries: object, pages: object, demo: true }>}
+ */
+async function importAll({ dryRun = false } = {}) {
+ if (hasCredentials()) {
+ throw new Error('GSC live API not enabled (no service account) — implement the TODO real API branch.');
+ }
+
+ const [queries, pages] = await Promise.all([
+ importQueries(dryRun),
+ importPages(dryRun),
+ ]);
+
+ return { queries, pages, demo: true };
+}
+
+module.exports = {
+ connectionTest,
+ importAll,
+ importQueries,
+ importPages,
+ classifyBrandVsNonbrand,
+ clusterQuery,
+ hasCredentials,
+};
diff --git a/src/export/build.js b/src/export/build.js
new file mode 100644
index 0000000..d92fc8e
--- /dev/null
+++ b/src/export/build.js
@@ -0,0 +1,657 @@
+'use strict';
+/**
+ * buildDownloadEverything — the main export orchestrator (spec §28).
+ *
+ * Produces: RENTV-Advertiser-Intelligence-YYYY-MM-DD.zip
+ *
+ * ZIP contents (§28):
+ * executive-viewer.html
+ * README.html
+ * advertisers.csv / .xlsx
+ * contacts.csv / .xlsx
+ * ad-sightings.csv
+ * creatives.csv
+ * conference-sponsors.csv
+ * conferences.csv
+ * prospects.csv
+ * analytics-summary.csv
+ * ga4-landing-pages.csv
+ * ga4-geography.csv
+ * gsc-queries.csv
+ * gsc-pages.csv
+ * google-ads-campaigns.csv (only if google_ads_campaign_metrics has rows)
+ * source-manifest.json
+ * methodology.json
+ * export-audit.json
+ * thumbnails/ (only EXPORT_ALLOWED assets with file on disk)
+ * evidence/ (only export_allowed evidence records with file)
+ *
+ * Rights: every dataset passes through applyExportRights() before export.
+ * Writes an exports row (status → DONE) + an audit_logs row.
+ * Returns { zipPath, rowCounts }.
+ *
+ * @module src/export/build
+ */
+
+require('../../lib/env'); // populate process.env from .env if present
+const fs = require('fs');
+const path = require('path');
+
+const { pool, query } = require('../../db');
+const { ZipWriter } = require('./zip');
+const { toCsvBuffer } = require('./csv');
+const { toXlsxBuffer } = require('./xlsx');
+const { generateExecutiveViewer } = require('./executive-viewer');
+const {
+ applyExportRights,
+ buildSuppressionSets,
+ buildContactSuppressionSet,
+ classifyAssetForExport,
+} = require('./rights');
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Return today's date as YYYY-MM-DD using the system clock. */
+function todayStr() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+/**
+ * Safe JSON stringify — never throws.
+ * @param {*} obj
+ * @returns {string}
+ */
+function safeJson(obj) {
+ try {
+ return JSON.stringify(obj, null, 2);
+ } catch (_) {
+ return '{}';
+ }
+}
+
+/**
+ * Try to load a file from the local asset store.
+ * Returns a Buffer or null if the file doesn't exist / can't be read.
+ * @param {string} objectKey
+ * @returns {Buffer|null}
+ */
+function loadAssetFile(objectKey) {
+ if (!objectKey) return null;
+ const base = process.env.OBJECT_STORAGE_LOCAL_DIR || path.join(__dirname, '../../data/assets');
+ const filePath = path.join(base, objectKey);
+ try {
+ // Prevent path traversal: resolved path must be under base
+ const resolved = path.resolve(filePath);
+ const resolvedBase = path.resolve(base);
+ if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
+ return null;
+ }
+ return fs.readFileSync(resolved);
+ } catch (_) {
+ return null;
+ }
+}
+
+/**
+ * Convert a file Buffer to a data: URI given a mime type.
+ * @param {Buffer} buf
+ * @param {string} mimeType
+ * @returns {string}
+ */
+function toDataUri(buf, mimeType) {
+ const safe = mimeType || 'image/png';
+ return `data:${safe};base64,${buf.toString('base64')}`;
+}
+
+// ---------------------------------------------------------------------------
+// README HTML (self-contained)
+// ---------------------------------------------------------------------------
+function buildReadmeHtml(exportDate) {
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>RENTV Advertiser Intelligence — Export README</title>
+<style>
+ body { font-family: -apple-system, sans-serif; max-width: 760px; margin: 40px auto; padding: 0 20px; color: #1e293b; line-height: 1.6; }
+ h1 { font-size: 1.5rem; margin-bottom: 8px; }
+ h2 { font-size: 1.1rem; margin-top: 24px; margin-bottom: 6px; border-bottom: 2px solid #e2e8f0; padding-bottom: 4px; }
+ table { border-collapse: collapse; width: 100%; font-size: .85rem; margin-top: 8px; }
+ th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; }
+ th { background: #f8fafc; }
+ code { background: #f1f5f9; border-radius: 4px; padding: 1px 5px; font-size: .82rem; }
+ .warn { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 10px 14px; margin: 16px 0; }
+</style>
+</head>
+<body>
+<h1>RENTV Advertiser Intelligence Export</h1>
+<p>Export date: <strong>${exportDate}</strong></p>
+<p class="warn"><strong>Confidential.</strong> This export contains non-public business intelligence. Do not distribute to unauthorised recipients.</p>
+
+<h2>Files in this package</h2>
+<table>
+<thead><tr><th>File</th><th>Description</th></tr></thead>
+<tbody>
+<tr><td><code>executive-viewer.html</code></td><td>Interactive offline viewer — open by double-clicking. Works without internet.</td></tr>
+<tr><td><code>advertisers.csv / .xlsx</code></td><td>All non-suppressed organizations with their best classification status.</td></tr>
+<tr><td><code>contacts.csv / .xlsx</code></td><td>Public business contacts (do-not-contact excluded).</td></tr>
+<tr><td><code>ad-sightings.csv</code></td><td>Every verified ad/sponsor sighting with source URL and date.</td></tr>
+<tr><td><code>creatives.csv</code></td><td>Creative asset metadata. Files in <code>thumbnails/</code> where rights allow.</td></tr>
+<tr><td><code>conference-sponsors.csv</code></td><td>Event relationship rows for conference sponsors and exhibitors.</td></tr>
+<tr><td><code>conferences.csv</code></td><td>CRE conferences and events.</td></tr>
+<tr><td><code>prospects.csv</code></td><td>Opportunity scores with scoring factors.</td></tr>
+<tr><td><code>analytics-summary.csv</code></td><td>GA4 daily totals (aggregate only, no user-level data).</td></tr>
+<tr><td><code>ga4-landing-pages.csv</code></td><td>Top landing pages by sessions.</td></tr>
+<tr><td><code>ga4-geography.csv</code></td><td>Sessions and users by country/region/city.</td></tr>
+<tr><td><code>gsc-queries.csv</code></td><td>Google Search Console search queries.</td></tr>
+<tr><td><code>gsc-pages.csv</code></td><td>Google Search Console top pages.</td></tr>
+<tr><td><code>source-manifest.json</code></td><td>All enabled source policies and their provenance.</td></tr>
+<tr><td><code>methodology.json</code></td><td>Classification rules, export settings, and row counts.</td></tr>
+<tr><td><code>export-audit.json</code></td><td>Audit trail: which rights rules ran, what was excluded.</td></tr>
+<tr><td><code>thumbnails/</code></td><td>Ad/logo images where rights_status = EXPORT_ALLOWED.</td></tr>
+<tr><td><code>evidence/</code></td><td>Evidence files where export_allowed = true.</td></tr>
+</tbody>
+</table>
+
+<h2>Rights and privacy</h2>
+<ul>
+<li>Suppressed organizations are excluded from all files.</li>
+<li>Do-not-contact and export-blocked contacts are excluded from contacts files.</li>
+<li>Private notes are never exported.</li>
+<li>Images marked INTERNAL_EVIDENCE_ONLY are not included in thumbnails/ or evidence/.</li>
+<li>Unknown-rights images are treated as internal only (conservative default).</li>
+</ul>
+
+<h2>How to use the offline viewer</h2>
+<ol>
+<li>Double-click <code>executive-viewer.html</code> to open in your browser.</li>
+<li>Use the search box to find a company by name, domain, city, or state.</li>
+<li>Use California / Arizona buttons to filter by market.</li>
+<li>Toggle "Verified only" to show only confirmed advertisers and sponsors.</li>
+<li>Click company names or Source links to open the original evidence pages.</li>
+</ol>
+
+<p>For questions contact <a href="mailto:admin@rentv.com">admin@rentv.com</a>.</p>
+</body>
+</html>`;
+}
+
+// ---------------------------------------------------------------------------
+// CSV column definitions for each dataset
+// ---------------------------------------------------------------------------
+
+const ADVERTISER_COLUMNS = [
+ 'id', 'display_name', 'legal_name', 'domain', 'organization_type',
+ 'headquarters_city', 'headquarters_state', 'active_status',
+ 'advertiser_categories', 'best_status', 'sighting_count',
+ 'first_seen_at', 'last_seen_at', 'created_at',
+];
+
+const CONTACT_COLUMNS = [
+ 'id', 'organization_id', 'person_id', 'type', 'value',
+ 'explicitly_public', 'verified_at', 'confidence', 'created_at',
+];
+
+const AD_SIGHTINGS_COLUMNS = [
+ 'id', 'organization_id', 'publication_id', 'relationship_status',
+ 'source_page_url', 'headline', 'visible_copy',
+ 'observed_at', 'first_observed_at', 'last_observed_at',
+ 'verification_status', 'confidence', 'created_at',
+];
+
+const CREATIVES_COLUMNS = [
+ 'id', 'organization_id', 'file_name', 'mime_type', 'width', 'height',
+ 'rights_status', 'capture_method', 'source_image_url',
+ 'captured_at', 'alt_text', 'export_class', 'created_at',
+];
+
+const CONF_SPONSORS_COLUMNS = [
+ 'id', 'event_id', 'organization_id', 'person_id', 'relationship_status',
+ 'sponsor_level', 'booth_number', 'session_title', 'panel_role',
+ 'observed_at', 'confidence', 'created_at',
+];
+
+const CONFERENCES_COLUMNS = [
+ 'id', 'name', 'event_type', 'start_date', 'end_date',
+ 'venue', 'city', 'state', 'official_url',
+ 'sponsor_page_url', 'exhibitor_page_url', 'created_at',
+];
+
+const PROSPECTS_COLUMNS = [
+ 'id', 'organization_id', 'display_name', 'domain',
+ 'score', 'factors', 'computed_at',
+];
+
+const ANALYTICS_SUMMARY_COLUMNS = [
+ 'metric_date', 'sessions', 'total_users', 'new_users',
+ 'engaged_sessions', 'engagement_rate', 'avg_engagement_time',
+ 'views', 'event_count', 'key_events', 'is_demo',
+];
+
+const GA4_LANDING_COLUMNS = [
+ 'id', 'metric_date', 'landing_page', 'sessions', 'users', 'views',
+ 'engaged_sessions', 'engagement_rate', 'key_events', 'is_demo',
+];
+
+const GA4_GEO_COLUMNS = [
+ 'id', 'metric_date', 'country', 'region', 'city',
+ 'sessions', 'users', 'is_demo',
+];
+
+const GSC_QUERIES_COLUMNS = [
+ 'id', 'metric_date', 'query', 'country', 'device',
+ 'clicks', 'impressions', 'ctr', 'position', 'is_brand', 'cluster', 'is_demo',
+];
+
+const GSC_PAGES_COLUMNS = [
+ 'id', 'metric_date', 'page', 'country', 'device',
+ 'clicks', 'impressions', 'ctr', 'position', 'is_demo',
+];
+
+const GADS_COLUMNS = [
+ 'id', 'metric_date', 'campaign', 'cost_micros', 'clicks',
+ 'impressions', 'conversions', 'currency', 'is_demo',
+];
+
+// ---------------------------------------------------------------------------
+// Main build function
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the Download Everything ZIP and return the result.
+ *
+ * @param {Object} opts
+ * @param {string} opts.outDir - directory to write the ZIP into
+ * @returns {Promise<{zipPath: string, rowCounts: Object}>}
+ */
+async function buildDownloadEverything({ outDir }) {
+ const date = todayStr();
+ const folderName = `RENTV-Advertiser-Intelligence-${date}`;
+ const zipFileName = `${folderName}.zip`;
+ const zipPath = path.join(outDir, zipFileName);
+
+ // Create outDir if needed
+ fs.mkdirSync(outDir, { recursive: true });
+
+ // -----------------------------------------------------------------------
+ // 1. Load suppression context (rights enforcement)
+ // -----------------------------------------------------------------------
+ const suppressionRes = await query(
+ `SELECT id, scope, target_value, organization_id, person_id, active
+ FROM suppression_requests WHERE active = true`
+ );
+ const suppressionRows = suppressionRes.rows;
+ const { suppressedOrgIds, suppressedPersonIds } = buildSuppressionSets(suppressionRows);
+ const suppressedContactValues = buildContactSuppressionSet(suppressionRows);
+ const rightsCtx = { suppressedOrgIds, suppressedPersonIds, suppressedContactValues };
+
+ // -----------------------------------------------------------------------
+ // 2. Load all datasets
+ // -----------------------------------------------------------------------
+
+ // Organizations with aggregated sighting status
+ const orgRes = await query(`
+ SELECT o.*,
+ (SELECT relationship_status
+ FROM ad_sightings s
+ WHERE s.organization_id = o.id
+ ORDER BY ARRAY_POSITION(ARRAY[
+ 'VERIFIED_ADVERTISER','VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR',
+ 'VERIFIED_MEDIA_PARTNER','VERIFIED_CONTENT_PARTNER','PAST_ADVERTISER',
+ 'LIKELY_PROSPECT','RESEARCH_NEEDED','SPEAKER_OR_PANELIST_ONLY','DISQUALIFIED'
+ ], s.relationship_status), s.created_at DESC
+ LIMIT 1
+ ) AS best_status,
+ (SELECT COUNT(*) FROM ad_sightings s2 WHERE s2.organization_id = o.id)::int AS sighting_count
+ FROM organizations o
+ ORDER BY o.display_name
+ `);
+ const rawOrgs = orgRes.rows.map((r) => ({
+ ...r,
+ advertiser_categories: JSON.stringify(r.advertiser_categories || []),
+ best_status: r.best_status || 'RESEARCH_NEEDED',
+ }));
+ const filteredOrgs = applyExportRights(rawOrgs, 'organizations', rightsCtx);
+
+ // Contacts
+ const contactRes = await query(
+ `SELECT * FROM contact_points ORDER BY created_at`
+ );
+ const filteredContacts = applyExportRights(contactRes.rows, 'contacts', rightsCtx);
+
+ // Ad sightings
+ const sightingRes = await query(
+ `SELECT * FROM ad_sightings ORDER BY observed_at DESC NULLS LAST, created_at DESC`
+ );
+ const filteredSightings = applyExportRights(sightingRes.rows, 'ad_sightings', rightsCtx);
+
+ // Creative assets
+ const creativeRes = await query(
+ `SELECT * FROM creative_assets ORDER BY created_at`
+ );
+ const filteredCreatives = applyExportRights(creativeRes.rows, 'creative_assets', rightsCtx);
+ const creativesForCsv = filteredCreatives.map((r) => ({
+ ...r,
+ export_class: r._exportClass,
+ _exportClass: undefined,
+ }));
+
+ // Event relationships (conference sponsors/exhibitors)
+ const eventRelRes = await query(
+ `SELECT * FROM event_relationships ORDER BY created_at`
+ );
+ const filteredEventRels = applyExportRights(eventRelRes.rows, 'generic', rightsCtx);
+
+ // Events (conferences)
+ const eventRes = await query(
+ `SELECT * FROM events ORDER BY start_date DESC NULLS LAST`
+ );
+ const filteredEvents = applyExportRights(eventRes.rows, 'generic', {
+ suppressedOrgIds,
+ orgIdField: 'organizer_organization_id',
+ });
+
+ // Opportunity scores (prospects)
+ const prospectsRes = await query(`
+ SELECT os.*, o.display_name, o.domain
+ FROM opportunity_scores os
+ JOIN organizations o ON o.id = os.organization_id
+ ORDER BY os.score DESC
+ `);
+ const filteredProspects = applyExportRights(prospectsRes.rows, 'generic', rightsCtx)
+ .map((r) => ({ ...r, factors: JSON.stringify(r.factors) }));
+
+ // GA4 daily
+ const ga4DailyRes = await query(
+ `SELECT * FROM ga4_daily_metrics ORDER BY metric_date DESC`
+ );
+ const filteredGa4Daily = ga4DailyRes.rows; // no org suppression needed for analytics
+
+ // GA4 landing pages
+ const ga4LpRes = await query(
+ `SELECT * FROM ga4_landing_page_metrics ORDER BY metric_date DESC, sessions DESC NULLS LAST`
+ );
+
+ // GA4 geo
+ const ga4GeoRes = await query(
+ `SELECT * FROM ga4_geo_metrics ORDER BY metric_date DESC, sessions DESC NULLS LAST`
+ );
+
+ // GSC queries
+ const gscQueryRes = await query(
+ `SELECT * FROM gsc_query_metrics ORDER BY metric_date DESC, impressions DESC NULLS LAST`
+ );
+
+ // GSC pages
+ const gscPageRes = await query(
+ `SELECT * FROM gsc_page_metrics ORDER BY metric_date DESC, clicks DESC NULLS LAST`
+ );
+
+ // Google Ads (optional — skip file if empty)
+ const gadsRes = await query(
+ `SELECT * FROM google_ads_campaign_metrics ORDER BY metric_date DESC`
+ );
+ const includeGads = gadsRes.rows.length > 0;
+
+ // Source policies
+ const sourcePoliciesRes = await query(
+ `SELECT * FROM source_policies ORDER BY display_name`
+ );
+
+ // Evidence records
+ const evidenceRes = await query(
+ `SELECT * FROM evidence_records ORDER BY created_at`
+ );
+ const filteredEvidence = applyExportRights(evidenceRes.rows, 'evidence_records', rightsCtx);
+
+ // Rate + audience snapshots (for methodology.json)
+ const rateRes = await query(
+ `SELECT * FROM rentv_rate_snapshots ORDER BY observed_at DESC`
+ );
+ const audienceRes = await query(
+ `SELECT * FROM rentv_audience_snapshots ORDER BY observed_at DESC`
+ );
+
+ // -----------------------------------------------------------------------
+ // 3. Build thumbnail data URIs (EXPORT_ALLOWED assets only)
+ // -----------------------------------------------------------------------
+ const thumbnailDataUris = new Map();
+ for (const asset of filteredCreatives) {
+ if (asset._exportClass !== 'include') continue;
+ const buf = loadAssetFile(asset.object_key);
+ if (!buf) continue;
+ thumbnailDataUris.set(asset.id, toDataUri(buf, asset.mime_type || 'image/png'));
+ }
+
+ // -----------------------------------------------------------------------
+ // 4. Generate content
+ // -----------------------------------------------------------------------
+
+ // executive-viewer.html
+ const viewerHtml = generateExecutiveViewer({
+ advertisers: filteredOrgs,
+ contacts: filteredContacts,
+ adSightings: filteredSightings,
+ events: filteredEvents,
+ eventRels: filteredEventRels,
+ prospects: filteredProspects,
+ ga4Summary: filteredGa4Daily,
+ thumbnailDataUris,
+ exportDate: date,
+ sourceCoverage: {
+ total: filteredOrgs.length,
+ withEvidence: filteredSightings.reduce((acc, s) => {
+ acc.add(s.organization_id); return acc;
+ }, new Set()).size,
+ lastUpdated: date,
+ },
+ });
+
+ // README.html
+ const readmeHtml = buildReadmeHtml(date);
+
+ // source-manifest.json
+ const sourceManifest = {
+ generated: new Date().toISOString(),
+ sources: sourcePoliciesRes.rows.map((p) => ({
+ source_key: p.source_key,
+ display_name: p.display_name,
+ owner: p.owner,
+ base_url: p.base_url,
+ access_method: p.access_method,
+ allows_automated_access: p.allows_automated_access,
+ allows_export: p.allows_export,
+ enabled: p.enabled,
+ reviewed_at: p.reviewed_at,
+ })),
+ };
+
+ // methodology.json
+ const methodology = {
+ generated: new Date().toISOString(),
+ export_date: date,
+ spec_version: '28',
+ classification_statuses: [
+ 'VERIFIED_ADVERTISER','VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR',
+ 'VERIFIED_MEDIA_PARTNER','VERIFIED_CONTENT_PARTNER','SPEAKER_OR_PANELIST_ONLY',
+ 'PAST_ADVERTISER','LIKELY_PROSPECT','RESEARCH_NEEDED','DISQUALIFIED',
+ ],
+ rights_rules: [
+ 'suppression_requests.active=true → exclude org/person entirely',
+ 'contact_points.do_not_contact=true → exclude contact row',
+ 'contact_points.export_allowed=false → exclude contact row',
+ 'notes.is_private=true → never exported',
+ 'creative_assets.rights_status=EXPORT_ALLOWED → include file in thumbnails/',
+ 'creative_assets.rights_status=INTERNAL_EVIDENCE_ONLY → omit file, keep metadata',
+ 'creative_assets.rights_status=UNKNOWN → omit file (conservative)',
+ 'evidence_records.export_allowed=false → omit file, keep citation',
+ `EXPORT_MAX_ROWS cap: ${process.env.EXPORT_MAX_ROWS || '100000'}`,
+ ],
+ sqlite_note: 'database-readonly.sqlite omitted — no sqlite dependency in this build (pure Node, no npm extras). All data is available in the CSV/XLSX files.',
+ rentv_rate_snapshots: rateRes.rows,
+ rentv_audience_snapshots: audienceRes.rows,
+ };
+
+ // export-audit.json
+ const rowCounts = {
+ organizations: filteredOrgs.length,
+ contacts: filteredContacts.length,
+ ad_sightings: filteredSightings.length,
+ creative_assets: filteredCreatives.length,
+ event_relationships: filteredEventRels.length,
+ events: filteredEvents.length,
+ prospects: filteredProspects.length,
+ ga4_daily: filteredGa4Daily.length,
+ ga4_landing_pages: ga4LpRes.rows.length,
+ ga4_geography: ga4GeoRes.rows.length,
+ gsc_queries: gscQueryRes.rows.length,
+ gsc_pages: gscPageRes.rows.length,
+ google_ads_campaigns: gadsRes.rows.length,
+ evidence_records: filteredEvidence.length,
+ source_policies: sourcePoliciesRes.rows.length,
+ thumbnails_embedded: thumbnailDataUris.size,
+ };
+
+ const exportAudit = {
+ generated: new Date().toISOString(),
+ export_date: date,
+ rights_enforcement: {
+ suppressed_orgs: suppressedOrgIds.size,
+ suppressed_persons: suppressedPersonIds.size,
+ suppressed_contacts: suppressedContactValues.size,
+ private_notes_excluded: true,
+ do_not_contact_excluded: true,
+ internal_evidence_files_excluded: true,
+ unknown_rights_files_excluded: true,
+ },
+ row_counts: rowCounts,
+ thumbnails_included: thumbnailDataUris.size,
+ google_ads_included: includeGads,
+ };
+
+ // -----------------------------------------------------------------------
+ // 5. Write exports record (status → DONE) + audit_logs
+ // -----------------------------------------------------------------------
+ let exportId;
+ try {
+ const expRes = await query(
+ `INSERT INTO exports (kind, status, row_counts, finished_at)
+ VALUES ('DOWNLOAD_EVERYTHING', 'DONE', $1, now())
+ RETURNING id`,
+ [JSON.stringify(rowCounts)]
+ );
+ exportId = expRes.rows[0].id;
+
+ await query(
+ `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+ VALUES ('EXPORT_CREATED', 'exports', $1, 'system', $2)`,
+ [exportId, JSON.stringify({ zip: zipFileName, date, row_counts: rowCounts })]
+ );
+ } catch (dbErr) {
+ // Non-fatal: audit failure should not abort the export
+ console.warn('[export/build] DB audit write failed:', dbErr.message);
+ }
+
+ // -----------------------------------------------------------------------
+ // 6. Assemble the ZIP
+ // -----------------------------------------------------------------------
+ const zip = new ZipWriter();
+ const prefix = folderName + '/';
+
+ // HTML viewers
+ zip.addEntry(prefix + 'executive-viewer.html', viewerHtml);
+ zip.addEntry(prefix + 'README.html', readmeHtml);
+
+ // CSV + XLSX datasets
+ zip.addEntry(prefix + 'advertisers.csv',
+ toCsvBuffer(filteredOrgs, { columns: ADVERTISER_COLUMNS }));
+ zip.addEntry(prefix + 'advertisers.xlsx',
+ toXlsxBuffer(filteredOrgs, { columns: ADVERTISER_COLUMNS }));
+
+ zip.addEntry(prefix + 'contacts.csv',
+ toCsvBuffer(filteredContacts, { columns: CONTACT_COLUMNS }));
+ zip.addEntry(prefix + 'contacts.xlsx',
+ toXlsxBuffer(filteredContacts, { columns: CONTACT_COLUMNS }));
+
+ zip.addEntry(prefix + 'ad-sightings.csv',
+ toCsvBuffer(filteredSightings, { columns: AD_SIGHTINGS_COLUMNS }));
+
+ zip.addEntry(prefix + 'creatives.csv',
+ toCsvBuffer(creativesForCsv, { columns: CREATIVES_COLUMNS }));
+
+ zip.addEntry(prefix + 'conference-sponsors.csv',
+ toCsvBuffer(filteredEventRels, { columns: CONF_SPONSORS_COLUMNS }));
+
+ zip.addEntry(prefix + 'conferences.csv',
+ toCsvBuffer(filteredEvents, { columns: CONFERENCES_COLUMNS }));
+
+ zip.addEntry(prefix + 'prospects.csv',
+ toCsvBuffer(filteredProspects, { columns: PROSPECTS_COLUMNS }));
+
+ zip.addEntry(prefix + 'analytics-summary.csv',
+ toCsvBuffer(filteredGa4Daily, { columns: ANALYTICS_SUMMARY_COLUMNS }));
+
+ zip.addEntry(prefix + 'ga4-landing-pages.csv',
+ toCsvBuffer(ga4LpRes.rows, { columns: GA4_LANDING_COLUMNS }));
+
+ zip.addEntry(prefix + 'ga4-geography.csv',
+ toCsvBuffer(ga4GeoRes.rows, { columns: GA4_GEO_COLUMNS }));
+
+ zip.addEntry(prefix + 'gsc-queries.csv',
+ toCsvBuffer(gscQueryRes.rows, { columns: GSC_QUERIES_COLUMNS }));
+
+ zip.addEntry(prefix + 'gsc-pages.csv',
+ toCsvBuffer(gscPageRes.rows, { columns: GSC_PAGES_COLUMNS }));
+
+ if (includeGads) {
+ zip.addEntry(prefix + 'google-ads-campaigns.csv',
+ toCsvBuffer(gadsRes.rows, { columns: GADS_COLUMNS }));
+ }
+
+ // JSON manifests
+ zip.addEntry(prefix + 'source-manifest.json', safeJson(sourceManifest));
+ zip.addEntry(prefix + 'methodology.json', safeJson(methodology));
+ zip.addEntry(prefix + 'export-audit.json', safeJson(exportAudit));
+
+ // thumbnails/ — only EXPORT_ALLOWED assets with a file on disk
+ let thumbCount = 0;
+ for (const asset of filteredCreatives) {
+ if (asset._exportClass !== 'include') continue;
+ const buf = loadAssetFile(asset.object_key);
+ if (!buf) continue;
+ const ext = (asset.file_name || 'asset').split('.').pop() || 'bin';
+ const thumbName = `${asset.id}.${ext}`;
+ zip.addEntry(prefix + 'thumbnails/' + thumbName, buf);
+ thumbCount++;
+ }
+
+ // evidence/ — only export_allowed=true evidence with a file on disk
+ let evidenceFileCount = 0;
+ for (const ev of filteredEvidence) {
+ if (ev._exportClass !== 'include') continue;
+ const buf = loadAssetFile(ev.object_key);
+ if (!buf) continue;
+ const ext = (ev.object_key || 'file').split('.').pop() || 'bin';
+ const evName = `${ev.id}.${ext}`;
+ zip.addEntry(prefix + 'evidence/' + evName, buf);
+ evidenceFileCount++;
+ }
+
+ // -----------------------------------------------------------------------
+ // 7. Write ZIP to disk
+ // -----------------------------------------------------------------------
+ const zipBuf = zip.finalize();
+ fs.writeFileSync(zipPath, zipBuf);
+
+ // Finalize row counts with file-level stats
+ rowCounts.thumbnails_files_bundled = thumbCount;
+ rowCounts.evidence_files_bundled = evidenceFileCount;
+ rowCounts.zip_bytes = zipBuf.length;
+
+ return { zipPath, rowCounts };
+}
+
+module.exports = { buildDownloadEverything };
diff --git a/src/export/csv.js b/src/export/csv.js
new file mode 100644
index 0000000..08ee80b
--- /dev/null
+++ b/src/export/csv.js
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * Pure-Node CSV writer — RFC 4180 compliant quoting/escaping.
+ *
+ * - All fields containing commas, double-quotes, or newlines are quoted.
+ * - Double-quotes inside a quoted field are escaped as "".
+ * - Null/undefined rendered as empty string.
+ * - Numbers and booleans serialized as strings.
+ * - CRLF line endings (RFC 4180).
+ *
+ * @module src/export/csv
+ */
+
+/**
+ * Serialize one field value to RFC 4180.
+ * @param {*} value
+ * @returns {string}
+ */
+function escapeField(value) {
+ if (value === null || value === undefined) return '';
+ let str = String(value);
+ // Must quote if contains comma, double-quote, CR, LF, or leading/trailing space
+ if (/[",\r\n]/.test(str) || str !== str.trim()) {
+ str = '"' + str.replace(/"/g, '""') + '"';
+ }
+ return str;
+}
+
+/**
+ * Serialize one row (array of values) to a CSV line with CRLF.
+ * @param {Array} row
+ * @returns {string}
+ */
+function rowToLine(row) {
+ return row.map(escapeField).join(',') + '\r\n';
+}
+
+/**
+ * Convert an array of objects to a CSV string.
+ * Column order is determined by the keys of the first row (or the explicit
+ * `columns` parameter).
+ *
+ * @param {Object[]} rows
+ * @param {Object} [opts]
+ * @param {string[]} [opts.columns] - explicit column order; defaults to Object.keys(rows[0])
+ * @param {boolean} [opts.header] - include header row (default true)
+ * @returns {string} UTF-8 CSV string (BOM prefix for Excel compatibility)
+ */
+function toCsv(rows, opts = {}) {
+ const { columns, header = true } = opts;
+
+ if (!rows || rows.length === 0) {
+ const cols = columns || [];
+ const headerLine = header && cols.length ? rowToLine(cols) : '';
+ return '' + headerLine; // BOM for Excel
+ }
+
+ const cols = columns || Object.keys(rows[0]);
+ const lines = [];
+
+ if (header) lines.push(rowToLine(cols));
+
+ for (const row of rows) {
+ lines.push(rowToLine(cols.map((c) => row[c])));
+ }
+
+ // BOM (U+FEFF) so Excel on Windows recognizes UTF-8 correctly
+ return '' + lines.join('');
+}
+
+/**
+ * Convert an array of objects to a Buffer (UTF-8 with BOM).
+ * @param {Object[]} rows
+ * @param {Object} [opts]
+ * @returns {Buffer}
+ */
+function toCsvBuffer(rows, opts = {}) {
+ return Buffer.from(toCsv(rows, opts), 'utf8');
+}
+
+module.exports = { toCsv, toCsvBuffer, escapeField, rowToLine };
diff --git a/src/export/executive-viewer.js b/src/export/executive-viewer.js
new file mode 100644
index 0000000..fc2e920
--- /dev/null
+++ b/src/export/executive-viewer.js
@@ -0,0 +1,644 @@
+'use strict';
+/**
+ * Executive viewer generator — produces the self-contained executive-viewer.html
+ * that is bundled inside the Download Everything ZIP (spec §28).
+ *
+ * Requirements:
+ * - Works by double-click (file:// protocol), NO server required.
+ * - All assets embedded inline: CSS in <style>, JS in <script>, data in
+ * const DATA = {...} JSON blob so file:// can read sibling files.
+ * - Thumbnail images embedded as data: URIs (only EXPORT_ALLOWED assets).
+ * - INTERNAL_EVIDENCE_ONLY images → neutral placeholder, not embedded.
+ * - Private notes and suppressed entities are already excluded upstream
+ * by applyExportRights() before this function is called.
+ * - CA/AZ filters, search (fuzzy text match), verified-only toggle.
+ * - Accessible (ARIA roles, tabindex, visible focus) and printable.
+ * - No external resources (no CDN, no fonts API, no images from http://).
+ *
+ * @module src/export/executive-viewer
+ */
+
+/**
+ * Escape a string for safe inline HTML output.
+ * @param {*} v
+ * @returns {string}
+ */
+function h(v) {
+ if (v === null || v === undefined) return '';
+ return String(v)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+/**
+ * Generate the self-contained executive-viewer.html.
+ *
+ * @param {Object} payload
+ * @param {Object[]} payload.advertisers - filtered organizations with joined fields
+ * @param {Object[]} payload.contacts - filtered contact_points rows
+ * @param {Object[]} payload.adSightings - filtered ad_sightings rows
+ * @param {Object[]} payload.events - filtered events rows
+ * @param {Object[]} payload.eventRels - filtered event_relationships rows
+ * @param {Object[]} payload.prospects - filtered opportunity_scores rows (joined)
+ * @param {Object[]} payload.ga4Summary - aggregated GA4 daily metrics
+ * @param {Map<string,string>} payload.thumbnailDataUris - assetId → data:image/... URI
+ * @param {string} payload.exportDate - ISO date string YYYY-MM-DD
+ * @param {Object} payload.sourceCoverage - { total, withEvidence, lastUpdated }
+ * @returns {string} complete HTML document
+ */
+function generateExecutiveViewer(payload) {
+ const {
+ advertisers = [],
+ contacts = [],
+ adSightings = [],
+ events = [],
+ eventRels = [],
+ prospects = [],
+ ga4Summary = [],
+ thumbnailDataUris = new Map(),
+ exportDate = new Date().toISOString().slice(0, 10),
+ sourceCoverage = {},
+ } = payload;
+
+ // Pre-index contacts and ad-sightings by org id for O(1) lookup in cards
+ const contactsByOrg = new Map();
+ for (const c of contacts) {
+ if (!contactsByOrg.has(c.organization_id)) contactsByOrg.set(c.organization_id, []);
+ contactsByOrg.get(c.organization_id).push(c);
+ }
+
+ const sightingsByOrg = new Map();
+ for (const s of adSightings) {
+ if (!sightingsByOrg.has(s.organization_id)) sightingsByOrg.set(s.organization_id, []);
+ sightingsByOrg.get(s.organization_id).push(s);
+ }
+
+ const eventRelsByOrg = new Map();
+ for (const er of eventRels) {
+ if (!eventRelsByOrg.has(er.organization_id)) eventRelsByOrg.set(er.organization_id, []);
+ eventRelsByOrg.get(er.organization_id).push(er);
+ }
+
+ // Build the inline DATA blob that drives client-side JS
+ // Thumbnails already filtered to EXPORT_ALLOWED only
+ const dataBlobObj = {
+ exportDate,
+ sourceCoverage,
+ advertisers: advertisers.map((org) => ({
+ id: org.id,
+ display_name: org.display_name,
+ domain: org.domain || null,
+ headquarters_state: org.headquarters_state || null,
+ headquarters_city: org.headquarters_city || null,
+ organization_type: org.organization_type || null,
+ advertiser_categories: org.advertiser_categories || [],
+ active_status: org.active_status || 'ACTIVE',
+ // Computed best relationship status from ad_sightings
+ best_status: (() => {
+ const sightings = sightingsByOrg.get(org.id) || [];
+ const statuses = sightings.map((s) => s.relationship_status);
+ const priority = [
+ 'VERIFIED_ADVERTISER',
+ 'VERIFIED_CONFERENCE_SPONSOR',
+ 'VERIFIED_EXHIBITOR',
+ 'VERIFIED_MEDIA_PARTNER',
+ 'VERIFIED_CONTENT_PARTNER',
+ 'PAST_ADVERTISER',
+ 'LIKELY_PROSPECT',
+ 'RESEARCH_NEEDED',
+ 'SPEAKER_OR_PANELIST_ONLY',
+ 'DISQUALIFIED',
+ ];
+ for (const p of priority) {
+ if (statuses.includes(p)) return p;
+ }
+ return 'RESEARCH_NEEDED';
+ })(),
+ thumbnail_asset_id: (() => {
+ const sightings = sightingsByOrg.get(org.id) || [];
+ for (const s of sightings) {
+ if (s.thumbnail_asset_id && thumbnailDataUris.has(s.thumbnail_asset_id)) {
+ return s.thumbnail_asset_id;
+ }
+ }
+ return null;
+ })(),
+ contacts: (contactsByOrg.get(org.id) || []).map((c) => ({
+ type: c.type,
+ value: c.value,
+ explicitly_public: c.explicitly_public,
+ })),
+ sightings: (sightingsByOrg.get(org.id) || []).map((s) => ({
+ relationship_status: s.relationship_status,
+ source_page_url: s.source_page_url || null,
+ headline: s.headline || null,
+ observed_at: s.observed_at || null,
+ verification_status: s.verification_status,
+ })),
+ event_rels: (eventRelsByOrg.get(org.id) || []).map((er) => ({
+ relationship_status: er.relationship_status,
+ sponsor_level: er.sponsor_level || null,
+ })),
+ })),
+ ga4Summary: ga4Summary.slice(0, 90), // last ~3 months for dashboard
+ // Convert Map to object for JSON serialization
+ thumbnails: Object.fromEntries(thumbnailDataUris),
+ };
+
+ const dataJson = JSON.stringify(dataBlobObj);
+
+ // Status label lookup (mirrors lib/types.js)
+ const STATUS_LABELS = {
+ VERIFIED_ADVERTISER: 'Verified advertiser',
+ VERIFIED_CONFERENCE_SPONSOR: 'Verified conference sponsor',
+ VERIFIED_EXHIBITOR: 'Verified exhibitor',
+ VERIFIED_MEDIA_PARTNER: 'Verified media partner',
+ VERIFIED_CONTENT_PARTNER: 'Content partner',
+ SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist only',
+ PAST_ADVERTISER: 'Past advertiser',
+ LIKELY_PROSPECT: 'Likely prospect',
+ RESEARCH_NEEDED: 'Research needed',
+ DISQUALIFIED: 'Disqualified',
+ };
+
+ const VERIFIED_STATUSES = new Set([
+ 'VERIFIED_ADVERTISER',
+ 'VERIFIED_CONFERENCE_SPONSOR',
+ 'VERIFIED_EXHIBITOR',
+ 'VERIFIED_MEDIA_PARTNER',
+ 'VERIFIED_CONTENT_PARTNER',
+ ]);
+
+ const statusLabelsJson = JSON.stringify(STATUS_LABELS);
+ const verifiedStatusesJson = JSON.stringify([...VERIFIED_STATUSES]);
+
+ // Stats for the header bar
+ const totalAdvertisers = advertisers.length;
+ const verifiedCount = advertisers.filter((o) => {
+ const sightings = sightingsByOrg.get(o.id) || [];
+ return sightings.some((s) => VERIFIED_STATUSES.has(s.relationship_status));
+ }).length;
+ const caCount = advertisers.filter(
+ (o) => (o.headquarters_state || '').toUpperCase() === 'CA'
+ ).length;
+ const azCount = advertisers.filter(
+ (o) => (o.headquarters_state || '').toUpperCase() === 'AZ'
+ ).length;
+
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="description" content="RENTV Advertiser Intelligence — offline viewer, data as of ${h(exportDate)}">
+<title>RENTV Advertiser Intelligence — ${h(exportDate)}</title>
+<style>
+/* ========================================================
+ RENTV Advertiser Intelligence — Offline Viewer Styles
+ Self-contained, printable, accessible.
+ ======================================================== */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --blue: #1a56db;
+ --blue-d: #1140b0;
+ --green: #0e7a3a;
+ --amber: #9a5800;
+ --red: #b91c1c;
+ --gray-1: #f8fafc;
+ --gray-2: #f1f5f9;
+ --gray-3: #e2e8f0;
+ --gray-4: #cbd5e1;
+ --gray-5: #94a3b8;
+ --gray-7: #334155;
+ --gray-9: #0f172a;
+ --radius: 8px;
+ --shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.08);
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,.1);
+ font-size: 16px;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ background: var(--gray-1);
+ color: var(--gray-9);
+ line-height: 1.5;
+ min-height: 100vh;
+}
+
+/* Header */
+.header {
+ background: var(--blue);
+ color: #fff;
+ padding: 16px 24px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
+.header .badge {
+ background: rgba(255,255,255,.18);
+ border-radius: 999px;
+ font-size: .75rem;
+ padding: 2px 10px;
+ font-weight: 600;
+}
+.header .meta { margin-left: auto; font-size: .8rem; opacity: .85; }
+
+/* Stats bar */
+.stats-bar {
+ background: #fff;
+ border-bottom: 1px solid var(--gray-3);
+ padding: 12px 24px;
+ display: flex;
+ gap: 24px;
+ flex-wrap: wrap;
+ font-size: .85rem;
+}
+.stat { display: flex; align-items: baseline; gap: 6px; }
+.stat .num { font-size: 1.35rem; font-weight: 700; color: var(--blue); }
+.stat .lbl { color: var(--gray-7); }
+
+/* Toolbar */
+.toolbar {
+ background: #fff;
+ border-bottom: 1px solid var(--gray-3);
+ padding: 12px 24px;
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.toolbar label { font-size: .85rem; font-weight: 600; color: var(--gray-7); }
+
+#searchInput {
+ flex: 1;
+ min-width: 200px;
+ max-width: 400px;
+ border: 2px solid var(--gray-3);
+ border-radius: var(--radius);
+ padding: 8px 12px;
+ font-size: .9rem;
+ transition: border-color .15s;
+}
+#searchInput:focus { outline: none; border-color: var(--blue); }
+
+.filter-btns { display: flex; gap: 6px; flex-wrap: wrap; }
+.filter-btn {
+ border: 2px solid var(--gray-3);
+ background: #fff;
+ border-radius: var(--radius);
+ padding: 6px 14px;
+ font-size: .82rem;
+ font-weight: 600;
+ cursor: pointer;
+ color: var(--gray-7);
+ transition: border-color .12s, background .12s, color .12s;
+}
+.filter-btn:hover { border-color: var(--blue); color: var(--blue); }
+.filter-btn.active { background: var(--blue); border-color: var(--blue); color: #fff; }
+.filter-btn:focus { outline: 2px solid var(--blue); outline-offset: 2px; }
+
+#verifiedToggle { accent-color: var(--blue); width: 18px; height: 18px; cursor: pointer; }
+
+/* Main grid */
+.main { padding: 20px 24px; }
+
+#resultCount { font-size: .82rem; color: var(--gray-5); margin-bottom: 14px; }
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 16px;
+}
+
+/* Advertiser card */
+.card {
+ background: #fff;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transition: box-shadow .15s;
+}
+.card:hover { box-shadow: var(--shadow-md); }
+
+.card-thumb {
+ width: 100%;
+ aspect-ratio: 16/7;
+ background: var(--gray-2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+.card-thumb img { width: 100%; height: 100%; object-fit: contain; }
+.card-thumb .placeholder {
+ color: var(--gray-5);
+ font-size: .75rem;
+ text-align: center;
+ padding: 8px;
+ user-select: none;
+}
+
+.card-body { padding: 14px 16px; flex: 1; display: flex; flex-direction: column; gap: 8px; }
+
+.card-name { font-size: 1rem; font-weight: 700; color: var(--gray-9); }
+.card-name a { color: inherit; text-decoration: none; }
+.card-name a:hover { text-decoration: underline; color: var(--blue); }
+
+.card-meta { font-size: .78rem; color: var(--gray-5); }
+
+/* Status badge */
+.status-badge {
+ display: inline-block;
+ border-radius: 999px;
+ font-size: .72rem;
+ font-weight: 700;
+ padding: 2px 10px;
+ letter-spacing: .02em;
+ text-transform: uppercase;
+}
+.badge-verified { background: #d1fae5; color: var(--green); }
+.badge-past { background: #e0f2fe; color: #0369a1; }
+.badge-prospect { background: #fef3c7; color: var(--amber); }
+.badge-research { background: var(--gray-2); color: var(--gray-5); }
+.badge-speaker { background: #f3e8ff; color: #6b21a8; }
+.badge-disqualified{ background: #fee2e2; color: var(--red); }
+
+/* Sightings list */
+.sightings-list { list-style: none; font-size: .78rem; color: var(--gray-7); }
+.sightings-list li { padding: 3px 0; border-bottom: 1px solid var(--gray-2); }
+.sightings-list li:last-child { border: none; }
+.sightings-list a { color: var(--blue); word-break: break-all; }
+.sightings-list a:hover { text-decoration: underline; }
+
+/* Contacts list */
+.contacts-list { list-style: none; font-size: .78rem; }
+.contacts-list li { padding: 2px 0; color: var(--gray-7); }
+
+/* No-results */
+.empty {
+ grid-column: 1/-1;
+ text-align: center;
+ padding: 60px 0;
+ color: var(--gray-5);
+ font-size: .9rem;
+}
+
+/* Footer */
+.footer {
+ background: var(--gray-2);
+ border-top: 1px solid var(--gray-3);
+ padding: 14px 24px;
+ font-size: .75rem;
+ color: var(--gray-5);
+ text-align: center;
+ margin-top: 24px;
+}
+.footer a { color: var(--blue); }
+
+/* Print styles */
+@media print {
+ .toolbar, .stats-bar { display: none; }
+ .header { background: #222; }
+ .card { break-inside: avoid; box-shadow: none; border: 1px solid var(--gray-3); }
+ body { background: #fff; }
+ .card-thumb img { max-height: 120px; }
+}
+
+/* Focus ring for keyboard nav */
+a:focus, button:focus, input:focus, [tabindex]:focus {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
+}
+</style>
+</head>
+<body>
+
+<header class="header" role="banner">
+ <h1>RENTV Advertiser Intelligence</h1>
+ <span class="badge" aria-label="Offline viewer">Offline Viewer</span>
+ <div class="meta" aria-label="Data export date">Data as of <strong>${h(exportDate)}</strong></div>
+</header>
+
+<section class="stats-bar" aria-label="Summary statistics">
+ <div class="stat"><span class="num" id="statTotal">${totalAdvertisers}</span><span class="lbl">Companies</span></div>
+ <div class="stat"><span class="num" id="statVerified">${verifiedCount}</span><span class="lbl">Verified advertisers/sponsors</span></div>
+ <div class="stat"><span class="num">${caCount}</span><span class="lbl">California</span></div>
+ <div class="stat"><span class="num">${azCount}</span><span class="lbl">Arizona</span></div>
+ <div class="stat"><span class="lbl">Source coverage: ${h(sourceCoverage.total || 0)} companies, ${h(sourceCoverage.withEvidence || 0)} with evidence</span></div>
+</section>
+
+<section class="toolbar" role="search" aria-label="Search and filter advertisers">
+ <label for="searchInput">Search</label>
+ <input
+ type="search"
+ id="searchInput"
+ placeholder="Company name, domain, city, state..."
+ aria-label="Search advertisers"
+ autocomplete="off"
+ spellcheck="false"
+ >
+
+ <div class="filter-btns" role="group" aria-label="State filters">
+ <button class="filter-btn active" data-state="ALL" aria-pressed="true">All</button>
+ <button class="filter-btn" data-state="CA" aria-pressed="false">California</button>
+ <button class="filter-btn" data-state="AZ" aria-pressed="false">Arizona</button>
+ </div>
+
+ <div style="display:flex;align-items:center;gap:6px;">
+ <input type="checkbox" id="verifiedToggle" aria-label="Show verified only">
+ <label for="verifiedToggle" style="font-size:.82rem;font-weight:600;color:var(--gray-7);cursor:pointer;">
+ Verified only
+ </label>
+ </div>
+</section>
+
+<main class="main" id="main" role="main">
+ <div id="resultCount" aria-live="polite" aria-atomic="true"></div>
+ <div class="grid" id="grid" role="list" aria-label="Advertiser cards"></div>
+</main>
+
+<footer class="footer" role="contentinfo">
+ RENTV Advertiser Intelligence — exported ${h(exportDate)}.
+ This file contains confidential business intelligence. Do not redistribute.
+ Source: <a href="https://rentv.com" target="_blank" rel="noopener">rentv.com</a>.
+</footer>
+
+<script>
+/* ================================================================
+ RENTV Advertiser Intelligence — Offline Viewer Runtime
+ Vanilla JS, no external dependencies, works on file:// protocol.
+ ================================================================ */
+
+// --------------- Inline data ---------------
+const DATA = ${dataJson};
+
+const STATUS_LABELS = ${statusLabelsJson};
+const VERIFIED_SET = new Set(${verifiedStatusesJson});
+
+// --------------- Utilities ---------------
+function h(v) {
+ if (v == null) return '';
+ return String(v)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+function statusBadgeClass(status) {
+ if (VERIFIED_SET.has(status)) return 'badge-verified';
+ if (status === 'PAST_ADVERTISER') return 'badge-past';
+ if (status === 'LIKELY_PROSPECT') return 'badge-prospect';
+ if (status === 'SPEAKER_OR_PANELIST_ONLY') return 'badge-speaker';
+ if (status === 'DISQUALIFIED') return 'badge-disqualified';
+ return 'badge-research';
+}
+
+// Simple multi-word search: all space-separated tokens must appear somewhere
+function matchesSearch(org, q) {
+ if (!q) return true;
+ const tokens = q.toLowerCase().split(/\\s+/).filter(Boolean);
+ const haystack = [
+ org.display_name || '',
+ org.domain || '',
+ org.headquarters_city || '',
+ org.headquarters_state || '',
+ org.organization_type || '',
+ (org.advertiser_categories || []).join(' '),
+ (org.sightings || []).map(s => s.headline || '').join(' '),
+ ].join(' ').toLowerCase();
+ return tokens.every(t => haystack.includes(t));
+}
+
+// --------------- State ---------------
+let activeState = 'ALL';
+let verifiedOnly = false;
+let searchQ = '';
+
+// --------------- Render ---------------
+function renderCard(org) {
+ const label = STATUS_LABELS[org.best_status] || org.best_status;
+ const badgeClass = statusBadgeClass(org.best_status);
+
+ // Thumbnail
+ let thumbHtml;
+ if (org.thumbnail_asset_id && DATA.thumbnails[org.thumbnail_asset_id]) {
+ const dataUri = DATA.thumbnails[org.thumbnail_asset_id];
+ thumbHtml = \`<div class="card-thumb"><img src="\${dataUri}" alt="Ad thumbnail for \${h(org.display_name)}" loading="lazy"></div>\`;
+ } else {
+ thumbHtml = \`<div class="card-thumb"><span class="placeholder" aria-hidden="true">No ad thumbnail available</span></div>\`;
+ }
+
+ // Domain link
+ const nameHtml = org.domain
+ ? \`<a href="https://\${h(org.domain)}" target="_blank" rel="noopener noreferrer" aria-label="\${h(org.display_name)} website">\${h(org.display_name)}</a>\`
+ : h(org.display_name);
+
+ // Location
+ const loc = [org.headquarters_city, org.headquarters_state].filter(Boolean).join(', ');
+
+ // Up to 3 sightings with source links
+ const sightings = (org.sightings || []).slice(0, 3);
+ const sightingsHtml = sightings.length
+ ? \`<ul class="sightings-list" aria-label="Ad sightings">
+ \${sightings.map(s => {
+ const label = STATUS_LABELS[s.relationship_status] || s.relationship_status;
+ const date = s.observed_at ? new Date(s.observed_at).toLocaleDateString() : '';
+ const linkHtml = s.source_page_url
+ ? \` — <a href="\${h(s.source_page_url)}" target="_blank" rel="noopener noreferrer">Source</a>\`
+ : '';
+ return \`<li>\${h(label)}\${s.headline ? ': ' + h(s.headline) : ''}\${date ? ' (' + date + ')' : ''}\${linkHtml}</li>\`;
+ }).join('')}
+ </ul>\`
+ : '';
+
+ // Up to 2 public contacts (email/phone only)
+ const publicContacts = (org.contacts || [])
+ .filter(c => c.explicitly_public && ['BUSINESS_EMAIL','BUSINESS_PHONE'].includes(c.type))
+ .slice(0, 2);
+ const contactsHtml = publicContacts.length
+ ? \`<ul class="contacts-list" aria-label="Public contacts">
+ \${publicContacts.map(c => {
+ if (c.type === 'BUSINESS_EMAIL') {
+ return \`<li><a href="mailto:\${h(c.value)}">\${h(c.value)}</a></li>\`;
+ }
+ return \`<li>\${h(c.value)}</li>\`;
+ }).join('')}
+ </ul>\`
+ : '';
+
+ return \`
+<article class="card" role="listitem" aria-label="\${h(org.display_name)} — \${h(label)}">
+ \${thumbHtml}
+ <div class="card-body">
+ <div class="card-name">\${nameHtml}</div>
+ \${loc ? \`<div class="card-meta">\${h(loc)}</div>\` : ''}
+ <div>
+ <span class="status-badge \${badgeClass}" title="Classification: \${h(label)}">\${h(label)}</span>
+ </div>
+ \${sightingsHtml}
+ \${contactsHtml}
+ </div>
+</article>\`;
+}
+
+function applyFilters() {
+ const q = searchQ.trim();
+ const filtered = DATA.advertisers.filter(org => {
+ if (activeState !== 'ALL' && (org.headquarters_state || '').toUpperCase() !== activeState) return false;
+ if (verifiedOnly && !VERIFIED_SET.has(org.best_status)) return false;
+ if (q && !matchesSearch(org, q)) return false;
+ return true;
+ });
+
+ const grid = document.getElementById('grid');
+ const countEl = document.getElementById('resultCount');
+
+ if (filtered.length === 0) {
+ grid.innerHTML = '<div class="empty" role="status">No advertisers match the current filters.</div>';
+ countEl.textContent = 'No results.';
+ return;
+ }
+
+ countEl.textContent = \`Showing \${filtered.length} of \${DATA.advertisers.length} companies.\`;
+ grid.innerHTML = filtered.map(renderCard).join('');
+}
+
+// --------------- Event wiring ---------------
+document.getElementById('searchInput').addEventListener('input', (e) => {
+ searchQ = e.target.value;
+ applyFilters();
+});
+
+document.querySelectorAll('.filter-btn[data-state]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ activeState = btn.dataset.state;
+ document.querySelectorAll('.filter-btn[data-state]').forEach(b => {
+ const on = b === btn;
+ b.classList.toggle('active', on);
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
+ });
+ applyFilters();
+ });
+});
+
+document.getElementById('verifiedToggle').addEventListener('change', (e) => {
+ verifiedOnly = e.target.checked;
+ applyFilters();
+});
+
+// Initial render
+applyFilters();
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { generateExecutiveViewer };
diff --git a/src/export/rights.js b/src/export/rights.js
new file mode 100644
index 0000000..9ff3115
--- /dev/null
+++ b/src/export/rights.js
@@ -0,0 +1,314 @@
+'use strict';
+/**
+ * Export rights enforcement — spec §6, §28.
+ *
+ * Every dataset flowing into any export MUST pass through applyExportRights()
+ * or one of its dataset-specific helpers before being written to disk or ZIP.
+ *
+ * Rules enforced here (spec §6, §28):
+ * SR suppression_requests: if active=true and scope matches org/person/contact,
+ * the entire entity is excluded from the export.
+ * DNC contact_points.do_not_contact=true → exclude that contact row.
+ * XA contact_points.export_allowed=false → exclude that contact row.
+ * PN notes.is_private=true → exclude that note row.
+ * CA creative_assets.rights_status:
+ * EXPORT_ALLOWED → include file bytes in thumbnails/
+ * INTERNAL_EVIDENCE_ONLY → omit file, keep link-only placeholder
+ * LINK_ONLY → omit file, keep link-only placeholder
+ * UNKNOWN → omit file (conservative)
+ * ER evidence_records.export_allowed=false → omit the asset, keep citation text.
+ * MAX EXPORT_MAX_ROWS cap per dataset (env, default 100000).
+ *
+ * @module src/export/rights
+ */
+
+const EXPORT_MAX_ROWS = parseInt(process.env.EXPORT_MAX_ROWS || '100000', 10);
+
+/**
+ * @typedef {'organizations'|'contacts'|'notes'|'creative_assets'|'evidence_records'|'generic'} DatasetKind
+ */
+
+/**
+ * Compute the set of suppressed organization IDs and person IDs from a
+ * supression_requests result set. Call once and pass the sets to helpers.
+ *
+ * @param {Object[]} suppressionRows - rows from suppression_requests WHERE active=true
+ * @returns {{ suppressedOrgIds: Set<string>, suppressedPersonIds: Set<string> }}
+ */
+function buildSuppressionSets(suppressionRows) {
+ const suppressedOrgIds = new Set();
+ const suppressedPersonIds = new Set();
+
+ for (const row of suppressionRows || []) {
+ if (!row.active) continue;
+ const scope = (row.scope || '').toUpperCase();
+ if (scope === 'ORGANIZATION' && row.organization_id) {
+ suppressedOrgIds.add(row.organization_id);
+ }
+ if (scope === 'PERSON' && row.person_id) {
+ suppressedPersonIds.add(row.person_id);
+ }
+ // CONTACT_POINT / EMAIL / PHONE are handled at the contact level
+ }
+
+ return { suppressedOrgIds, suppressedPersonIds };
+}
+
+/**
+ * Compute the set of suppressed contact value strings from suppression_requests.
+ *
+ * @param {Object[]} suppressionRows
+ * @returns {Set<string>}
+ */
+function buildContactSuppressionSet(suppressionRows) {
+ const suppressed = new Set();
+ for (const row of suppressionRows || []) {
+ if (!row.active) continue;
+ const scope = (row.scope || '').toUpperCase();
+ if (['CONTACT_POINT', 'EMAIL', 'PHONE'].includes(scope) && row.target_value) {
+ suppressed.add(row.target_value.toLowerCase().trim());
+ }
+ }
+ return suppressed;
+}
+
+/**
+ * Filter organizations, excluding suppressed orgs.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @returns {Object[]}
+ */
+function filterOrganizations(rows, suppressedOrgIds) {
+ let out = (rows || []).filter((r) => !suppressedOrgIds.has(r.id));
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Filter contact_points rows, removing:
+ * - do_not_contact = true
+ * - export_allowed = false
+ * - contacts belonging to suppressed orgs or persons
+ * - contacts whose value matches a suppressed contact value
+ *
+ * @param {Object[]} rows
+ * @param {Object} suppressionSets
+ * @param {Set<string>} suppressionSets.suppressedOrgIds
+ * @param {Set<string>} suppressionSets.suppressedPersonIds
+ * @param {Set<string>} suppressionSets.suppressedContactValues
+ * @returns {Object[]}
+ */
+function filterContacts(rows, suppressionSets) {
+ const { suppressedOrgIds, suppressedPersonIds, suppressedContactValues } = suppressionSets;
+ let out = (rows || []).filter((r) => {
+ if (r.do_not_contact === true) return false;
+ if (r.export_allowed === false) return false;
+ if (suppressedOrgIds && r.organization_id && suppressedOrgIds.has(r.organization_id)) return false;
+ if (suppressedPersonIds && r.person_id && suppressedPersonIds.has(r.person_id)) return false;
+ if (suppressedContactValues && r.value) {
+ const norm = String(r.value).toLowerCase().trim();
+ if (suppressedContactValues.has(norm)) return false;
+ }
+ return true;
+ });
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Filter people rows, removing suppressed persons and people belonging to
+ * suppressed orgs.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @param {Set<string>} suppressedPersonIds
+ * @returns {Object[]}
+ */
+function filterPeople(rows, suppressedOrgIds, suppressedPersonIds) {
+ let out = (rows || []).filter((r) => {
+ if (suppressedPersonIds && suppressedPersonIds.has(r.id)) return false;
+ if (suppressedOrgIds && r.organization_id && suppressedOrgIds.has(r.organization_id)) return false;
+ return true;
+ });
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Filter notes rows, removing private notes and notes for suppressed orgs/people.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @param {Set<string>} suppressedPersonIds
+ * @returns {Object[]}
+ */
+function filterNotes(rows, suppressedOrgIds, suppressedPersonIds) {
+ let out = (rows || []).filter((r) => {
+ if (r.is_private === true) return false;
+ if (suppressedOrgIds && r.organization_id && suppressedOrgIds.has(r.organization_id)) return false;
+ if (suppressedPersonIds && r.person_id && suppressedPersonIds.has(r.person_id)) return false;
+ return true;
+ });
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Filter ad_sightings rows, removing entries for suppressed orgs.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @returns {Object[]}
+ */
+function filterAdSightings(rows, suppressedOrgIds) {
+ let out = (rows || []).filter((r) => !suppressedOrgIds.has(r.organization_id));
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Classify a creative_asset row for export.
+ *
+ * Returns:
+ * 'include' - include the file bytes in thumbnails/
+ * 'link_only' - include a placeholder row but NOT the file bytes
+ * 'omit' - exclude from export entirely
+ *
+ * @param {Object} asset - creative_assets row
+ * @returns {'include'|'link_only'|'omit'}
+ */
+function classifyAssetForExport(asset) {
+ if (!asset) return 'omit';
+ const rs = (asset.rights_status || 'UNKNOWN').toUpperCase();
+ switch (rs) {
+ case 'EXPORT_ALLOWED':
+ return 'include';
+ case 'INTERNAL_EVIDENCE_ONLY':
+ return 'link_only';
+ case 'LINK_ONLY':
+ return 'link_only';
+ case 'UNKNOWN':
+ default:
+ return 'link_only'; // conservative: don't export unknown-rights images
+ }
+}
+
+/**
+ * Filter creative_assets rows for the creatives.csv metadata export.
+ * All assets are included as metadata rows; the classification tells callers
+ * whether to also bundle the file bytes.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @returns {Object[]} rows annotated with _exportClass: 'include'|'link_only'
+ */
+function filterCreativeAssets(rows, suppressedOrgIds) {
+ let out = (rows || [])
+ .filter((r) => !suppressedOrgIds.has(r.organization_id))
+ .map((r) => ({ ...r, _exportClass: classifyAssetForExport(r) }));
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Filter evidence_records rows.
+ * All evidence is included as citation metadata; export_allowed=false means
+ * the file/image bytes are omitted (only the citation text/URL is kept).
+ *
+ * @param {Object[]} rows
+ * @returns {Object[]} rows annotated with _exportClass: 'include'|'link_only'
+ */
+function filterEvidenceRecords(rows) {
+ let out = (rows || []).map((r) => ({
+ ...r,
+ _exportClass: r.export_allowed === true ? 'include' : 'link_only',
+ }));
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Generic row filter — applies suppressed-org exclusion and row cap.
+ *
+ * @param {Object[]} rows
+ * @param {Set<string>} suppressedOrgIds
+ * @param {string} [orgIdField='organization_id']
+ * @returns {Object[]}
+ */
+function filterGeneric(rows, suppressedOrgIds, orgIdField = 'organization_id') {
+ let out = (rows || []).filter((r) => {
+ if (r[orgIdField] && suppressedOrgIds.has(r[orgIdField])) return false;
+ return true;
+ });
+ if (out.length > EXPORT_MAX_ROWS) out = out.slice(0, EXPORT_MAX_ROWS);
+ return out;
+}
+
+/**
+ * Master rights filter — the single entry point for all export datasets.
+ *
+ * @param {Object[]} rows - raw DB rows
+ * @param {DatasetKind} kind - controls which rules apply
+ * @param {Object} ctx - rights context
+ * @param {Set<string>} ctx.suppressedOrgIds
+ * @param {Set<string>} ctx.suppressedPersonIds
+ * @param {Set<string>} [ctx.suppressedContactValues]
+ * @param {string} [ctx.orgIdField] - for 'generic' kind
+ * @returns {Object[]}
+ */
+function applyExportRights(rows, kind, ctx) {
+ const {
+ suppressedOrgIds = new Set(),
+ suppressedPersonIds = new Set(),
+ suppressedContactValues = new Set(),
+ orgIdField,
+ } = ctx || {};
+
+ switch (kind) {
+ case 'organizations':
+ return filterOrganizations(rows, suppressedOrgIds);
+
+ case 'contacts':
+ return filterContacts(rows, {
+ suppressedOrgIds,
+ suppressedPersonIds,
+ suppressedContactValues,
+ });
+
+ case 'people':
+ return filterPeople(rows, suppressedOrgIds, suppressedPersonIds);
+
+ case 'notes':
+ return filterNotes(rows, suppressedOrgIds, suppressedPersonIds);
+
+ case 'ad_sightings':
+ return filterAdSightings(rows, suppressedOrgIds);
+
+ case 'creative_assets':
+ return filterCreativeAssets(rows, suppressedOrgIds);
+
+ case 'evidence_records':
+ return filterEvidenceRecords(rows);
+
+ case 'generic':
+ default:
+ return filterGeneric(rows, suppressedOrgIds, orgIdField);
+ }
+}
+
+module.exports = {
+ applyExportRights,
+ buildSuppressionSets,
+ buildContactSuppressionSet,
+ classifyAssetForExport,
+ filterOrganizations,
+ filterContacts,
+ filterPeople,
+ filterNotes,
+ filterAdSightings,
+ filterCreativeAssets,
+ filterEvidenceRecords,
+ filterGeneric,
+ EXPORT_MAX_ROWS,
+};
diff --git a/src/export/xlsx.js b/src/export/xlsx.js
new file mode 100644
index 0000000..dbe88cf
--- /dev/null
+++ b/src/export/xlsx.js
@@ -0,0 +1,241 @@
+'use strict';
+/**
+ * Pure-Node minimal XLSX writer — Open XML SpreadsheetML.
+ *
+ * An .xlsx file is a ZIP (PKWARE) containing Open XML parts:
+ * [Content_Types].xml
+ * _rels/.rels
+ * xl/workbook.xml
+ * xl/_rels/workbook.xml.rels
+ * xl/worksheets/sheet1.xml
+ * xl/sharedStrings.xml
+ *
+ * This implementation:
+ * - Produces one sheet per call.
+ * - Stores strings in the shared string table (type="s") for small file size.
+ * - Stores numbers as inline numbers (type="n").
+ * - Does NOT support formulas, styles, or multi-sheet workbooks (not needed here).
+ * - Reuses zip.js (also pure Node, STORE method).
+ *
+ * Usage:
+ * const buf = toXlsxBuffer(rows, { columns: ['name','score'] });
+ * fs.writeFileSync('out.xlsx', buf);
+ *
+ * @module src/export/xlsx
+ */
+
+const { ZipWriter } = require('./zip');
+
+/**
+ * Escape XML special characters.
+ * @param {string} str
+ * @returns {string}
+ */
+function xmlEscape(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ // Strip control characters (not valid XML 1.0 except tab/CR/LF)
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
+}
+
+/**
+ * Convert a 1-based column index to Excel letter notation (1=A, 26=Z, 27=AA).
+ * @param {number} n
+ * @returns {string}
+ */
+function colLetter(n) {
+ let s = '';
+ while (n > 0) {
+ const rem = (n - 1) % 26;
+ s = String.fromCharCode(65 + rem) + s;
+ n = Math.floor((n - 1) / 26);
+ }
+ return s;
+}
+
+/**
+ * Build the shared strings table XML and index map.
+ * Returns { xml, index } where index is a Map<string, number>.
+ *
+ * @param {Object[]} rows
+ * @param {string[]} columns
+ * @returns {{ xml: string, index: Map<string,number>, count: number }}
+ */
+function buildSharedStrings(rows, columns) {
+ const index = new Map();
+ const strings = [];
+
+ function intern(val) {
+ if (val === null || val === undefined) return;
+ const s = String(val);
+ if (typeof val === 'number' && isFinite(val)) return; // kept inline
+ if (!index.has(s)) {
+ index.set(s, strings.length);
+ strings.push(s);
+ }
+ }
+
+ // Intern column headers
+ for (const col of columns) intern(col);
+
+ // Intern all string cell values
+ for (const row of rows) {
+ for (const col of columns) {
+ const v = row[col];
+ if (typeof v !== 'number' || !isFinite(v)) {
+ intern(v === null || v === undefined ? '' : String(v));
+ }
+ }
+ }
+
+ const total = strings.length;
+ const items = strings.map((s) => `<si><t xml:space="preserve">${xmlEscape(s)}</t></si>`).join('');
+ const xml =
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+ `<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${total}" uniqueCount="${total}">` +
+ items +
+ `</sst>`;
+
+ return { xml, index, count: total };
+}
+
+/**
+ * Build sheet1.xml — the worksheet XML.
+ *
+ * @param {Object[]} rows
+ * @param {string[]} columns
+ * @param {Map<string,number>} ssIndex - shared string index
+ * @returns {string}
+ */
+function buildSheetXml(rows, columns, ssIndex) {
+ const totalRows = rows.length + 1; // +1 for header
+
+ /**
+ * Build a cell reference like "A1".
+ * @param {number} col 1-based column index
+ * @param {number} row 1-based row index
+ */
+ const ref = (col, row) => `${colLetter(col)}${row}`;
+
+ /** Build a cell element. */
+ function cellEl(colIdx, rowIdx, value) {
+ const r = ref(colIdx, rowIdx);
+
+ if (value === null || value === undefined || value === '') {
+ // Empty string shared string
+ const si = ssIndex.get('');
+ if (si !== undefined) {
+ return `<c r="${r}" t="s"><v>${si}</v></c>`;
+ }
+ return `<c r="${r}"/>`;
+ }
+
+ if (typeof value === 'number' && isFinite(value)) {
+ return `<c r="${r}" t="n"><v>${value}</v></c>`;
+ }
+
+ const s = String(value);
+ const si = ssIndex.get(s);
+ if (si !== undefined) {
+ return `<c r="${r}" t="s"><v>${si}</v></c>`;
+ }
+ // Fallback: inline string (shouldn't happen if buildSharedStrings ran first)
+ return `<c r="${r}" t="inlineStr"><is><t>${xmlEscape(s)}</t></is></c>`;
+ }
+
+ // Build row XML strings
+ const rowEls = [];
+
+ // Header row (row 1)
+ const headerCells = columns.map((col, i) => cellEl(i + 1, 1, col)).join('');
+ rowEls.push(`<row r="1">${headerCells}</row>`);
+
+ // Data rows
+ for (let ri = 0; ri < rows.length; ri++) {
+ const rowIdx = ri + 2;
+ const cells = columns.map((col, ci) => cellEl(ci + 1, rowIdx, rows[ri][col])).join('');
+ rowEls.push(`<row r="${rowIdx}">${cells}</row>`);
+ }
+
+ // Dimension ref e.g. "A1:E5"
+ const dimRef =
+ totalRows > 0 && columns.length > 0
+ ? `A1:${colLetter(columns.length)}${totalRows}`
+ : 'A1';
+
+ return (
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+ `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` +
+ ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
+ `<dimension ref="${dimRef}"/>` +
+ `<sheetData>` +
+ rowEls.join('') +
+ `</sheetData>` +
+ `</worksheet>`
+ );
+}
+
+/** Static Open XML relationship + content-type parts. */
+const CONTENT_TYPES_XML =
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
+ `<Default Extension="xml" ContentType="application/xml"/>` +
+ `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
+ `<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
+ `<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>` +
+ `</Types>`;
+
+const RELS_XML =
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
+ `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
+ `</Relationships>`;
+
+const WORKBOOK_XML =
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+ `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` +
+ ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
+ `<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>` +
+ `</workbook>`;
+
+const WORKBOOK_RELS_XML =
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
+ `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
+ `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>` +
+ `</Relationships>`;
+
+/**
+ * Convert an array of row objects to an XLSX Buffer.
+ *
+ * @param {Object[]} rows
+ * @param {Object} [opts]
+ * @param {string[]} [opts.columns] - column order; defaults to Object.keys(rows[0])
+ * @returns {Buffer}
+ */
+function toXlsxBuffer(rows, opts = {}) {
+ const safeRows = rows || [];
+ const columns =
+ opts.columns ||
+ (safeRows.length > 0 ? Object.keys(safeRows[0]) : []);
+
+ const { xml: ssXml, index: ssIndex } = buildSharedStrings(safeRows, columns);
+ const sheetXml = buildSheetXml(safeRows, columns, ssIndex);
+
+ const zip = new ZipWriter();
+ zip.addEntry('[Content_Types].xml', CONTENT_TYPES_XML);
+ zip.addEntry('_rels/.rels', RELS_XML);
+ zip.addEntry('xl/workbook.xml', WORKBOOK_XML);
+ zip.addEntry('xl/_rels/workbook.xml.rels', WORKBOOK_RELS_XML);
+ zip.addEntry('xl/worksheets/sheet1.xml', sheetXml);
+ zip.addEntry('xl/sharedStrings.xml', ssXml);
+
+ return zip.finalize();
+}
+
+module.exports = { toXlsxBuffer, colLetter, xmlEscape };
diff --git a/src/export/zip.js b/src/export/zip.js
new file mode 100644
index 0000000..c71eae7
--- /dev/null
+++ b/src/export/zip.js
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * Pure-Node minimal ZIP writer — STORE method (no compression).
+ * Produces a valid ZIP file openable by macOS, Windows, and standard tools.
+ *
+ * Format references:
+ * PKWARE APPNOTE.TXT §4.3 — local file headers + central directory + EOCD
+ * CRC-32 via standard IEEE 802.3 polynomial (0xEDB88320)
+ *
+ * Usage:
+ * const zip = new ZipWriter();
+ * zip.addEntry('folder/file.txt', Buffer.from('hello'));
+ * const buf = zip.finalize(); // returns a Buffer
+ * fs.writeFileSync('out.zip', buf);
+ *
+ * @module src/export/zip
+ */
+
+/** Pre-built CRC-32 lookup table (IEEE 802.3 polynomial 0xEDB88320). */
+const CRC_TABLE = (() => {
+ const t = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let j = 0; j < 8; j++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ t[i] = c;
+ }
+ return t;
+})();
+
+/**
+ * Compute CRC-32 checksum of a Buffer.
+ * @param {Buffer} buf
+ * @returns {number} unsigned 32-bit integer
+ */
+function crc32(buf) {
+ let c = 0xffffffff;
+ for (let i = 0; i < buf.length; i++) {
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+ }
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+/**
+ * Write a 32-bit little-endian uint into a Buffer at offset.
+ * @param {Buffer} buf
+ * @param {number} offset
+ * @param {number} value
+ */
+function writeUInt32LE(buf, offset, value) {
+ buf.writeUInt32LE(value >>> 0, offset);
+}
+
+/**
+ * Write a 16-bit little-endian uint into a Buffer at offset.
+ * @param {Buffer} buf
+ * @param {number} offset
+ * @param {number} value
+ */
+function writeUInt16LE(buf, offset, value) {
+ buf.writeUInt16LE(value & 0xffff, offset);
+}
+
+/**
+ * DOS date/time encoding for a JS Date.
+ * @param {Date} date
+ * @returns {{ dosTime: number, dosDate: number }}
+ */
+function dosDateTime(date) {
+ const d = date instanceof Date ? date : new Date();
+ const dosTime =
+ ((d.getHours() & 0x1f) << 11) |
+ ((d.getMinutes() & 0x3f) << 5) |
+ (Math.floor(d.getSeconds() / 2) & 0x1f);
+ const dosDate =
+ (((d.getFullYear() - 1980) & 0x7f) << 9) |
+ (((d.getMonth() + 1) & 0x0f) << 5) |
+ (d.getDate() & 0x1f);
+ return { dosTime, dosDate };
+}
+
+/**
+ * @typedef {Object} ZipEntry
+ * @property {string} name - file path inside the ZIP (forward slashes)
+ * @property {Buffer} data - raw file bytes
+ * @property {number} crc - CRC-32 of data
+ * @property {number} localOffset - byte offset of local file header in the archive
+ * @property {number} dosTime
+ * @property {number} dosDate
+ */
+
+class ZipWriter {
+ constructor() {
+ /** @type {Buffer[]} */
+ this._parts = [];
+ /** @type {ZipEntry[]} */
+ this._entries = [];
+ this._offset = 0;
+ this._now = new Date();
+ }
+
+ /**
+ * Add a file entry.
+ * @param {string} name - path inside zip, e.g. 'dir/file.txt'
+ * @param {Buffer|string} data - content (string will be UTF-8 encoded)
+ */
+ addEntry(name, data) {
+ const dataBuf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
+ const nameBuf = Buffer.from(name, 'utf8');
+ const crc = crc32(dataBuf);
+ const { dosTime, dosDate } = dosDateTime(this._now);
+
+ const localOffset = this._offset;
+
+ // Local file header (signature 0x04034b50, 30 bytes + name)
+ const lhSize = 30 + nameBuf.length;
+ const lh = Buffer.alloc(lhSize, 0);
+ writeUInt32LE(lh, 0, 0x04034b50); // local file header sig
+ writeUInt16LE(lh, 4, 20); // version needed: 2.0
+ writeUInt16LE(lh, 6, 0); // general purpose bit flag
+ writeUInt16LE(lh, 8, 0); // compression method: STORE
+ writeUInt16LE(lh, 10, dosTime);
+ writeUInt16LE(lh, 12, dosDate);
+ writeUInt32LE(lh, 14, crc);
+ writeUInt32LE(lh, 18, dataBuf.length); // compressed size
+ writeUInt32LE(lh, 22, dataBuf.length); // uncompressed size
+ writeUInt16LE(lh, 26, nameBuf.length);
+ writeUInt16LE(lh, 28, 0); // extra field length
+ nameBuf.copy(lh, 30);
+
+ this._parts.push(lh);
+ this._parts.push(dataBuf);
+ this._offset += lhSize + dataBuf.length;
+
+ this._entries.push({ name, nameBuf, data: dataBuf, crc, localOffset, dosTime, dosDate });
+ }
+
+ /**
+ * Finalize the ZIP archive and return the complete Buffer.
+ * @returns {Buffer}
+ */
+ finalize() {
+ const cdOffset = this._offset;
+
+ // Central directory headers
+ const cdParts = [];
+ for (const entry of this._entries) {
+ const cdSize = 46 + entry.nameBuf.length;
+ const cd = Buffer.alloc(cdSize, 0);
+ writeUInt32LE(cd, 0, 0x02014b50); // central dir signature
+ writeUInt16LE(cd, 4, 20); // version made by
+ writeUInt16LE(cd, 6, 20); // version needed
+ writeUInt16LE(cd, 8, 0); // general purpose bit flag
+ writeUInt16LE(cd, 10, 0); // compression: STORE
+ writeUInt16LE(cd, 12, entry.dosTime);
+ writeUInt16LE(cd, 14, entry.dosDate);
+ writeUInt32LE(cd, 16, entry.crc);
+ writeUInt32LE(cd, 20, entry.data.length); // compressed size
+ writeUInt32LE(cd, 24, entry.data.length); // uncompressed size
+ writeUInt16LE(cd, 28, entry.nameBuf.length);
+ writeUInt16LE(cd, 30, 0); // extra field length
+ writeUInt16LE(cd, 32, 0); // file comment length
+ writeUInt16LE(cd, 34, 0); // disk number start
+ writeUInt16LE(cd, 36, 0); // internal file attributes
+ writeUInt32LE(cd, 38, 0); // external file attributes
+ writeUInt32LE(cd, 42, entry.localOffset);
+ entry.nameBuf.copy(cd, 46);
+ cdParts.push(cd);
+ }
+
+ const cdBuf = Buffer.concat(cdParts);
+ const cdSize = cdBuf.length;
+
+ // End of central directory record (22 bytes)
+ const eocd = Buffer.alloc(22, 0);
+ writeUInt32LE(eocd, 0, 0x06054b50); // EOCD signature
+ writeUInt16LE(eocd, 4, 0); // disk number
+ writeUInt16LE(eocd, 6, 0); // disk with central dir start
+ writeUInt16LE(eocd, 8, this._entries.length); // entries on disk
+ writeUInt16LE(eocd, 10, this._entries.length); // total entries
+ writeUInt32LE(eocd, 12, cdSize);
+ writeUInt32LE(eocd, 16, cdOffset);
+ writeUInt16LE(eocd, 20, 0); // comment length
+
+ return Buffer.concat([...this._parts, cdBuf, eocd]);
+ }
+}
+
+module.exports = { ZipWriter, crc32 };
diff --git a/src/routes/api.js b/src/routes/api.js
new file mode 100644
index 0000000..976f3cc
--- /dev/null
+++ b/src/routes/api.js
@@ -0,0 +1,654 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence — /api/v1 router (spec §29).
+ * Express Router, CommonJS, pg 8, zero external dependencies.
+ * All list endpoints: { rows, nextCursor, total, demo? }
+ * All writes: append audit_logs row. Hand-rolled validation, no zod.
+ */
+
+const express = require('express');
+const { pool, query } = require('../../db');
+const T = require('../../lib/types');
+const scoring = require('../../lib/scoring');
+
+const router = express.Router();
+
+// ── Request ID middleware ──────────────────────────────────────────────────────
+router.use((req, res, next) => {
+ req.requestId = require('crypto').randomUUID();
+ res.set('X-Request-Id', req.requestId);
+ next();
+});
+
+// ── Helper: structured JSON error ────────────────────────────────────────────
+function apiErr(res, code, error, message) {
+ return res.status(code).json({ error, message: message || error });
+}
+
+// ── Helper: safe integer ─────────────────────────────────────────────────────
+function safeInt(v, def, min, max) {
+ const n = parseInt(v, 10);
+ if (isNaN(n)) return def;
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+}
+
+// ── Helper: whitelist sort key ────────────────────────────────────────────────
+function safeSort(v, allowed, def) {
+ return allowed.includes(v) ? v : def;
+}
+
+// ── Helper: UUID or null ──────────────────────────────────────────────────────
+function isUuid(s) {
+ return typeof s === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
+}
+
+// ── Helper: check if a table exists ─────────────────────────────────────────
+async function tableExists(tableName) {
+ try {
+ const r = await query(
+ `SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1`,
+ [tableName]
+ );
+ return r.rows.length > 0;
+ } catch (_) { return false; }
+}
+
+// ── Helper: demo-safe query ───────────────────────────────────────────────────
+// Returns { rows, demo } — if table missing/empty returns {rows:[], demo:true}
+async function safeQuery(sql, params, demoCheck) {
+ try {
+ const r = await query(sql, params || []);
+ if (demoCheck && r.rows.length === 0) return { rows: [], demo: true };
+ return { rows: r.rows, demo: false };
+ } catch (e) {
+ if (e.code === '42P01') return { rows: [], demo: true }; // undefined_table
+ throw e;
+ }
+}
+
+// ── Helper: write audit log ───────────────────────────────────────────────────
+async function auditLog(action, entityTable, entityId, detail, actor) {
+ try {
+ await query(
+ `INSERT INTO audit_logs (action, entity_table, entity_id, actor, detail)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [action, entityTable || null, entityId || null, actor || 'api', JSON.stringify(detail || {})]
+ );
+ } catch (_) { /* non-fatal */ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers
+// Filters: market=CA|AZ|ALL, verifiedOnly=1, category, q, cursor, limit, sort
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers', async (req, res) => {
+ try {
+ const limit = safeInt(req.query.limit, 50, 1, 200);
+ const cursor = req.query.cursor || null;
+ const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+ const verifiedOnly = req.query.verifiedOnly === '1';
+ const category = req.query.category ? String(req.query.category).slice(0, 120) : null;
+ const q = req.query.q ? String(req.query.q).slice(0, 200) : null;
+ const ALLOWED_SORTS = ['display_name', 'last_seen_at', 'created_at', 'score'];
+ const sort = safeSort(req.query.sort, ALLOWED_SORTS, 'last_seen_at');
+
+ const conditions = [];
+ const params = [];
+
+ function addParam(v) { params.push(v); return '$' + params.length; }
+
+ if (market !== 'ALL') {
+ conditions.push(`o.headquarters_state = ${addParam(market)}`);
+ }
+ if (verifiedOnly) {
+ conditions.push(`(
+ SELECT COUNT(*) FROM ad_sightings s WHERE s.organization_id = o.id
+ AND s.relationship_status = ANY(${addParam(T.VERIFIED_STATUSES)})
+ ) > 0`);
+ }
+ if (category) {
+ conditions.push(`o.advertiser_categories @> ${addParam(JSON.stringify([category]))}::jsonb`);
+ }
+ if (q) {
+ conditions.push(`(
+ o.normalized_name ILIKE ${addParam('%' + q.replace(/[%_]/g, '\\$&') + '%')}
+ OR o.display_name ILIKE ${addParam('%' + q.replace(/[%_]/g, '\\$&') + '%')}
+ OR o.domain ILIKE ${addParam('%' + q.replace(/[%_]/g, '\\$&') + '%')}
+ )`);
+ // last param added twice — fix the indices
+ const last = params.length;
+ params[last - 1] = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+ params[last - 2] = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+ }
+ if (cursor) {
+ conditions.push(`o.created_at < ${addParam(cursor)}`);
+ }
+
+ const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
+ const sortSql = sort === 'score'
+ ? 'opp.score DESC NULLS LAST'
+ : sort === 'display_name'
+ ? 'o.display_name ASC'
+ : 'o.last_seen_at DESC NULLS LAST';
+
+ let result;
+ try {
+ result = await query(`
+ SELECT
+ o.id, o.display_name, o.legal_name, o.domain, o.headquarters_state,
+ o.headquarters_city, o.advertiser_categories, o.active_status,
+ o.logo_asset_id, o.first_seen_at, o.last_seen_at, o.created_at,
+ (SELECT s.relationship_status
+ FROM ad_sightings s
+ WHERE s.organization_id = o.id
+ ORDER BY array_position(
+ ARRAY['VERIFIED_ADVERTISER','VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR',
+ 'VERIFIED_MEDIA_PARTNER','VERIFIED_CONTENT_PARTNER','PAST_ADVERTISER',
+ 'LIKELY_PROSPECT','SPEAKER_OR_PANELIST_ONLY','RESEARCH_NEEDED','DISQUALIFIED'],
+ s.relationship_status)
+ LIMIT 1
+ ) AS top_status,
+ (SELECT COUNT(*) FROM ad_sightings s WHERE s.organization_id = o.id
+ AND s.verification_status = 'VERIFIED') AS verified_sightings,
+ (SELECT s.observed_at FROM ad_sightings s WHERE s.organization_id = o.id
+ ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS last_sighting_at,
+ (SELECT COUNT(*) FROM event_relationships er WHERE er.organization_id = o.id) AS conference_activity,
+ (SELECT opp.score FROM opportunity_scores opp WHERE opp.organization_id = o.id
+ ORDER BY opp.computed_at DESC LIMIT 1) AS score,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'BUSINESS_PHONE' AND cp.do_not_contact = false LIMIT 1) AS best_phone,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'BUSINESS_EMAIL' AND cp.do_not_contact = false LIMIT 1) AS best_email,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'WEBSITE' LIMIT 1) AS website,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'LINKEDIN' LIMIT 1) AS linkedin_url,
+ (SELECT p.full_name FROM people p WHERE p.organization_id = o.id
+ ORDER BY array_position(
+ ARRAY['CHIEF_MARKETING_OFFICER','VP_MARKETING','MARKETING_DIRECTOR',
+ 'COMMUNICATIONS_PR_DIRECTOR','EVENTS_PARTNERSHIPS_SPONSORSHIPS_DIRECTOR',
+ 'BUSINESS_DEVELOPMENT_DIRECTOR','REGIONAL_PRESIDENT_MARKET_LEADER',
+ 'MANAGING_DIRECTOR_PRINCIPAL','PUBLIC_MEDIA_CONTACT','GENERAL_COMPANY_CONTACT'],
+ p.role_category) ASC NULLS LAST LIMIT 1) AS best_contact_name,
+ (SELECT s.source_page_url FROM ad_sightings s WHERE s.organization_id = o.id
+ ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS latest_source_url
+ FROM organizations o
+ LEFT JOIN opportunity_scores opp ON opp.organization_id = o.id
+ AND opp.computed_at = (SELECT MAX(opp2.computed_at) FROM opportunity_scores opp2 WHERE opp2.organization_id = o.id)
+ ${where}
+ ORDER BY ${sortSql}, o.id ASC
+ LIMIT ${addParam(limit + 1)}
+ `, params);
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+ throw e;
+ }
+
+ const rows = result.rows;
+ const hasMore = rows.length > limit;
+ if (hasMore) rows.pop();
+ const nextCursor = hasMore ? rows[rows.length - 1].created_at : null;
+
+ // Total count (estimated) — fast path
+ let total = 0;
+ try {
+ const ct = await query(`SELECT COUNT(*) FROM organizations o ${where}`, params.slice(0, params.length - 1));
+ total = parseInt(ct.rows[0].count, 10);
+ } catch (_) {}
+
+ const demo = total === 0 && rows.length === 0;
+ res.json({ rows, nextCursor, total, demo: demo || undefined });
+ } catch (e) {
+ console.error('[api] GET /advertisers', e.message);
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers/:id
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers/:id', async (req, res) => {
+ const { id } = req.params;
+ if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+ try {
+ const orgRes = await query(`SELECT * FROM organizations WHERE id = $1`, [id]);
+ if (!orgRes.rows.length) return apiErr(res, 404, 'not_found', 'Organization not found');
+ const org = orgRes.rows[0];
+
+ const [sightings, events, contacts, latestScore, evidence] = await Promise.all([
+ query(`SELECT s.*, p.name AS publication_name FROM ad_sightings s
+ LEFT JOIN publications p ON p.id = s.publication_id
+ WHERE s.organization_id = $1 ORDER BY s.observed_at DESC NULLS LAST LIMIT 50`, [id]),
+ query(`SELECT er.*, e.name AS event_name, e.start_date, e.city, e.state, e.official_url
+ FROM event_relationships er JOIN events e ON e.id = er.event_id
+ WHERE er.organization_id = $1 ORDER BY e.start_date DESC NULLS LAST LIMIT 50`, [id]),
+ query(`SELECT cp.*, p.full_name AS person_name, p.public_title AS person_title
+ FROM contact_points cp LEFT JOIN people p ON p.id = cp.person_id
+ WHERE cp.organization_id = $1 AND cp.do_not_contact = false
+ ORDER BY cp.confidence DESC LIMIT 50`, [id]),
+ query(`SELECT * FROM opportunity_scores WHERE organization_id = $1
+ ORDER BY computed_at DESC LIMIT 1`, [id]),
+ query(`SELECT er.* FROM evidence_records er
+ WHERE er.id IN (
+ SELECT evidence_id FROM ad_sightings WHERE organization_id = $1 AND evidence_id IS NOT NULL
+ LIMIT 20
+ )`, [id]),
+ ]);
+
+ const scoreRow = latestScore.rows[0];
+ let scoreExplain = null;
+ if (scoreRow && scoreRow.factors) {
+ try { scoreExplain = scoring.explainScore(scoreRow.factors); } catch (_) {}
+ }
+
+ res.json({
+ ...org,
+ sightings: sightings.rows,
+ events: events.rows,
+ contacts: contacts.rows,
+ score: scoreRow ? { ...scoreRow, explain: scoreExplain } : null,
+ evidence: evidence.rows,
+ });
+ } catch (e) {
+ console.error('[api] GET /advertisers/:id', e.message);
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /ads
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/ads', async (req, res) => {
+ const limit = safeInt(req.query.limit, 50, 1, 200);
+ const cursor = req.query.cursor || null;
+ const params = [];
+ const conds = [];
+ function p(v) { params.push(v); return '$' + params.length; }
+
+ if (cursor) conds.push(`s.created_at < ${p(cursor)}`);
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+
+ try {
+ const r = await query(`
+ SELECT s.*, o.display_name AS org_name, p.name AS publication_name
+ FROM ad_sightings s
+ LEFT JOIN organizations o ON o.id = s.organization_id
+ LEFT JOIN publications p ON p.id = s.publication_id
+ ${where}
+ ORDER BY s.observed_at DESC NULLS LAST, s.id ASC
+ LIMIT ${p(limit + 1)}
+ `, params);
+
+ const rows = r.rows;
+ const hasMore = rows.length > limit;
+ if (hasMore) rows.pop();
+ const demo = rows.length === 0;
+ res.json({ rows, nextCursor: hasMore ? rows[rows.length - 1].created_at : null, total: rows.length, demo: demo || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// GET /ads/:id
+router.get('/ads/:id', async (req, res) => {
+ if (!isUuid(req.params.id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+ try {
+ const r = await query(`
+ SELECT s.*, o.display_name AS org_name, p.name AS publication_name,
+ er.source_url AS evidence_source_url, er.source_title AS evidence_title,
+ er.excerpt AS evidence_excerpt, er.observed_at AS evidence_observed_at
+ FROM ad_sightings s
+ LEFT JOIN organizations o ON o.id = s.organization_id
+ LEFT JOIN publications p ON p.id = s.publication_id
+ LEFT JOIN evidence_records er ON er.id = s.evidence_id
+ WHERE s.id = $1
+ `, [req.params.id]);
+ if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+ res.json(r.rows[0]);
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ demo: true, rows: [] });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /events
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/events', async (req, res) => {
+ const limit = safeInt(req.query.limit, 50, 1, 200);
+ const cursor = req.query.cursor || null;
+ const params = [];
+ function p(v) { params.push(v); return '$' + params.length; }
+ const conds = cursor ? [`e.start_date < ${p(cursor)}`] : [];
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+ try {
+ const r = await query(`
+ SELECT e.*,
+ (SELECT COUNT(*) FROM event_relationships er
+ WHERE er.event_id = e.id AND er.relationship_status LIKE 'VERIFIED_%') AS sponsor_count,
+ (SELECT COUNT(*) FROM event_relationships er
+ WHERE er.event_id = e.id AND er.relationship_status = 'SPEAKER_OR_PANELIST_ONLY') AS panelist_count
+ FROM events e ${where}
+ ORDER BY e.start_date DESC NULLS LAST LIMIT ${p(limit + 1)}
+ `, params);
+ const rows = r.rows; const hasMore = rows.length > limit; if (hasMore) rows.pop();
+ res.json({ rows, nextCursor: hasMore ? rows[rows.length - 1].start_date : null, total: rows.length, demo: rows.length === 0 || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// GET /events/:id
+router.get('/events/:id', async (req, res) => {
+ if (!isUuid(req.params.id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+ try {
+ const [evRes, rels] = await Promise.all([
+ query(`SELECT * FROM events WHERE id = $1`, [req.params.id]),
+ query(`SELECT er.*, o.display_name AS org_name, o.domain, o.advertiser_categories
+ FROM event_relationships er
+ JOIN organizations o ON o.id = er.organization_id
+ WHERE er.event_id = $1
+ ORDER BY array_position(
+ ARRAY['VERIFIED_CONFERENCE_SPONSOR','VERIFIED_EXHIBITOR','VERIFIED_MEDIA_PARTNER',
+ 'VERIFIED_CONTENT_PARTNER','SPEAKER_OR_PANELIST_ONLY','LIKELY_PROSPECT','RESEARCH_NEEDED'],
+ er.relationship_status), o.display_name`, [req.params.id]),
+ ]);
+ if (!evRes.rows.length) return apiErr(res, 404, 'not_found', 'Event not found');
+ const ev = evRes.rows[0];
+ const sponsors = rels.rows.filter(r => r.relationship_status !== 'SPEAKER_OR_PANELIST_ONLY');
+ const panelists = rels.rows.filter(r => r.relationship_status === 'SPEAKER_OR_PANELIST_ONLY');
+ res.json({ ...ev, sponsors, panelists, relationships: rels.rows });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /contacts
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/contacts', async (req, res) => {
+ const limit = safeInt(req.query.limit, 100, 1, 500);
+ const cursor = req.query.cursor || null;
+ const q = req.query.q ? String(req.query.q).slice(0, 200) : null;
+ const params = []; const conds = [];
+ function p(v) { params.push(v); return '$' + params.length; }
+ if (q) {
+ const like = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+ conds.push(`(pe.full_name ILIKE ${p(like)} OR o.display_name ILIKE ${p(like)} OR cp.value ILIKE ${p(like)})`);
+ }
+ if (cursor) conds.push(`cp.created_at < ${p(cursor)}`);
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+ try {
+ const r = await query(`
+ SELECT cp.*, pe.full_name, pe.public_title, pe.linkedin_url,
+ o.display_name AS org_name, o.id AS organization_id
+ FROM contact_points cp
+ LEFT JOIN people pe ON pe.id = cp.person_id
+ LEFT JOIN organizations o ON o.id = cp.organization_id
+ ${where}
+ AND cp.do_not_contact = false
+ ORDER BY cp.confidence DESC, cp.created_at DESC
+ LIMIT ${p(limit + 1)}
+ `, params);
+ const rows = r.rows; const hasMore = rows.length > limit; if (hasMore) rows.pop();
+ res.json({ rows, nextCursor: hasMore ? rows[rows.length - 1].created_at : null, total: rows.length, demo: rows.length === 0 || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], nextCursor: null, total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /prospects — orgs ranked by latest opportunity score, non-verified
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/prospects', async (req, res) => {
+ const limit = safeInt(req.query.limit, 50, 1, 200);
+ const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+ const params = []; const conds = [];
+ function p(v) { params.push(v); return '$' + params.length; }
+ // Prospects = no VERIFIED_* sightings
+ conds.push(`NOT EXISTS (
+ SELECT 1 FROM ad_sightings s WHERE s.organization_id = o.id
+ AND s.relationship_status = ANY(${p(T.VERIFIED_STATUSES)})
+ )`);
+ if (market !== 'ALL') conds.push(`o.headquarters_state = ${p(market)}`);
+ const where = 'WHERE ' + conds.join(' AND ');
+ try {
+ const r = await query(`
+ SELECT o.id, o.display_name, o.domain, o.headquarters_state, o.headquarters_city,
+ o.advertiser_categories, o.last_seen_at, o.created_at,
+ opp.score, opp.computed_at AS score_computed_at
+ FROM organizations o
+ LEFT JOIN opportunity_scores opp ON opp.organization_id = o.id
+ AND opp.computed_at = (SELECT MAX(o2.computed_at) FROM opportunity_scores o2 WHERE o2.organization_id = o.id)
+ ${where}
+ ORDER BY opp.score DESC NULLS LAST, o.last_seen_at DESC NULLS LAST
+ LIMIT ${p(limit)}
+ `, params);
+ res.json({ rows: r.rows, total: r.rows.length, demo: r.rows.length === 0 || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics/summary
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics/summary', async (req, res) => {
+ try {
+ const r = await query(`
+ SELECT
+ (SELECT COUNT(*) FROM organizations) AS total_orgs,
+ (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_ADVERTISER') AS verified_advertisers,
+ (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_CONFERENCE_SPONSOR') AS verified_sponsors,
+ (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'CA') AS california_orgs,
+ (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'AZ') AS arizona_orgs,
+ (SELECT COUNT(*) FROM ad_sightings WHERE observed_at >= NOW() - INTERVAL '30 days') AS sightings_30d,
+ (SELECT COUNT(*) FROM events) AS total_events,
+ (SELECT COUNT(*) FROM contact_points WHERE do_not_contact = false) AS total_contacts
+ `);
+ const summary = r.rows[0] || {};
+ const demo = Object.values(summary).every(v => v === '0' || v === 0);
+ res.json({ ...summary, demo: demo || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics/ga4
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics/ga4', async (req, res) => {
+ const days = safeInt(req.query.days, 30, 7, 365);
+ try {
+ const r = await query(`
+ SELECT * FROM ga4_daily_metrics
+ WHERE metric_date >= CURRENT_DATE - $1::int
+ ORDER BY metric_date DESC
+ `, [days]);
+ const demo = r.rows.length === 0 || r.rows[0].is_demo;
+ res.json({ rows: r.rows, total: r.rows.length, demo: demo || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics/gsc
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics/gsc', async (req, res) => {
+ const limit = safeInt(req.query.limit, 50, 1, 500);
+ const days = safeInt(req.query.days, 28, 7, 365);
+ try {
+ const r = await query(`
+ SELECT * FROM gsc_query_metrics
+ WHERE metric_date >= CURRENT_DATE - $1::int
+ ORDER BY impressions DESC NULLS LAST
+ LIMIT $2
+ `, [days, limit]);
+ const demo = r.rows.length === 0 || r.rows[0].is_demo;
+ res.json({ rows: r.rows, total: r.rows.length, demo: demo || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /sources
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/sources', async (req, res) => {
+ try {
+ const r = await query(`
+ SELECT sp.*,
+ (SELECT COUNT(*) FROM sources s WHERE s.source_policy_id = sp.id) AS item_count,
+ (SELECT MAX(shc.checked_at) FROM source_health_checks shc WHERE shc.source_key = sp.source_key) AS last_checked
+ FROM source_policies sp
+ ORDER BY sp.display_name ASC
+ `);
+ const demo = r.rows.length === 0;
+ res.json({ rows: r.rows, total: r.rows.length, demo: demo || undefined });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ rows: [], total: 0, demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /imports — 202 stub (queues import job)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/imports', async (req, res) => {
+ const kind = req.body && req.body.kind ? String(req.body.kind).slice(0, 80) : 'MANUAL_UPLOAD';
+ try {
+ await auditLog('import_queued', 'imports', null, { kind, body: req.body }, 'api');
+ } catch (_) {}
+ res.status(202).json({ queued: true, kind, message: 'Import job queued — upload your file via the /imports UI.' });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /research/jobs — 202 stub
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/research/jobs', async (req, res) => {
+ const jobType = req.body && req.body.type ? String(req.body.type).slice(0, 80) : 'GENERAL';
+ const dryRun = req.body && req.body.dryRun ? true : false;
+ try {
+ await query(
+ `INSERT INTO ingestion_runs (source_key, dry_run, status, stats)
+ VALUES ($1, $2, 'PENDING', '{}')`,
+ [jobType, dryRun]
+ );
+ } catch (_) {}
+ res.status(202).json({ queued: true, jobType, dryRun, message: 'Research job queued — monitor at /admin/jobs.' });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /review/:id/verify
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/review/:id/verify', async (req, res) => {
+ const { id } = req.params;
+ if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+ const idempotencyKey = req.headers['idempotency-key'] || null;
+ try {
+ const r = await query(
+ `UPDATE ad_sightings SET verification_status = 'VERIFIED', verified_by_user_id = NULL
+ WHERE id = $1 RETURNING *`,
+ [id]
+ );
+ if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+ await auditLog('verify', 'ad_sightings', id, { idempotencyKey }, 'api');
+ res.json({ ok: true, id, status: 'VERIFIED' });
+ } catch (e) {
+ if (e.code === '42P01') return apiErr(res, 404, 'not_found', 'Ad sightings table does not exist yet');
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /review/:id/reject
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/review/:id/reject', async (req, res) => {
+ const { id } = req.params;
+ if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+ const reason = req.body && req.body.reason ? String(req.body.reason).slice(0, 500) : '';
+ try {
+ const r = await query(
+ `UPDATE ad_sightings SET verification_status = 'REJECTED' WHERE id = $1 RETURNING *`,
+ [id]
+ );
+ if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Ad sighting not found');
+ await auditLog('reject', 'ad_sightings', id, { reason }, 'api');
+ res.json({ ok: true, id, status: 'REJECTED', reason });
+ } catch (e) {
+ if (e.code === '42P01') return apiErr(res, 404, 'not_found', 'Ad sightings table does not exist yet');
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// POST /exports
+// ═══════════════════════════════════════════════════════════════════════════════
+router.post('/exports', async (req, res) => {
+ const kind = (req.body && req.body.kind) ? String(req.body.kind).slice(0, 80) : 'DOWNLOAD_EVERYTHING';
+
+ // If the export builder exists, delegate to it
+ let buildExport;
+ try { buildExport = require('../export/build'); } catch (_) { buildExport = null; }
+
+ if (buildExport) {
+ try {
+ const result = await buildExport({ kind });
+ return res.status(202).json({ queued: true, ...result });
+ } catch (e) {
+ return apiErr(res, 500, 'export_error', e.message);
+ }
+ }
+
+ // Stub: create an export record and return it
+ try {
+ const r = await query(
+ `INSERT INTO exports (kind, status, row_counts) VALUES ($1, 'PENDING', '{}') RETURNING id, created_at`,
+ [kind]
+ );
+ const exportRec = r.rows[0];
+ await auditLog('export_queued', 'exports', exportRec.id, { kind }, 'api');
+ res.status(202).json({ queued: true, id: exportRec.id, kind, status: 'PENDING', created_at: exportRec.created_at });
+ } catch (e) {
+ // Table might not exist yet
+ res.status(202).json({ queued: true, kind, message: 'Export queued (db not migrated yet)' });
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /exports/:id
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/exports/:id', async (req, res) => {
+ const { id } = req.params;
+ if (!isUuid(id)) return apiErr(res, 400, 'invalid_id', 'id must be a UUID');
+ try {
+ const r = await query(`SELECT * FROM exports WHERE id = $1`, [id]);
+ if (!r.rows.length) return apiErr(res, 404, 'not_found', 'Export not found');
+ const exp = r.rows[0];
+ res.json({
+ ...exp,
+ download_url: exp.status === 'DONE' && exp.object_key ? '/assets/' + exp.object_key : null,
+ });
+ } catch (e) {
+ if (e.code === '42P01') return res.json({ status: 'PENDING', demo: true });
+ apiErr(res, 500, 'query_error', e.message);
+ }
+});
+
+// ── 404 fallback within /api/v1 ───────────────────────────────────────────────
+router.use((req, res) => {
+ apiErr(res, 404, 'not_found', `No route for ${req.method} ${req.path}`);
+});
+
+module.exports = router;
diff --git a/src/routes/pages.js b/src/routes/pages.js
new file mode 100644
index 0000000..2294a96
--- /dev/null
+++ b/src/routes/pages.js
@@ -0,0 +1,1553 @@
+'use strict';
+/**
+ * RENTV Advertiser Intelligence — page router (spec §24, §25, §26, §27).
+ * Server-rendered HTML strings. Direct DB reads for first paint.
+ * Every page degrades gracefully when tables are empty (DEMO DATA badge).
+ */
+
+const express = require('express');
+const { query } = require('../../db');
+const T = require('../../lib/types');
+const scoring = require('../../lib/scoring');
+
+const router = express.Router();
+
+// ── Formatting helpers ────────────────────────────────────────────────────────
+const esc = (s) => (s == null ? '' : String(s))
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+const fmtDate = (s) => {
+ if (!s) return '—';
+ try {
+ const d = new Date(s);
+ if (isNaN(d)) return String(s);
+ return d.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ } catch (_) { return String(s); }
+};
+
+const fmtDateOnly = (s) => {
+ if (!s) return '—';
+ try { return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }
+ catch (_) { return String(s); }
+};
+
+const STATUS_BADGE_CLASS = {
+ VERIFIED_ADVERTISER: 'badge-verified-advertiser',
+ VERIFIED_CONFERENCE_SPONSOR: 'badge-verified-sponsor',
+ VERIFIED_EXHIBITOR: 'badge-verified-exhibitor',
+ VERIFIED_MEDIA_PARTNER: 'badge-verified-media',
+ VERIFIED_CONTENT_PARTNER: 'badge-content-partner',
+ SPEAKER_OR_PANELIST_ONLY: 'badge-panelist',
+ PAST_ADVERTISER: 'badge-past-advertiser',
+ LIKELY_PROSPECT: 'badge-likely-prospect',
+ RESEARCH_NEEDED: 'badge-research-needed',
+ DISQUALIFIED: 'badge-disqualified',
+};
+
+function statusBadge(status) {
+ const cls = STATUS_BADGE_CLASS[status] || 'badge-research-needed';
+ const lbl = T.STATUS_LABELS[status] || status || '—';
+ return `<span class="badge ${cls}">${esc(lbl)}</span>`;
+}
+
+function scoreBadge(score) {
+ if (score == null) return '<span class="miss">—</span>';
+ const n = Number(score);
+ const cls = n >= 70 ? 'score-high' : n >= 40 ? 'score-mid' : 'score-low';
+ return `<span class="score-badge ${cls}">${n}</span>`;
+}
+
+function whenChip(ts) {
+ if (!ts) return '';
+ return `<span class="when-chip" title="${esc(new Date(ts).toISOString())}">Created ${esc(fmtDate(ts))}</span>`;
+}
+
+// ── Safe DB query (returns [] on missing table) ──────────────────────────────
+async function safeQ(sql, params) {
+ try { return (await query(sql, params || [])).rows; }
+ catch (e) { if (e.code === '42P01') return []; throw e; }
+}
+
+// ── Shared HTML layout ────────────────────────────────────────────────────────
+function layout(title, bodyHtml, opts) {
+ opts = opts || {};
+ const market = opts.market || 'ALL';
+ const verified = opts.verified ? '1' : '0';
+ return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)} — RENTV Advertiser Intelligence</title>
+<link rel="stylesheet" href="/css/app.css">
+</head>
+<body>
+
+<header class="site-header" role="banner">
+ <a class="brand" href="/" aria-label="RENTV Advertiser Intelligence home">
+ <span>RENTV</span> Advertiser Intel
+ </a>
+
+ <input
+ id="global-search"
+ class="header-search"
+ type="search"
+ placeholder="Search advertisers, contacts, conferences…"
+ aria-label="Search all advertisers and contacts"
+ value="${esc(opts.q || '')}">
+
+ <div class="switch-group" role="group" aria-label="Market filter">
+ <div class="market-switch">
+ <button data-market="ALL" class="${market === 'ALL' ? 'active' : ''}" aria-pressed="${market === 'ALL'}">All</button>
+ <button data-market="CA" class="${market === 'CA' ? 'active' : ''}" aria-pressed="${market === 'CA'}">California</button>
+ <button data-market="AZ" class="${market === 'AZ' ? 'active' : ''}" aria-pressed="${market === 'AZ'}">Arizona</button>
+ </div>
+ <label class="verified-switch ${verified === '1' ? 'active' : ''}" aria-label="Show verified only">
+ <input type="checkbox" ${verified === '1' ? 'checked' : ''} aria-checked="${verified === '1'}">
+ Verified Only
+ </label>
+ </div>
+
+ <div class="header-spacer"></div>
+
+ <button id="download-everything-btn" class="dl-btn" aria-label="Download everything as ZIP">
+ Download Everything
+ </button>
+ <span id="export-msg" style="display:none;font-size:14px;color:var(--warn);margin-left:8px"></span>
+</header>
+
+<nav aria-label="Main navigation" style="background:var(--surface);border-bottom:1px solid var(--border);padding:0 24px;display:flex;gap:0;overflow-x:auto">
+ ${[
+ ['/', 'Dashboard'],
+ ['/advertisers', 'Advertisers'],
+ ['/ads', 'Ads Gallery'],
+ ['/conferences', 'Conferences'],
+ ['/contacts', 'Contacts'],
+ ['/prospects', 'Prospects'],
+ ['/analytics', 'Analytics'],
+ ['/search-intelligence', 'Search Intelligence'],
+ ['/media-kit', 'Media Kit'],
+ ['/sources', 'Sources'],
+ ['/imports', 'Imports'],
+ ['/review', 'Review'],
+ ['/exports', 'Exports'],
+ ['/settings', 'Settings'],
+ ].map(([href, label]) => {
+ const active = opts.activeNav === href;
+ return `<a href="${esc(href)}" style="display:inline-flex;align-items:center;padding:10px 14px;font-size:15px;font-weight:${active ? '700' : '500'};color:${active ? 'var(--accent)' : 'var(--ink-muted)'};text-decoration:none;border-bottom:3px solid ${active ? 'var(--accent)' : 'transparent'};white-space:nowrap;min-height:44px" ${active ? 'aria-current="page"' : ''}>${esc(label)}</a>`;
+ }).join('')}
+</nav>
+
+<main id="main-content" role="main">
+${bodyHtml}
+</main>
+
+<!-- Score drawer -->
+<div id="score-drawer-overlay" class="drawer-overlay" role="dialog" aria-modal="true" aria-label="Show Me Why score drawer">
+ <div class="score-drawer" style="position:relative">
+ <button onclick="closeScoreDrawer()" class="btn btn-ghost" style="position:absolute;top:16px;right:16px" aria-label="Close score drawer">Close ✕</button>
+ <div id="score-drawer-body"></div>
+ </div>
+</div>
+
+<script src="/js/table.js"></script>
+<script src="/js/app.js"></script>
+</body>
+</html>`;
+}
+
+// ── Demo banner helper ────────────────────────────────────────────────────────
+const demoBanner = `<div class="demo-banner" role="status" aria-live="polite">
+ DEMO DATA — No live data imported yet. Run <code>npm run db:migrate && npm run db:seed</code> then connect GA4/GSC via <a href="/settings">Settings</a>.
+</div>`;
+
+// ── Page helpers ─────────────────────────────────────────────────────────────
+function pageShell(leftRailHtml, contentHtml) {
+ return `<div class="page-shell">
+ <aside class="left-rail" aria-label="Filters and column controls">${leftRailHtml}</aside>
+ <div class="page-content">${contentHtml}</div>
+</div>`;
+}
+
+function standardRail(extraHtml) {
+ return `
+ <div class="rail-section">
+ <h4>Column controls</h4>
+ <div class="field-toggles" id="field-toggles"></div>
+ <div style="margin-top:8px">
+ <button class="mini-btn" id="cols-all">All</button>
+ <button class="mini-btn" id="cols-none">None</button>
+ <button class="mini-btn" id="cols-reset" title="Reset to defaults">↻ Reset</button>
+ </div>
+ <p style="font-size:13px;color:var(--ink-faint);margin-top:8px;line-height:1.4">
+ Drag column header to reorder · click to sort · toggle above to show/hide.
+ </p>
+ </div>
+ ${extraHtml || ''}`;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET / — Dashboard (§26)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/', async (req, res) => {
+ try {
+ const [summary, recentOrgs, upcomingEvents, audiences] = await Promise.all([
+ safeQ(`SELECT
+ (SELECT COUNT(*) FROM organizations) AS total_orgs,
+ (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_ADVERTISER') AS verified_advertisers,
+ (SELECT COUNT(*) FROM ad_sightings WHERE relationship_status = 'VERIFIED_CONFERENCE_SPONSOR') AS verified_sponsors,
+ (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'CA') AS california_orgs,
+ (SELECT COUNT(*) FROM organizations WHERE headquarters_state = 'AZ') AS arizona_orgs,
+ (SELECT COUNT(*) FROM ad_sightings WHERE observed_at >= NOW() - INTERVAL '30 days') AS sightings_30d,
+ (SELECT COUNT(*) FROM ad_sightings WHERE observed_at >= NOW() - INTERVAL '7 days') AS sightings_7d,
+ (SELECT COUNT(*) FROM events) AS total_events,
+ (SELECT COUNT(*) FROM contact_points WHERE do_not_contact = false) AS total_contacts`),
+ safeQ(`SELECT o.id, o.display_name, o.last_seen_at, o.headquarters_state, o.created_at
+ FROM organizations o ORDER BY o.created_at DESC LIMIT 5`),
+ safeQ(`SELECT * FROM events WHERE start_date >= CURRENT_DATE ORDER BY start_date ASC LIMIT 5`),
+ safeQ(`SELECT * FROM rentv_audience_snapshots ORDER BY observed_at DESC LIMIT 4`),
+ ]);
+
+ const s = summary[0] || {};
+ const isDemo = !s.total_orgs || s.total_orgs === '0';
+
+ const statCards = [
+ { label: 'Verified Advertisers', value: s.verified_advertisers || 0, href: '/advertisers?verifiedOnly=1' },
+ { label: 'Verified Sponsors', value: s.verified_sponsors || 0, href: '/advertisers?verifiedOnly=1' },
+ { label: 'California Companies', value: s.california_orgs || 0, href: '/advertisers?market=CA' },
+ { label: 'Arizona Companies', value: s.arizona_orgs || 0, href: '/advertisers?market=AZ' },
+ { label: 'New Sightings (7d)', value: s.sightings_7d || 0, href: '/ads' },
+ { label: 'New Sightings (30d)', value: s.sightings_30d || 0, href: '/ads' },
+ { label: 'Total Contacts', value: s.total_contacts || 0, href: '/contacts' },
+ { label: 'Total Events', value: s.total_events || 0, href: '/conferences' },
+ ];
+
+ const statsHtml = statCards.map(c => `
+ <div class="stat-pill">
+ <span class="sp-label">${esc(c.label)}</span>
+ <span class="sp-value"><a href="${esc(c.href)}">${Number(c.value).toLocaleString()}</a></span>
+ </div>`).join('');
+
+ const recentHtml = recentOrgs.length
+ ? recentOrgs.map(o => `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);font-size:15px">
+ <a href="/advertisers/${esc(o.id)}">${esc(o.display_name)}</a>
+ <span class="miss" style="font-size:13px">${esc(o.headquarters_state || '')} · ${fmtDateOnly(o.last_seen_at)}</span>
+ </div>`).join('')
+ : '<p class="miss">No organizations yet.</p>';
+
+ const eventsHtml = upcomingEvents.length
+ ? upcomingEvents.map(e => `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);font-size:15px">
+ <a href="/conferences/${esc(e.id)}">${esc(e.name)}</a>
+ <span class="miss" style="font-size:13px">${fmtDateOnly(e.start_date)} · ${esc(e.city || '')}${e.state ? ', ' + esc(e.state) : ''}</span>
+ </div>`).join('')
+ : '<p class="miss">No upcoming events.</p>';
+
+ const audienceHtml = audiences.length
+ ? audiences.map(a => `<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border);font-size:15px">
+ <span>${esc(a.metric_label)}</span>
+ <span><strong>${esc(a.value_text || (a.value_numeric ? Number(a.value_numeric).toLocaleString() : '—'))}</strong>
+ <span class="miss" style="font-size:12px"> as of ${fmtDateOnly(a.observed_at)}</span></span>
+ </div>`).join('')
+ : '<p class="miss">No audience snapshots. Import from <a href="/imports">Imports</a>.</p>';
+
+ const body = `
+<div class="page-content">
+ <h1 style="margin-bottom:20px">Dashboard</h1>
+ ${isDemo ? demoBanner : ''}
+ <div class="stats-bar">${statsHtml}</div>
+
+ <div class="dash-grid">
+ <div class="dash-card">
+ <h3>Recently added companies</h3>
+ ${recentHtml}
+ <p style="margin-top:10px"><a href="/advertisers" class="btn btn-outline" style="font-size:14px">View all advertisers</a></p>
+ </div>
+ <div class="dash-card">
+ <h3>Upcoming conferences</h3>
+ ${eventsHtml}
+ <p style="margin-top:10px"><a href="/conferences" class="btn btn-outline" style="font-size:14px">View all conferences</a></p>
+ </div>
+ <div class="dash-card">
+ <h3>RENTV audience snapshots</h3>
+ ${audienceHtml}
+ <p style="margin-top:10px"><a href="/media-kit" class="btn btn-outline" style="font-size:14px">View media kit</a></p>
+ </div>
+ <div class="dash-card">
+ <h3>Quick actions</h3>
+ <div style="display:flex;flex-direction:column;gap:10px;margin-top:8px">
+ <a href="/advertisers" class="btn btn-primary">View Advertisers</a>
+ <a href="/prospects" class="btn btn-outline">Top Prospects</a>
+ <a href="/review" class="btn btn-outline">Review Queue</a>
+ <a href="/imports" class="btn btn-outline">Import Data</a>
+ </div>
+ </div>
+ </div>
+</div>`;
+
+ res.type('html').send(layout('Dashboard', body, { activeNav: '/' }));
+ } catch (e) {
+ console.error('[pages] /', e.message);
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">Server error: ${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers — main table (§25)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers', async (req, res) => {
+ const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+ const verifiedOnly = req.query.verifiedOnly === '1';
+ const q = req.query.q ? String(req.query.q).slice(0, 200) : '';
+ const category = req.query.category ? String(req.query.category).slice(0, 120) : '';
+
+ try {
+ const params = []; const conds = [];
+ function p(v) { params.push(v); return '$' + params.length; }
+
+ if (market !== 'ALL') conds.push(`o.headquarters_state = ${p(market)}`);
+ if (verifiedOnly) conds.push(`EXISTS (
+ SELECT 1 FROM ad_sightings s WHERE s.organization_id = o.id
+ AND s.relationship_status = ANY(${p(T.VERIFIED_STATUSES)}))`);
+ if (category) conds.push(`o.advertiser_categories @> ${p(JSON.stringify([category]))}::jsonb`);
+ if (q) {
+ const like = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+ conds.push(`(o.display_name ILIKE ${p(like)} OR o.domain ILIKE ${p(like)})`);
+ }
+ const where = conds.length ? 'WHERE ' + conds.join(' AND ') : '';
+
+ const rows = await safeQ(`
+ SELECT
+ o.id, o.display_name, o.domain, o.headquarters_state, o.headquarters_city,
+ o.advertiser_categories, o.logo_asset_id, o.last_seen_at, o.created_at,
+ (SELECT s.relationship_status FROM ad_sightings s WHERE s.organization_id = o.id
+ ORDER BY CASE s.relationship_status
+ WHEN 'VERIFIED_ADVERTISER' THEN 1 WHEN 'VERIFIED_CONFERENCE_SPONSOR' THEN 2
+ WHEN 'VERIFIED_EXHIBITOR' THEN 3 WHEN 'VERIFIED_MEDIA_PARTNER' THEN 4
+ WHEN 'VERIFIED_CONTENT_PARTNER' THEN 5 WHEN 'PAST_ADVERTISER' THEN 6
+ WHEN 'LIKELY_PROSPECT' THEN 7 WHEN 'SPEAKER_OR_PANELIST_ONLY' THEN 8
+ WHEN 'RESEARCH_NEEDED' THEN 9 ELSE 10 END ASC LIMIT 1) AS top_status,
+ (SELECT COUNT(*) FROM ad_sightings s WHERE s.organization_id = o.id
+ AND s.verification_status = 'VERIFIED') AS verified_sightings,
+ (SELECT s.observed_at FROM ad_sightings s WHERE s.organization_id = o.id
+ ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS last_sighting_at,
+ (SELECT COUNT(*) FROM event_relationships er WHERE er.organization_id = o.id) AS conf_activity,
+ (SELECT opp.score FROM opportunity_scores opp WHERE opp.organization_id = o.id
+ ORDER BY opp.computed_at DESC LIMIT 1) AS score,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'BUSINESS_PHONE' AND cp.do_not_contact = false LIMIT 1) AS best_phone,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'BUSINESS_EMAIL' AND cp.do_not_contact = false LIMIT 1) AS best_email,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'WEBSITE' LIMIT 1) AS website,
+ (SELECT cp.value FROM contact_points cp WHERE cp.organization_id = o.id
+ AND cp.type = 'LINKEDIN' LIMIT 1) AS linkedin_url,
+ (SELECT p.full_name FROM people p WHERE p.organization_id = o.id LIMIT 1) AS best_contact,
+ (SELECT s.source_page_url FROM ad_sightings s WHERE s.organization_id = o.id
+ ORDER BY s.observed_at DESC NULLS LAST LIMIT 1) AS latest_source
+ FROM organizations o ${where}
+ ORDER BY o.last_seen_at DESC NULLS LAST
+ LIMIT 500
+ `, params);
+
+ const isDemo = rows.length === 0;
+
+ // Category chips for rail
+ const catCounts = {};
+ rows.forEach(r => {
+ try {
+ const cats = Array.isArray(r.advertiser_categories) ? r.advertiser_categories : JSON.parse(r.advertiser_categories || '[]');
+ cats.forEach(c => { catCounts[c] = (catCounts[c] || 0) + 1; });
+ } catch (_) {}
+ });
+ const catChips = Object.entries(catCounts).sort((a, b) => b[1] - a[1]).slice(0, 16)
+ .map(([cat, n]) => `<span class="chip${category === cat ? ' active' : ''}" data-cat="${esc(cat)}">${esc(cat.slice(0, 40))}<span class="ct">${n}</span></span>`).join('');
+
+ const tableRows = rows.map(r => {
+ const thumb = r.logo_asset_id
+ ? `<img class="org-thumb" src="/assets/${esc(r.logo_asset_id)}" alt="${esc(r.display_name)} logo" loading="lazy">`
+ : `<div class="org-thumb-placeholder" aria-hidden="true">🏢</div>`;
+ const cats = (() => { try { return JSON.parse(r.advertiser_categories || '[]'); } catch (_) { return []; } })();
+ const srcLink = r.latest_source
+ ? `<a href="${esc(r.latest_source)}" target="_blank" rel="noopener noreferrer" title="${esc(r.latest_source)}">Source ↗</a>`
+ : '<span class="miss">—</span>';
+ return `<tr data-id="${esc(r.id)}" class="adv-row">
+ <td style="width:52px">${thumb}</td>
+ <td><a href="/advertisers/${esc(r.id)}">${esc(r.display_name)}</a></td>
+ <td>${statusBadge(r.top_status)}</td>
+ <td style="max-width:180px;white-space:normal;font-size:13px">${esc(cats.slice(0, 2).join(', ') || '—')}</td>
+ <td>${esc(r.headquarters_state || '—')}</td>
+ <td>${fmtDateOnly(r.last_sighting_at)}</td>
+ <td class="num"><a href="/advertisers/${esc(r.id)}#ads">${Number(r.verified_sightings || 0).toLocaleString()}</a></td>
+ <td class="num"><a href="/advertisers/${esc(r.id)}#conferences">${Number(r.conf_activity || 0).toLocaleString()}</a></td>
+ <td>
+ ${scoreBadge(r.score)}
+ ${r.id ? `<button class="btn btn-ghost" style="font-size:12px;padding:4px 8px;min-height:32px;margin-left:6px" data-smw-org="${esc(r.id)}" data-smw-name="${esc(r.display_name)}">Why?</button>` : ''}
+ </td>
+ <td>${r.best_contact ? esc(r.best_contact) : '<span class="miss">—</span>'}</td>
+ <td>${r.best_phone ? `<a href="tel:${esc(r.best_phone)}">${esc(r.best_phone)}</a>` : '<span class="miss">—</span>'}</td>
+ <td>${r.best_email ? `<a href="mailto:${esc(r.best_email)}">${esc(r.best_email)}</a>` : '<span class="miss">—</span>'}</td>
+ <td>${r.website ? `<a href="${/^https?:/.test(r.website) ? esc(r.website) : 'https://' + esc(r.website)}" target="_blank" rel="noopener noreferrer">${esc(r.domain || r.website)} ↗</a>` : '<span class="miss">—</span>'}</td>
+ <td>${r.linkedin_url ? `<a href="${esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : '<span class="miss">—</span>'}</td>
+ <td>${srcLink}</td>
+ </tr>`;
+ }).join('');
+
+ const rail = standardRail(`
+ <div class="rail-section">
+ <h4>Category</h4>
+ <div class="chips" id="cat-chips">${catChips}</div>
+ </div>
+ <div class="rail-section">
+ <h4>Status</h4>
+ <div class="chips" id="status-chips">
+ ${T.VERIFIED_STATUSES.concat(['LIKELY_PROSPECT', 'RESEARCH_NEEDED']).map(s =>
+ `<span class="chip" data-status="${esc(s)}">${esc(T.STATUS_LABELS[s] || s)}</span>`
+ ).join('')}
+ </div>
+ </div>`);
+
+ const content = `
+ <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:16px">
+ <h1>Advertisers</h1>
+ <a href="/imports" class="btn btn-outline" style="font-size:15px">+ Import Data</a>
+ </div>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-controls">
+ <input class="tbl-search" id="tbl-search" type="search" placeholder="Search all fields (space = AND)…" value="${esc(q)}" aria-label="Search advertisers">
+ <select class="tbl-sort-sel" id="sort-sel" aria-label="Sort by"></select>
+ <span class="tbl-count" id="row-count">${rows.length.toLocaleString()} records</span>
+ <button class="btn btn-ghost" id="csv-btn" style="font-size:14px">Export CSV</button>
+ </div>
+ <div class="tbl-wrap">
+ <table class="adv-tbl" role="grid" aria-label="Advertisers table">
+ <thead id="main-thead">
+ <tr>
+ <th>Logo</th><th>Company</th><th>Status</th><th>Category</th>
+ <th>Market</th><th>Last Sighting</th><th>#Verified</th><th>Conf. Activity</th>
+ <th>Score</th><th>Best Contact</th><th>Phone</th><th>Email</th>
+ <th>Website</th><th>LinkedIn</th><th>Source</th>
+ </tr>
+ </thead>
+ <tbody id="main-tbody">
+ ${tableRows || '<tr><td colspan="15" class="empty-state"><p>No advertisers match your filters.</p></td></tr>'}
+ </tbody>
+ </table>
+ </div>
+ <script>
+ (function(){
+ // Client-side search
+ var q='', statusF=new Set(), catF=new Set();
+ var allRows=Array.from(document.querySelectorAll('#main-tbody tr.adv-row'));
+ function filter(){
+ var terms=q.toLowerCase().split(/\s+/).filter(Boolean);
+ var shown=0;
+ allRows.forEach(function(tr){
+ var txt=tr.textContent.toLowerCase();
+ var st=tr.querySelector('.badge')?tr.querySelector('.badge').textContent.trim():'';
+ var pass=terms.every(function(t){return txt.includes(t);});
+ if(statusF.size && !Array.from(statusF).some(function(s){return st.includes(s);})) pass=false;
+ tr.style.display=pass?'':'none';
+ if(pass) shown++;
+ });
+ document.getElementById('row-count').textContent=shown+' of '+allRows.length+' records';
+ }
+ document.getElementById('tbl-search').addEventListener('input',function(e){q=e.target.value;filter();});
+ document.querySelectorAll('#status-chips .chip').forEach(function(c){
+ c.addEventListener('click',function(){
+ c.classList.toggle('active');
+ if(c.classList.contains('active')) statusF.add(c.textContent.trim());
+ else statusF.delete(c.textContent.trim());
+ filter();
+ });
+ });
+ document.querySelectorAll('#cat-chips .chip').forEach(function(c){
+ c.addEventListener('click',function(){
+ c.classList.toggle('active');
+ if(c.classList.contains('active')) catF.add(c.dataset.cat);
+ else catF.delete(c.dataset.cat);
+ filter();
+ });
+ });
+ // Sort select
+ var COLS=[
+ {k:'display_name',l:'Company',g:'Identity'},{k:'top_status',l:'Status',g:'Identity'},
+ {k:'headquarters_state',l:'Market',g:'Identity'},{k:'last_sighting_at',l:'Last Sighting',g:'Activity'},
+ {k:'verified_sightings',l:'# Verified',g:'Activity'},{k:'conf_activity',l:'Conf. Activity',g:'Activity'},
+ {k:'score',l:'Score',g:'Activity'}
+ ];
+ var sel=document.getElementById('sort-sel');
+ COLS.forEach(function(c){var o=document.createElement('option');o.value=c.k;o.textContent=c.l;sel.appendChild(o);});
+ // CSV export
+ document.getElementById('csv-btn').addEventListener('click',function(){
+ var rows=allRows.filter(function(tr){return tr.style.display!=='none';});
+ var cols=['Company','Status','Category','Market','Last Sighting','#Verified','Conf','Score','Contact','Phone','Email','Website'];
+ var csv=cols.map(function(c){return '"'+c+'"';}).join(',')+'\n'
+ +rows.map(function(tr){
+ var tds=Array.from(tr.querySelectorAll('td'));
+ return [1,2,3,4,5,6,7,8,9,10,11,12].map(function(i){
+ var v=(tds[i]||{}).textContent||'';
+ return '"'+v.replace(/"/g,'""').trim()+'"';
+ }).join(',');
+ }).join('\n');
+ var a=document.createElement('a');
+ a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));
+ a.download='rentv-advertisers.csv';a.click();URL.revokeObjectURL(a.href);
+ });
+ })();
+ </script>`;
+
+ res.type('html').send(layout('Advertisers', pageShell(rail, content), { activeNav: '/advertisers', market, verified: verifiedOnly, q }));
+ } catch (e) {
+ console.error('[pages] /advertisers', e.message);
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">Server error: ${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /advertisers/:id — org profile (§25 tabs)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/advertisers/:id', async (req, res) => {
+ const { id } = req.params;
+ if (!/^[0-9a-f-]{36}$/i.test(id)) return res.redirect('/advertisers');
+
+ try {
+ const [orgRows, sightings, events, contacts, scoreRows, evidenceRows, auditRows] = await Promise.all([
+ safeQ(`SELECT * FROM organizations WHERE id = $1`, [id]),
+ safeQ(`SELECT s.*, p.name AS pub_name, p.domain AS pub_domain
+ FROM ad_sightings s LEFT JOIN publications p ON p.id = s.publication_id
+ WHERE s.organization_id = $1 ORDER BY s.observed_at DESC NULLS LAST LIMIT 100`, [id]),
+ safeQ(`SELECT er.*, e.name AS event_name, e.start_date, e.end_date, e.city, e.state, e.official_url
+ FROM event_relationships er JOIN events e ON e.id = er.event_id
+ WHERE er.organization_id = $1 ORDER BY e.start_date DESC NULLS LAST LIMIT 50`, [id]),
+ safeQ(`SELECT cp.*, pe.full_name, pe.public_title, pe.linkedin_url AS person_li
+ FROM contact_points cp LEFT JOIN people pe ON pe.id = cp.person_id
+ WHERE cp.organization_id = $1 AND cp.do_not_contact = false
+ ORDER BY cp.confidence DESC LIMIT 50`, [id]),
+ safeQ(`SELECT * FROM opportunity_scores WHERE organization_id = $1 ORDER BY computed_at DESC LIMIT 1`, [id]),
+ safeQ(`SELECT er.* FROM evidence_records er WHERE er.id IN (
+ SELECT evidence_id FROM ad_sightings WHERE organization_id = $1 AND evidence_id IS NOT NULL
+ ) LIMIT 30`, [id]),
+ safeQ(`SELECT * FROM audit_logs WHERE entity_id = $1 ORDER BY created_at DESC LIMIT 30`, [id]),
+ ]);
+
+ if (!orgRows.length) return res.status(404).type('html').send(layout('Not Found', `<div class="page-content"><div class="alert alert-danger">Organization not found. <a href="/advertisers">Back to advertisers</a></div></div>`));
+
+ const org = orgRows[0];
+ const scoreRow = scoreRows[0];
+ let scoreExplain = null;
+ if (scoreRow && scoreRow.factors) {
+ try { scoreExplain = scoring.explainScore(scoreRow.factors); } catch (_) {}
+ }
+
+ const cats = (() => { try { return Array.isArray(org.advertiser_categories) ? org.advertiser_categories : JSON.parse(org.advertiser_categories || '[]'); } catch (_) { return []; } })();
+ const bestEmail = contacts.find(c => c.type === 'BUSINESS_EMAIL');
+ const bestPhone = contacts.find(c => c.type === 'BUSINESS_PHONE');
+ const website = contacts.find(c => c.type === 'WEBSITE');
+ const linkedin = contacts.find(c => c.type === 'LINKEDIN');
+
+ const FACTOR_LABELS = {
+ verifiedAdvertising: 'Verified advertising',
+ verifiedConferenceSpendSignal: 'Conference spend signal',
+ recency: 'Recency',
+ repeatActivity: 'Repeat activity',
+ californiaFit: 'California fit',
+ arizonaFit: 'Arizona fit',
+ categoryFit: 'Category fit',
+ rentvAudienceFit: 'RENTV audience fit',
+ contactCompleteness: 'Contact completeness',
+ evidenceQuality: 'Evidence quality',
+ };
+
+ const scoreHtml = scoreExplain
+ ? `<div class="settings-card">
+ <h3>Opportunity Score: ${scoreExplain.score} / 100</h3>
+ <p style="font-size:14px;color:var(--ink-muted)">RENTV sales opportunity score — not an ad-spend estimate. Computed ${fmtDate(scoreRow.computed_at)}.</p>
+ ${scoreExplain.factors.map(f => `
+ <div class="factor-row">
+ <span class="factor-name">${esc(FACTOR_LABELS[f.factor] || f.factor)}</span>
+ <div class="factor-bar-wrap"><div class="factor-bar" style="width:${f.value}%"></div></div>
+ <span class="factor-contrib">${f.value}</span>
+ </div>`).join('')}
+ </div>`
+ : `<p class="miss">No score computed yet. Run the scoring job.</p>`;
+
+ const sightingsHtml = sightings.length
+ ? sightings.map(s => `
+ <div class="evidence-card">
+ <div class="ev-title">${statusBadge(s.relationship_status)} ${esc(s.headline || 'Ad sighting')}</div>
+ <div class="ev-meta">
+ <span>${fmtDate(s.observed_at)}</span>
+ ${s.pub_name ? `<span>via <a href="/advertisers?q=${encodeURIComponent(s.pub_name)}">${esc(s.pub_name)}</a></span>` : ''}
+ ${s.source_page_url ? `<span><a href="${esc(s.source_page_url)}" target="_blank" rel="noopener noreferrer">Source ↗</a></span>` : ''}
+ <span>Verification: ${esc(s.verification_status || '—')}</span>
+ <span>Confidence: ${s.confidence ? Math.round(s.confidence * 100) + '%' : '—'}</span>
+ </div>
+ ${s.visible_copy ? `<div class="ev-excerpt">${esc(s.visible_copy)}</div>` : ''}
+ <div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap">
+ ${s.verification_status !== 'VERIFIED' ? `<button class="btn btn-outline" style="font-size:13px;min-height:36px" onclick="reviewAction('${esc(s.id)}','verify')">Mark Verified</button>` : ''}
+ ${s.verification_status !== 'REJECTED' ? `<button class="btn btn-ghost" style="font-size:13px;min-height:36px" onclick="reviewAction('${esc(s.id)}','reject')">Reject</button>` : ''}
+ </div>
+ </div>`).join('')
+ : '<p class="miss">No ad sightings recorded yet.</p>';
+
+ const confSponsors = events.filter(e => e.relationship_status !== 'SPEAKER_OR_PANELIST_ONLY');
+ const panelists = events.filter(e => e.relationship_status === 'SPEAKER_OR_PANELIST_ONLY');
+
+ const confsHtml = events.length ? `
+ ${confSponsors.length ? `<div class="section-divider">Sponsor / Exhibitor Roles (${confSponsors.length})</div>
+ ${confSponsors.map(e => `<div class="evidence-card">
+ <div class="ev-title"><a href="/conferences/${esc(e.event_id)}">${esc(e.event_name)}</a></div>
+ <div class="ev-meta">
+ <span>${statusBadge(e.relationship_status)}</span>
+ <span>${fmtDateOnly(e.start_date)}${e.city ? ' · ' + esc(e.city) : ''}${e.state ? ', ' + esc(e.state) : ''}</span>
+ ${e.sponsor_level ? `<span>Level: ${esc(e.sponsor_level)}</span>` : ''}
+ ${e.official_url ? `<span><a href="${esc(e.official_url)}" target="_blank" rel="noopener noreferrer">Official ↗</a></span>` : ''}
+ </div>
+ </div>`).join('')}` : ''}
+ ${panelists.length ? `<div class="section-divider">Speaker / Panelist Roles (${panelists.length})</div>
+ <div class="alert alert-info" style="margin-bottom:16px">These are speaking roles only — not verified sponsorships.</div>
+ ${panelists.map(e => `<div class="evidence-card">
+ <div class="ev-title"><a href="/conferences/${esc(e.event_id)}">${esc(e.event_name)}</a></div>
+ <div class="ev-meta">
+ <span>${statusBadge(e.relationship_status)}</span>
+ <span>${fmtDateOnly(e.start_date)}${e.city ? ' · ' + esc(e.city) : ''}${e.state ? ', ' + esc(e.state) : ''}</span>
+ ${e.session_title ? `<span>Session: ${esc(e.session_title)}</span>` : ''}
+ </div>
+ </div>`).join('')}` : ''}
+ ` : '<p class="miss">No conference activity recorded.</p>';
+
+ const contactsHtml = contacts.length
+ ? contacts.map(c => `
+ <div class="evidence-card">
+ <div class="ev-title">${esc(c.full_name || 'Organization contact')}${c.public_title ? ` <span class="miss" style="font-weight:400">· ${esc(c.public_title)}</span>` : ''}</div>
+ <div class="ev-meta">
+ <span>${esc(c.type.replace(/_/g, ' '))}</span>
+ <span>${c.type === 'BUSINESS_EMAIL' ? `<a href="mailto:${esc(c.value)}">${esc(c.value)}</a>` :
+ c.type === 'BUSINESS_PHONE' ? `<a href="tel:${esc(c.value)}">${esc(c.value)}</a>` :
+ c.type === 'WEBSITE' || c.type === 'LINKEDIN' ? `<a href="${esc(c.value)}" target="_blank" rel="noopener noreferrer">${esc(c.value)} ↗</a>` :
+ esc(c.value)}</span>
+ ${c.confidence ? `<span>Confidence: ${Math.round(c.confidence * 100)}%</span>` : ''}
+ ${c.verified_at ? `<span>Verified: ${fmtDateOnly(c.verified_at)}</span>` : ''}
+ </div>
+ ${c.person_li ? `<div style="margin-top:6px"><a href="${esc(c.person_li)}" target="_blank" rel="noopener noreferrer" class="btn btn-ghost" style="font-size:13px;min-height:32px">Open LinkedIn ↗</a></div>` : ''}
+ </div>`).join('')
+ : '<p class="miss">No contacts recorded. <a href="/review">Add via review queue</a>.</p>';
+
+ const evidenceHtml = evidenceRows.length
+ ? evidenceRows.map(e => `
+ <div class="evidence-card">
+ <div class="ev-title">${esc(e.source_title || e.evidence_type)}</div>
+ <div class="ev-meta">
+ <span class="badge badge-research-needed" style="font-size:11px">${esc(e.evidence_type)}</span>
+ ${e.source_url ? `<span><a href="${esc(e.source_url)}" target="_blank" rel="noopener noreferrer">Source ↗</a></span>` : ''}
+ <span>Observed: ${fmtDateOnly(e.observed_at)}</span>
+ <span>Retrieved: ${fmtDate(e.retrieved_at)}</span>
+ <span>Confidence: ${Math.round((e.confidence || 0) * 100)}%</span>
+ <span>Export: ${e.export_allowed ? 'Allowed' : 'Internal only'}</span>
+ </div>
+ ${e.excerpt ? `<div class="ev-excerpt">${esc(e.excerpt)}</div>` : ''}
+ </div>`).join('')
+ : '<p class="miss">No evidence records linked yet.</p>';
+
+ const auditHtml = auditRows.length
+ ? auditRows.map(a => `
+ <div class="audit-row">
+ <div style="flex:0 0 180px">
+ <span class="when-chip" title="${esc(new Date(a.created_at).toISOString())}">${fmtDate(a.created_at)}</span>
+ </div>
+ <div>
+ <span class="audit-action">${esc(a.action)}</span>
+ <span class="audit-meta"> — by ${esc(a.actor || 'system')}</span>
+ </div>
+ </div>`).join('')
+ : '<p class="miss">No audit history.</p>';
+
+ const header = `
+ <div class="org-header">
+ <div class="org-thumb-placeholder" style="flex-shrink:0;width:64px;height:64px;font-size:28px" aria-hidden="true">🏢</div>
+ <div class="org-header-info">
+ <h1>${esc(org.display_name)}</h1>
+ <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:6px">
+ ${statusBadge(sightings[0] && sightings[0].relationship_status)}
+ ${scoreBadge(scoreRow && scoreRow.score)}
+ ${org.headquarters_state ? `<span class="badge badge-panelist">${esc(org.headquarters_state)}</span>` : ''}
+ ${cats.slice(0, 2).map(c => `<span class="badge badge-panelist" style="font-size:11px">${esc(c)}</span>`).join('')}
+ </div>
+ ${org.description ? `<p style="color:var(--ink-muted);font-size:15px;margin:0">${esc(org.description)}</p>` : ''}
+ <div class="org-actions">
+ ${website ? `<a href="${/^https?:/.test(website.value) ? esc(website.value) : 'https://' + esc(website.value)}" class="btn btn-outline" target="_blank" rel="noopener noreferrer">Website ↗</a>` : ''}
+ ${linkedin ? `<a href="${esc(linkedin.value)}" class="btn btn-outline" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : ''}
+ ${bestEmail ? `<a href="mailto:${esc(bestEmail.value)}" class="btn btn-outline">Email</a>` : ''}
+ ${bestPhone ? `<a href="tel:${esc(bestPhone.value)}" class="btn btn-outline">Call</a>` : ''}
+ ${scoreRow ? `<button class="btn btn-ghost" data-smw-org="${esc(id)}" data-smw-name="${esc(org.display_name)}">Show Me Why ⓘ</button>` : ''}
+ </div>
+ </div>
+ </div>`;
+
+ const tabs = [
+ ['summary', 'Summary'],
+ ['ads', 'Ads & Creatives'],
+ ['conferences', 'Conference Sponsorships'],
+ ['contacts', 'Contacts'],
+ ['evidence', 'Sources & Evidence'],
+ ['audit', 'Audit History'],
+ ];
+
+ const tabBar = `<div class="tab-bar" role="tablist">
+ ${tabs.map(([k, l]) => `<button class="tab-btn" role="tab" data-tab="${k}" aria-selected="false">${esc(l)}</button>`).join('')}
+ </div>`;
+
+ const summaryTab = `<div class="tab-panel" id="tab-summary">
+ <div class="settings-card">
+ <h3>Organization details</h3>
+ <table style="width:100%;border-collapse:collapse;font-size:15px">
+ ${[
+ ['Legal name', org.legal_name],
+ ['Domain', org.domain ? `<a href="https://${esc(org.domain)}" target="_blank" rel="noopener noreferrer">${esc(org.domain)} ↗</a>` : '—'],
+ ['Headquarters', [org.headquarters_city, org.headquarters_state].filter(Boolean).join(', ') || '—'],
+ ['Status', org.active_status],
+ ['First seen', fmtDateOnly(org.first_seen_at)],
+ ['Last seen', fmtDate(org.last_seen_at)],
+ ['Created', fmtDate(org.created_at)],
+ ['Categories', cats.join(', ') || '—'],
+ ].map(([k, v]) => `<tr><td style="padding:8px 0;color:var(--ink-muted);width:180px;border-bottom:1px solid var(--border)">${esc(k)}</td><td style="padding:8px 0;border-bottom:1px solid var(--border)">${v}</td></tr>`).join('')}
+ </table>
+ </div>
+ ${scoreHtml}
+ </div>`;
+
+ const body = `<div class="page-content">
+ <p><a href="/advertisers">← All advertisers</a></p>
+ ${header}
+ ${tabBar}
+ ${summaryTab}
+ <div class="tab-panel" id="tab-ads">${sightingsHtml}</div>
+ <div class="tab-panel" id="tab-conferences">${confsHtml}</div>
+ <div class="tab-panel" id="tab-contacts">${contactsHtml}</div>
+ <div class="tab-panel" id="tab-evidence">${evidenceHtml}</div>
+ <div class="tab-panel" id="tab-audit">${auditHtml}</div>
+ <script>
+ (function(){
+ var tabs=document.querySelectorAll('.tab-btn');
+ var panels=document.querySelectorAll('.tab-panel');
+ function show(k){
+ tabs.forEach(function(t){t.classList.toggle('active',t.dataset.tab===k);t.setAttribute('aria-selected',t.dataset.tab===k);});
+ panels.forEach(function(p){p.classList.toggle('active',p.id==='tab-'+k);});
+ try{localStorage.setItem('adv-tab-${esc(id)}',k);}catch(_){}
+ }
+ tabs.forEach(function(t){t.addEventListener('click',function(){show(t.dataset.tab);});});
+ var saved=localStorage.getItem('adv-tab-${esc(id)}');
+ show(saved&&document.getElementById('tab-'+saved)?saved:'summary');
+ function reviewAction(sid,action){
+ fetch('/api/v1/review/'+sid+'/'+action,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})
+ .then(function(r){return r.json();})
+ .then(function(d){if(d.ok){location.reload();}else{alert(d.message||'Error');}})
+ .catch(function(e){alert(e.message);});
+ }
+ window.reviewAction=reviewAction;
+ })();
+ </script>
+ </div>`;
+
+ res.type('html').send(layout(org.display_name, body, { activeNav: '/advertisers' }));
+ } catch (e) {
+ console.error('[pages] /advertisers/:id', e.message);
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">Server error: ${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /ads — visual ad gallery
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/ads', async (req, res) => {
+ try {
+ const rows = await safeQ(`
+ SELECT s.id, s.relationship_status, s.headline, s.observed_at, s.source_page_url,
+ s.verification_status, s.confidence, s.created_at,
+ o.display_name AS org_name, o.id AS org_id,
+ p.name AS pub_name, ca.object_key AS thumb_key
+ FROM ad_sightings s
+ LEFT JOIN organizations o ON o.id = s.organization_id
+ LEFT JOIN publications p ON p.id = s.publication_id
+ LEFT JOIN creative_assets ca ON ca.id = s.thumbnail_asset_id
+ ORDER BY s.observed_at DESC NULLS LAST LIMIT 200`);
+
+ const isDemo = rows.length === 0;
+ const cards = rows.map(r => `
+ <div class="ad-card">
+ ${r.thumb_key
+ ? `<img src="/assets/${esc(r.thumb_key)}" alt="${esc(r.org_name || '')} ad thumbnail" loading="lazy">`
+ : `<div style="height:160px;display:flex;align-items:center;justify-content:center;background:var(--surface-alt);font-size:40px">🖼</div>`}
+ <div class="ad-card-body">
+ <div class="org-name"><a href="/advertisers/${esc(r.org_id)}">${esc(r.org_name || '—')}</a></div>
+ <div class="ad-meta">${statusBadge(r.relationship_status)} · ${fmtDateOnly(r.observed_at)}</div>
+ ${r.headline ? `<p style="font-size:14px;color:var(--ink-muted);margin:4px 0 0">${esc(r.headline)}</p>` : ''}
+ </div>
+ <div class="ad-card-footer">
+ <a href="/ads/${esc(r.id)}" class="btn btn-outline" style="font-size:13px;min-height:36px">Details</a>
+ ${r.source_page_url ? `<a href="${esc(r.source_page_url)}" class="btn btn-ghost" style="font-size:13px;min-height:36px" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}
+ </div>
+ </div>`).join('');
+
+ const body = `<div class="page-content">
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:12px">
+ <h1>Ad Gallery</h1>
+ <div style="display:flex;align-items:center;gap:12px">
+ <label class="density-wrap">Density <input type="range" id="density-sl" min="1" max="10" step="1"></label>
+ </div>
+ </div>
+ ${isDemo ? demoBanner : ''}
+ <div class="card-grid">${cards || '<p class="miss">No ad sightings yet. Import data or run a research job.</p>'}</div>
+ <script>
+ (function(){
+ var sl=document.getElementById('density-sl');
+ if(!sl) return;
+ var stored=parseInt(localStorage.getItem('ads:density'),10);
+ var val=(stored>=1&&stored<=10)?stored:5;
+ sl.value=val;
+ function apply(v){var px=Math.round(450-(Math.max(1,Math.min(10,v))-1)*(450-200)/9);document.documentElement.style.setProperty('--card-min',px+'px');}
+ apply(val);
+ sl.addEventListener('input',function(){apply(+sl.value);localStorage.setItem('ads:density',sl.value);});
+ })();
+ </script>
+ </div>`;
+
+ res.type('html').send(layout('Ad Gallery', body, { activeNav: '/ads' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// GET /ads/:id
+router.get('/ads/:id', async (req, res) => {
+ if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) return res.redirect('/ads');
+ try {
+ const rows = await safeQ(`
+ SELECT s.*, o.display_name AS org_name, o.id AS org_id, p.name AS pub_name,
+ er.source_url, er.source_title, er.excerpt, er.observed_at AS ev_observed_at,
+ er.confidence AS ev_confidence, er.export_allowed
+ FROM ad_sightings s
+ LEFT JOIN organizations o ON o.id = s.organization_id
+ LEFT JOIN publications p ON p.id = s.publication_id
+ LEFT JOIN evidence_records er ON er.id = s.evidence_id
+ WHERE s.id = $1`, [req.params.id]);
+
+ if (!rows.length) return res.status(404).type('html').send(layout('Not Found', `<div class="page-content"><p>Ad sighting not found. <a href="/ads">Back to gallery</a></p></div>`));
+ const s = rows[0];
+ const body = `<div class="page-content">
+ <p><a href="/ads">← Ad Gallery</a></p>
+ <h1>${esc(s.headline || 'Ad Sighting')}</h1>
+ <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px">
+ ${statusBadge(s.relationship_status)}
+ <span class="badge badge-panelist">Verification: ${esc(s.verification_status || '—')}</span>
+ </div>
+ <div class="settings-card">
+ ${[
+ ['Company', s.org_id ? `<a href="/advertisers/${esc(s.org_id)}">${esc(s.org_name || '—')}</a>` : '—'],
+ ['Publication', esc(s.pub_name || '—')],
+ ['Observed', fmtDate(s.observed_at)],
+ ['First observed', fmtDateOnly(s.first_observed_at)],
+ ['Last observed', fmtDateOnly(s.last_observed_at)],
+ ['Source URL', s.source_page_url ? `<a href="${esc(s.source_page_url)}" target="_blank" rel="noopener noreferrer">${esc(s.source_page_url)} ↗</a>` : '—'],
+ ['Landing URL', s.landing_url ? `<a href="${esc(s.landing_url)}" target="_blank" rel="noopener noreferrer">${esc(s.landing_url)} ↗</a>` : '—'],
+ ['Confidence', s.confidence ? Math.round(s.confidence * 100) + '%' : '—'],
+ ['Created', fmtDate(s.created_at)],
+ ].map(([k, v]) => `<div style="display:flex;gap:16px;padding:8px 0;border-bottom:1px solid var(--border);font-size:15px"><span style="color:var(--ink-muted);flex:0 0 160px">${esc(k)}</span><span>${v}</span></div>`).join('')}
+ </div>
+ ${s.visible_copy ? `<div class="settings-card"><h3>Ad copy</h3><p style="font-style:italic">${esc(s.visible_copy)}</p></div>` : ''}
+ ${s.excerpt ? `<div class="settings-card"><h3>Evidence excerpt</h3><blockquote class="ev-excerpt" style="margin:0">${esc(s.excerpt)}</blockquote></div>` : ''}
+ <div style="display:flex;gap:10px;margin-top:16px">
+ <button class="btn btn-outline" onclick="reviewAction('${esc(s.id)}','verify')">Mark Verified</button>
+ <button class="btn btn-ghost" onclick="reviewAction('${esc(s.id)}','reject')">Reject</button>
+ </div>
+ <script>function reviewAction(id,a){fetch('/api/v1/review/'+id+'/'+a,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>{if(d.ok)location.reload();else alert(d.message||'Error');}).catch(e=>alert(e.message));}</script>
+ </div>`;
+ res.type('html').send(layout('Ad: ' + (s.headline || s.org_name || ''), body, { activeNav: '/ads' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /conferences
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/conferences', async (req, res) => {
+ try {
+ const rows = await safeQ(`
+ SELECT e.*,
+ (SELECT COUNT(*) FROM event_relationships er WHERE er.event_id = e.id
+ AND er.relationship_status != 'SPEAKER_OR_PANELIST_ONLY') AS sponsor_count,
+ (SELECT COUNT(*) FROM event_relationships er WHERE er.event_id = e.id
+ AND er.relationship_status = 'SPEAKER_OR_PANELIST_ONLY') AS panelist_count
+ FROM events e ORDER BY e.start_date DESC NULLS LAST LIMIT 200`);
+
+ const isDemo = rows.length === 0;
+ const tableRows = rows.map(r => `<tr>
+ <td><a href="/conferences/${esc(r.id)}">${esc(r.name)}</a></td>
+ <td>${fmtDateOnly(r.start_date)}</td>
+ <td>${esc(r.city || '—')}${r.state ? ', ' + esc(r.state) : ''}</td>
+ <td>${esc(r.event_type || '—')}</td>
+ <td class="num"><a href="/conferences/${esc(r.id)}">${Number(r.sponsor_count || 0).toLocaleString()}</a></td>
+ <td class="num">${Number(r.panelist_count || 0).toLocaleString()}</td>
+ <td>${r.official_url ? `<a href="${esc(r.official_url)}" target="_blank" rel="noopener noreferrer">Official ↗</a>` : '<span class="miss">—</span>'}</td>
+ </tr>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Conferences</h1>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-controls">
+ <input class="tbl-search" id="conf-search" type="search" placeholder="Search conferences…" aria-label="Search conferences">
+ <span class="tbl-count" id="conf-count">${rows.length} events</span>
+ </div>
+ <div class="tbl-wrap">
+ <table class="adv-tbl" id="conf-tbl" aria-label="Conferences">
+ <thead><tr><th>Name</th><th>Date</th><th>Location</th><th>Type</th><th>Sponsors</th><th>Panelists</th><th>Link</th></tr></thead>
+ <tbody>${tableRows || '<tr><td colspan="7"><p class="miss" style="padding:20px">No conferences recorded yet.</p></td></tr>'}</tbody>
+ </table>
+ </div>
+ <script>
+ (function(){
+ var s=document.getElementById('conf-search'),rows=Array.from(document.querySelectorAll('#conf-tbl tbody tr'));
+ if(!s)return;
+ s.addEventListener('input',function(){var q=s.value.toLowerCase().split(/\s+/).filter(Boolean),n=0;rows.forEach(function(tr){var pass=q.every(function(t){return tr.textContent.toLowerCase().includes(t);});tr.style.display=pass?'':'none';if(pass)n++;});document.getElementById('conf-count').textContent=n+' of '+rows.length+' events';});
+ })();
+ </script>
+ </div>`;
+ res.type('html').send(layout('Conferences', body, { activeNav: '/conferences' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// GET /conferences/:id
+router.get('/conferences/:id', async (req, res) => {
+ if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) return res.redirect('/conferences');
+ try {
+ const [evRows, rels] = await Promise.all([
+ safeQ(`SELECT * FROM events WHERE id = $1`, [req.params.id]),
+ safeQ(`SELECT er.*, o.display_name AS org_name, o.id AS org_id, o.domain,
+ o.advertiser_categories
+ FROM event_relationships er
+ JOIN organizations o ON o.id = er.organization_id
+ WHERE er.event_id = $1
+ ORDER BY CASE er.relationship_status
+ WHEN 'VERIFIED_CONFERENCE_SPONSOR' THEN 1 WHEN 'VERIFIED_EXHIBITOR' THEN 2
+ WHEN 'VERIFIED_MEDIA_PARTNER' THEN 3 WHEN 'VERIFIED_CONTENT_PARTNER' THEN 4
+ WHEN 'SPEAKER_OR_PANELIST_ONLY' THEN 5 ELSE 6 END, o.display_name`, [req.params.id]),
+ ]);
+ if (!evRows.length) return res.status(404).type('html').send(layout('Not Found', `<div class="page-content"><p>Conference not found. <a href="/conferences">Back</a></p></div>`));
+ const ev = evRows[0];
+ const sponsors = rels.filter(r => r.relationship_status !== 'SPEAKER_OR_PANELIST_ONLY');
+ const panelists = rels.filter(r => r.relationship_status === 'SPEAKER_OR_PANELIST_ONLY');
+
+ const relCard = (r) => `<div class="evidence-card">
+ <div class="ev-title"><a href="/advertisers/${esc(r.org_id)}">${esc(r.org_name)}</a></div>
+ <div class="ev-meta">
+ ${statusBadge(r.relationship_status)}
+ ${r.sponsor_level ? `<span>Level: ${esc(r.sponsor_level)}</span>` : ''}
+ ${r.session_title ? `<span>Session: ${esc(r.session_title)}</span>` : ''}
+ ${r.panel_role ? `<span>Role: ${esc(r.panel_role)}</span>` : ''}
+ ${r.domain ? `<span><a href="https://${esc(r.domain)}" target="_blank" rel="noopener noreferrer">${esc(r.domain)} ↗</a></span>` : ''}
+ <span>Confidence: ${Math.round((r.confidence || 0) * 100)}%</span>
+ </div>
+ </div>`;
+
+ const body = `<div class="page-content">
+ <p><a href="/conferences">← Conferences</a></p>
+ <h1>${esc(ev.name)}</h1>
+ <div class="settings-card" style="margin-bottom:24px">
+ ${[
+ ['Date', fmtDateOnly(ev.start_date) + (ev.end_date ? ' – ' + fmtDateOnly(ev.end_date) : '')],
+ ['Location', [ev.venue, ev.city, ev.state].filter(Boolean).join(', ') || '—'],
+ ['Type', ev.event_type || '—'],
+ ['Official URL', ev.official_url ? `<a href="${esc(ev.official_url)}" target="_blank" rel="noopener noreferrer">${esc(ev.official_url)} ↗</a>` : '—'],
+ ['Sponsor page', ev.sponsor_page_url ? `<a href="${esc(ev.sponsor_page_url)}" target="_blank" rel="noopener noreferrer">Sponsor page ↗</a>` : '—'],
+ ].map(([k,v]) => `<div style="display:flex;gap:16px;padding:8px 0;border-bottom:1px solid var(--border);font-size:15px"><span style="color:var(--ink-muted);flex:0 0 140px">${esc(k)}</span><span>${v}</span></div>`).join('')}
+ </div>
+
+ <div class="section-divider">Sponsors, Exhibitors & Partners (${sponsors.length})</div>
+ ${sponsors.length ? sponsors.map(relCard).join('') : '<p class="miss">No verified sponsors recorded. Panelists are listed separately below.</p>'}
+
+ <div class="section-divider" style="margin-top:28px">Speakers & Panelists (${panelists.length})</div>
+ ${panelists.length
+ ? `<div class="alert alert-info" style="margin-bottom:16px">Speaking roles only — not sponsors unless separately verified.</div>` + panelists.map(relCard).join('')
+ : '<p class="miss">No panelist records.</p>'}
+ </div>`;
+ res.type('html').send(layout(ev.name, body, { activeNav: '/conferences' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /contacts
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/contacts', async (req, res) => {
+ const q = req.query.q ? String(req.query.q).slice(0, 200) : '';
+ try {
+ const params = [];
+ function p(v) { params.push(v); return '$' + params.length; }
+ const conds = ['cp.do_not_contact = false'];
+ if (q) {
+ const like = '%' + q.replace(/[%_]/g, '\\$&') + '%';
+ conds.push(`(pe.full_name ILIKE ${p(like)} OR o.display_name ILIKE ${p(like)} OR cp.value ILIKE ${p(like)})`);
+ }
+ const rows = await safeQ(`
+ SELECT cp.id, cp.type, cp.value, cp.confidence, cp.verified_at, cp.created_at,
+ pe.full_name, pe.public_title, pe.linkedin_url AS person_li,
+ o.display_name AS org_name, o.id AS org_id
+ FROM contact_points cp
+ LEFT JOIN people pe ON pe.id = cp.person_id
+ LEFT JOIN organizations o ON o.id = cp.organization_id
+ WHERE ${conds.join(' AND ')}
+ ORDER BY cp.confidence DESC, cp.created_at DESC
+ LIMIT 500`, params);
+
+ const isDemo = rows.length === 0;
+ const tableRows = rows.map(r => {
+ const val = r.type === 'BUSINESS_EMAIL' ? `<a href="mailto:${esc(r.value)}">${esc(r.value)}</a>`
+ : r.type === 'BUSINESS_PHONE' ? `<a href="tel:${esc(r.value)}">${esc(r.value)}</a>`
+ : r.type === 'WEBSITE' || r.type === 'LINKEDIN' ? `<a href="${esc(r.value)}" target="_blank" rel="noopener noreferrer">${esc(r.value)} ↗</a>`
+ : esc(r.value);
+ return `<tr>
+ <td>${r.full_name ? esc(r.full_name) : '<span class="miss">—</span>'}</td>
+ <td>${r.public_title ? esc(r.public_title) : '<span class="miss">—</span>'}</td>
+ <td><a href="/advertisers/${esc(r.org_id)}">${esc(r.org_name || '—')}</a></td>
+ <td><span class="badge badge-panelist" style="font-size:11px">${esc(r.type.replace(/_/g, ' '))}</span></td>
+ <td>${val}</td>
+ <td>${r.confidence ? Math.round(r.confidence * 100) + '%' : '—'}</td>
+ <td>${fmtDateOnly(r.verified_at)}</td>
+ <td>${r.person_li ? `<a href="${esc(r.person_li)}" target="_blank" rel="noopener noreferrer">LinkedIn ↗</a>` : '<span class="miss">—</span>'}</td>
+ <td><span class="when-chip" title="${esc(r.created_at ? new Date(r.created_at).toISOString() : '')}">${fmtDate(r.created_at)}</span></td>
+ </tr>`;
+ }).join('');
+
+ const body = `<div class="page-content">
+ <h1>Contacts</h1>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-controls">
+ <input class="tbl-search" id="contact-search" type="search" placeholder="Search contacts…" value="${esc(q)}" aria-label="Search contacts">
+ <span class="tbl-count" id="contact-count">${rows.length} contacts</span>
+ </div>
+ <div class="tbl-wrap">
+ <table class="adv-tbl" id="contact-tbl" aria-label="Contacts">
+ <thead><tr><th>Name</th><th>Title</th><th>Company</th><th>Type</th><th>Value</th><th>Confidence</th><th>Verified</th><th>LinkedIn</th><th>Created</th></tr></thead>
+ <tbody>${tableRows || '<tr><td colspan="9"><p class="miss" style="padding:20px">No contacts yet.</p></td></tr>'}</tbody>
+ </table>
+ </div>
+ <script>
+ (function(){var s=document.getElementById('contact-search'),rows=Array.from(document.querySelectorAll('#contact-tbl tbody tr'));if(!s)return;s.addEventListener('input',function(){var q=s.value.toLowerCase().split(/\s+/).filter(Boolean),n=0;rows.forEach(function(tr){var pass=q.every(function(t){return tr.textContent.toLowerCase().includes(t);});tr.style.display=pass?'':'none';if(pass)n++;});document.getElementById('contact-count').textContent=n+' of '+rows.length+' contacts';});})();
+ </script>
+ </div>`;
+ res.type('html').send(layout('Contacts', body, { activeNav: '/contacts', q }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /prospects
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/prospects', async (req, res) => {
+ const market = ['CA', 'AZ', 'ALL'].includes(req.query.market) ? req.query.market : 'ALL';
+ try {
+ const params = [T.VERIFIED_STATUSES];
+ const conds = [`NOT EXISTS (SELECT 1 FROM ad_sightings s WHERE s.organization_id = o.id AND s.relationship_status = ANY($1))`];
+ if (market !== 'ALL') { params.push(market); conds.push(`o.headquarters_state = $${params.length}`); }
+
+ const rows = await safeQ(`
+ SELECT o.id, o.display_name, o.domain, o.headquarters_state, o.headquarters_city,
+ o.advertiser_categories, o.last_seen_at, o.created_at,
+ opp.score, opp.computed_at AS score_at
+ FROM organizations o
+ LEFT JOIN opportunity_scores opp ON opp.organization_id = o.id
+ AND opp.computed_at = (SELECT MAX(x.computed_at) FROM opportunity_scores x WHERE x.organization_id = o.id)
+ WHERE ${conds.join(' AND ')}
+ ORDER BY opp.score DESC NULLS LAST, o.last_seen_at DESC NULLS LAST
+ LIMIT 200`, params);
+
+ const isDemo = rows.length === 0;
+ const tableRows = rows.map((r, i) => {
+ const cats = (() => { try { return Array.isArray(r.advertiser_categories) ? r.advertiser_categories : JSON.parse(r.advertiser_categories || '[]'); } catch (_) { return []; } })();
+ return `<tr>
+ <td class="num">${i + 1}</td>
+ <td><a href="/advertisers/${esc(r.id)}">${esc(r.display_name)}</a></td>
+ <td>${esc(r.headquarters_state || '—')}</td>
+ <td style="font-size:13px;max-width:200px;white-space:normal">${esc(cats.slice(0, 2).join(', ') || '—')}</td>
+ <td>${scoreBadge(r.score)}</td>
+ <td>${fmtDateOnly(r.score_at)}</td>
+ <td>${r.domain ? `<a href="https://${esc(r.domain)}" target="_blank" rel="noopener noreferrer">${esc(r.domain)} ↗</a>` : '<span class="miss">—</span>'}</td>
+ <td>${r.id ? `<button class="btn btn-ghost" style="font-size:12px;padding:4px 8px;min-height:32px" data-smw-org="${esc(r.id)}" data-smw-name="${esc(r.display_name)}">Why?</button>` : ''}</td>
+ </tr>`;
+ }).join('');
+
+ const body = `<div class="page-content">
+ <h1>Top Prospects</h1>
+ <p style="color:var(--ink-muted);font-size:15px">Companies ranked by RENTV advertising opportunity score — not verified advertisers yet.</p>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-wrap">
+ <table class="adv-tbl" aria-label="Prospects">
+ <thead><tr><th>#</th><th>Company</th><th>State</th><th>Category</th><th>Score</th><th>Scored</th><th>Website</th><th>Why?</th></tr></thead>
+ <tbody>${tableRows || '<tr><td colspan="8"><p class="miss" style="padding:20px">No prospects yet — run the scoring job.</p></td></tr>'}</tbody>
+ </table>
+ </div>
+ </div>`;
+ res.type('html').send(layout('Prospects', body, { activeNav: '/prospects', market }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /analytics
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/analytics', async (req, res) => {
+ try {
+ const [ga4, conn] = await Promise.all([
+ safeQ(`SELECT * FROM ga4_daily_metrics ORDER BY metric_date DESC LIMIT 30`),
+ safeQ(`SELECT * FROM analytics_connections WHERE kind = 'GA4' LIMIT 1`),
+ ]);
+ const isDemo = ga4.length === 0 || (ga4[0] && ga4[0].is_demo);
+ const connStatus = conn[0] ? conn[0].status : 'NOT_CONNECTED';
+
+ const tableRows = ga4.map(r => `<tr>
+ <td>${fmtDateOnly(r.metric_date)}</td>
+ <td class="num">${(r.sessions || 0).toLocaleString()}</td>
+ <td class="num">${(r.total_users || 0).toLocaleString()}</td>
+ <td class="num">${(r.new_users || 0).toLocaleString()}</td>
+ <td class="num">${(r.engaged_sessions || 0).toLocaleString()}</td>
+ <td class="num">${r.engagement_rate ? (r.engagement_rate * 100).toFixed(1) + '%' : '—'}</td>
+ <td class="num">${r.avg_engagement_time ? Number(r.avg_engagement_time).toFixed(1) + 's' : '—'}</td>
+ <td class="num">${(r.views || 0).toLocaleString()}</td>
+ </tr>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Analytics — GA4</h1>
+ <div class="alert alert-info" style="margin-bottom:16px">
+ Connection status: <strong>${esc(connStatus)}</strong>.
+ ${connStatus !== 'CONNECTED' ? `<a href="/settings">Connect GA4 →</a>` : 'Import running.'}
+ </div>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-wrap">
+ <table class="adv-tbl" aria-label="GA4 daily metrics">
+ <thead><tr><th>Date</th><th>Sessions</th><th>Users</th><th>New Users</th><th>Engaged</th><th>Eng. Rate</th><th>Avg Time</th><th>Views</th></tr></thead>
+ <tbody>${tableRows || '<tr><td colspan="8"><p class="miss" style="padding:20px">No GA4 data. <a href="/imports">Import CSV</a> or <a href="/settings">connect API</a>.</p></td></tr>'}</tbody>
+ </table>
+ </div>
+ <p style="margin-top:16px"><a href="/search-intelligence" class="btn btn-outline">View Search Intelligence →</a></p>
+ </div>`;
+ res.type('html').send(layout('Analytics', body, { activeNav: '/analytics' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /search-intelligence
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/search-intelligence', async (req, res) => {
+ try {
+ const rows = await safeQ(`
+ SELECT query, SUM(clicks) AS clicks, SUM(impressions) AS impressions,
+ AVG(ctr) AS ctr, AVG(position) AS position, MAX(is_brand) AS is_brand
+ FROM gsc_query_metrics
+ WHERE metric_date >= CURRENT_DATE - 28
+ GROUP BY query ORDER BY impressions DESC NULLS LAST LIMIT 100`);
+
+ const isDemo = rows.length === 0;
+ const tableRows = rows.map(r => `<tr>
+ <td>${esc(r.query || '—')}</td>
+ <td class="num">${Number(r.impressions || 0).toLocaleString()}</td>
+ <td class="num">${Number(r.clicks || 0).toLocaleString()}</td>
+ <td class="num">${r.ctr ? (r.ctr * 100).toFixed(1) + '%' : '—'}</td>
+ <td class="num">${r.position ? Number(r.position).toFixed(1) : '—'}</td>
+ <td>${r.is_brand ? '<span class="badge badge-verified-media">Brand</span>' : '<span class="badge badge-panelist">Non-brand</span>'}</td>
+ </tr>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Search Intelligence — GSC</h1>
+ <p style="color:var(--ink-muted);font-size:15px">Organic search data from Google Search Console. Last 28 days.</p>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-controls">
+ <input class="tbl-search" id="gsc-search" type="search" placeholder="Search queries…" aria-label="Search queries">
+ <span class="tbl-count" id="gsc-count">${rows.length} queries</span>
+ </div>
+ <div class="tbl-wrap">
+ <table class="adv-tbl" id="gsc-tbl" aria-label="GSC queries">
+ <thead><tr><th>Query</th><th>Impressions</th><th>Clicks</th><th>CTR</th><th>Avg Position</th><th>Type</th></tr></thead>
+ <tbody>${tableRows || '<tr><td colspan="6"><p class="miss" style="padding:20px">No GSC data. <a href="/imports">Import CSV</a> or <a href="/settings">connect API</a>.</p></td></tr>'}</tbody>
+ </table>
+ </div>
+ <script>(function(){var s=document.getElementById('gsc-search'),rows=Array.from(document.querySelectorAll('#gsc-tbl tbody tr'));if(!s)return;s.addEventListener('input',function(){var q=s.value.toLowerCase().split(/\s+/).filter(Boolean),n=0;rows.forEach(function(tr){var pass=q.every(function(t){return tr.textContent.toLowerCase().includes(t);});tr.style.display=pass?'':'none';if(pass)n++;});document.getElementById('gsc-count').textContent=n+' of '+rows.length+' queries';});})();</script>
+ </div>`;
+ res.type('html').send(layout('Search Intelligence', body, { activeNav: '/search-intelligence' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /media-kit (§27)
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/media-kit', async (req, res) => {
+ try {
+ const [audiences, rates] = await Promise.all([
+ safeQ(`SELECT * FROM rentv_audience_snapshots ORDER BY observed_at DESC`),
+ safeQ(`SELECT * FROM rentv_rate_snapshots ORDER BY product_key, observed_at DESC`),
+ ]);
+
+ const isDemo = audiences.length === 0 && rates.length === 0;
+
+ // Group audience snapshots by metric_key to show conflict warning when >1 value
+ const byKey = {};
+ audiences.forEach(a => { (byKey[a.metric_key] = byKey[a.metric_key] || []).push(a); });
+
+ const audienceHtml = Object.entries(byKey).map(([key, snaps]) => {
+ const latest = snaps[0];
+ const hasConflict = snaps.length > 1 &&
+ snaps.some(s => String(s.value_numeric || s.value_text) !== String(latest.value_numeric || latest.value_text));
+ return `
+ <div class="mk-metric">
+ <div class="mk-val">${esc(latest.value_text || (latest.value_numeric ? Number(latest.value_numeric).toLocaleString() : '—'))}</div>
+ <div class="mk-label">${esc(latest.metric_label)}</div>
+ <div class="mk-source">As of ${fmtDateOnly(latest.observed_at)} · ${esc(latest.source_key || 'manual entry')}</div>
+ ${hasConflict ? `<div class="conflict-warning" style="margin-top:8px;font-size:12px">
+ Multiple values recorded. Showing most recent. History:
+ ${snaps.map(s => `${esc(s.value_text || String(s.value_numeric))} (${fmtDateOnly(s.observed_at)})`).join(' / ')}
+ </div>` : ''}
+ </div>`;
+ }).join('');
+
+ // Group rates by product
+ const byProduct = {};
+ rates.forEach(r => { (byProduct[r.product_key] = byProduct[r.product_key] || []).push(r); });
+
+ const ratesHtml = Object.entries(byProduct).map(([pk, snaps]) => {
+ const latest = snaps[0];
+ const hasMultiple = snaps.length > 1;
+ return `<div class="settings-card" style="margin-bottom:12px">
+ <h3>${esc(latest.product_label)}</h3>
+ <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:baseline">
+ <span style="font-size:28px;font-weight:900">$${latest.rate ? Number(latest.rate).toLocaleString() : '—'}</span>
+ <span style="color:var(--ink-muted);font-size:15px">${esc(latest.unit || '')}</span>
+ </div>
+ <div class="mk-source">Rate as of ${fmtDateOnly(latest.observed_at)} · ${esc(latest.source_key || 'manual')}</div>
+ ${latest.package_notes ? `<p style="font-size:14px;color:var(--ink-muted);margin-top:8px">${esc(latest.package_notes)}</p>` : ''}
+ ${hasMultiple ? `<details style="margin-top:10px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">Rate history (${snaps.length} snapshots)</summary>
+ <div style="margin-top:8px">${snaps.map(s => `<div style="font-size:13px;padding:4px 0;border-bottom:1px solid var(--border)">${fmtDateOnly(s.observed_at)}: <strong>$${s.rate ? Number(s.rate).toLocaleString() : '—'}</strong> ${esc(s.unit || '')} — ${esc(s.source_key || 'manual')}</div>`).join('')}</div>
+ </details>` : ''}
+ </div>`;
+ }).join('');
+
+ const body = `<div class="page-content">
+ <h1>Media Kit</h1>
+ <p style="color:var(--ink-muted);font-size:15px">Sourced, dated audience and rate data. All figures show their source and observation date. Conflicting snapshots are shown together — not merged.</p>
+ ${isDemo ? demoBanner : ''}
+
+ <h2 style="margin-bottom:16px">Audience Snapshots</h2>
+ ${audienceHtml || '<p class="miss">No audience data. <a href="/imports">Import or add manually</a>.</p>'}
+ <div class="mk-snapshot">${audienceHtml}</div>
+
+ <h2 style="margin:28px 0 16px">Rate Card</h2>
+ ${ratesHtml || '<p class="miss">No rate snapshots. Seed data or import a rate card.</p>'}
+
+ <div style="margin-top:24px">
+ <button onclick="window.print()" class="btn btn-outline">Print Media Kit</button>
+ </div>
+ </div>`;
+ res.type('html').send(layout('Media Kit', body, { activeNav: '/media-kit' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /sources
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/sources', async (req, res) => {
+ try {
+ const rows = await safeQ(`
+ SELECT sp.*,
+ (SELECT COUNT(*) FROM sources s WHERE s.source_policy_id = sp.id) AS item_count
+ FROM source_policies sp ORDER BY sp.display_name`);
+
+ const isDemo = rows.length === 0;
+ const tableRows = rows.map(r => `<tr>
+ <td>${esc(r.display_name)}</td>
+ <td>${esc(r.source_key)}</td>
+ <td>${esc(r.access_method || '—')}</td>
+ <td>${r.enabled ? '<span class="badge badge-verified-advertiser">Enabled</span>' : '<span class="badge badge-disqualified">Disabled</span>'}</td>
+ <td>${r.allows_automated_access ? 'Yes' : 'No'}</td>
+ <td class="num"><a href="/sources">${Number(r.item_count || 0).toLocaleString()}</a></td>
+ <td>${r.base_url ? `<a href="${esc(r.base_url)}" target="_blank" rel="noopener noreferrer">${esc(r.base_url)} ↗</a>` : '<span class="miss">—</span>'}</td>
+ <td><span class="when-chip" title="${esc(r.created_at ? new Date(r.created_at).toISOString() : '')}">${fmtDate(r.created_at)}</span></td>
+ </tr>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Sources</h1>
+ ${isDemo ? demoBanner : ''}
+ <div class="tbl-wrap">
+ <table class="adv-tbl" aria-label="Source policies">
+ <thead><tr><th>Name</th><th>Key</th><th>Access Method</th><th>Status</th><th>Automated?</th><th>Items</th><th>URL</th><th>Created</th></tr></thead>
+ <tbody>${tableRows || '<tr><td colspan="8"><p class="miss" style="padding:20px">No source policies yet. Run <code>npm run db:seed</code>.</p></td></tr>'}</tbody>
+ </table>
+ </div>
+ </div>`;
+ res.type('html').send(layout('Sources', body, { activeNav: '/sources' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /imports
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/imports', async (req, res) => {
+ try {
+ const runs = await safeQ(`SELECT * FROM analytics_import_runs ORDER BY started_at DESC LIMIT 20`);
+ const runsHtml = runs.map(r => `<div class="audit-row">
+ <div style="flex:0 0 180px"><span class="when-chip" title="${esc(r.started_at ? new Date(r.started_at).toISOString() : '')}">${fmtDate(r.started_at)}</span></div>
+ <div>
+ <span class="audit-action">${esc(r.kind)}</span>
+ <span class="badge ${r.status === 'DONE' ? 'badge-verified-advertiser' : r.status === 'ERROR' ? 'badge-disqualified' : 'badge-likely-prospect'}" style="margin-left:8px">${esc(r.status)}</span>
+ ${r.row_count != null ? `<span class="audit-meta"> — ${Number(r.row_count).toLocaleString()} rows</span>` : ''}
+ ${r.error ? `<span class="audit-meta" style="color:var(--danger)"> — ${esc(r.error)}</span>` : ''}
+ </div>
+ </div>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Imports</h1>
+ <p style="color:var(--ink-muted);font-size:15px">Upload CSV, XLSX, EML, PDF, or image files. Connect GA4, GSC, or Gmail via <a href="/settings">Settings</a>.</p>
+
+ <div class="dash-grid" style="margin-bottom:28px">
+ ${[
+ ['GA4 CSV', 'Upload a GA4 export CSV', '/imports#ga4'],
+ ['Search Console CSV', 'Upload GSC export CSV', '/imports#gsc'],
+ ['Advertiser Spreadsheet', 'Upload advertiser list CSV/XLSX', '/imports#advertisers'],
+ ['Conference Sponsor List', 'Upload sponsor list CSV', '/imports#conferences'],
+ ['Ad Image / PDF', 'Upload ad creative or email', '/imports#ad'],
+ ].map(([title, desc, href]) => `
+ <div class="dash-card">
+ <h3>${esc(title)}</h3>
+ <p style="font-size:15px;color:var(--ink-muted)">${esc(desc)}</p>
+ <form method="POST" action="/api/v1/imports" enctype="multipart/form-data" style="margin-top:10px">
+ <input type="hidden" name="kind" value="${esc(title.toUpperCase().replace(/\s/g,'_'))}">
+ <input type="file" name="file" style="font-size:14px;margin-bottom:10px;display:block" accept=".csv,.xlsx,.eml,.pdf,.jpg,.png,.webp">
+ <button type="submit" class="btn btn-primary" style="font-size:15px">Upload ${esc(title)}</button>
+ </form>
+ </div>`).join('')}
+ </div>
+
+ <h2>Recent import runs</h2>
+ ${runs.length ? runsHtml : '<p class="miss">No imports yet.</p>'}
+ </div>`;
+ res.type('html').send(layout('Imports', body, { activeNav: '/imports' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /review
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/review', async (req, res) => {
+ try {
+ const items = await safeQ(`
+ SELECT s.id, s.relationship_status, s.verification_status, s.observed_at,
+ s.source_page_url, s.headline, s.confidence, s.created_at,
+ o.display_name AS org_name, o.id AS org_id
+ FROM ad_sightings s
+ LEFT JOIN organizations o ON o.id = s.organization_id
+ WHERE s.verification_status = 'UNVERIFIED'
+ ORDER BY s.created_at DESC LIMIT 100`);
+
+ const isDemo = items.length === 0;
+ const cards = items.map(s => `
+ <div class="dash-card" style="margin-bottom:14px">
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
+ <div>
+ ${whenChip(s.created_at)}
+ <div style="margin-top:8px"><a href="/advertisers/${esc(s.org_id)}" style="font-size:18px;font-weight:700">${esc(s.org_name || '—')}</a></div>
+ <div style="margin-top:6px;display:flex;gap:8px;flex-wrap:wrap">
+ ${statusBadge(s.relationship_status)}
+ <span class="badge badge-research-needed">Unverified</span>
+ ${s.confidence ? `<span class="badge badge-panelist">Conf: ${Math.round(s.confidence * 100)}%</span>` : ''}
+ </div>
+ ${s.headline ? `<p style="font-size:15px;margin:8px 0 0;color:var(--ink-muted)">${esc(s.headline)}</p>` : ''}
+ ${s.source_page_url ? `<p style="margin:6px 0 0"><a href="${esc(s.source_page_url)}" target="_blank" rel="noopener noreferrer" style="font-size:14px">Source ↗</a> · ${fmtDateOnly(s.observed_at)}</p>` : ''}
+ </div>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
+ <button class="btn btn-outline" onclick="reviewAction('${esc(s.id)}','verify')" style="font-size:15px">Verify</button>
+ <button class="btn btn-ghost" onclick="reviewAction('${esc(s.id)}','reject')" style="font-size:15px">Reject</button>
+ <a href="/ads/${esc(s.id)}" class="btn btn-ghost" style="font-size:15px">Details</a>
+ </div>
+ </div>
+ </div>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Review Queue</h1>
+ <p style="color:var(--ink-muted);font-size:15px">Verify or reject unverified ad sightings. Do not mark a speaker/panelist as a sponsor without separate evidence.</p>
+ ${isDemo ? demoBanner : ''}
+ ${cards || '<div class="empty-state"><div class="em-icon">✓</div><p>All sightings reviewed. No items pending.</p></div>'}
+ <script>function reviewAction(id,a){fetch('/api/v1/review/'+id+'/'+a,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>{if(d.ok)location.reload();else alert(d.message||'Error');}).catch(e=>alert(e.message));}</script>
+ </div>`;
+ res.type('html').send(layout('Review', body, { activeNav: '/review' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /exports
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/exports', async (req, res) => {
+ try {
+ const rows = await safeQ(`SELECT * FROM exports ORDER BY created_at DESC LIMIT 20`);
+ const tableRows = rows.map(r => `<tr>
+ <td><span class="when-chip" title="${esc(r.created_at ? new Date(r.created_at).toISOString() : '')}">${fmtDate(r.created_at)}</span></td>
+ <td>${esc(r.kind)}</td>
+ <td><span class="badge ${r.status === 'DONE' ? 'badge-verified-advertiser' : r.status === 'ERROR' ? 'badge-disqualified' : 'badge-likely-prospect'}">${esc(r.status)}</span></td>
+ <td>${r.status === 'DONE' && r.object_key ? `<a href="/assets/${esc(r.object_key)}" class="btn btn-outline" style="font-size:13px;min-height:36px">Download</a>` : '<span class="miss">—</span>'}</td>
+ </tr>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Exports</h1>
+ <div class="alert alert-info" style="margin-bottom:20px">Use <strong>Download Everything</strong> in the header to create a full ZIP export. Exports run as background jobs.</div>
+ <div style="margin-bottom:24px">
+ <button id="start-export-btn" class="btn btn-primary" style="font-size:16px" onclick="startExportPage()">Create New Export</button>
+ <span id="export-status-msg" style="margin-left:12px;font-size:15px;color:var(--ink-muted)"></span>
+ </div>
+ <h2>Export history</h2>
+ ${rows.length ? `<div class="tbl-wrap"><table class="adv-tbl"><thead><tr><th>Created</th><th>Kind</th><th>Status</th><th>Download</th></tr></thead><tbody>${tableRows}</tbody></table></div>`
+ : '<p class="miss">No exports yet.</p>'}
+ <script>
+ function startExportPage(){
+ var btn=document.getElementById('start-export-btn');
+ var msg=document.getElementById('export-status-msg');
+ btn.disabled=true; btn.textContent='Creating…'; msg.textContent='';
+ fetch('/api/v1/exports',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'DOWNLOAD_EVERYTHING'})})
+ .then(function(r){return r.json();})
+ .then(function(d){msg.textContent=d.message||'Export queued — refresh this page to check status.';btn.textContent='Create New Export';btn.disabled=false;})
+ .catch(function(e){msg.textContent='Error: '+e.message;btn.textContent='Create New Export';btn.disabled=false;});
+ }
+ </script>
+ </div>`;
+ res.type('html').send(layout('Exports', body, { activeNav: '/exports' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /settings
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/settings', async (req, res) => {
+ try {
+ const conns = await safeQ(`SELECT * FROM analytics_connections ORDER BY kind`);
+ const connHtml = ['GA4', 'GSC', 'GOOGLE_ADS'].map(kind => {
+ const c = conns.find(x => x.kind === kind);
+ return `<div class="settings-card">
+ <h3>${esc(kind)}</h3>
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
+ <span class="badge ${c && c.status === 'CONNECTED' ? 'badge-verified-advertiser' : 'badge-research-needed'}">${c ? esc(c.status) : 'NOT_CONNECTED'}</span>
+ ${c && c.last_import_at ? `<span class="miss" style="font-size:14px">Last import: ${fmtDate(c.last_import_at)}</span>` : ''}
+ </div>
+ <p style="font-size:14px;color:var(--ink-muted);margin-top:8px">Configure via environment variables in <code>.env</code>. See <code>docs/ANALYTICS_CONNECTORS.md</code>.</p>
+ <form method="POST" action="/api/v1/research/jobs" style="margin-top:10px">
+ <input type="hidden" name="type" value="${esc(kind + '_IMPORT')}">
+ <button type="submit" class="btn btn-outline" style="font-size:14px">Trigger ${esc(kind)} import</button>
+ </form>
+ </div>`;
+ }).join('');
+
+ const body = `<div class="page-content">
+ <h1>Settings</h1>
+ <h2 style="margin:0 0 16px">Analytics Connections</h2>
+ ${connHtml}
+ <h2 style="margin:24px 0 16px">Research Jobs</h2>
+ <div class="settings-card">
+ <h3>Run research jobs</h3>
+ <div style="display:flex;flex-wrap:wrap;gap:10px;margin-top:10px">
+ ${['california', 'arizona', 'conferences'].map(j => `
+ <form method="POST" action="/api/v1/research/jobs">
+ <input type="hidden" name="type" value="${j}">
+ <button type="submit" class="btn btn-outline" style="font-size:14px">Research: ${j.charAt(0).toUpperCase() + j.slice(1)}</button>
+ </form>`).join('')}
+ </div>
+ <p style="font-size:14px;color:var(--ink-muted);margin-top:10px">Jobs run according to source policies. Check <a href="/admin/jobs">Admin → Jobs</a> for status.</p>
+ </div>
+ <h2 style="margin:24px 0 16px">Score Weights</h2>
+ <div class="settings-card">
+ <p style="font-size:15px;color:var(--ink-muted)">Default weights are configured in <code>lib/scoring.js</code>. Admin weight overrides stored in <code>opportunity_scores.factors</code>. See documentation for details.</p>
+ </div>
+ </div>`;
+ res.type('html').send(layout('Settings', body, { activeNav: '/settings' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /admin/jobs
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/admin/jobs', async (req, res) => {
+ try {
+ const runs = await safeQ(`SELECT * FROM ingestion_runs ORDER BY started_at DESC LIMIT 50`);
+ const isDemo = runs.length === 0;
+ const cards = runs.map(r => `
+ <div class="dash-card" style="margin-bottom:12px">
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
+ <div>
+ ${whenChip(r.started_at)}
+ <div style="margin-top:8px;font-size:18px;font-weight:700">${esc(r.source_key || 'General')}</div>
+ <div style="margin-top:6px;display:flex;gap:8px;flex-wrap:wrap">
+ <span class="badge ${r.status === 'DONE' ? 'badge-verified-advertiser' : r.status === 'ERROR' ? 'badge-disqualified' : r.status === 'RUNNING' ? 'badge-verified-media' : 'badge-panelist'}">${esc(r.status)}</span>
+ ${r.dry_run ? '<span class="badge badge-content-partner">Dry run</span>' : ''}
+ </div>
+ ${r.error ? `<p style="font-size:14px;color:var(--danger);margin:6px 0 0">${esc(r.error)}</p>` : ''}
+ ${r.finished_at ? `<p style="font-size:13px;color:var(--ink-faint);margin:4px 0 0">Finished: ${fmtDate(r.finished_at)}</p>` : ''}
+ </div>
+ </div>
+ </div>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Admin — Jobs</h1>
+ ${isDemo ? demoBanner : ''}
+ ${cards || '<div class="empty-state"><p>No job runs recorded yet.</p></div>'}
+ </div>`;
+ res.type('html').send(layout('Admin: Jobs', body, { activeNav: '/admin/jobs' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// GET /admin/audit
+// ═══════════════════════════════════════════════════════════════════════════════
+router.get('/admin/audit', async (req, res) => {
+ try {
+ const rows = await safeQ(`SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 200`);
+ const isDemo = rows.length === 0;
+ const rowsHtml = rows.map(r => `
+ <div class="audit-row">
+ <div style="flex:0 0 220px">${whenChip(r.created_at)}</div>
+ <div>
+ <span class="audit-action">${esc(r.action)}</span>
+ ${r.entity_table ? `<span class="audit-meta"> — ${esc(r.entity_table)}</span>` : ''}
+ ${r.entity_id ? `<span class="audit-meta"> / ${esc(String(r.entity_id).slice(0,8))}…</span>` : ''}
+ <span class="audit-meta"> — by ${esc(r.actor || 'system')}</span>
+ </div>
+ </div>`).join('');
+
+ const body = `<div class="page-content">
+ <h1>Admin — Audit Log</h1>
+ ${isDemo ? demoBanner : ''}
+ ${rowsHtml || '<div class="empty-state"><p>No audit entries yet.</p></div>'}
+ </div>`;
+ res.type('html').send(layout('Admin: Audit', body, { activeNav: '/admin/audit' }));
+ } catch (e) {
+ res.status(500).type('html').send(layout('Error', `<div class="page-content"><div class="alert alert-danger">${esc(e.message)}</div></div>`));
+ }
+});
+
+module.exports = router;
diff --git a/src/search/provider.js b/src/search/provider.js
new file mode 100644
index 0000000..d4aa533
--- /dev/null
+++ b/src/search/provider.js
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * SearchProvider interface (spec §9, §14).
+ *
+ * getSearchProvider(name) returns a provider for one of:
+ * google_cse | brave | bing | serper | manual
+ * Default comes from process.env.SEARCH_PROVIDER (falls back to 'manual').
+ *
+ * Real API providers are thin stubs: they read their env key and throw a
+ * friendly "configure X" error if it is absent — the app must WORK with the
+ * manual provider when no key is configured (§9: "Do not scrape Google result
+ * HTML. The app must work with manual search links when no API key is
+ * configured.").
+ *
+ * The `manual` provider returns one-click SEARCH URLS (google/bing web search)
+ * and NEVER fetches. buildLinkedInSearchUrls() returns Google/Bing search URLs
+ * using site:linkedin.com/in and site:linkedin.com/company templates — these
+ * are HUMAN-CLICK URLs only; the app must NEVER fetch linkedin.com (§6.3/§6.4).
+ */
+
+const T = require('../../lib/types');
+const queries = require('./queries');
+
+function googleSearchUrl(query) {
+ return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
+}
+function bingSearchUrl(query) {
+ return `https://www.bing.com/search?q=${encodeURIComponent(query)}`;
+}
+
+/**
+ * Manual provider — never fetches. search() returns click-URLs the human opens.
+ */
+const manualProvider = {
+ name: 'manual',
+ fetches: false,
+ async search(query, _opts = {}) {
+ return {
+ provider: 'manual',
+ query,
+ urls: {
+ google: googleSearchUrl(query),
+ bing: bingSearchUrl(query),
+ },
+ note: 'Manual mode: open one of these URLs in a browser. The app does not fetch results.',
+ };
+ },
+};
+
+/** Factory for the API-key-gated stub providers. */
+function apiStub(name, envKey, humanLabel) {
+ return {
+ name,
+ fetches: true,
+ async search(_query, _opts = {}) {
+ if (!process.env[envKey]) {
+ throw new Error(
+ `Search provider "${name}" is not configured — set ${envKey} to enable it, or use SEARCH_PROVIDER=manual (§9).`
+ );
+ }
+ // NOTE: real HTTP calls to the provider's OFFICIAL API go here. They must
+ // use the official search API (§6.20) — never scrape result HTML — and any
+ // outbound fetch must go through lib/compliance/fetch-guard.safeFetch.
+ throw new Error(
+ `Search provider "${name}" (${humanLabel}) API call not yet implemented — key present but live query wiring pending.`
+ );
+ },
+ };
+}
+
+const PROVIDERS = {
+ manual: () => manualProvider,
+ google_cse: () => apiStub('google_cse', 'GOOGLE_CSE_API_KEY', 'Google Programmable Search'),
+ brave: () => apiStub('brave', 'BRAVE_SEARCH_API_KEY', 'Brave Search'),
+ bing: () => apiStub('bing', 'BING_SEARCH_API_KEY', 'Bing Web Search'),
+ serper: () => apiStub('serper', 'SERPER_API_KEY', 'Serper.dev'),
+};
+
+/** getSearchProvider(name) — resolves name → provider, defaulting via env. */
+function getSearchProvider(name) {
+ const chosen = name || process.env.SEARCH_PROVIDER || 'manual';
+ if (!T.SEARCH_PROVIDERS.includes(chosen)) {
+ throw new Error(
+ `getSearchProvider: unknown provider "${chosen}" — must be one of ${T.SEARCH_PROVIDERS.join(', ')}`
+ );
+ }
+ return PROVIDERS[chosen]();
+}
+
+/**
+ * buildLinkedInSearchUrls(org) — returns Google/Bing SEARCH URLs (human-click
+ * only) for the three §14 LinkedIn templates. The app NEVER fetches linkedin.com;
+ * these open a normal browser tab for a person to inspect the public profile.
+ *
+ * org: { companyName, companyDomain }
+ */
+function buildLinkedInSearchUrls(org = {}) {
+ const companyQ = queries.linkedinCompanyQuery(org);
+ const inCaQ = queries.linkedinInCaliforniaQuery(org);
+ const inAzQ = queries.linkedinInArizonaQuery(org);
+ return {
+ company: { query: companyQ, google: googleSearchUrl(companyQ), bing: bingSearchUrl(companyQ) },
+ peopleCalifornia: { query: inCaQ, google: googleSearchUrl(inCaQ), bing: bingSearchUrl(inCaQ) },
+ peopleArizona: { query: inAzQ, google: googleSearchUrl(inAzQ), bing: bingSearchUrl(inAzQ) },
+ note: 'Human-click search URLs only. The application must NEVER fetch linkedin.com (§6.3/§6.4).',
+ };
+}
+
+/**
+ * openLinkedInProfileUrl(url) — passthrough. Returns the URL for a human to
+ * open in a normal browser (the "Open LinkedIn" button, §14). Deliberately
+ * does NOT fetch; it only validates the shape and hands the URL back.
+ */
+function openLinkedInProfileUrl(url) {
+ if (!url || typeof url !== 'string') {
+ throw new Error('openLinkedInProfileUrl: a url string is required');
+ }
+ return url; // for a human to open — never fetched by the app
+}
+
+module.exports = {
+ getSearchProvider,
+ buildLinkedInSearchUrls,
+ openLinkedInProfileUrl,
+ googleSearchUrl,
+ bingSearchUrl,
+};
diff --git a/src/search/queries.js b/src/search/queries.js
new file mode 100644
index 0000000..6649d14
--- /dev/null
+++ b/src/search/queries.js
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * §14 search-query templates.
+ *
+ * Each function takes { companyName, companyDomain } and returns the exact
+ * query strings listed in §14. These feed a permitted search-provider API OR
+ * become one-click manual search URLs. The two site:linkedin.com/in templates
+ * and the site:linkedin.com/company template produce HUMAN-CLICK search queries
+ * only — the app NEVER fetches linkedin.com (§6.3/§6.4, §14).
+ */
+
+function q(companyName) {
+ return `"${companyName}"`;
+}
+
+/** All §14 templates, in order, as a flat array of query strings. */
+function allQueries({ companyName, companyDomain } = {}) {
+ const name = companyName || '';
+ const domain = (companyDomain || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '');
+ const c = q(name);
+ const out = [
+ `${c} commercial real estate California marketing`,
+ `${c} Arizona commercial real estate sponsor`,
+ `${c} media contact`,
+ `${c} press release marketing director`,
+ `${c} partnerships events`,
+ ];
+ if (domain) {
+ out.push(`site:${domain} contact marketing`);
+ out.push(`site:${domain} newsroom "media contact"`);
+ }
+ out.push(`site:linkedin.com/company ${c}`);
+ out.push(`site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${c} California`);
+ out.push(`site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${c} Arizona`);
+ out.push(`${c} sponsor OR exhibitor OR advertising`);
+ return out;
+}
+
+// --- Individual named templates (for callers that want them one at a time) ---
+
+function marketingQuery({ companyName }) {
+ return `${q(companyName)} commercial real estate California marketing`;
+}
+function arizonaSponsorQuery({ companyName }) {
+ return `${q(companyName)} Arizona commercial real estate sponsor`;
+}
+function mediaContactQuery({ companyName }) {
+ return `${q(companyName)} media contact`;
+}
+function pressReleaseQuery({ companyName }) {
+ return `${q(companyName)} press release marketing director`;
+}
+function partnershipsQuery({ companyName }) {
+ return `${q(companyName)} partnerships events`;
+}
+function siteContactQuery({ companyDomain }) {
+ const d = (companyDomain || '').replace(/^www\./, '');
+ return `site:${d} contact marketing`;
+}
+function siteNewsroomQuery({ companyDomain }) {
+ const d = (companyDomain || '').replace(/^www\./, '');
+ return `site:${d} newsroom "media contact"`;
+}
+function linkedinCompanyQuery({ companyName }) {
+ return `site:linkedin.com/company ${q(companyName)}`;
+}
+function linkedinInCaliforniaQuery({ companyName }) {
+ return `site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${q(companyName)} California`;
+}
+function linkedinInArizonaQuery({ companyName }) {
+ return `site:linkedin.com/in (marketing OR communications OR partnerships OR "business development") ${q(companyName)} Arizona`;
+}
+function sponsorExhibitorQuery({ companyName }) {
+ return `${q(companyName)} sponsor OR exhibitor OR advertising`;
+}
+
+module.exports = {
+ allQueries,
+ marketingQuery,
+ arizonaSponsorQuery,
+ mediaContactQuery,
+ pressReleaseQuery,
+ partnershipsQuery,
+ siteContactQuery,
+ siteNewsroomQuery,
+ linkedinCompanyQuery,
+ linkedinInCaliforniaQuery,
+ linkedinInArizonaQuery,
+ sponsorExhibitorQuery,
+};
diff --git a/test/classification.test.js b/test/classification.test.js
new file mode 100644
index 0000000..44affa6
--- /dev/null
+++ b/test/classification.test.js
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Tests for the classification guard (spec §6.16, §2, §21).
+ * The module is being written concurrently by a teammate; import it
+ * defensively and t.skip() every case if it isn't present yet so
+ * `node --test` never hard-crashes.
+ *
+ * Rule under test: a SPEAKER_OR_PANELIST_ONLY relationship may NOT be promoted
+ * to a verified sponsor/advertiser status without separate sponsor evidence
+ * (§6.16 "Do not treat a panelist as a sponsor without sponsor evidence"; the
+ * CoStar content-partner seed in §21 must not be mislabeled either).
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+let classification;
+try {
+ classification = require('../lib/classification');
+} catch (_e) {
+ classification = null;
+}
+
+test('promoting panelist -> verified sponsor WITHOUT evidence throws', (t) => {
+ if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+ return t.skip('classification not present yet');
+ }
+ assert.throws(() =>
+ classification.assertNotPanelistMislabeledAsSponsor(
+ 'SPEAKER_OR_PANELIST_ONLY',
+ 'VERIFIED_CONFERENCE_SPONSOR',
+ false // hasSponsorEvidence
+ )
+ );
+});
+
+test('promoting panelist -> verified sponsor WITH evidence does not throw', (t) => {
+ if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+ return t.skip('classification not present yet');
+ }
+ assert.doesNotThrow(() =>
+ classification.assertNotPanelistMislabeledAsSponsor(
+ 'SPEAKER_OR_PANELIST_ONLY',
+ 'VERIFIED_CONFERENCE_SPONSOR',
+ true // hasSponsorEvidence
+ )
+ );
+});
+
+test('promoting panelist -> verified ADVERTISER without evidence throws', (t) => {
+ if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+ return t.skip('classification not present yet');
+ }
+ assert.throws(() =>
+ classification.assertNotPanelistMislabeledAsSponsor(
+ 'SPEAKER_OR_PANELIST_ONLY',
+ 'VERIFIED_ADVERTISER',
+ false
+ )
+ );
+});
+
+test('a non-panelist starting status is not blocked by the guard', (t) => {
+ if (!classification || !classification.assertNotPanelistMislabeledAsSponsor) {
+ return t.skip('classification not present yet');
+ }
+ // A RESEARCH_NEEDED -> VERIFIED_CONFERENCE_SPONSOR transition is out of scope
+ // of the panelist rule; it should not throw for lack of sponsor evidence here.
+ assert.doesNotThrow(() =>
+ classification.assertNotPanelistMislabeledAsSponsor(
+ 'RESEARCH_NEEDED',
+ 'VERIFIED_CONFERENCE_SPONSOR',
+ false
+ )
+ );
+});
+
+test('statusLabel returns a plain-English label', (t) => {
+ if (!classification || !classification.statusLabel) {
+ return t.skip('classification.statusLabel not present yet');
+ }
+ assert.strictEqual(typeof classification.statusLabel('VERIFIED_ADVERTISER'), 'string');
+ assert.ok(classification.statusLabel('VERIFIED_ADVERTISER').length > 0);
+});
diff --git a/test/compliance.test.js b/test/compliance.test.js
new file mode 100644
index 0000000..29719fb
--- /dev/null
+++ b/test/compliance.test.js
@@ -0,0 +1,144 @@
+'use strict';
+/**
+ * Tests for the compliance guards (spec §6, §32).
+ * Modules under test are being written concurrently by teammates, so each is
+ * imported DEFENSIVELY and the relevant test t.skip()s when the module is
+ * absent — `node --test` must never hard-crash on a missing file.
+ *
+ * Pure-function level only: no DB, no live network. The fetch-guard cases we
+ * assert (LinkedIn hosts, literal private/metadata/loopback IPs) all reject
+ * BEFORE any DNS resolution, so these stay offline and deterministic.
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+function tryRequire(p) {
+ try {
+ return require(p);
+ } catch (_e) {
+ return null;
+ }
+}
+
+const fetchGuard = tryRequire('../lib/compliance/fetch-guard');
+const noInferredEmail = tryRequire('../lib/compliance/no-inferred-email');
+const sourcePolicy = tryRequire('../lib/compliance/source-policy');
+
+// --------------------------------------------------------------------------
+// fetch-guard — assertFetchAllowed / isBlockedIp (§6.4, §32)
+// --------------------------------------------------------------------------
+
+test('assertFetchAllowed throws for a LinkedIn profile URL', async (t) => {
+ if (!fetchGuard || !fetchGuard.assertFetchAllowed) return t.skip('fetch-guard not present yet');
+ await assert.rejects(
+ () => fetchGuard.assertFetchAllowed('https://www.linkedin.com/in/x'),
+ /linkedin/i
+ );
+});
+
+test('assertFetchAllowed throws for the cloud metadata IP 169.254.169.254', async (t) => {
+ if (!fetchGuard || !fetchGuard.assertFetchAllowed) return t.skip('fetch-guard not present yet');
+ await assert.rejects(() => fetchGuard.assertFetchAllowed('http://169.254.169.254/'));
+});
+
+test('assertFetchAllowed throws for loopback 127.0.0.1', async (t) => {
+ if (!fetchGuard || !fetchGuard.assertFetchAllowed) return t.skip('fetch-guard not present yet');
+ await assert.rejects(() => fetchGuard.assertFetchAllowed('http://127.0.0.1/'));
+});
+
+test('isBlockedIp flags private/loopback/metadata ranges, allows a public IP', (t) => {
+ if (!fetchGuard || !fetchGuard.isBlockedIp) return t.skip('isBlockedIp not present yet');
+ const { isBlockedIp } = fetchGuard;
+ assert.strictEqual(isBlockedIp('169.254.169.254'), true); // metadata / link-local
+ assert.strictEqual(isBlockedIp('127.0.0.1'), true); // loopback
+ assert.strictEqual(isBlockedIp('10.0.0.5'), true); // private
+ assert.strictEqual(isBlockedIp('192.168.1.1'), true); // private
+ assert.strictEqual(isBlockedIp('::1'), true); // IPv6 loopback
+ assert.strictEqual(isBlockedIp('8.8.8.8'), false); // public — allowed
+});
+
+// --------------------------------------------------------------------------
+// source-policy — validateSourcePolicy (§6.2)
+// --------------------------------------------------------------------------
+
+test('validateSourcePolicy rejects an automated costar.com policy', (t) => {
+ if (!sourcePolicy || !sourcePolicy.validateSourcePolicy) return t.skip('source-policy not present yet');
+ const result = sourcePolicy.validateSourcePolicy({
+ sourceKey: 'costar',
+ displayName: 'CoStar',
+ baseUrl: 'https://www.costar.com',
+ accessMethod: 'first_party_public_web',
+ allowsAutomatedAccess: true,
+ enabled: true,
+ });
+ assert.strictEqual(result.valid, false);
+ assert.ok(Array.isArray(result.errors) && result.errors.length > 0);
+});
+
+test('validateSourcePolicy accepts a permitted first-party public source', (t) => {
+ if (!sourcePolicy || !sourcePolicy.validateSourcePolicy) return t.skip('source-policy not present yet');
+ const result = sourcePolicy.validateSourcePolicy({
+ sourceKey: 'rentv',
+ displayName: 'RENTV public pages',
+ baseUrl: 'https://www.rentv.com',
+ accessMethod: 'first_party_public_web',
+ allowsAutomatedAccess: true,
+ maxRequestsPerMinute: 6,
+ enabled: true,
+ });
+ assert.strictEqual(result.valid, true, JSON.stringify(result.errors));
+});
+
+test('validateSourcePolicy rejects an automated linkedin.com policy', (t) => {
+ if (!sourcePolicy || !sourcePolicy.validateSourcePolicy) return t.skip('source-policy not present yet');
+ const result = sourcePolicy.validateSourcePolicy({
+ sourceKey: 'li',
+ displayName: 'LinkedIn',
+ baseUrl: 'https://www.linkedin.com',
+ accessMethod: 'first_party_public_web',
+ allowsAutomatedAccess: true,
+ enabled: true,
+ });
+ assert.strictEqual(result.valid, false);
+});
+
+// --------------------------------------------------------------------------
+// no-inferred-email — assertContactEvidence (§6.8, §6.9, §6.11)
+// --------------------------------------------------------------------------
+
+test('assertContactEvidence throws for an empty contact', (t) => {
+ if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+ assert.throws(() => noInferredEmail.assertContactEvidence({}));
+});
+
+test('assertContactEvidence throws without source_evidence_id', (t) => {
+ if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+ assert.throws(() => noInferredEmail.assertContactEvidence({ explicitly_public: true }));
+});
+
+test('assertContactEvidence throws when not explicitly_public', (t) => {
+ if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+ assert.throws(() => noInferredEmail.assertContactEvidence({ source_evidence_id: 'x' }));
+});
+
+test('assertContactEvidence passes for an explicitly-public, evidenced contact', (t) => {
+ if (!noInferredEmail || !noInferredEmail.assertContactEvidence) return t.skip('no-inferred-email not present yet');
+ assert.strictEqual(
+ noInferredEmail.assertContactEvidence({ source_evidence_id: 'x', explicitly_public: true }),
+ true
+ );
+});
+
+test('looksLikePatternEmail flags a first.last@domain permutation', (t) => {
+ if (!noInferredEmail || !noInferredEmail.looksLikePatternEmail) return t.skip('looksLikePatternEmail not present yet');
+ assert.strictEqual(
+ noInferredEmail.looksLikePatternEmail('jane.smith@acme.com', 'Jane Smith', 'acme.com'),
+ true
+ );
+ // A genuinely-published generic mailbox is not a name permutation.
+ assert.strictEqual(
+ noInferredEmail.looksLikePatternEmail('media@acme.com', 'Jane Smith', 'acme.com'),
+ false
+ );
+});
diff --git a/test/export-filter.test.js b/test/export-filter.test.js
new file mode 100644
index 0000000..4f86fd7
--- /dev/null
+++ b/test/export-filter.test.js
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Tests for rights-aware export filtering (spec §28, §6.14, §6.18).
+ * The module is being written concurrently by a teammate; import it
+ * defensively and t.skip() every case if it isn't present yet so
+ * `node --test` never hard-crashes.
+ *
+ * Rule under test: exports must honor suppression, do-not-contact, private
+ * notes, and source rights — those rows/fields must be filtered OUT of any
+ * exported dataset (§28 "Exports must honor suppression, do-not-contact,
+ * private notes, source rights").
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+let rights;
+try {
+ rights = require('../src/export/rights');
+} catch (_e) {
+ rights = null;
+}
+
+test('applyExportRights excludes do_not_contact contacts', (t) => {
+ if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+ const rows = [
+ { id: 1, value: 'ok@acme.com', do_not_contact: false, export_allowed: true },
+ { id: 2, value: 'stop@acme.com', do_not_contact: true, export_allowed: true },
+ ];
+ const out = rights.applyExportRights(rows, 'contacts');
+ const ids = out.map((r) => r.id);
+ assert.ok(ids.includes(1));
+ assert.ok(!ids.includes(2), 'do_not_contact contact must be excluded');
+});
+
+test('applyExportRights excludes export_allowed=false contacts', (t) => {
+ if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+ const rows = [
+ { id: 1, value: 'ok@acme.com', do_not_contact: false, export_allowed: true },
+ { id: 3, value: 'internal@acme.com', do_not_contact: false, export_allowed: false },
+ ];
+ const out = rights.applyExportRights(rows, 'contacts');
+ const ids = out.map((r) => r.id);
+ assert.ok(ids.includes(1));
+ assert.ok(!ids.includes(3), 'export_allowed=false contact must be excluded');
+});
+
+test('applyExportRights excludes private notes', (t) => {
+ if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+ const rows = [
+ { id: 10, body: 'public note', is_private: false },
+ { id: 11, body: 'private note', is_private: true },
+ ];
+ const out = rights.applyExportRights(rows, 'notes');
+ const ids = out.map((r) => r.id);
+ assert.ok(ids.includes(10));
+ assert.ok(!ids.includes(11), 'is_private note must be excluded from export');
+});
+
+test('applyExportRights excludes rows for a suppressed organization', (t) => {
+ if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+ const rows = [
+ { id: 'org-keep', name: 'Keep Co' },
+ { id: 'org-suppressed', name: 'Suppressed Co' },
+ ];
+ // Suppression is driven by a suppression-request set passed in ctx (§6.14).
+ // filterOrganizations matches the org's own id against suppressedOrgIds.
+ const ctx = { suppressedOrgIds: new Set(['org-suppressed']) };
+ const out = rights.applyExportRights(rows, 'organizations', ctx);
+ const ids = out.map((r) => r.id);
+ assert.ok(ids.includes('org-keep'));
+ assert.ok(!ids.includes('org-suppressed'), 'suppressed organization must be excluded from export');
+});
+
+test('applyExportRights classifies internal-only creatives as link-only (bytes omitted)', (t) => {
+ if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+ const rows = [
+ { id: 30, organization_id: 'a', file_name: 'ad.png', rights_status: 'EXPORT_ALLOWED' },
+ { id: 31, organization_id: 'a', file_name: 'internal.png', rights_status: 'INTERNAL_EVIDENCE_ONLY' },
+ ];
+ const out = rights.applyExportRights(rows, 'creative_assets', {});
+ const byId = Object.fromEntries(out.map((r) => [r.id, r]));
+ // Export-allowed → file bytes bundled; internal-only → metadata kept but bytes omitted.
+ assert.strictEqual(byId[30]._exportClass, 'include');
+ assert.strictEqual(byId[31]._exportClass, 'link_only');
+ assert.notStrictEqual(byId[31]._exportClass, 'include', 'internal-only bytes must not be exported');
+});
+
+test('classifyAssetForExport never exports UNKNOWN-rights image bytes', (t) => {
+ if (!rights || !rights.classifyAssetForExport) return t.skip('classifyAssetForExport not present yet');
+ assert.strictEqual(rights.classifyAssetForExport({ rights_status: 'EXPORT_ALLOWED' }), 'include');
+ assert.notStrictEqual(rights.classifyAssetForExport({ rights_status: 'UNKNOWN' }), 'include');
+ assert.notStrictEqual(rights.classifyAssetForExport({ rights_status: 'INTERNAL_EVIDENCE_ONLY' }), 'include');
+});
+
+test('applyExportRights returns an array and does not mutate the input', (t) => {
+ if (!rights || !rights.applyExportRights) return t.skip('export/rights not present yet');
+ const rows = [{ id: 1, do_not_contact: false, export_allowed: true }];
+ const copy = JSON.parse(JSON.stringify(rows));
+ const out = rights.applyExportRights(rows, 'contacts', {});
+ assert.ok(Array.isArray(out));
+ assert.deepStrictEqual(rows, copy, 'input rows must not be mutated');
+});
diff --git a/test/scoring.test.js b/test/scoring.test.js
new file mode 100644
index 0000000..c4e4bb6
--- /dev/null
+++ b/test/scoring.test.js
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * Tests for the Advertising Opportunity Score (spec §13).
+ * Pure functions only — no DB, no network. Runs under the built-in
+ * `node --test` runner (no jest/vitest/mocha).
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+const {
+ calculateAdvertiserOpportunityScore,
+ explainScore,
+ DEFAULT_WEIGHTS,
+ clampScore,
+ FACTOR_KEYS,
+} = require('../lib/scoring');
+
+// The ten factors the §13 formula weights.
+const FULL_INPUT = {
+ verifiedAdvertising: 100,
+ verifiedConferenceSpendSignal: 100,
+ recency: 100,
+ repeatActivity: 100,
+ californiaFit: 100,
+ arizonaFit: 100,
+ categoryFit: 100,
+ rentvAudienceFit: 100,
+ contactCompleteness: 100,
+ evidenceQuality: 100,
+};
+
+test('all-100 input scores exactly 100 (weights sum to 1.0)', () => {
+ assert.strictEqual(calculateAdvertiserOpportunityScore(FULL_INPUT), 100);
+});
+
+test('all-zero input scores exactly 0', () => {
+ const zeroed = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
+ assert.strictEqual(calculateAdvertiserOpportunityScore(zeroed), 0);
+});
+
+test('known input maps to the known §13 output', () => {
+ // Verify the exact §13 spec formula by computing it independently.
+ const input = {
+ verifiedAdvertising: 80, // *0.22 = 17.6
+ verifiedConferenceSpendSignal: 60, // *0.15 = 9.0
+ recency: 90, // *0.12 = 10.8
+ repeatActivity: 50, // *0.10 = 5.0
+ californiaFit: 100, // *0.10 = 10.0
+ arizonaFit: 0, // *0.05 = 0.0
+ categoryFit: 70, // *0.08 = 5.6
+ rentvAudienceFit: 40, // *0.08 = 3.2
+ contactCompleteness: 100, // *0.04 = 4.0
+ evidenceQuality: 90, // *0.06 = 5.4
+ };
+ // sum = 70.6 → round → 71
+ assert.strictEqual(calculateAdvertiserOpportunityScore(input), 71);
+});
+
+test('DEFAULT_WEIGHTS sum to ~1.0', () => {
+ const sum = Object.values(DEFAULT_WEIGHTS).reduce((a, b) => a + b, 0);
+ assert.ok(Math.abs(sum - 1.0) < 1e-9, `weights sum to ${sum}, expected 1.0`);
+});
+
+test('FACTOR_KEYS covers every weighted factor (and nothing else)', () => {
+ assert.strictEqual(FACTOR_KEYS.length, 10);
+ assert.deepStrictEqual([...FACTOR_KEYS].sort(), Object.keys(DEFAULT_WEIGHTS).sort());
+});
+
+test('clampScore bounds values into [0,100]', () => {
+ assert.strictEqual(clampScore(-50), 0);
+ assert.strictEqual(clampScore(0), 0);
+ assert.strictEqual(clampScore(50), 50);
+ assert.strictEqual(clampScore(100), 100);
+ assert.strictEqual(clampScore(150), 100);
+ assert.strictEqual(clampScore(NaN), 0);
+ assert.strictEqual(clampScore(undefined), 0);
+});
+
+test('out-of-range factor inputs are clamped before weighting', () => {
+ const over = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 999]));
+ assert.strictEqual(calculateAdvertiserOpportunityScore(over), 100);
+ const under = Object.fromEntries(FACTOR_KEYS.map((k) => [k, -999]));
+ assert.strictEqual(calculateAdvertiserOpportunityScore(under), 0);
+});
+
+test('admin weight overrides are applied', () => {
+ // Put ALL weight on one factor; only that factor should matter.
+ const weights = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
+ weights.recency = 1.0;
+ const input = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
+ input.recency = 77;
+ assert.strictEqual(calculateAdvertiserOpportunityScore(input, weights), 77);
+});
+
+test('explainScore returns a per-factor contribution for "Show Me Why"', () => {
+ const out = explainScore(FULL_INPUT);
+ assert.strictEqual(out.score, 100);
+ assert.strictEqual(out.factors.length, 10);
+ for (const f of out.factors) {
+ assert.ok(FACTOR_KEYS.includes(f.factor));
+ assert.strictEqual(typeof f.value, 'number');
+ assert.strictEqual(typeof f.weight, 'number');
+ assert.strictEqual(typeof f.contribution, 'number');
+ }
+});
+
+test('explainScore contributions sum to the score (±1 for rounding)', () => {
+ const input = {
+ verifiedAdvertising: 83,
+ verifiedConferenceSpendSignal: 41,
+ recency: 77,
+ repeatActivity: 12,
+ californiaFit: 95,
+ arizonaFit: 8,
+ categoryFit: 66,
+ rentvAudienceFit: 54,
+ contactCompleteness: 30,
+ evidenceQuality: 71,
+ };
+ const out = explainScore(input);
+ const sum = out.factors.reduce((a, f) => a + f.contribution, 0);
+ assert.ok(
+ Math.abs(sum - out.score) <= 1,
+ `contributions ${sum} should be within 1 of score ${out.score}`
+ );
+});
diff --git a/test/types.test.js b/test/types.test.js
new file mode 100644
index 0000000..26cbf5e
--- /dev/null
+++ b/test/types.test.js
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * Tests for the shared contract vocabulary (spec §2, §8, §14).
+ * Pure functions + frozen constant lists — no DB, no network.
+ */
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+const {
+ RELATIONSHIP_STATUS,
+ VERIFIED_STATUSES,
+ STATUS_LABELS,
+ ADVERTISER_CATEGORIES,
+ CONTACT_ROLE_PRIORITY,
+ LINKEDIN_BLOCKED_HOSTS,
+ normalizeName,
+ isVerified,
+} = require('../lib/types');
+
+test('normalizeName strips corporate suffixes and articles', () => {
+ assert.strictEqual(normalizeName('The Rockefeller Group, Inc.'), 'rockefeller');
+});
+
+test('normalizeName is stable across punctuation/casing variants', () => {
+ const a = normalizeName('Hanley Investment Group, LLC');
+ const b = normalizeName('hanley investment');
+ assert.strictEqual(a, b);
+ assert.strictEqual(a, 'hanley investment');
+});
+
+test('normalizeName handles empty / null input safely', () => {
+ assert.strictEqual(normalizeName(''), '');
+ assert.strictEqual(normalizeName(null), '');
+ assert.strictEqual(normalizeName(undefined), '');
+});
+
+test('isVerified is true only for VERIFIED_* relationship statuses', () => {
+ assert.strictEqual(isVerified('VERIFIED_ADVERTISER'), true);
+ assert.strictEqual(isVerified('VERIFIED_CONFERENCE_SPONSOR'), true);
+ assert.strictEqual(isVerified('VERIFIED_EXHIBITOR'), true);
+ assert.strictEqual(isVerified('VERIFIED_MEDIA_PARTNER'), true);
+ assert.strictEqual(isVerified('VERIFIED_CONTENT_PARTNER'), true);
+});
+
+test('isVerified is false for panelist / prospect / research / disqualified', () => {
+ assert.strictEqual(isVerified('SPEAKER_OR_PANELIST_ONLY'), false);
+ assert.strictEqual(isVerified('PAST_ADVERTISER'), false);
+ assert.strictEqual(isVerified('LIKELY_PROSPECT'), false);
+ assert.strictEqual(isVerified('RESEARCH_NEEDED'), false);
+ assert.strictEqual(isVerified('DISQUALIFIED'), false);
+ assert.strictEqual(isVerified('NOT_A_STATUS'), false);
+});
+
+test('every VERIFIED_STATUS is a member of RELATIONSHIP_STATUS', () => {
+ for (const s of VERIFIED_STATUSES) {
+ assert.ok(RELATIONSHIP_STATUS.includes(s), `${s} not in RELATIONSHIP_STATUS`);
+ }
+});
+
+test('STATUS_LABELS covers every RELATIONSHIP_STATUS', () => {
+ for (const s of RELATIONSHIP_STATUS) {
+ assert.ok(
+ typeof STATUS_LABELS[s] === 'string' && STATUS_LABELS[s].length > 0,
+ `missing plain-English label for ${s}`
+ );
+ }
+ // no orphan labels for statuses that don't exist
+ for (const k of Object.keys(STATUS_LABELS)) {
+ assert.ok(RELATIONSHIP_STATUS.includes(k), `orphan label ${k}`);
+ }
+});
+
+test('ADVERTISER_CATEGORIES has the full §8 taxonomy', () => {
+ // §8 lists 24 controlled categories.
+ assert.strictEqual(ADVERTISER_CATEGORIES.length, 24);
+ assert.ok(ADVERTISER_CATEGORIES.includes('Brokerage and investment sales'));
+ assert.ok(ADVERTISER_CATEGORIES.includes('Other CRE service'));
+ // no duplicates
+ assert.strictEqual(new Set(ADVERTISER_CATEGORIES).size, ADVERTISER_CATEGORIES.length);
+});
+
+test('CONTACT_ROLE_PRIORITY ranks marketing leadership highest (§14)', () => {
+ assert.strictEqual(CONTACT_ROLE_PRIORITY[0], 'CHIEF_MARKETING_OFFICER');
+ assert.strictEqual(CONTACT_ROLE_PRIORITY.length, 10);
+ assert.strictEqual(new Set(CONTACT_ROLE_PRIORITY).size, CONTACT_ROLE_PRIORITY.length);
+});
+
+test('LINKEDIN_BLOCKED_HOSTS lists the LinkedIn hosts (§6.4)', () => {
+ assert.ok(LINKEDIN_BLOCKED_HOSTS.includes('linkedin.com'));
+ assert.ok(LINKEDIN_BLOCKED_HOSTS.includes('www.linkedin.com'));
+ assert.ok(LINKEDIN_BLOCKED_HOSTS.includes('lnkd.in'));
+});
+
+test('the constant lists are frozen (single source of truth)', () => {
+ assert.ok(Object.isFrozen(RELATIONSHIP_STATUS));
+ assert.ok(Object.isFrozen(ADVERTISER_CATEGORIES));
+ assert.ok(Object.isFrozen(CONTACT_ROLE_PRIORITY));
+});
← 806e0b0 auto-data-snapshot: 2026-08-07T16:56:57 (8 data files) — REA
·
back to Rentv Adintel
·
review(contrarian): wire panelist-mislabel guard onto the li 721e080 →