commit 3914b8443a3bb53050a54f9909a1de18ede79555 Author: George Lambert Date: Fri Sep 11 15:17:59 2026 -0400 Initial import of zappier-customer-service from zapier monorepo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd950c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8ee4e9 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# zappier-customer-service + +Department API for **customer-service credit additions** (goodwill, make-goods). Prepaid balances stay on **zappier-edge**; this service is the CS front door and audit copy. + +**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 /health` | `{ ok, role }` | +| `POST /credits` | Add/subtract prepaid cents on the customer | +| `GET /credits?customerId=` | Ledger | + +Does not talk to NATS or Zapier cloud. diff --git a/package.json b/package.json new file mode 100644 index 0000000..8b7fc1a --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "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" + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..301a543 --- /dev/null +++ b/src/server.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Customer-service department API: goodwill credits, CS notes. + * Source of truth for balances remains zappier-edge admin. + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; + +const PORT = Number(process.env.PORT || 3011); +const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); +const KEY = process.env.ZAPPIER_ADMIN_KEY || 'admin-dev-key'; +const STORE = process.env.CS_STORE || path.join(process.cwd(), 'data', 'credits.json'); + +function load() { + try { + return JSON.parse(fs.readFileSync(STORE, 'utf8')); + } catch { + return { credits: [] }; + } +} +function save(data) { + fs.mkdirSync(path.dirname(STORE), { recursive: true }); + fs.writeFileSync(STORE, JSON.stringify(data, null, 2)); +} + +async function edge(pathname, { method = 'GET', body } = {}) { + const r = await fetch(`${EDGE}${pathname}`, { + method, + headers: { 'content-type': 'application/json', 'x-admin-key': KEY }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await r.text(); + try { + return { status: r.status, body: JSON.parse(text) }; + } catch { + return { status: r.status, body: text }; + } +} + +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 === '/health') { + return json(200, { ok: true, role: 'zappier-customer-service' }); + } + if (req.method === 'POST' && url.pathname === '/credits') { + const chunks = []; + for await (const c of req) chunks.push(c); + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + const forwarded = await edge('/admin/api/credits', { method: 'POST', body: payload }); + const data = load(); + data.credits.unshift({ ...payload, at: new Date().toISOString(), edge: forwarded.status }); + save(data); + return json(forwarded.status, forwarded.body); + } + if (req.method === 'GET' && url.pathname === '/credits') { + const forwarded = await edge(`/admin/api/credits${url.search}`); + return json(forwarded.status, forwarded.body); + } + json(404, { error: 'not found' }); + } catch (err) { + json(502, { error: err.message }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`zappier-customer-service http://127.0.0.1:${PORT}/\n`); +}); diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..26586c0 --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +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'); + } finally { + child.kill('SIGTERM'); + } +});