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