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