← back to Dw Signup Fulfillment
lib/trade.js
195 lines
'use strict';
// Trade application store + moderated approval flow.
//
// Applications are appended to data/trade-applications.jsonl (append-only). On
// approve/reject we rewrite the file with the updated record (small volume, so a
// full-file rewrite is simplest and safe).
//
// APPROVE performs, all DRY_RUN-safe:
// (a) assign to the single DW House Account (lib/reps.js — fixed assignment)
// (b) tag the customer `trade` via Admin API (tagsAdd — appends, no clobber)
// (c) set customer metafield custom.assigned_rep
// (d) notify the assigned rep by email (George)
// (e) email the applicant "you're approved"
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const reps = require('./reps');
const shopify = require('./shopify');
const email = require('./email');
const config = require('./config');
// One-click email approve/reject magic-links: HMAC over `action:id:exp` with a server
// secret, so the button in the notification email approves/rejects WITHOUT a login.
// Token is per-application AND per-action (an approve link can't be replayed as a
// reject) AND time-boxed. Format: `<exp-ms>.<sig>` where sig = HMAC(action:id:exp)
// truncated to 32 hex (128-bit). The expiry is IN the signed payload, so it can't be
// extended without the secret; the verifier reads exp from the token and rejects it
// once past. Stateless — no per-token record needed.
//
// FAIL-CLOSED (contrarian critical, 2026-07-28): if APPROVE_LINK_SECRET is unset we
// return null (mint) / false (verify) rather than signing with an empty key — a token
// signed with '' is trivially forgeable. Callers treat null/false as "magic-links off".
// EXPIRY (contrarian LOW-MED, 2026-07-28): links die after APPROVE_LINK_TTL_HOURS (48h
// default) so a forwarded/leaked, not-yet-clicked email can't be redeemed indefinitely.
function secretOk() { return typeof config.APPROVE_LINK_SECRET === 'string' && config.APPROVE_LINK_SECRET.length >= 16; }
function sign(id, action, exp) {
return crypto.createHmac('sha256', config.APPROVE_LINK_SECRET).update(`${action}:${id}:${exp}`).digest('hex').slice(0, 32);
}
function actionToken(id, action, ttlMs) {
if (!secretOk()) return null;
const exp = Date.now() + (ttlMs || config.APPROVE_LINK_TTL_HOURS * 3600 * 1000);
return `${exp}.${sign(id, action, exp)}`;
}
function verifyActionToken(id, action, token) {
if (!token || !secretOk()) return false;
const s = String(token);
const dot = s.indexOf('.');
if (dot < 1) return false; // malformed (old single-value format also fails → invalid)
const exp = Number(s.slice(0, dot));
const sig = s.slice(dot + 1);
if (!Number.isFinite(exp) || exp < Date.now()) return false; // malformed or EXPIRED
const expected = sign(id, action, exp);
const a = Buffer.from(expected), b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const DATA_DIR = path.join(__dirname, '..', 'data');
const APPS_PATH = path.join(DATA_DIR, 'trade-applications.jsonl');
function readAll() {
try {
return fs.readFileSync(APPS_PATH, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
} catch { return []; }
}
function rewriteAll(rows) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(APPS_PATH, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
}
function appendOne(row) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.appendFileSync(APPS_PATH, JSON.stringify(row) + '\n');
}
// Create a new pending application.
function apply(body) {
const now = new Date().toISOString();
const app = {
id: 'TRADE-' + now.slice(0, 10).replace(/-/g, '') + '-' + crypto.randomBytes(3).toString('hex'),
email: (body.email || '').trim().toLowerCase(),
business_name: body.business_name || '',
resale_cert: body.resale_cert || '',
phone: body.phone || '',
// carry any extra fields the form sent, minus the ones we already captured
extra: Object.fromEntries(Object.entries(body).filter(([k]) => !['email', 'business_name', 'resale_cert', 'phone'].includes(k))),
// shopify customer id if the caller knows it (webhook/customer-account link);
// otherwise null — approval logs a note that the id must be resolved at go-live.
shopify_customer_id: body.shopify_customer_id || null,
status: 'pending',
created_at: now,
decided_at: null,
decision: null,
assigned_rep: null,
};
appendOne(app);
return app;
}
function listPending() {
return readAll().filter(a => a.status === 'pending').sort((a, b) => a.created_at.localeCompare(b.created_at));
}
function get(id) {
return readAll().find(a => a.id === id) || null;
}
// APPROVE — runs the full fan-out, all writes dry-run-safe via lib/shopify.
async function approve(id) {
const rows = readAll();
const app = rows.find(a => a.id === id);
if (!app) return { ok: false, error: 'not found' };
if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };
const steps = [];
// (a) assign to the DW House Account (fixed — not round-robin)
const rep = reps.assignRep();
steps.push({ step: 'assign_rep', rep: { id: rep.id, name: rep.name, email: rep.email } });
// Resolve the Shopify customer id. Public-form applications carry no id, so look it
// up by email (read_customers scope). NEVER write against a placeholder id — if we
// can't resolve a real customer, HARD-FAIL the approve with a clear admin message so
// the customer is never left silently untagged (which would still charge them for
// samples). (Closes the original memo §5 "customer-id-by-email" TODO + Cody #4.)
let custId = app.shopify_customer_id;
let resolvedBy = custId ? 'application' : null;
if (!custId) {
custId = await shopify.findCustomerByEmail(app.email);
if (custId) resolvedBy = 'email_lookup';
}
if (!custId) {
return {
ok: false,
error: 'cannot_resolve_customer',
message: `No Shopify customer found for ${app.email}. The applicant must have a store account (signed up) before trade approval. Not writing anything.`,
email: app.email,
};
}
steps.push({ step: 'resolve_customer', customer: custId, via: resolvedBy });
// (b) tag `trade`
const tagRes = await shopify.addTags(custId, ['trade']);
steps.push({ step: 'tag_trade', customer: custId, result: summarizeShopify(tagRes) });
// (c) metafield custom.assigned_rep
const mfRes = await shopify.setCustomerMetafield(custId, {
namespace: 'custom', key: 'assigned_rep', value: `${rep.name} <${rep.email}>`, type: 'single_line_text_field',
});
steps.push({ step: 'set_metafield', customer: custId, result: summarizeShopify(mfRes) });
// (d) notify rep
const repTpl = email.repNotifyEmail({ repName: rep.name, applicant: app });
const repMail = await email.sendEmail({ to: rep.email, subject: repTpl.subject, html: repTpl.html, source: 'trade-rep-notify' });
steps.push({ step: 'email_rep', to: rep.email, subject: repTpl.subject, dryRun: repMail.dryRun || false });
// (e) email applicant approved
const appTpl = email.tradeApprovedEmail({ applicant: app, repName: rep.name });
const appMail = await email.sendEmail({ to: app.email, subject: appTpl.subject, html: appTpl.html, source: 'trade-approved' });
steps.push({ step: 'email_applicant', to: app.email, subject: appTpl.subject, dryRun: appMail.dryRun || false });
app.status = 'approved';
app.decision = 'approved';
app.decided_at = new Date().toISOString();
app.shopify_customer_id = custId; // persist the resolved id for audit
app.assigned_rep = { id: rep.id, name: rep.name, email: rep.email };
rewriteAll(rows);
return { ok: true, id, status: 'approved', rep: app.assigned_rep, steps };
}
async function reject(id) {
const rows = readAll();
const app = rows.find(a => a.id === id);
if (!app) return { ok: false, error: 'not found' };
if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };
const tpl = email.tradeRejectedEmail();
const mail = await email.sendEmail({ to: app.email, subject: tpl.subject, html: tpl.html, source: 'trade-rejected' });
app.status = 'rejected';
app.decision = 'rejected';
app.decided_at = new Date().toISOString();
rewriteAll(rows);
return { ok: true, id, status: 'rejected', email: { to: app.email, subject: tpl.subject, dryRun: mail.dryRun || false } };
}
function summarizeShopify(r) {
if (r.dryRun) return { WOULD: `${r.method} ${r.url}`, payload: r.body, noToken: r.noToken || false };
return { status: r.status, ok: r.ok };
}
module.exports = { apply, listPending, get, approve, reject, readAll, APPS_PATH, actionToken, verifyActionToken };