← back to AbramsEgo
lib/spend-reviews/store.js
183 lines
'use strict';
/**
* Spend → Reviews — storage + the common `spend_item` record contract.
*
* THE CONTRACT every ingestion adapter normalizes to (makeItem):
* {
* id, // stable hash of dedupeKey
* source, // 'amazon' | 'gmail' | 'csv'
* merchant, // best display name of the business/brand
* product, // product/line-item name, or null
* order_id, // vendor order id, or null
* asin, // Amazon ASIN, or null
* seller, // Amazon seller/brand string, or null
* date, // ISO date of the purchase (best-effort)
* amount, // number (spend), 0 if unknown
* currency, // 'USD' default
* url, // source/product url, or null
* raw, // original row/string for audit
* norm_merchant, // normalized merchant key (dedupe + cache join)
* dedupeKey,
* created_at, // ISO ingestion time (drives the 🕓 card chip)
* resolved: { google:{place_id,mapsUrl,status,method}, amazon:{productUrl,seller,status} },
* drafts: { google_review, amazon_review, seller_email }, // {text|subject/body, cost, generatedAt}
* targets: { google_review, amazon_review, seller_email }, // per-target lifecycle
* }
*
* Per-target lifecycle status: 'none' -> 'draft' -> 'approved' -> 'posted' | 'skipped' | 'error'.
* Nothing posts/sends without the target reaching 'approved' (a human click) AND a
* confirm token at execute time — see executors.js.
*/
const fs = require('fs');
const crypto = require('crypto');
const cfg = require('./config');
function ensureDir(p) { try { fs.mkdirSync(p, { recursive: true }); } catch (e) {} }
ensureDir(cfg.DATA_DIR);
ensureDir(cfg.CSV_DROP_DIR);
function readJsonl(file) {
try {
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => {
try { return JSON.parse(l); } catch (e) { return null; }
}).filter(Boolean);
} catch (e) { return []; }
}
/** Normalize a messy merchant/descriptor string into a stable join/dedupe key. */
function normalizeMerchant(s) {
if (!s) return '';
let x = String(s).toLowerCase();
// strip common processor/POS prefixes and noise
x = x.replace(/\b(sq|tst|pos|paypal|pp|ppd|amzn mktp us|amzn|amazon mktpl|amazon\.com|ach|dbt|crd|pmt|purchase|debit|pos debit|visa|mastercard)\b\*?/g, ' ');
x = x.replace(/[*#]+/g, ' ');
x = x.replace(/\bstore\s*#?\d+\b/g, ' '); // store numbers
x = x.replace(/\b\d{2}\/\d{2}(\/\d{2,4})?\b/g, ' '); // dates
x = x.replace(/\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b/g, ' '); // phone (BEFORE card-tail so 6464 isn't eaten)
x = x.replace(/\bx?\d{4,}\b/g, ' '); // card tails / long digit runs
x = x.replace(/\.(com|net|org|co)\b/g, ' '); // tld
x = x.replace(/\bwww\b/g, ' ');
x = x.replace(/\b[a-z]{2}\b\s*$/g, ' '); // trailing state
x = x.replace(/[^a-z0-9&' ]+/g, ' ');
x = x.replace(/\s+/g, ' ').trim();
return x;
}
/** Human-friendly display name from the normalized key (title-case). */
function displayMerchant(merchant, norm) {
const base = (norm && norm.length >= 3) ? norm : String(merchant || '').toLowerCase();
const t = base.replace(/\b[a-z]/g, (c) => c.toUpperCase()).trim();
return t || String(merchant || '').trim();
}
function makeItem(input) {
const source = input.source;
const merchant = (input.merchant || '').toString().trim();
const norm = normalizeMerchant(merchant);
const date = input.date || null;
const amount = Number(input.amount) || 0;
// dedupe: prefer order_id; else normalized merchant + date + amount
const dedupeKey = input.order_id
? `${source}:${input.order_id}${input.asin ? ':' + input.asin : ''}`
: `${source}:${norm}|${date || ''}|${amount}`;
const id = crypto.createHash('sha1').update(dedupeKey).digest('hex').slice(0, 12);
return {
id,
source,
merchant,
display_merchant: displayMerchant(merchant, norm),
product: input.product || null,
order_id: input.order_id || null,
asin: input.asin || null,
seller: input.seller || null,
date,
amount,
currency: input.currency || 'USD',
url: input.url || null,
raw: input.raw != null ? input.raw : null,
norm_merchant: norm,
dedupeKey,
created_at: new Date().toISOString(),
resolved: { google: null, amazon: null },
drafts: { google_review: null, amazon_review: null, seller_email: null },
targets: { google_review: 'none', amazon_review: 'none', seller_email: 'none' },
// per-target approval binding: {contentHash, approvedAt} snapshot of EXACTLY
// what a human approved, so a later content drift can't be silently executed.
approvals: { google_review: null, amazon_review: null, seller_email: null },
seller_email_to: null,
};
}
/** Hash the EXACT executable payload for a target — the anti-drift approval seal. */
function targetContentHash(item, target) {
const d = (item.drafts && item.drafts[target]) || {};
let payload;
if (target === 'seller_email') {
payload = [item.seller_email_to || '', d.subject || '', d.body || ''].join('');
} else {
const g = (item.resolved && item.resolved.google) || {};
const a = (item.resolved && item.resolved.amazon) || {};
const tgtUrl = target === 'google_review' ? (g.writeUrl || g.mapsUrl || '') : (a.reviewUrl || a.productUrl || '');
payload = [d.title || '', d.text || '', String(d.rating || ''), tgtUrl].join('');
}
return crypto.createHash('sha1').update(target + '' + payload).digest('hex');
}
function readItems() { return readJsonl(cfg.ITEMS_FILE); }
function getItem(id) { return readItems().find((i) => i.id === id) || null; }
function writeItems(items) {
fs.writeFileSync(cfg.ITEMS_FILE, items.map((i) => JSON.stringify(i)).join('\n') + (items.length ? '\n' : ''));
}
/** Insert only genuinely-new items (by dedupeKey). Returns {added, skipped, total}. */
function upsertItems(newItems) {
const existing = readItems();
const seen = new Set(existing.map((i) => i.dedupeKey));
let added = 0;
for (const it of newItems) {
if (!it || !it.dedupeKey) continue;
if (seen.has(it.dedupeKey)) continue;
existing.push(it); seen.add(it.dedupeKey); added++;
}
writeItems(existing);
return { added, skipped: newItems.length - added, total: existing.length };
}
/** Patch one item in place (shallow-merge top-level; caller passes nested objects whole). */
function updateItem(id, patch) {
const items = readItems();
const idx = items.findIndex((i) => i.id === id);
if (idx < 0) return null;
items[idx] = Object.assign({}, items[idx], patch);
writeItems(items);
return items[idx];
}
// ---- merchant → place_id cache (DTD Option B) --------------------------------
function getPlace(norm) {
if (!norm) return null;
const rows = readJsonl(cfg.PLACE_CACHE);
// last write wins
let hit = null;
for (const r of rows) if (r && r.norm === norm) hit = r;
return hit;
}
function setPlace(norm, place_id, extra) {
const row = Object.assign({ norm, place_id: place_id || null, at: new Date().toISOString() }, extra || {});
fs.appendFileSync(cfg.PLACE_CACHE, JSON.stringify(row) + '\n');
return row;
}
// ---- audit log of every approve / execute action ----------------------------
function logAction(row) {
const r = Object.assign({ at: new Date().toISOString() }, row);
try { fs.appendFileSync(cfg.ACTIONS_LOG, JSON.stringify(r) + '\n'); } catch (e) {}
return r;
}
module.exports = {
normalizeMerchant, displayMerchant, makeItem, targetContentHash, readItems, getItem, upsertItems, updateItem,
getPlace, setPlace, logAction, readJsonl,
};