const { query } = require('../../utils/db') /** * The suppression list (ENGAGEMENT.md G16, Phase 9). * * Every function here takes an ALREADY HASHED address. Hashing lives in * `engagement/suppressions.js` beside the masking, so the two can never disagree * about what a row for one address looks like; this file only reads and writes. */ /** Is this address suppressed on this channel? The row, or null. */ const get = async (addressHash, channel = 'email') => { const rows = await query( 'SELECT * FROM engagement_suppressions WHERE address_hash = ? AND channel = ?', [addressHash, channel], ) return rows.length ? rows[0] : null } /** * Suppress an address, or leave an existing row exactly as it is. * * `INSERT IGNORE`, deliberately, rather than an upsert. The FIRST reason an * address was suppressed is the true one and the one an operator needs: an * address that hard-bounced in March and was then manually re-added in June * should still read `bounce`, because that is the fact that explains the mail * stopping. An upsert would let the most recent write overwrite the diagnosis. * * @returns {Promise} true when this call created the row */ const add = async (entry) => { const result = await query( `INSERT IGNORE INTO engagement_suppressions (address_hash, address_masked, channel, reason, detail, created_by) VALUES (?, ?, ?, ?, ?, ?)`, [ entry.address_hash, entry.address_masked ?? null, entry.channel || 'email', entry.reason, entry.detail ? String(entry.detail).slice(0, 500) : null, entry.created_by ?? null, ], ) return Number(result.affectedRows || 0) > 0 } /** * Un-suppress an address. The one way out of this table, which is why the admin * screen exists at all (Phase 9 decision 3). * * @returns {Promise} true when a row was removed */ const remove = async (addressHash, channel = 'email') => { const result = await query( 'DELETE FROM engagement_suppressions WHERE address_hash = ? AND channel = ?', [addressHash, channel], ) return Number(result.affectedRows || 0) > 0 } /** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */ const filters = ({ reason = null, channel = null, search = null } = {}) => { const where = [] const params = [] if (reason) { where.push('reason = ?') params.push(reason) } if (channel) { where.push('channel = ?') params.push(channel) } if (search) { // Against the MASKED column only. Searching the hash would need the caller to // hash first, which makes a partial search impossible, and there is no // plaintext column to search — that is the point of the table. A domain // ("example.com") is what an operator actually types here, and the mask keeps // the domain intact precisely so this works. where.push('address_masked LIKE ?') params.push(`%${String(search).slice(0, 120)}%`) } return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params } } /** The admin list, newest first. */ const list = (opts = {}) => { const { clause, params } = filters(opts) return query( `SELECT * FROM engagement_suppressions ${clause} ORDER BY created_at DESC, address_hash LIMIT ? OFFSET ?`, [...params, opts.limit || 50, opts.offset || 0], ) } /** How many rows match the same filters — the paged screen's total. */ const count = async (opts = {}) => { const { clause, params } = filters(opts) const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_suppressions ${clause}`, params) return Number(row?.n || 0) } /** * How many suppressions per reason — the summary the screen leads with. * * A count by reason is the difference between "eleven addresses are suppressed" * and "eleven addresses hard-bounced", and only the second tells an operator * whether to go and look at their relay. */ const countsByReason = async () => { const rows = await query( 'SELECT reason, COUNT(*) AS n FROM engagement_suppressions GROUP BY reason', ) const out = {} for (const r of rows) out[r.reason] = Number(r.n || 0) return out } /** * The subset of these hashes that is suppressed, as a Set. * * One query for a whole audience rather than one per recipient. Not used by the * delivery path — that checks a single address at send time, after the delay * window, which is the only check that can be correct — but by the admin reach * preview, which is asked about thousands of users at once and must not become * thousands of round trips. */ const suppressedAmong = async (addressHashes, channel = 'email') => { const wanted = [...new Set((addressHashes || []).filter(Boolean))] if (!wanted.length) return new Set() const marks = wanted.map(() => '?').join(', ') const rows = await query( `SELECT address_hash FROM engagement_suppressions WHERE channel = ? AND address_hash IN (${marks})`, [channel, ...wanted], ) return new Set(rows.map((r) => r.address_hash)) } module.exports = { get, add, remove, list, count, countsByReason, suppressedAmong }