gigafibre-fsm/services/targo-hub/lib/flow-api.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

127 lines
5.6 KiB
JavaScript

'use strict'
/**
* flow-api.js — HTTP endpoints for the flow runtime.
*
* POST /flow/start { template, doctype?, docname?, customer?, variables? }
* POST /flow/advance { run } — re-evaluate a run (idempotent)
* POST /flow/complete { run, step_id, result? } — external completion (scheduler / webhook / manual)
* POST /flow/event { event, ctx? } — dispatch a trigger event to all listening templates
* GET /flow/runs ?customer=…&template=…&status=…
* GET /flow/runs/:name — fetch a run (with step_state parsed)
*
* Security: internal endpoints (start/advance/complete/event) optionally require
* cfg.INTERNAL_TOKEN via `Authorization: Bearer …` so only the ERPNext scheduler
* and trusted services can launch flows.
*/
const { log, json, parseBody, erpFetch } = require('./helpers')
const runtime = require('./flow-runtime')
const cfg = require('./config')
const ENC_FR = encodeURIComponent('Flow Run')
// ── Auth guard (soft — allows open in dev when INTERNAL_TOKEN is unset) ────
function checkInternalAuth (req) {
if (!cfg.INTERNAL_TOKEN) return true
const a = req.headers.authorization || ''
return a === 'Bearer ' + cfg.INTERNAL_TOKEN
}
// ── Handlers ────────────────────────────────────────────────────────────────
async function startFlow (req, res) {
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
const body = await parseBody(req)
if (!body.template) return json(res, 400, { error: 'template required' })
try {
const result = await runtime.startFlow(body.template, {
doctype: body.doctype,
docname: body.docname,
customer: body.customer,
variables: body.variables || {},
triggerEvent: body.trigger_event || body.triggerEvent,
})
return json(res, 201, result)
} catch (e) {
log('[flow-api] start failed:', e.message)
return json(res, 500, { error: e.message })
}
}
async function advanceFlow (req, res) {
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
const body = await parseBody(req)
if (!body.run) return json(res, 400, { error: 'run required' })
try { return json(res, 200, await runtime.advanceFlow(body.run)) }
catch (e) { return json(res, 500, { error: e.message }) }
}
async function completeStep (req, res) {
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
const body = await parseBody(req)
if (!body.run || !body.step_id) return json(res, 400, { error: 'run and step_id required' })
try { return json(res, 200, await runtime.completeStep(body.run, body.step_id, body.result || {})) }
catch (e) { return json(res, 500, { error: e.message }) }
}
async function dispatchEvent (req, res) {
if (!checkInternalAuth(req)) return json(res, 401, { error: 'Unauthorized' })
const body = await parseBody(req)
if (!body.event) return json(res, 400, { error: 'event required' })
try {
const results = await runtime.dispatchEvent(body.event, body.ctx || {})
return json(res, 200, { event: body.event, started: results.length, results })
} catch (e) {
return json(res, 500, { error: e.message })
}
}
async function listRuns (req, res, urlObj) {
const p = Object.fromEntries(urlObj.searchParams)
const filters = []
if (p.customer) filters.push(['customer', '=', p.customer])
if (p.template) filters.push(['flow_template', '=', p.template])
if (p.status) filters.push(['status', '=', p.status])
if (p.doctype && p.docname) {
filters.push(['context_doctype', '=', p.doctype])
filters.push(['context_docname', '=', p.docname])
}
const qs = new URLSearchParams({
fields: JSON.stringify(['name', 'flow_template', 'status', 'customer', 'context_doctype', 'context_docname', 'started_at', 'completed_at', 'last_error']),
filters: JSON.stringify(filters),
limit_page_length: String(p.limit || 50),
order_by: 'started_at desc',
})
const r = await erpFetch(`/api/resource/${ENC_FR}?${qs}`)
if (r.status !== 200) return json(res, r.status, { error: 'Failed to list', detail: r.data })
return json(res, 200, { runs: r.data.data || [] })
}
async function getRun (req, res, name) {
const r = await erpFetch(`/api/resource/${ENC_FR}/${encodeURIComponent(name)}`)
if (r.status !== 200) return json(res, r.status, { error: 'Not found' })
const run = r.data.data
try { run.variables = JSON.parse(run.variables || '{}') } catch { /* noop */ }
try { run.step_state = JSON.parse(run.step_state || '{}') } catch { /* noop */ }
return json(res, 200, { run })
}
// ── Router ──────────────────────────────────────────────────────────────────
async function handle (req, res, method, urlPath, urlObj) {
if (urlPath === '/flow/start' && method === 'POST') return startFlow(req, res)
if (urlPath === '/flow/advance' && method === 'POST') return advanceFlow(req, res)
if (urlPath === '/flow/complete' && method === 'POST') return completeStep(req, res)
if (urlPath === '/flow/event' && method === 'POST') return dispatchEvent(req, res)
const runMatch = urlPath.match(/^\/flow\/runs\/([^/]+)$/)
if (runMatch && method === 'GET') return getRun(req, res, decodeURIComponent(runMatch[1]))
if (urlPath === '/flow/runs' && method === 'GET') return listRuns(req, res, urlObj)
return json(res, 404, { error: 'Flow endpoint not found' })
}
module.exports = { handle }