← back to AbramsOS
lib/amazon-orders.js
97 lines
// lib/amazon-orders.js — poll Gmail (via the George bridge) for Amazon order
// confirmations and wire each into `purchase` + `reorder_item`. Runs every 30 min
// from the in-process scheduler. Dedupes on the Gmail message id, so re-runs are safe.
//
// Gmail access = George HTTP bridge (GEORGE_URL + GEORGE_BASIC_AUTH). No-ops cleanly
// if George isn't configured (same credential that gates the claims deadline email).
// Honest: total_amount stays NULL unless a price is present; nothing is bought.
const db = require('./db');
const { id } = require('./ids');
const GEORGE_URL = (process.env.GEORGE_URL || '').replace(/\/$/, '');
const GEORGE_BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || '';
const USER = process.env.ABRAMSOS_USER_ID || 'user_steve';
const ACCOUNTS = (process.env.AMAZON_ORDER_ACCOUNTS || 'info,steve-personal').split(',').map(s => s.trim());
const QUERY = 'from:auto-confirm@amazon.com subject:Ordered newer_than:190d';
// "Ordered: \"Nespresso Capsules Vertuo,...\" and 4 more items" -> "Nespresso Vertuo Capsules"
function productFromSubject(subj) {
let s = String(subj || '').replace(/^Ordered:\s*/i, '').replace(/\s*and\s+[\d]+\s+more items?\.?$/i, '');
s = s.replace(/["“”]/g, '').replace(/,?\.\.\.$/,'').replace(/[,\s]+$/,'').trim();
if (/^\d+\s+\w+\s+item/i.test(s) || !s) return null; // generic "1 Office item"
return s;
}
// crude category for the reorder tracker
function categoryFor(name) {
const n = name.toLowerCase();
if (/nespresso|coffee|capsule|pod/.test(n)) return 'coffee';
if (/label|shipping|poly bag|box|tape|office|printer|ink|toner/.test(n)) return 'office/shipping';
if (/headset|plantronics|ssd|adapter|usb|cable|electronic/.test(n)) return 'electronics';
return 'general';
}
async function georgeSearch(account) {
const url = `${GEORGE_URL}/api/messages?account=${encodeURIComponent(account)}&maxResults=50&q=${encodeURIComponent(QUERY)}`;
const res = await fetch(url, { headers: { Authorization: 'Basic ' + GEORGE_BASIC_AUTH } });
if (!res.ok) throw new Error(`George /api/messages ${res.status}`);
const j = await res.json();
return j.messages || j || [];
}
// Insert one order as a purchase (dedupe on source_message_id) + upsert a reorder_item.
async function wireOrder(o) {
const gmailId = o.id;
const subj = o.subject || '';
const when = o.internalDate ? new Date(Number(o.internalDate)) : (o.date ? new Date(o.date) : new Date());
const product = productFromSubject(subj);
const ins = await db.query(
`INSERT INTO purchase (id, user_id, source_message_id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence, raw_extract)
VALUES ($1,$2,$3,'Amazon','amazon.com',NULL,$4,NULL,'USD',0.6,$5)
ON CONFLICT (source_message_id) DO NOTHING RETURNING id`,
[id('purchase'), USER, gmailId, when.toISOString().slice(0,10), JSON.stringify({ subject: subj, product, gmail_id: gmailId })]
).catch(() => ({ rows: [] }));
const newPurchase = ins.rows.length > 0;
let reorderTouched = false;
if (product) {
// upsert reorder_item — match word-order-insensitively so "Nespresso Capsules Vertuo"
// and "Nespresso Vertuo Capsules" are the same item (no dup).
const norm = (s) => String(s||'').toLowerCase().replace(/[^a-z0-9 ]/g,'').split(/\s+/).filter(Boolean).sort().join(' ');
const all = await db.query(`SELECT id, name, reorder_cadence_days FROM reorder_item WHERE user_id=$1`, [USER]).catch(() => ({ rows: [] }));
const match = all.rows.find(r => norm(r.name) === norm(product));
const ex = { rows: match ? [match] : [] };
if (ex.rows.length) {
const cad = ex.rows[0].reorder_cadence_days || 90;
await db.query(`UPDATE reorder_item SET last_ordered_at=GREATEST(coalesce(last_ordered_at,'1970-01-01'),$2::timestamptz), next_due_date=$2::date + ($3||' days')::interval, updated_at=now() WHERE id=$1`, [ex.rows[0].id, when.toISOString(), cad]).catch(()=>{});
} else {
await db.query(
`INSERT INTO reorder_item (id,user_id,name,merchant,category,currency,last_ordered_at,status,notes,created_at,updated_at)
VALUES ($1,$2,$3,'Amazon',$4,'USD',$5,'active',$6,now(),now())`,
[id('reorder'), USER, product, categoryFor(product), when.toISOString(), 'auto-imported from Amazon order ' + when.toISOString().slice(0,10)]
).catch(()=>{});
}
reorderTouched = true;
}
return { newPurchase, reorderTouched, product };
}
async function pollAndImport() {
if (!GEORGE_URL || !GEORGE_BASIC_AUTH) return { skipped: 'george-not-configured', imported: 0, reorders: 0 };
let imported = 0, reorders = 0, scanned = 0;
for (const acct of ACCOUNTS) {
let msgs = [];
try { msgs = await georgeSearch(acct); } catch (e) { continue; }
for (const m of msgs) {
scanned++;
const r = await wireOrder(m);
if (r.newPurchase) imported++;
if (r.reorderTouched) reorders++;
}
}
return { imported, reorders, scanned };
}
module.exports = { pollAndImport, wireOrder, productFromSubject, categoryFor };