← back to Rentv Adintel
src/export/rights.js
315 lines
'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,
};