[object Object]

← back to AbramsOS

reviews: native Amazon review + seller thank-you feature over AbramsOS purchases

282273230007398168684e725b701a142b3b99c6 · 2026-09-11 10:21:43 -0700 · Steve

- migration 0017 review_draft (keyed to purchase, additive, UNIQUE(purchase,target))
- lib/reviews/{drafts,model,executors}: draft generation (short product names, variety, deslop) from purchase.raw_extract, DB-backed, content-seal on approve
- routes/reviews.js + views/reviews.ejs: /reviews batch-review page (read all, select, approve-in-one-pass), paced per-item gated posting (no bulk fire, no auto-approve)
- gated edge: openclaw resolves product by search (email receipts carry no ASIN) -> needs_verify (human submits); George sends approved seller emails; REVIEWS_EXECUTORS_LIVE + approve + seal + confirm required
- nav link, review id prefix
- 74 drafts generated from 37 real Amazon purchases; posted:0; executors OFF by default

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p

Files touched

Diff

commit 282273230007398168684e725b701a142b3b99c6
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 11 10:21:43 2026 -0700

    reviews: native Amazon review + seller thank-you feature over AbramsOS purchases
    
    - migration 0017 review_draft (keyed to purchase, additive, UNIQUE(purchase,target))
    - lib/reviews/{drafts,model,executors}: draft generation (short product names, variety, deslop) from purchase.raw_extract, DB-backed, content-seal on approve
    - routes/reviews.js + views/reviews.ejs: /reviews batch-review page (read all, select, approve-in-one-pass), paced per-item gated posting (no bulk fire, no auto-approve)
    - gated edge: openclaw resolves product by search (email receipts carry no ASIN) -> needs_verify (human submits); George sends approved seller emails; REVIEWS_EXECUTORS_LIVE + approve + seal + confirm required
    - nav link, review id prefix
    - 74 drafts generated from 37 real Amazon purchases; posted:0; executors OFF by default
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
---
 db/migrations/0017_review_draft.sql |  23 ++++++
 lib/ids.js                          |   1 +
 lib/reviews/drafts.js               | 161 ++++++++++++++++++++++++++++++++++++
 lib/reviews/executors.js            | 115 ++++++++++++++++++++++++++
 lib/reviews/model.js                | Bin 0 -> 6340 bytes
 routes/reviews.js                   |  66 +++++++++++++++
 server.js                           |   2 +
 views/partials/header.ejs           |   1 +
 views/reviews.ejs                   | 111 +++++++++++++++++++++++++
 9 files changed, 480 insertions(+)

