← back to AbramsEgo
lib/spend-reviews/router.js
249 lines
'use strict';
/**
* Spend → Reviews — express router. Mounted at /api/spend in server.js AFTER the
* Basic-Auth middleware, so every route here is auth-gated. The post/send routes
* are additionally per-item gated (see executors.js).
*/
const express = require('express');
const cfg = require('./config');
const store = require('./store');
const resolver = require('./resolver');
const drafts = require('./drafts');
const executors = require('./executors');
const router = express.Router();
// Adapters are independent files (built as separate units). Load lazily + safely
// so a missing/half-written adapter can never crash the whole dashboard.
function loadAdapter(source) {
try { return require('./adapters/' + source); }
catch (e) { return { ingest: async () => ({ ok: false, items: [], meta: { source, error: 'adapter not available: ' + e.message } }) }; }
}
function newBudget() { return { remaining: cfg.PLACES_MAX_LOOKUPS_PER_RUN, spent: 0 }; }
// Downgrade any 'approved' target whose sealed content no longer matches (drift).
function invalidateDriftedApprovals(item, targets, approvals) {
for (const t of cfg.TARGETS) {
if (targets[t] === 'approved') {
const seal = approvals[t];
if (!seal || seal.contentHash !== store.targetContentHash(item, t)) { targets[t] = 'draft'; approvals[t] = null; }
}
}
}
async function resolveAndDraft(item, budget) {
const resolved = await resolver.resolveItem(item, budget);
const withResolved = Object.assign({}, item, { resolved });
const d = drafts.generateAll(withResolved);
const targets = Object.assign({}, item.targets);
const approvals = Object.assign({}, item.approvals);
for (const t of cfg.TARGETS) {
if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
}
const next = Object.assign({}, withResolved, { drafts: d, targets, approvals });
invalidateDriftedApprovals(next, targets, approvals);
return store.updateItem(item.id, { resolved, drafts: d, targets, approvals });
}
function summary() {
const items = store.readItems();
const s = { total: items.length, bySource: {}, byTarget: {}, resolvedGoogle: 0, unresolvedGoogle: 0,
amazonProducts: 0, drafted: 0, approved: 0, posted: 0, lastIngestAt: null };
for (const t of cfg.TARGETS) s.byTarget[t] = { draft: 0, approved: 0, posted: 0, skipped: 0 };
for (const it of items) {
s.bySource[it.source] = (s.bySource[it.source] || 0) + 1;
if (it.resolved && it.resolved.google) (it.resolved.google.place_id ? s.resolvedGoogle++ : s.unresolvedGoogle++);
if (it.resolved && it.resolved.amazon && it.resolved.amazon.status === 'resolved') s.amazonProducts++;
let anyDraft = false;
for (const t of cfg.TARGETS) {
const st = (it.targets && it.targets[t]) || 'none';
if (st !== 'none' && s.byTarget[t][st] !== undefined) s.byTarget[t][st]++;
if (st === 'draft' || st === 'approved' || st === 'posted') anyDraft = true;
if (st === 'approved') s.approved++;
if (st === 'posted') s.posted++;
}
if (anyDraft) s.drafted++;
if (!s.lastIngestAt || it.created_at > s.lastIngestAt) s.lastIngestAt = it.created_at;
}
return s;
}
router.get('/items', (req, res) => res.json({ ok: true, items: store.readItems(), summary: summary() }));
router.get('/summary', (req, res) => res.json(summary()));
// Ingest from a source adapter. amazon/gmail may report needsAction (a one-time
// Steve login) instead of items — the panel surfaces that.
router.post('/ingest/:source', async (req, res) => {
const source = req.params.source;
if (!cfg.SOURCES.includes(source)) return res.status(400).json({ ok: false, error: 'unknown source' });
try {
const adapter = loadAdapter(source);
const out = await adapter.ingest(Object.assign({}, req.body, { source }));
const rawItems = (out.items || []).map((i) => store.makeItem(Object.assign({ source }, i)));
let excluded = 0;
for (const it of rawItems) {
if (resolver.isExcluded(it)) { it.excluded = true; for (const t of cfg.TARGETS) it.targets[t] = 'skipped'; excluded++; }
}
const up = store.upsertItems(rawItems);
res.json({ ok: out.ok !== false, source, meta: out.meta || {}, ingested: up, excluded, needsAction: out.meta && out.meta.needsAction || null });
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// CSV drop: accepts {csv:"<raw text>", filename?} OR reads files already dropped
// into data/csv-drop/. Steve uploads a statement export; this parses it.
router.post('/upload-csv', async (req, res) => {
try {
const adapter = loadAdapter('csv');
const out = await adapter.ingest({ source: 'csv', csv: req.body && req.body.csv, filename: req.body && req.body.filename });
const rawItems = (out.items || []).map((i) => store.makeItem(Object.assign({ source: 'csv' }, i)));
let excluded = 0;
for (const it of rawItems) {
if (resolver.isExcluded(it)) { it.excluded = true; for (const t of cfg.TARGETS) it.targets[t] = 'skipped'; excluded++; }
}
const up = store.upsertItems(rawItems);
res.json({ ok: out.ok !== false, meta: out.meta || {}, ingested: up, excluded });
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// Resolve + draft one item, or all unresolved (bounded by the Places budget when on).
router.post('/resolve/:id', async (req, res) => {
const it = store.getItem(req.params.id);
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
const budget = newBudget();
const updated = await resolveAndDraft(it, budget);
res.json({ ok: true, item: updated, placesSpentUsd: budget.spent });
});
router.post('/resolve-all', async (req, res) => {
const budget = newBudget();
const items = store.readItems();
let done = 0;
for (const it of items) {
if (it.resolved && it.resolved.google) continue; // already resolved once
await resolveAndDraft(it, budget);
done++;
if (done >= (Number(req.body && req.body.max) || 200)) break;
}
res.json({ ok: true, resolved: done, placesSpentUsd: budget.spent, placesCostLabel: budget.spent ? `$${budget.spent.toFixed(3)}` : '$0 (local)' });
});
router.post('/draft/:id', (req, res) => {
const it = store.getItem(req.params.id);
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
const d = drafts.generateAll(it);
const targets = Object.assign({}, it.targets);
const approvals = Object.assign({}, it.approvals);
for (const t of cfg.TARGETS) if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
const next = Object.assign({}, it, { drafts: d, targets, approvals });
invalidateDriftedApprovals(next, targets, approvals);
res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets, approvals }) });
});
// Shared per-item status flip WITH the content-seal (the whole point — nothing
// posts without this, and approval seals EXACTLY the approved content). Used by
// both the single-item route and the batch route so the seal is never bypassed.
function applyStatus(id, target, status) {
if (!cfg.TARGETS.includes(target)) return { ok: false, error: 'unknown target' };
const it = store.getItem(id);
if (!it) return { ok: false, error: 'not found' };
if (status === 'approved' && (!it.drafts || !it.drafts[target])) return { ok: false, error: 'no draft to approve for this target' };
if (status === 'approved' && it.targets && it.targets[target] === 'skipped' && it.excluded) return { ok: false, error: 'merchant excluded (own business/infra) — cannot approve' };
const targets = Object.assign({}, it.targets, { [target]: status });
const approvals = Object.assign({}, it.approvals);
approvals[target] = status === 'approved' ? { contentHash: store.targetContentHash(it, target), approvedAt: new Date().toISOString() } : null;
store.logAction({ action: 'set-status', id, target, status });
return { ok: true, item: store.updateItem(id, { targets, approvals }) };
}
function setTarget(req, res, status) {
const { id, target } = req.body || {};
const r = applyStatus(id, target, status);
if (!r.ok) return res.status(r.error === 'not found' ? 404 : 400).json(r);
res.json(r);
}
router.post('/approve', (req, res) => setTarget(req, res, 'approved'));
router.post('/unapprove', (req, res) => setTarget(req, res, 'draft'));
router.post('/skip', (req, res) => setTarget(req, res, 'skipped'));
// BATCH APPROVE — marks each selected (id,target) approved in one pass, reusing
// the SAME per-item seal (applyStatus). This is NOT a new approval mechanism and
// NOT an auto-approve: the caller (Steve) supplies the exact list he selected.
router.post('/approve-batch', (req, res) => {
const list = (req.body && req.body.items) || [];
if (!Array.isArray(list) || !list.length) return res.status(400).json({ ok: false, error: 'items[] required' });
const results = list.slice(0, 500).map(({ id, target }) => {
const r = applyStatus(id, target, 'approved');
return { id, target, ok: r.ok, error: r.error || null };
});
res.json({ ok: true, approved: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results });
});
// POST APPROVED — fires the gated executor SEQUENTIALLY, one item at a time, with
// a delay between each. NOT a bulk fire: every call still passes through
// executeTarget, so it needs EXECUTORS_LIVE + per-item approved + a matching
// content-seal + confirm token. A drifted/blocked item is skipped and reported;
// the run continues. If EXECUTORS_LIVE is off, every item comes back gated
// (nothing sent). The caller supplies the selected list, or omit to act on all
// currently-approved targets.
router.post('/post-approved', async (req, res) => {
const delayMs = Math.min(10000, Math.max(300, Number(req.body && req.body.delayMs) || 1500));
let list = (req.body && req.body.items) || null;
if (!Array.isArray(list) || !list.length) {
list = [];
for (const it of store.readItems()) for (const t of cfg.TARGETS) if (it.targets && it.targets[t] === 'approved') list.push({ id: it.id, target: t });
}
const results = [];
for (const { id, target } of list.slice(0, 500)) {
const it = store.getItem(id);
if (!it) { results.push({ id, target, status: 'error', reason: 'not found' }); continue; }
const r = await executors.executeTarget(it, target, { live: true, confirm: id });
if (r.ok && r.status === 'posted') store.updateItem(id, { targets: Object.assign({}, it.targets, { [target]: 'posted' }) });
results.push({ id, target, status: r.status || (r.gated ? 'gated' : 'error'), gated: !!r.gated, reason: r.reason || null });
await new Promise((rs) => setTimeout(rs, delayMs));
}
const posted = results.filter((r) => r.status === 'posted').length;
const gated = results.filter((r) => r.gated).length;
res.json({ ok: true, attempted: results.length, posted, gated, skipped: results.length - posted - gated, delayMs, results });
});
// Optional: set a resolved seller/brand email address on an item (for seller_email).
router.post('/seller-email-to', (req, res) => {
const { id, to } = req.body || {};
const it = store.getItem(id);
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
// changing the destination invalidates a prior seller_email approval seal
const targets = Object.assign({}, it.targets);
const approvals = Object.assign({}, it.approvals);
const next = Object.assign({}, it, { seller_email_to: (to || '').toString().slice(0, 200) });
invalidateDriftedApprovals(next, targets, approvals);
res.json({ ok: true, item: store.updateItem(id, { seller_email_to: next.seller_email_to, targets, approvals }) });
});
// THE GATED EXECUTE. Per item + per target. Returns the plan (gated) unless every
// gate passes; on a real post it captures a place_id / marks the target posted.
router.post('/execute', async (req, res) => {
const { id, target, confirm, live } = req.body || {};
const it = store.getItem(id);
if (!it) return res.status(404).json({ ok: false, error: 'not found' });
const result = await executors.executeTarget(it, target, { confirm, live: live === true });
if (result.ok && result.status === 'posted') {
const targets = Object.assign({}, it.targets, { [target]: 'posted' });
store.updateItem(id, { targets });
}
res.json({ ok: !!result.ok, gated: !!result.gated, result });
});
// Draft a batch memo of every approved-but-unposted item to pending-approval. Fires nothing.
router.post('/draft-memo', (req, res) => {
const items = store.readItems();
const approved = [];
for (const it of items) {
const ats = cfg.TARGETS.filter((t) => it.targets && it.targets[t] === 'approved');
if (ats.length) approved.push(Object.assign({}, it, { approvedTargets: ats }));
}
if (!approved.length) return res.json({ ok: true, note: 'no approved items to memo' });
const file = executors.draftPendingApprovalMemo(approved, req.body && req.body.note);
res.json({ ok: true, memo: file, count: approved.length });
});
module.exports = { router, summary };