Initial import of zappier-customer-service from zapier monorepo
This commit is contained in:
commit
b6ab4d8281
11 changed files with 547 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
24
README.md
Normal file
24
README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# zappier-customer-service
|
||||||
|
|
||||||
|
Department API for **customer-service credit additions** and **review** of credits, balances, usage, and payments. Internally uses **NATS** `verae.billing.*` (account-balance). HTTP fallback to account-balance then zappier-edge.
|
||||||
|
|
||||||
|
**Forgejo:** https://git.georgelambert.org/marchon/zappier-customer-service
|
||||||
|
**Catalog:** https://zapier.georgelambert.org/packages/zappier-customer-service/README.pdf
|
||||||
|
**Plugs into:** zappier-edge admin (`x-admin-key`) at `ZAPPIER_ADMIN_URL` (default `http://127.0.0.1:3000`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3011 ZAPPIER_ADMIN_URL=http://127.0.0.1:3000 node src/server.js
|
||||||
|
curl -X POST http://127.0.0.1:3011/credits -H 'content-type: application/json' \
|
||||||
|
-d '{"customerId":"cust_1","cents":500,"reason":"goodwill","agent":"cs-anna"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Route | Job |
|
||||||
|
|-------|-----|
|
||||||
|
| `GET /` | Staff review UI (typeahead names). `STAFF_AUTH=1` redirects to `verae-staff-session`. |
|
||||||
|
| `GET /customers` | `{ customers }` from zappier-edge |
|
||||||
|
| `GET /health` | `{ ok, role }` |
|
||||||
|
| `POST /credits` | Add/subtract prepaid cents (NATS `balance.adjust`) |
|
||||||
|
| `GET /credits?customerId=` | Ledger |
|
||||||
|
| `GET /review/:customerId` | Full statement (balance, credits, usage, payments) |
|
||||||
|
|
||||||
|
Talks to NATS `verae.billing.*` internally. Never talks to Zapier cloud.
|
||||||
46
package-lock.json
generated
Normal file
46
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"name": "zappier-customer-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "zappier-customer-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"nats": "^2.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nats": {
|
||||||
|
"version": "2.29.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
|
||||||
|
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
|
||||||
|
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"nkeys.js": "1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nkeys.js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "zappier-customer-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Customer-service credits and notes; writes prepaid balances on zappier-edge",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nats": "^2.28.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
157
public/index.html
Normal file
157
public/index.html
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Customer service — account review</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0;
|
||||||
|
--accent:#4f46e5; --accent-ink:#fff; --soft:#eef0fe; --danger:#dc2626; --ok:#047857;
|
||||||
|
--radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06);
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
|
||||||
|
.skip { position:absolute; left:-999px; }
|
||||||
|
.skip:focus { left:1rem; top:1rem; z-index:20; background:#fff; color:var(--accent); padding:.5rem .9rem; border-radius:8px; }
|
||||||
|
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.15rem 1.5rem 1.25rem; }
|
||||||
|
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
|
||||||
|
header.app h1 { margin:.2rem 0 .25rem; font-size:1.25rem; }
|
||||||
|
header.app p { margin:0; font-size:13px; opacity:.88; max-width:42rem; }
|
||||||
|
main { max-width:1080px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
|
||||||
|
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
|
||||||
|
.card h3 { margin:0 0 .75rem; font-size:.95rem; }
|
||||||
|
label { display:block; font-size:.72rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); margin:.7rem 0 .25rem; }
|
||||||
|
input { width:100%; max-width:22rem; padding:.5rem .65rem; border:1px solid var(--line); border-radius:8px; font-size:.95rem; }
|
||||||
|
input:focus-visible, button:focus-visible, a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||||
|
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
|
||||||
|
.row > div { min-width:10rem; }
|
||||||
|
button, a.btn {
|
||||||
|
border:0; border-radius:8px; padding:.5rem .9rem; font:650 13px system-ui; cursor:pointer;
|
||||||
|
background:var(--accent); color:#fff; text-decoration:none; display:inline-block;
|
||||||
|
}
|
||||||
|
button.ghost, a.btn.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
|
||||||
|
table { border-collapse:collapse; width:100%; }
|
||||||
|
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.45rem .55rem; }
|
||||||
|
td { border-bottom:1px solid var(--line); padding:.5rem .55rem; }
|
||||||
|
.money { font-variant-numeric:tabular-nums; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:.8rem; margin-bottom:1rem; }
|
||||||
|
.stat { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:.9rem 1.1rem; }
|
||||||
|
.stat .k { font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
|
||||||
|
.stat .v { font-size:1.35rem; font-weight:800; margin-top:.2rem; font-variant-numeric:tabular-nums; }
|
||||||
|
.empty { text-align:center; padding:1.5rem 1rem; color:var(--muted); }
|
||||||
|
.empty svg { display:block; margin:0 auto .6rem; }
|
||||||
|
.err { color:var(--danger); }
|
||||||
|
.who { font-weight:750; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
<header class="app">
|
||||||
|
<div class="kicker">staff · customer service</div>
|
||||||
|
<h1>Customer service</h1>
|
||||||
|
<p>Look up a customer by name or id. Review prepaid balance, credits, usage, and payments. Credit amounts are in dollars.</p>
|
||||||
|
</header>
|
||||||
|
<main id="main">
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="id">Customer</label>
|
||||||
|
<input id="id" list="cust-list" placeholder="Type a name or id" autocomplete="off" />
|
||||||
|
<datalist id="cust-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="review()">Review account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Apply credit</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="dollars">Amount (USD)</label>
|
||||||
|
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="reason">Reason</label>
|
||||||
|
<input id="reason" value="goodwill" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="agent">Agent name</label>
|
||||||
|
<input id="agent" value="cs" />
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="credit()">Apply credit</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin:.7rem 0 0;font-size:12px">Credits write to account-balance. The customer sees dollars, not cents.</p>
|
||||||
|
</div>
|
||||||
|
<div id="out"></div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
|
||||||
|
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
|
||||||
|
const empty = (title, hint) =>
|
||||||
|
`<div class="empty"><svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#eef0fe"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#4f46e5" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#4f46e5" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#4f46e5" opacity=".2"/></svg><div class="who">${title}</div><p>${hint}</p></div>`;
|
||||||
|
function moneyTable(list, cols, emptyTitle) {
|
||||||
|
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
|
||||||
|
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
|
||||||
|
const body = list.map((r) => '<tr>' + cols.map((c) => {
|
||||||
|
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
|
||||||
|
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
|
||||||
|
}).join('') + '</tr>').join('');
|
||||||
|
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||||
|
}
|
||||||
|
function paint(st, id) {
|
||||||
|
const name = st.name || st.customerName || id;
|
||||||
|
document.getElementById('out').innerHTML = `
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
|
||||||
|
<div class="stat"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
|
||||||
|
<div class="stat"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
|
||||||
|
<div class="stat"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
|
||||||
|
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
|
||||||
|
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
|
||||||
|
}
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const { customers } = await (await fetch('/customers')).json();
|
||||||
|
document.getElementById('cust-list').innerHTML = (customers || [])
|
||||||
|
.map((c) => `<option value="${c.name}">${c.id}</option>`)
|
||||||
|
.join('');
|
||||||
|
if (customers && customers[0] && !document.getElementById('id').value) {
|
||||||
|
document.getElementById('id').value = customers[0].name;
|
||||||
|
}
|
||||||
|
} catch { /* edge optional */ }
|
||||||
|
}
|
||||||
|
loadCustomers();
|
||||||
|
async function review() {
|
||||||
|
const id = document.getElementById('id').value.trim();
|
||||||
|
try {
|
||||||
|
const r = await fetch('/review/' + encodeURIComponent(id));
|
||||||
|
const st = await r.json();
|
||||||
|
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
|
||||||
|
paint(st, id);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function credit() {
|
||||||
|
const dollars = Number(document.getElementById('dollars').value);
|
||||||
|
const body = {
|
||||||
|
customerId: document.getElementById('id').value.trim(),
|
||||||
|
cents: Math.round(dollars * 100),
|
||||||
|
reason: document.getElementById('reason').value,
|
||||||
|
agent: document.getElementById('agent').value,
|
||||||
|
};
|
||||||
|
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
if (!r.ok) {
|
||||||
|
const st = await r.json().catch(() => ({}));
|
||||||
|
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await review();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
src/iam-gate.js
Normal file
35
src/iam-gate.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export function iamBase() {
|
||||||
|
return (process.env.STAFF_IAM_URL || '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function iamCheck(req, permission) {
|
||||||
|
const base = iamBase();
|
||||||
|
if (!base) {
|
||||||
|
if (process.env.STAFF_AUTH === '1') {
|
||||||
|
const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, '');
|
||||||
|
const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null);
|
||||||
|
return { ok: Boolean(r && r.ok) };
|
||||||
|
}
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
const q = permission ? `?permission=${encodeURIComponent(permission)}` : '';
|
||||||
|
const r = await fetch(`${base}/check${q}`, {
|
||||||
|
headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' },
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!r) return { ok: false, status: 502 };
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
return { ok: r.ok, status: r.status, ...body };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function denyOrRedirect(req, res, json, { permission, html }) {
|
||||||
|
const out = await iamCheck(req, permission);
|
||||||
|
if (out.ok) return out;
|
||||||
|
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
|
||||||
|
if (html) {
|
||||||
|
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });
|
||||||
|
res.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
37
src/names.js
Normal file
37
src/names.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/** Join ledger ids to zappier-edge customer display names. */
|
||||||
|
export async function listCustomers(edge, key) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||||
|
headers: { 'x-admin-key': key },
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return (body.customers || []).map(({ passwordHash, apiKey, totpSecret, ...rest }) => rest);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withCustomerName(st, idOrName, edge, key) {
|
||||||
|
const out = { ...(st || {}) };
|
||||||
|
if (out.name && out.customerId) return out;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${edge.replace(/\/$/, '')}/admin/api/customers`, {
|
||||||
|
headers: { 'x-admin-key': key },
|
||||||
|
});
|
||||||
|
const { customers } = await r.json();
|
||||||
|
const want = String(idOrName || out.customerId || '').toLowerCase();
|
||||||
|
const c = (customers || []).find(
|
||||||
|
(x) =>
|
||||||
|
x.id === idOrName ||
|
||||||
|
x.id === out.customerId ||
|
||||||
|
String(x.name || '').toLowerCase() === want,
|
||||||
|
);
|
||||||
|
if (c) {
|
||||||
|
out.name = c.name;
|
||||||
|
out.customerId = c.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* edge optional */
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
44
src/nats-billing.js
Normal file
44
src/nats-billing.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export const SUBJECTS = {
|
||||||
|
STATEMENT_GET: 'verae.billing.statement.get',
|
||||||
|
BALANCE_ADJUST: 'verae.billing.balance.adjust',
|
||||||
|
AUTHZ_CHECK: 'verae.access.authz.check',
|
||||||
|
};
|
||||||
|
const PLANE = 'staff';
|
||||||
|
|
||||||
|
async function authz(subject, payload) {
|
||||||
|
const http = process.env.AUTHZ_URL;
|
||||||
|
if (!http && !process.env.NATS_URL) return { allow: true, subject };
|
||||||
|
if (http) {
|
||||||
|
const r = await fetch(`${http.replace(/\/$/, '')}/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ plane: PLANE, subject, kind: payload?.kind, payload, principal: payload?.agent }),
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
return { allow: true, subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function billingRequest(subject, payload) {
|
||||||
|
const decision = await authz(subject, payload);
|
||||||
|
if (decision && decision.allow === false) {
|
||||||
|
const err = new Error(decision.reason || 'denied');
|
||||||
|
err.status = 403;
|
||||||
|
err.decision = decision;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const url = process.env.NATS_URL;
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const { connect, StringCodec } = await import('nats');
|
||||||
|
const nc = await connect({ servers: url.split(','), name: 'zappier-customer-service' });
|
||||||
|
const sc = StringCodec();
|
||||||
|
const m = await nc.request(subject, sc.encode(JSON.stringify({ ...payload, plane: PLANE })), { timeout: 2000 });
|
||||||
|
const out = JSON.parse(sc.decode(m.data) || '{}');
|
||||||
|
await nc.close();
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 403) throw err;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/server.js
Normal file
104
src/server.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* CS HTTP front door. Internally prefers NATS account-balance, then HTTP.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SUBJECTS, billingRequest } from './nats-billing.js';
|
||||||
|
import { listCustomers, withCustomerName } from './names.js';
|
||||||
|
import { staffPageHtml } from './staff-page.js';
|
||||||
|
import { denyOrRedirect } from './iam-gate.js';
|
||||||
|
|
||||||
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 3011);
|
||||||
|
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
|
||||||
|
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
||||||
|
const KEY = process.env.ZAPPIER_ADMIN_KEY || 'admin-dev-key';
|
||||||
|
|
||||||
|
async function statement(customerId) {
|
||||||
|
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId });
|
||||||
|
if (nats) return { status: 200, body: { ...nats, source: 'nats' } };
|
||||||
|
const r = await fetch(`${BOOKS}/statement/${customerId}`);
|
||||||
|
if (r.ok) return { status: r.status, body: { ...(await r.json()), source: 'account-balance' } };
|
||||||
|
const e = await fetch(`${EDGE}/admin/api/statement/${customerId}`, { headers: { 'x-admin-key': KEY } });
|
||||||
|
return { status: e.status, body: { ...(await e.json().catch(() => ({}))), source: 'edge' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
|
||||||
|
const json = (code, obj) => {
|
||||||
|
res.writeHead(code, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
||||||
|
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review', html: true }))) return;
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(
|
||||||
|
await staffPageHtml(
|
||||||
|
{
|
||||||
|
title: 'Customer service',
|
||||||
|
kicker: 'staff · customer service',
|
||||||
|
lede: 'Look up a customer by name. Review prepaid balance, credits, usage, and payments. Credit amounts are in dollars.',
|
||||||
|
},
|
||||||
|
path.join(PUBLIC, 'index.html'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/health') {
|
||||||
|
return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/customers') {
|
||||||
|
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
|
||||||
|
return json(200, { customers: await listCustomers(EDGE, KEY) });
|
||||||
|
}
|
||||||
|
const review = url.pathname.match(/^\/review\/([^/]+)$/);
|
||||||
|
if (req.method === 'GET' && review) {
|
||||||
|
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
|
||||||
|
const id = decodeURIComponent(review[1]);
|
||||||
|
const out = await statement(id);
|
||||||
|
out.body = await withCustomerName(out.body, id, EDGE, KEY);
|
||||||
|
return json(out.status, out.body);
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && url.pathname === '/credits') {
|
||||||
|
const who = await denyOrRedirect(req, res, json, { permission: 'cs.credit' });
|
||||||
|
if (!who) return;
|
||||||
|
const chunks = [];
|
||||||
|
for await (const c of req) chunks.push(c);
|
||||||
|
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||||
|
if (who.user?.username) payload.agent = who.user.username;
|
||||||
|
const nats = await billingRequest(SUBJECTS.BALANCE_ADJUST, { ...payload, kind: 'credit', principal: payload.agent });
|
||||||
|
if (nats) return json(200, { ...nats, source: 'nats' });
|
||||||
|
const r = await fetch(`${BOOKS}/adjust`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
await fetch(`${EDGE}/admin/api/credits`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-admin-key': KEY },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).catch(() => {});
|
||||||
|
return json(r.status, body);
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && url.pathname === '/credits') {
|
||||||
|
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
|
||||||
|
const id = url.searchParams.get('customerId');
|
||||||
|
if (!id) return json(400, { error: 'customerId required' });
|
||||||
|
const out = await statement(id);
|
||||||
|
return json(out.status, { credits: out.body.credits || [] });
|
||||||
|
}
|
||||||
|
json(404, { error: 'not found' });
|
||||||
|
} catch (err) {
|
||||||
|
json(err.status === 403 ? 403 : 502, { error: err.message, ...(err.decision || {}) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
process.stdout.write(`zappier-customer-service http://0.0.0.0:${PORT}/\n`);
|
||||||
|
});
|
||||||
16
src/staff-page.js
Normal file
16
src/staff-page.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
export async function staffPageHtml(opts, fallbackPath) {
|
||||||
|
const sibling = path.join(path.dirname(fallbackPath), '..', '..', 'verae-staff-ui', 'src', 'load.js');
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(sibling)) {
|
||||||
|
const { loadReviewHtml } = await import(pathToFileURL(sibling).href);
|
||||||
|
return loadReviewHtml(opts, fallbackPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* standalone clone */
|
||||||
|
}
|
||||||
|
return fs.readFileSync(fallbackPath, 'utf8');
|
||||||
|
}
|
||||||
68
test/health.test.js
Normal file
68
test/health.test.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(new URL('.', import.meta.url)));
|
||||||
|
|
||||||
|
test('customer-service health', async () => {
|
||||||
|
const port = 18011;
|
||||||
|
const child = spawn(process.execPath, ['src/server.js'], {
|
||||||
|
cwd: path.join(root),
|
||||||
|
env: { ...process.env, PORT: String(port) },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
try {
|
||||||
|
const r = await fetch(`http://127.0.0.1:${port}/health`);
|
||||||
|
const body = await r.json();
|
||||||
|
assert.equal(body.role, 'zappier-customer-service');
|
||||||
|
const home = await fetch(`http://127.0.0.1:${port}/`);
|
||||||
|
assert.equal(home.status, 200);
|
||||||
|
assert.match(await home.text(), /Customer service/);
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('customer-service review via account-balance HTTP', async () => {
|
||||||
|
const booksPort = 18014;
|
||||||
|
const csPort = 18015;
|
||||||
|
const booksFile = path.join(os.tmpdir(), `cs-books-${process.pid}.json`);
|
||||||
|
const books = spawn(process.execPath, ['src/server.js'], {
|
||||||
|
cwd: path.join(root, '..', 'zappier-account-balance'),
|
||||||
|
env: { ...process.env, PORT: String(booksPort), BOOKS_PATH: booksFile, NATS_URL: '' },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
const cs = spawn(process.execPath, ['src/server.js'], {
|
||||||
|
cwd: path.join(root),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(csPort),
|
||||||
|
ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`,
|
||||||
|
NATS_URL: '',
|
||||||
|
STAFF_IAM_URL: '',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
try {
|
||||||
|
const add = await fetch(`http://127.0.0.1:${csPort}/credits`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customerId: 'c-review', cents: 700, reason: 'goodwill', agent: 'cs' }),
|
||||||
|
});
|
||||||
|
assert.equal(add.status, 200);
|
||||||
|
const st = await (await fetch(`http://127.0.0.1:${csPort}/review/c-review`)).json();
|
||||||
|
assert.equal(st.prepaidCents, 700);
|
||||||
|
assert.equal(st.source, 'account-balance');
|
||||||
|
assert.equal(st.credits[0].reason, 'goodwill');
|
||||||
|
} finally {
|
||||||
|
cs.kill('SIGTERM');
|
||||||
|
books.kill('SIGTERM');
|
||||||
|
fs.rmSync(booksFile, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue