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>
222 lines
9.9 KiB
JavaScript
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,
|
|
}
|