diff --git a/db/migrations/0017_review_draft.sql b/db/migrations/0017_review_draft.sql
new file mode 100644
index 0000000..0f72005
--- /dev/null
+++ b/db/migrations/0017_review_draft.sql
@@ -0,0 +1,23 @@
+-- 0017_review_draft.sql — Amazon/seller review + thank-you drafts, keyed to purchase.
+-- ADDITIVE ONLY. One draft row per (purchase, target). Nothing here posts/sends;
+-- posting is gated in lib/reviews/executors.js (approve + content_hash seal +
+-- REVIEWS_EXECUTORS_LIVE + per-item confirm). Reviews never auto-submit.
+CREATE TABLE IF NOT EXISTS review_draft (
+  id              text PRIMARY KEY,
+  user_id         text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  purchase_id     text NOT NULL REFERENCES purchase(id) ON DELETE CASCADE,
+  target          text NOT NULL CHECK (target IN ('amazon_review','seller_email','google_review')),
+  product_name    text,
+  draft_title     text,
+  draft_text      text,
+  rating          integer,
+  status          text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','approved','posted','skipped')),
+  content_hash    text,                 -- seal captured at approval (anti stale-approval-replay)
+  seller_email_to text,
+  resolved        jsonb,                -- resolver output (google place_id / amazon product url, etc.)
+  created_at      timestamptz NOT NULL DEFAULT now(),
+  updated_at      timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (purchase_id, target)
+);
+CREATE INDEX IF NOT EXISTS review_draft_user_idx ON review_draft (user_id, status);
+CREATE INDEX IF NOT EXISTS review_draft_purchase_idx ON review_draft (purchase_id);
diff --git a/lib/ids.js b/lib/ids.js
index d2d124b..f49a503 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -22,6 +22,7 @@ const PREFIX = {
   asset: 'ast',
   biometric: 'bio',
   claim: 'claim',
+  review: 'rev',
 };
 
 function id(kind) {
diff --git a/lib/reviews/drafts.js b/lib/reviews/drafts.js
new file mode 100644
index 0000000..1dc63d9
--- /dev/null
+++ b/lib/reviews/drafts.js
@@ -0,0 +1,161 @@
+'use strict';
+/**
+ * Draft generator — Steve's genuine positive voice, $0 local. Ported from the
+ * AbramsEgo spend-reviews module (TK-11433) into AbramsOS.
+ *
+ * VARIETY is a safety requirement: batch-approving near-identical 5-star reviews
+ * gets Google/Amazon accounts penalized. Each draft rotates OPENER, STRUCTURE,
+ * LENGTH, and RATING LANGUAGE — seeded deterministically by the item id (stable
+ * per item so the content-seal holds across re-generation, varied across items).
+ * No LLM, no network.
+ */
+
+const SLOP_SWAPS = [
+  [/\belevate(s|d)?\b/gi, (m) => m.endsWith('s') ? 'improves' : m.endsWith('d') ? 'improved' : 'improve'],
+  [/\bunlock(s|ed)?\b/gi, 'made possible'],
+  [/\bseamless(ly)?\b/gi, (m) => /ly$/i.test(m) ? 'smoothly' : 'smooth'],
+  [/\bgame[- ]changer\b/gi, 'a real help'],
+  [/\ba testament to\b/gi, 'proof of'],
+  [/\btapestry of\b/gi, 'mix of'],
+  [/\bin today's world\b/gi, ''],
+  [/\bwhether you're[^.,;]*\bor\b[^.,;]*/gi, ''],
+  [/\bnot only\b([^,]*),? but also\b/gi, '$1 and'],
+  [/\bboast(s|ed|ing)?\b/gi, 'has'],
+  [/\btruly\b/gi, ''],
+  [/\bstunning(ly)?\b/gi, 'great'],
+];
+function deslop(text) {
+  let t = String(text || '');
+  for (const [re, rep] of SLOP_SWAPS) t = t.replace(re, rep);
+  t = t.replace(/\s*—\s*/g, ', ');
+  t = t.replace(/[ \t]{2,}/g, ' ').replace(/\s+([.,;!?])/g, '$1');
+  t = t.replace(/\s{2,}/g, ' ').replace(/(^|\. )([a-z])/g, (m, a, b) => a + b.toUpperCase());
+  return t.trim();
+}
+
+function hash(s) { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) | 0; return h; }
+function pick(arr, seed) { return arr[Math.abs(hash(seed)) % arr.length]; }
+
+// Amazon titles are 150-200 chars of keyword soup. A human says "the Maldon sea
+// salt", not the whole SKU. Trim to a short, natural name.
+function shortProduct(p) {
+  if (!p) return '';
+  let s = String(p).split(/[,(|;]/)[0].trim();
+  s = s.replace(/\s*[-–—]\s*/g, ' ').replace(/\s{2,}/g, ' ').trim();
+  const words = s.split(/\s+/);
+  if (words.length > 7) s = words.slice(0, 7).join(' ');
+  if (s.length > 48) s = s.slice(0, 48).replace(/\s+\S*$/, '').trim();
+  return s || String(p).slice(0, 40).trim();
+}
+
+const OPEN = [
+  'Really happy with this one.', 'Genuinely pleased with the whole experience.',
+  'This worked out exactly the way I hoped.', 'Glad I went with them.',
+  'No complaints at all here.', 'Exactly what I was looking for.',
+  'Honestly exceeded what I expected.', 'A good find that I keep coming back to.',
+  'Well worth it in the end.', 'Couldn\'t have asked for better.',
+];
+const MID_PRODUCT = [
+  'The {product} is well made and does exactly what I needed.',
+  'The {product} arrived in great shape and has held up well.',
+  'Quality on the {product} is better than I expected for the price.',
+  'The {product} has been solid so far and I use it often.',
+  'The {product} matched the description and feels built to last.',
+  'I\'ve put the {product} through real use and it hasn\'t let me down.',
+];
+const MID_MERCH = [
+  'Ordering from {merchant} was easy and everything showed up on time.',
+  '{merchant} made the whole thing painless from checkout to delivery.',
+  'Service from {merchant} was quick and straightforward.',
+  '{merchant} clearly cares about getting the details right.',
+  '{merchant} kept me posted the whole way and delivered as promised.',
+  'The people at {merchant} were helpful and made it simple.',
+];
+const DETAIL = [
+  'Packaging was tidy and nothing was damaged.',
+  'Shipping was faster than I expected.',
+  'Communication was clear from order to arrival.',
+  'It was priced fairly for what you get.',
+  'Setup was simple and the instructions were clear.',
+  'Everything was exactly as pictured.',
+];
+const CLOSE = [
+  'Would order again without hesitation.', 'Recommend them to anyone on the fence.',
+  'Will be back for more.', 'Easy recommendation from me.',
+  'I\'d happily buy from them again.', 'Solid experience start to finish.',
+];
+const RATING_LINE = [
+  'Five stars from me.', '★★★★★', '5/5, no notes.', 'Easily five stars.',
+  'Top marks.', 'A well-earned five.', 'Rating it a full five.', 'Five out of five.',
+];
+
+function fill(t, item) {
+  const merch = item.display_merchant || item.merchant || 'them';
+  return t.replace(/\{product\}/g, shortProduct(item.product) || 'item').replace(/\{merchant\}/g, merch);
+}
+
+function shapeGoogle(item) {
+  const s = Math.abs(hash(item.id + 'shape')) % 4;
+  const P = item.product;
+  let parts;
+  if (s === 0) parts = [pick(OPEN, item.id + 'g0'), fill(pick(MID_MERCH, item.id + 'gm'), item), pick(CLOSE, item.id + 'gc')];
+  else if (s === 1) parts = [fill(pick(MID_MERCH, item.id + 'g1'), item), pick(DETAIL, item.id + 'gd'), pick(CLOSE, item.id + 'gc')];
+  else if (s === 2) parts = [pick(OPEN, item.id + 'g2'), pick(CLOSE, item.id + 'gc')];
+  else parts = [pick(OPEN, item.id + 'g3'), fill(pick(MID_MERCH, item.id + 'gm'), item), P ? fill(pick(MID_PRODUCT, item.id + 'gp'), item) : pick(DETAIL, item.id + 'gd'), pick(CLOSE, item.id + 'gc')];
+  if (Math.abs(hash(item.id + 'grate')) % 3 !== 0) parts.push(pick(RATING_LINE, item.id + 'gr'));
+  return parts;
+}
+function genGoogleReview(item) {
+  return { title: null, text: deslop(shapeGoogle(item).join(' ')), rating: 5 };
+}
+
+function genAmazonReview(item) {
+  const s = Math.abs(hash(item.id + 'ashape')) % 3;
+  const prod = shortProduct(item.product) || 'this item';
+  const specifics = [];
+  if (item.product) specifics.push(`The ${prod} is exactly as described`);
+  specifics.push('it arrived quickly and well packed');
+  if (item.seller) specifics.push(`shipped by ${item.seller}`);
+  const spec = specifics.length ? deslop(specifics.join(', ') + '.') : '';
+  let parts;
+  if (s === 0) parts = [pick(OPEN, item.id + 'a0'), fill(pick(MID_PRODUCT, item.id + 'ap'), item), spec];
+  else if (s === 1) parts = [spec || fill(pick(MID_PRODUCT, item.id + 'a1'), item), pick(DETAIL, item.id + 'ad'), pick(CLOSE, item.id + 'ac')];
+  else parts = [pick(OPEN, item.id + 'a2'), spec, fill(pick(MID_PRODUCT, item.id + 'ap'), item), pick(CLOSE, item.id + 'ac')];
+  if (Math.abs(hash(item.id + 'arate')) % 3 !== 0) parts.push(pick(RATING_LINE, item.id + 'ar'));
+  const titleBank = item.product ? [`Great ${prod}`, `${prod} — exactly as described`, `Very happy with the ${prod}`, `${prod} does the job well`] : ['Happy with this purchase', 'Would buy again', 'Solid purchase'];
+  const title = deslop(pick(titleBank, item.id + 'at'));
+  return { title, text: deslop(parts.filter(Boolean).join(' ')), rating: 5 };
+}
+
+function genSellerEmail(item) {
+  const who = item.seller || item.display_merchant || item.merchant || 'there';
+  const thing = shortProduct(item.product) || 'the product';
+  const subjBank = [`Thank you — ${thing}`, `Really pleased with the ${thing}`, `A quick thank-you for the ${thing}`, `Great experience with the ${thing}`];
+  const subject = deslop(pick(subjBank, item.id + 'st'));
+  const openBank = [
+    `I recently bought ${thing}${item.order_id ? ` (order ${item.order_id})` : ''} and wanted to say thank you. It has been great, and the quality and service both stood out.`,
+    `Just wanted to reach out about the ${thing}${item.order_id ? ` (order ${item.order_id})` : ''} — it has worked out really well and I appreciated how smooth the whole process was.`,
+    `Thank you for the ${thing}${item.order_id ? ` (order ${item.order_id})` : ''}. It arrived in great shape and has been exactly what I needed.`,
+  ];
+  const body = deslop([
+    `Hi ${who},`, '', pick(openBank, item.id + 'sb'), '',
+    `I left a positive review to help other buyers find you. Keep up the good work — I'll be back.`,
+    '', 'Best,', 'Steve Abrams',
+  ].join('\n'));
+  return { title: subject, text: body, rating: null };
+}
+
+// item: { id, product, merchant, display_merchant, seller, order_id, isAmazon, merchant_domain }
+// Returns { target: {title,text,rating} } — amazon+seller for Amazon; google only for non-Amazon w/ domain.
+function generateForItem(item) {
+  const out = {};
+  if (item.isAmazon) {
+    out.amazon_review = genAmazonReview(item);
+    out.seller_email = genSellerEmail(item);
+  } else if (item.merchant && item.merchant_domain) {
+    out.google_review = genGoogleReview(item);
+  }
+  return out;
+}
+
+module.exports = { deslop, shortProduct, genGoogleReview, genAmazonReview, genSellerEmail, generateForItem };
diff --git a/lib/reviews/executors.js b/lib/reviews/executors.js
new file mode 100644
index 0000000..72c10d3
--- /dev/null
+++ b/lib/reviews/executors.js
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * Post/send executors — THE GATED EDGE. Ported from AbramsEgo (TK-11433).
+ *
+ * HARD RAILS: nothing fires unless ALL of —
+ *   (1) the draft is per-item status='approved',
+ *   (2) its content_hash SEAL still matches the current content (anti stale-replay),
+ *   (3) env REVIEWS_EXECUTORS_LIVE=1, and
+ *   (4) the caller passes { live:true, confirm:<draft.id> }.
+ * Miss any → returns the PLAN, fires nothing (gated:true).
+ * Reviews NEVER auto-submit: openclaw opens + snapshots the page and returns
+ * needs_verify for a human to click submit. Only seller_email actually sends
+ * (via George), and only when a resolved to-address exists. No bulk path.
+ */
+const { execFile } = require('child_process');
+const db = require('../db');
+const model = require('./model');
+
+const OPENCLAW_BIN = process.env.OPENCLAW_BIN || 'openclaw';
+const GEORGE_URL = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const DEFAULT_FROM = process.env.REVIEWS_FROM || 'steve@designerwallcoverings.com';
+function georgeAuth() {
+  // AbramsOS stores the George secret as GEORGE_BASIC_AUTH (base64 of admin:<pass>).
+  const b = process.env.GEORGE_BASIC_AUTH;
+  if (b) return b.startsWith('Basic ') ? b : 'Basic ' + b;
+  const a = process.env.GEORGE_AUTH;
+  if (a && a.includes(':')) return 'Basic ' + Buffer.from(a).toString('base64');
+  if (a) return a.startsWith('Basic ') ? a : 'Basic ' + a;
+  const u = process.env.GEORGE_USER || 'admin';
+  const p = process.env.GEORGE_PASS || 'DW2024!';
+  return 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
+}
+function liveEnabled() { return process.env.REVIEWS_EXECUTORS_LIVE === '1'; }
+
+function openclaw(args, timeoutMs = 45000) {
+  return new Promise((resolve) => {
+    execFile(OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
+      resolve({ ok: !err, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
+    });
+  });
+}
+
+function planFor(row) {
+  if (row.target === 'seller_email') {
+    return { target: row.target, channel: 'George (Gmail :9850 /api/send)', cost: 0, costLabel: '$0 (local)',
+      request: { to: row.seller_email_to || null, from: DEFAULT_FROM, subject: row.draft_title, body: row.draft_text },
+      note: 'needs a resolved seller/brand address before it can send' };
+  }
+  const q = encodeURIComponent(row.product_name || '');
+  const searchUrl = row.target === 'amazon_review'
+    ? `https://www.amazon.com/s?k=${q}`
+    : `https://www.google.com/maps/search/?api=1&query=${q}`;
+  return { target: row.target, channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
+    steps: [
+      `openclaw browser open "${searchUrl}"   # email receipts carry no ASIN — resolve by product name`,
+      'openclaw browser snapshot              # locate the product / business, open its review page',
+      'openclaw browser click --ref <5-star>',
+      'openclaw browser fill  --fields-file <title+text>',
+      'openclaw browser click --ref <submit>  # a human confirms this step (needs_verify)',
+      'openclaw browser screenshot',
+    ],
+    review: { title: row.draft_title, text: row.draft_text, rating: row.rating } };
+}
+
+async function fireOpenclawReview(row) {
+  const plan = planFor(row);
+  const q = encodeURIComponent(row.product_name || '');
+  const url = row.target === 'amazon_review'
+    ? `https://www.amazon.com/s?k=${q}` : `https://www.google.com/maps/search/?api=1&query=${q}`;
+  const open = await openclaw(['browser', 'open', url]);
+  if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the resolve page', detail: open.stderr, plan };
+  const snap = await openclaw(['browser', 'snapshot']);
+  return { ok: false, status: 'needs_verify',
+    reason: 'search page opened for the product; find the item, open its review page, and submit in Chrome before marking posted',
+    snapshotBytes: (snap.stdout || '').length, plan };
+}
+
+async function fireSellerEmail(row) {
+  const plan = planFor(row);
+  if (!plan.request.to) return { ok: false, status: 'error', reason: 'no seller/brand email address resolved yet', plan };
+  try {
+    const r = await fetch(GEORGE_URL + '/api/send', {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: georgeAuth() },
+      body: JSON.stringify({ to: plan.request.to, subject: plan.request.subject, body: plan.request.body }),
+    });
+    const j = await r.json().catch(() => ({}));
+    if (!r.ok) return { ok: false, status: 'error', reason: `George ${r.status}`, detail: j, plan };
+    return { ok: true, status: 'posted', channel: 'george', detail: j, plan };
+  } catch (e) { return { ok: false, status: 'error', reason: 'George unreachable: ' + e.message, plan }; }
+}
+
+// Single guarded entry point. userId-scoped. Returns {gated:true, plan} unless every gate passes.
+async function executeDraft(userId, id, opts = {}) {
+  const row = await model.getDraft(userId, id);
+  if (!row) return { ok: false, error: 'not found' };
+  const plan = planFor(row);
+  // GATE 1: approved
+  if (row.status !== 'approved') return { ok: false, gated: true, reason: 'draft not per-item approved', plan };
+  // GATE 2: seal intact
+  if (!row.content_hash || row.content_hash !== model.contentHash(row)) {
+    await model.setStatus(userId, id, 'draft');
+    return { ok: false, gated: true, reason: 'content changed since approval — re-approve before sending', plan };
+  }
+  // GATE 3 + 4: live env AND matching confirm token
+  if (!liveEnabled() || !(opts.live === true && opts.confirm === id)) {
+    return { ok: false, gated: true,
+      reason: liveEnabled() ? 'approved — awaiting explicit {live:true, confirm:<id>}' : 'approved — REVIEWS_EXECUTORS_LIVE not set (gated to Steve)',
+      plan };
+  }
+  const res = row.target === 'seller_email' ? await fireSellerEmail(row) : await fireOpenclawReview(row);
+  if (res.status === 'posted') await db.query(`UPDATE review_draft SET status='posted', updated_at=now() WHERE id=$1`, [id]);
+  return res;
+}
+
+module.exports = { executeDraft, planFor, liveEnabled };
diff --git a/lib/reviews/model.js b/lib/reviews/model.js
new file mode 100644
index 0000000..f59e4d4
Binary files /dev/null and b/lib/reviews/model.js differ
diff --git a/routes/reviews.js b/routes/reviews.js
new file mode 100644
index 0000000..1495163
--- /dev/null
+++ b/routes/reviews.js
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Reviews — generate Amazon review + seller thank-you drafts from AbramsOS
+ * purchases, batch-approve, and post per-item through the gated executor.
+ * Auth: mounted after requireAuth. /api/* is CSRF-exempt (same-origin JSON) per
+ * middleware/csrf. Nothing posts without approve + seal + REVIEWS_EXECUTORS_LIVE
+ * + per-item confirm; reviews never auto-submit (openclaw returns needs_verify).
+ */
+const express = require('express');
+const model = require('../lib/reviews/model');
+const executors = require('../lib/reviews/executors');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+const uid = (req) => req.userId || DEV_USER_ID;
+
+// Page
+router.get('/reviews', async (req, res) => {
+  const items = await model.listWithDrafts(uid(req));
+  res.render('reviews', { items, summary: model.summarize(items), executorsLive: executors.liveEnabled() });
+});
+
+// Data
+router.get('/api/reviews', async (req, res) => {
+  const items = await model.listWithDrafts(uid(req));
+  res.json({ ok: true, items, summary: model.summarize(items), executorsLive: executors.liveEnabled() });
+});
+
+// Generate drafts from purchases (all, or a selected subset)
+router.post('/api/reviews/generate', async (req, res) => {
+  try {
+    const out = await model.generate(uid(req), Array.isArray(req.body?.purchaseIds) ? req.body.purchaseIds : null);
+    res.json({ ok: true, ...out });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// Batch approve (seals each row's current content — same seal path as single)
+router.post('/api/reviews/approve-batch', async (req, res) => {
+  const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
+  if (!ids.length) return res.status(400).json({ ok: false, error: 'no ids' });
+  const out = await model.approveBatch(uid(req), ids);
+  res.json({ ok: true, ...out });
+});
+
+router.post('/api/reviews/unapprove', async (req, res) => { const r = await model.setStatus(uid(req), req.body?.id, 'draft'); res.json({ ok: !!r, item: r }); });
+router.post('/api/reviews/skip', async (req, res) => { const r = await model.setStatus(uid(req), req.body?.id, 'skipped'); res.json({ ok: !!r, item: r }); });
+router.post('/api/reviews/seller-email-to', async (req, res) => { const r = await model.setSellerEmailTo(uid(req), req.body?.id, req.body?.to || ''); res.json({ ok: !!r, item: r }); });
+
+// Post approved — PACED, one at a time, each through the gated executor.
+// NO bulk fire and NO auto-approve: the caller supplies exactly the ids the human
+// selected, and each still needs approve + seal + live + confirm.
+router.post('/api/reviews/post-approved', async (req, res) => {
+  const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
+  if (!ids.length) return res.status(400).json({ ok: false, error: 'no ids' });
+  if (!executors.liveEnabled()) return res.json({ ok: true, live: false, results: ids.map((id) => ({ id, status: 'gated', reason: 'REVIEWS_EXECUTORS_LIVE not set' })) });
+  const delay = Math.min(Number(req.body?.delayMs) || 1500, 8000);
+  const results = [];
+  for (const id of ids) {
+    const r = await executors.executeDraft(uid(req), id, { live: true, confirm: id });
+    results.push({ id, status: r.status || (r.gated ? 'gated' : 'error'), reason: r.reason });
+    await new Promise((rz) => setTimeout(rz, delay));
+  }
+  res.json({ ok: true, live: true, results });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 7b0e022..4b14f58 100644
--- a/server.js
+++ b/server.js
@@ -37,6 +37,7 @@ const peopleRouter = require('./routes/people');
 const medicationsRouter = require('./routes/medications');
 const prescriptionsRouter = require('./routes/prescriptions');
 const chatRouter = require('./routes/chat');
+const reviewsRouter = require('./routes/reviews');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -101,6 +102,7 @@ app.use(home);                      // /
 app.use(oauth);                     // /auth/google/start, /auth/google/callback
 app.use(connectors);                // /connectors, /api/connectors, sync
 app.use(purchases);                 // /purchases, /api/purchases
+app.use(reviewsRouter);             // /reviews, /api/reviews* (Amazon review + seller thank-you drafts)
 app.use(plaidRouter);               // /api/plaid/*
 app.use(documentsRouter);           // /api/documents, /api/documents/:id/parse
 app.use(remindersRouter);           // /api/reminders/upcoming, regenerate, dismiss
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index c2e2814..e059e02 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -33,6 +33,7 @@
         <a href="/savings"><span class="ic">🐷</span><span class="lb">Savings</span></a>
         <a href="/assets"><span class="ic">🏦</span><span class="lb">Assets</span></a>
         <a href="/purchases"><span class="ic">🛍️</span><span class="lb">Purchases</span></a>
+        <a href="/reviews"><span class="ic">⭐</span><span class="lb">Reviews</span></a>
         <div class="nav-sec">Health</div>
         <a href="/health"><span class="ic">❤️</span><span class="lb">Health</span></a>
         <a href="/biometrics"><span class="ic">📈</span><span class="lb">Biometrics</span></a>
diff --git a/views/reviews.ejs b/views/reviews.ejs
new file mode 100644
index 0000000..9bd65b7
--- /dev/null
+++ b/views/reviews.ejs
@@ -0,0 +1,111 @@
+<%- include('partials/header', { title: 'Reviews' }) %>
+
+<section class="page-head">
+  <h1>Reviews &amp; Thank-yous</h1>
+  <div class="grid-controls">
+    <button id="genBtn" class="btn">Generate drafts</button>
+    <label>Show
+      <select id="filterSel">
+        <option value="needs">Needs approval</option>
+        <option value="approved">Approved</option>
+        <option value="all" selected>All</option>
+      </select>
+    </label>
+    <label>Density
+      <input id="densityRange" type="range" min="180" max="480" step="10" value="320">
+    </label>
+  </div>
+</section>
+
+<section class="reviews-bar glass" style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;padding:.6rem .9rem;margin-bottom:.8rem;">
+  <label style="display:flex;gap:.35rem;align-items:center;"><input type="checkbox" id="selAll"> Select all</label>
+  <button id="approveBtn" class="btn">Approve selected</button>
+  <button id="postBtn" class="btn" title="Posts per-item, one at a time">Post approved</button>
+  <span class="subtle">
+    <% const t = summary.byTarget || {}; %>
+    amazon_review: <%= (t.amazon_review||{}).draft||0 %> draft / <%= (t.amazon_review||{}).approved||0 %> appr ·
+    seller_email: <%= (t.seller_email||{}).draft||0 %> / <%= (t.seller_email||{}).approved||0 %> ·
+    <strong>posted: <%= summary.posted||0 %></strong>
+  </span>
+  <span class="confidence" title="Live executor switch">
+    executors: <%= executorsLive ? 'ARMED (per-item still gated)' : 'OFF (drafts only)' %>
+  </span>
+</section>
+
+<% if (!items.length) { %>
+  <section class="empty glass"><p>No purchases yet. <a href="/connectors">Sync a connector</a> to ingest receipts, then Generate drafts.</p></section>
+<% } else { %>
+  <section id="reviewGrid" class="review-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.8rem;">
+    <% items.forEach(p => { %>
+      <article class="review-card glass" style="padding:.8rem;display:flex;flex-direction:column;gap:.5rem;">
+        <header style="display:flex;justify-content:space-between;gap:.5rem;">
+          <h3 style="margin:0;font-size:1rem;"><%= p.product || p.merchant_name %></h3>
+          <span class="subtle" style="font-size:.72rem;"><%= p.merchant_name %></span>
+        </header>
+        <div class="when" title="<%= new Date(p.purchase_date).toISOString() %>" style="font-size:.72rem;opacity:.7;">
+          🕓 <%= new Date(p.purchase_date).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) %>
+          <% if (p.total_amount) { %> · <%= p.currency||'USD' %> <%= Number(p.total_amount).toFixed(2) %><% } %>
+        </div>
+        <% if (!p.drafts.length) { %>
+          <p class="subtle" style="font-size:.8rem;">No drafts yet — click <em>Generate drafts</em>.</p>
+        <% } %>
+        <% p.drafts.forEach(d => { %>
+          <div class="draft" data-id="<%= d.id %>" data-status="<%= d.status %>" data-target="<%= d.target %>"
+               style="border:1px solid var(--hair,#3333);border-radius:8px;padding:.5rem;display:flex;flex-direction:column;gap:.35rem;">
+            <div style="display:flex;justify-content:space-between;align-items:center;gap:.4rem;">
+              <label style="display:flex;gap:.4rem;align-items:center;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;">
+                <input type="checkbox" class="draftChk" value="<%= d.id %>" <%= d.status==='posted'?'disabled':'' %>>
+                <%= d.target.replace('_',' ') %>
+              </label>
+              <span class="pill pill-<%= d.status %>" style="font-size:.68rem;padding:.1rem .45rem;border-radius:999px;border:1px solid currentColor;opacity:.85;"><%= d.status %></span>
+            </div>
+            <% if (d.draft_title) { %><strong style="font-size:.85rem;"><%= d.draft_title %></strong><% } %>
+            <div style="font-size:.82rem;line-height:1.35;white-space:pre-wrap;"><%= d.draft_text %></div>
+            <% if (d.target === 'seller_email') { %>
+              <div style="display:flex;gap:.35rem;align-items:center;font-size:.72rem;">
+                to: <input class="sellerTo" data-id="<%= d.id %>" value="<%= d.seller_email_to || '' %>" placeholder="seller/brand email (unresolved)" style="flex:1;font-size:.72rem;padding:.2rem;">
+                <button class="btn saveTo" data-id="<%= d.id %>" style="font-size:.7rem;">save</button>
+              </div>
+            <% } %>
+            <div style="display:flex;gap:.4rem;">
+              <button class="btn skipBtn" data-id="<%= d.id %>" style="font-size:.7rem;">skip</button>
+              <% if (d.status==='approved') { %><button class="btn unapproveBtn" data-id="<%= d.id %>" style="font-size:.7rem;">unapprove</button><% } %>
+            </div>
+          </div>
+        <% }) %>
+      </article>
+    <% }) %>
+  </section>
+<% } %>
+
+<script>
+(function(){
+  const $ = (s,r=document)=>r.querySelector(s);
+  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
+  async function api(path, body){
+    const r = await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
+    return r.json();
+  }
+  const checked = ()=>$$('.draftChk:checked').map(c=>c.value);
+
+  $('#genBtn').onclick = async ()=>{ $('#genBtn').disabled=true; const o=await api('/api/reviews/generate',{}); alert(`Generated: ${o.created||0} new, ${o.refreshed||0} refreshed, ${o.skippedLocked||0} locked (across ${o.purchases||0} purchases).`); location.reload(); };
+  $('#selAll').onchange = e=>$$('.draftChk:not(:disabled)').forEach(c=>c.checked=e.target.checked);
+  $('#approveBtn').onclick = async ()=>{ const ids=checked(); if(!ids.length) return alert('Select at least one draft.'); const o=await api('/api/reviews/approve-batch',{ids}); alert(`Approved ${o.approved||0}.`); location.reload(); };
+  $('#postBtn').onclick = async ()=>{
+    const ids=checked(); if(!ids.length) return alert('Select approved drafts to post.');
+    if(!confirm(`Post ${ids.length} item(s), one at a time? Reviews open in Chrome for you to submit; only seller emails with an address send. Nothing fires unless executors are ARMED.`)) return;
+    const o=await api('/api/reviews/post-approved',{ids, live:true});
+    alert(o.live===false ? 'Executors are OFF — nothing sent. Arm REVIEWS_EXECUTORS_LIVE first.' : 'Results:\n'+o.results.map(r=>`${r.id}: ${r.status}${r.reason?' — '+r.reason:''}`).join('\n'));
+    location.reload();
+  };
+  $$('.skipBtn').forEach(b=>b.onclick=async()=>{ await api('/api/reviews/skip',{id:b.dataset.id}); location.reload(); });
+  $$('.unapproveBtn').forEach(b=>b.onclick=async()=>{ await api('/api/reviews/unapprove',{id:b.dataset.id}); location.reload(); });
+  $$('.saveTo').forEach(b=>b.onclick=async()=>{ const to=$(`.sellerTo[data-id="${b.dataset.id}"]`).value; await api('/api/reviews/seller-email-to',{id:b.dataset.id,to}); location.reload(); });
+  // filter
+  $('#filterSel').onchange = e=>{ const v=e.target.value; $$('.draft').forEach(d=>{ const st=d.dataset.status; d.style.display = v==='all'||(v==='needs'&&st==='draft')||(v==='approved'&&st==='approved') ? '' : 'none'; }); };
+  // density
+  const dr=$('#densityRange'); if(dr) dr.oninput=e=>{ $('#reviewGrid').style.gridTemplateColumns=`repeat(auto-fill,minmax(${e.target.value}px,1fr))`; };
+})();
+</script>
+
+<%- include('partials/footer', { }) %>

← 144d126 chore: lint, refactor, v0.6.0 (session close) — nav-bar chat  ·  back to AbramsOS  ·  reviews: arm launcher (sets REVIEWS_EXECUTORS_LIVE + opencla dbe3376 →