gigafibre-fsm/services/targo-hub/lib/erp.js
louispaulb 01bb99857f refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.

Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
  field with "Field not permitted in query" — the field is dropped and the
  call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
  service_location_name, …) in one query per link field — no N+1, no
  v16 breakage.
- Consistent {ok, error, status} shape for mutations.

Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
  Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper

Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.

Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:27 -04:00

222 lines
9.9 KiB
JavaScript

'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Structured Frappe/ERPNext REST wrapper.
//
// Wraps helpers.erpFetch with:
// - URL building (encodeURIComponent + JSON.stringify of filters/fields goes away)
// - Consistent error shape ({ ok, status, data, error })
// - v16 "Field not permitted" soft-retry (strip blocked fields and try once more)
// - Batch label resolution (replace fetched-from links without violating v16)
//
// Pattern across the codebase before this:
// const fields = JSON.stringify(['name', 'customer_name'])
// const filters = JSON.stringify([['status', '=', 'open']])
// const r = await erpFetch(`/api/resource/Dispatch%20Job?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&limit_page_length=50`)
// const rows = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : []
//
// Pattern after this:
// const rows = await erp.list('Dispatch Job', {
// fields: ['name', 'customer_name'],
// filters: [['status', '=', 'open']],
// limit: 50,
// })
// ─────────────────────────────────────────────────────────────────────────────
const { erpFetch, log } = require('./helpers')
// URL-encode a doctype name for /api/resource/ — spaces become %20, etc.
function encDocType (dt) { return encodeURIComponent(dt) }
function encName (n) { return encodeURIComponent(n) }
// Build /api/resource/<Doctype>[?...] URL with list params
function listUrl (doctype, { fields, filters, orderBy, limit = 20, start = 0 } = {}) {
const q = []
if (fields && fields.length) q.push('fields=' + encodeURIComponent(JSON.stringify(fields)))
if (filters && filters.length) q.push('filters=' + encodeURIComponent(JSON.stringify(filters)))
if (orderBy) q.push('order_by=' + encodeURIComponent(orderBy))
if (limit != null) q.push('limit_page_length=' + limit)
if (start) q.push('limit_start=' + start)
return `/api/resource/${encDocType(doctype)}${q.length ? '?' + q.join('&') : ''}`
}
// Extract a human-readable message from Frappe error responses
function errorMessage (r) {
if (!r) return 'no response'
if (r.error) return r.error
const d = r.data
if (!d) return `HTTP ${r.status}`
return d.exception || d._error_message || d.message || d._server_messages || `HTTP ${r.status}`
}
// Detect the v16 "Field not permitted in query: <fieldname>" error.
// Returns the offending field name, or null.
function fieldNotPermitted (r) {
const msg = [r?.data?.exception, r?.data?._error_message, r?.data?._server_messages]
.filter(Boolean).join(' ')
const m = msg.match(/Field not permitted in query:\s*([a-zA-Z0-9_]+)/)
return m ? m[1] : null
}
// ═════════════════════════════════════════════════════════════════════════
// Core CRUD
// ═════════════════════════════════════════════════════════════════════════
async function get (doctype, name, { fields } = {}) {
let url = `/api/resource/${encDocType(doctype)}/${encName(name)}`
if (fields && fields.length) url += `?fields=${encodeURIComponent(JSON.stringify(fields))}`
const r = await erpFetch(url)
if (r.status !== 200 || !r.data?.data) return null
return r.data.data
}
async function list (doctype, opts = {}) {
// Try the requested fields first; if v16 blocks one, retry without it.
let fields = opts.fields ? [...opts.fields] : null
const dropped = []
for (let attempt = 0; attempt < 5; attempt++) {
const url = listUrl(doctype, { ...opts, fields })
const r = await erpFetch(url)
if (r.status === 200 && Array.isArray(r.data?.data)) return r.data.data
const bad = fieldNotPermitted(r)
if (bad && fields && fields.includes(bad)) {
log(`erp.list ${doctype}: v16 blocks field "${bad}" — retrying without it`)
dropped.push(bad)
fields = fields.filter(f => f !== bad)
continue
}
log(`erp.list ${doctype} failed: ${errorMessage(r)}`)
return []
}
if (dropped.length) log(`erp.list ${doctype}: dropped ${dropped.join(',')} after retries`)
return []
}
async function listRaw (doctype, opts = {}) {
// Same as list() but returns { ok, rows, error } so callers that care about
// failures can distinguish them from "empty result".
let fields = opts.fields ? [...opts.fields] : null
for (let attempt = 0; attempt < 5; attempt++) {
const url = listUrl(doctype, { ...opts, fields })
const r = await erpFetch(url)
if (r.status === 200 && Array.isArray(r.data?.data)) return { ok: true, rows: r.data.data }
const bad = fieldNotPermitted(r)
if (bad && fields && fields.includes(bad)) { fields = fields.filter(f => f !== bad); continue }
return { ok: false, rows: [], error: errorMessage(r), status: r.status }
}
return { ok: false, rows: [], error: 'too many field drops' }
}
async function create (doctype, body) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}`, {
method: 'POST', body: JSON.stringify(body),
})
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
return { ok: true, data: r.data?.data, name: r.data?.data?.name }
}
async function update (doctype, name, body) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, {
method: 'PUT', body: JSON.stringify(body),
})
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
return { ok: true, data: r.data?.data }
}
async function remove (doctype, name) {
const r = await erpFetch(`/api/resource/${encDocType(doctype)}/${encName(name)}`, { method: 'DELETE' })
if (r.status >= 400) return { ok: false, error: errorMessage(r), status: r.status }
return { ok: true }
}
// ═════════════════════════════════════════════════════════════════════════
// Convenience helpers
// ═════════════════════════════════════════════════════════════════════════
// Fetch a batch of records by `name`, returning a { name: record } map.
// One query, no N+1. Used to resolve display labels after a list() that had
// link fields (e.g. customer → customer_name).
async function getMany (doctype, names, { fields } = {}) {
if (!names || !names.length) return {}
const ids = [...new Set(names.filter(Boolean))]
if (!ids.length) return {}
const rows = await list(doctype, {
filters: [['name', 'in', ids]],
fields: fields || ['name'],
limit: Math.max(ids.length, 50),
})
const map = {}
for (const r of rows) map[r.name] = r
return map
}
// Fill link-label fields on a list of records without tripping v16's
// "Field not permitted in query: <name>" for fetched fields.
//
// await erp.hydrateLabels(jobs, {
// customer: {
// doctype: 'Customer', out: 'customer_name',
// fields: ['name', 'customer_name'],
// },
// service_location: {
// doctype: 'Service Location', out: 'service_location_name',
// fields: ['name', 'address_line_1', 'city'],
// format: loc => [loc.address_line_1, loc.city].filter(Boolean).join(', ') || loc.name,
// },
// })
//
// Each config:
// linkField → doctype: doctype to look up
// out: field to write on the record (defaults to `<linkField>_name`)
// fields: fields to pull (must include 'name')
// format: fn(linkedRow) → string (defaults to row[fields[1]] || row.name)
async function hydrateLabels (records, configs) {
const linkFields = Object.keys(configs)
// 1. Collect unique ids per link field
const idsByField = {}
for (const lf of linkFields) {
idsByField[lf] = [...new Set(records.map(r => r[lf]).filter(Boolean))]
}
// 2. Fetch in parallel
const entries = await Promise.all(linkFields.map(async lf => {
const cfg = configs[lf]
if (!idsByField[lf].length) return [lf, {}]
const rows = await list(cfg.doctype, {
filters: [['name', 'in', idsByField[lf]]],
fields: cfg.fields || ['name'],
limit: Math.max(idsByField[lf].length, 50),
})
const fmt = cfg.format || (row => row[cfg.fields && cfg.fields[1]] || row.name)
const map = {}
for (const row of rows) map[row.name] = fmt(row)
return [lf, map]
}))
// 3. Write onto records
const mapsByField = Object.fromEntries(entries)
for (const r of records) {
for (const lf of linkFields) {
const out = configs[lf].out || (lf + '_name')
r[out] = mapsByField[lf][r[lf]] || r[lf] || ''
}
}
return records
}
// ═════════════════════════════════════════════════════════════════════════
// Escape hatch
// ═════════════════════════════════════════════════════════════════════════
// Raw passthrough for callers that need something outside this wrapper
// (e.g. custom Frappe method calls, reports, non-resource endpoints).
const raw = erpFetch
module.exports = {
get, list, listRaw, create, update, remove,
getMany, hydrateLabels,
raw,
// Useful internals if someone wants to build their own URL
listUrl, errorMessage, fieldNotPermitted,
}