/ src / utils / contacts-storage.js
contacts-storage.js
  1  /**
  2   * Filesystem-backed contacts_json storage for sites.
  3   *
  4   * Stores the full contacts JSON blob in:
  5   *   data/contacts/{site_id}.json
  6   *
  7   * This reduces DB size by ~38 MB and improves query performance
  8   * on the sites table.
  9   *
 10   * Pattern: read from filesystem first, fall back to DB column for
 11   * sites that haven't been extracted yet.
 12   */
 13  
 14  import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
 15  import { join, dirname } from 'path';
 16  
 17  const DATA_DIR = join(process.cwd(), 'data', 'contacts');
 18  
 19  /** Get file path for a site's contacts JSON */
 20  function sitePath(siteId) {
 21    const id = Number(siteId);
 22    if (!Number.isInteger(id) || id <= 0) {
 23      throw new Error(`Invalid siteId: ${siteId}`);
 24    }
 25    const dir = process.env.CONTACTS_STORAGE_BASE
 26      ? join(process.env.CONTACTS_STORAGE_BASE, 'contacts')
 27      : DATA_DIR;
 28    return join(dir, `${id}.json`);
 29  }
 30  
 31  /**
 32   * Read contacts_json from filesystem.
 33   * @param {number} siteId
 34   * @returns {string|null} Raw JSON string, or null if not on filesystem
 35   */
 36  function getContactsJson(siteId) {
 37    try {
 38      return readFileSync(sitePath(siteId), 'utf8') || null;
 39    } catch {
 40      return null;
 41    }
 42  }
 43  
 44  /**
 45   * Read and parse contacts_json from filesystem.
 46   * @param {number} siteId
 47   * @returns {Object|null} Parsed contacts data, or null if not on filesystem
 48   */
 49  function getContactsData(siteId) {
 50    const raw = getContactsJson(siteId);
 51    if (!raw) return null;
 52    try {
 53      return JSON.parse(raw);
 54    } catch {
 55      return null;
 56    }
 57  }
 58  
 59  /**
 60   * Write contacts_json to filesystem.
 61   * @param {number} siteId
 62   * @param {string|Object} contactsData - JSON string or object to store
 63   */
 64  function setContactsJson(siteId, contactsData) {
 65    if (!contactsData) return;
 66    const filePath = sitePath(siteId);
 67    try {
 68      mkdirSync(dirname(filePath), { recursive: true });
 69      const json = typeof contactsData === 'string' ? contactsData : JSON.stringify(contactsData);
 70      writeFileSync(filePath, json, 'utf8');
 71    } catch (err) {
 72      throw new Error(`Failed to write contacts storage for site ${siteId}: ${err.message}`);
 73    }
 74  }
 75  
 76  /**
 77   * Delete contacts_json from filesystem.
 78   * @param {number} siteId
 79   * @returns {boolean} true if file existed and was removed
 80   */
 81  function deleteContactsJson(siteId) {
 82    try {
 83      unlinkSync(sitePath(siteId));
 84      return true;
 85    } catch {
 86      return false;
 87    }
 88  }
 89  
 90  /**
 91   * Check if contacts_json exists on filesystem.
 92   * @param {number} siteId
 93   * @returns {boolean}
 94   */
 95  function hasContactsJson(siteId) {
 96    return existsSync(sitePath(siteId));
 97  }
 98  
 99  /**
100   * Read contacts_json with DB fallback.
101   * Checks filesystem first; if missing, reads from the DB row's contacts_json column.
102   * @param {number} siteId
103   * @param {Object} [dbRow] - Optional DB row with contacts_json column (avoids extra query)
104   * @returns {string|null} Raw JSON string
105   */
106  function getContactsJsonWithFallback(siteId, dbRow) {
107    // Filesystem first
108    const fsData = getContactsJson(siteId);
109    if (fsData) return fsData;
110  
111    // Fall back to DB column (skip sentinel value from extraction)
112    if (dbRow && dbRow.contacts_json && !dbRow.contacts_json.includes('"_fs"'))
113      return dbRow.contacts_json;
114  
115    return null;
116  }
117  
118  /**
119   * Read and parse contacts_json with DB fallback.
120   * @param {number} siteId
121   * @param {Object} [dbRow] - Optional DB row with contacts_json column
122   * @returns {Object|null} Parsed contacts data
123   */
124  function getContactsDataWithFallback(siteId, dbRow) {
125    const raw = getContactsJsonWithFallback(siteId, dbRow);
126    if (!raw) return null;
127    try {
128      return JSON.parse(raw);
129    } catch {
130      return null;
131    }
132  }
133  
134  export {
135    getContactsJson,
136    getContactsData,
137    setContactsJson,
138    deleteContactsJson,
139    hasContactsJson,
140    getContactsJsonWithFallback,
141    getContactsDataWithFallback,
142    DATA_DIR,
143  };