← back to Rentv Adintel

src/export/build.js

658 lines

'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 };