gigafibre-fsm/services/targo-hub/lib/flow-templates.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

279 lines
11 KiB
JavaScript

'use strict'
/**
* flow-templates.js — Hub REST API for Flow Template doctype.
*
* Endpoints (mounted under /flow/templates):
* GET /flow/templates List (?category=&applies_to=&is_active=&trigger_event=)
* GET /flow/templates/:name Fetch one (flow_definition parsed)
* POST /flow/templates Create new template
* PUT /flow/templates/:name Update + bump version
* DELETE /flow/templates/:name Delete (blocked if is_system=1)
* POST /flow/templates/:name/duplicate Clone a template (new name)
*
* Flow definition JSON schema: see erpnext/seed_flow_templates.py header.
*/
const { log, json, parseBody, erpFetch } = require('./helpers')
const DOCTYPE = 'Flow Template'
const ENC_DOC = encodeURIComponent(DOCTYPE)
// Fields returned in list view (keep small — flow_definition is heavy)
const LIST_FIELDS = [
'name', 'template_name', 'category', 'applies_to',
'icon', 'description', 'is_active', 'is_system',
'version', 'step_count', 'trigger_event', 'trigger_condition',
'tags', 'modified',
]
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseFlowDefinition (raw) {
if (!raw) return { version: 1, trigger: { event: 'manual', condition: '' }, variables: {}, steps: [] }
if (typeof raw === 'object') return raw
try { return JSON.parse(raw) }
catch (e) { log('Flow Template: invalid flow_definition JSON:', e.message); return null }
}
function validateFlowDefinition (def) {
if (!def || typeof def !== 'object') return 'flow_definition must be an object'
if (!Array.isArray(def.steps)) return 'flow_definition.steps must be an array'
const ids = new Set()
for (const step of def.steps) {
if (!step.id || typeof step.id !== 'string') return `step without id`
if (ids.has(step.id)) return `duplicate step id: ${step.id}`
ids.add(step.id)
if (!step.kind) return `step ${step.id}: kind required`
const validKinds = ['dispatch_job', 'issue', 'notify', 'webhook', 'erp_update',
'wait', 'condition', 'subscription_activate']
if (!validKinds.includes(step.kind)) return `step ${step.id}: invalid kind "${step.kind}"`
if (step.depends_on && !Array.isArray(step.depends_on)) return `step ${step.id}: depends_on must be array`
}
// Referential integrity: depends_on / parent_id must point to existing step IDs
for (const step of def.steps) {
for (const dep of step.depends_on || []) {
if (!ids.has(dep)) return `step ${step.id}: depends_on references unknown step "${dep}"`
}
if (step.parent_id && !ids.has(step.parent_id)) {
return `step ${step.id}: parent_id references unknown step "${step.parent_id}"`
}
}
return null // OK
}
function serializeFlowDefinition (def) {
return JSON.stringify(def, null, 2)
}
function hydrate (row) {
if (!row) return null
const def = parseFlowDefinition(row.flow_definition)
return { ...row, flow_definition: def }
}
// ---------------------------------------------------------------------------
// Endpoint handlers
// ---------------------------------------------------------------------------
async function listTemplates (req, res, params) {
const filters = []
if (params.category) filters.push(['category', '=', params.category])
if (params.applies_to) filters.push(['applies_to', '=', params.applies_to])
if (params.trigger_event) filters.push(['trigger_event', '=', params.trigger_event])
if (params.is_active !== undefined) {
filters.push(['is_active', '=', params.is_active === '1' || params.is_active === 'true' ? 1 : 0])
}
if (params.q) filters.push(['template_name', 'like', `%${params.q}%`])
const qs = new URLSearchParams({
fields: JSON.stringify(LIST_FIELDS),
filters: JSON.stringify(filters),
limit_page_length: String(params.limit || 200),
order_by: 'template_name asc',
})
const r = await erpFetch(`/api/resource/${ENC_DOC}?${qs.toString()}`)
if (r.status !== 200) return json(res, r.status, { error: 'Failed to list', detail: r.data })
return json(res, 200, { templates: r.data.data || [] })
}
async function getTemplate (req, res, name) {
const r = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
if (r.status !== 200) return json(res, r.status, { error: 'Not found' })
return json(res, 200, { template: hydrate(r.data.data) })
}
async function createTemplate (req, res) {
const body = await parseBody(req)
if (!body.template_name) return json(res, 400, { error: 'template_name required' })
if (!body.flow_definition) return json(res, 400, { error: 'flow_definition required' })
const def = typeof body.flow_definition === 'string'
? parseFlowDefinition(body.flow_definition)
: body.flow_definition
const err = validateFlowDefinition(def)
if (err) return json(res, 400, { error: 'Invalid flow_definition', detail: err })
const payload = {
doctype: DOCTYPE,
template_name: body.template_name,
category: body.category || 'Custom',
applies_to: body.applies_to || 'Service Contract',
icon: body.icon || 'account_tree',
description: body.description || '',
trigger_event: body.trigger_event || 'manual',
trigger_condition: body.trigger_condition || '',
is_active: body.is_active === false ? 0 : 1,
is_system: 0, // API cannot create system templates
version: 1,
flow_definition: serializeFlowDefinition(def),
step_count: def.steps.length,
tags: body.tags || '',
notes: body.notes || '',
}
const r = await erpFetch(`/api/resource/${ENC_DOC}`, {
method: 'POST', body: JSON.stringify(payload),
})
if (r.status !== 200 && r.status !== 201) {
return json(res, r.status, { error: 'Create failed', detail: r.data })
}
log(`Flow Template created: ${r.data.data?.name} (${body.template_name})`)
return json(res, 201, { template: hydrate(r.data.data) })
}
async function updateTemplate (req, res, name) {
const body = await parseBody(req)
// Fetch current to check is_system + increment version
const cur = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
if (cur.status !== 200) return json(res, 404, { error: 'Not found' })
const current = cur.data.data
const patch = {}
// Always-editable fields
for (const f of ['template_name', 'category', 'applies_to', 'icon', 'description',
'trigger_event', 'trigger_condition', 'is_active', 'tags', 'notes']) {
if (body[f] !== undefined) patch[f] = body[f]
}
// Flow definition — validate
if (body.flow_definition !== undefined) {
const def = typeof body.flow_definition === 'string'
? parseFlowDefinition(body.flow_definition)
: body.flow_definition
const err = validateFlowDefinition(def)
if (err) return json(res, 400, { error: 'Invalid flow_definition', detail: err })
patch.flow_definition = serializeFlowDefinition(def)
patch.step_count = def.steps.length
}
// Bump version on any change
if (Object.keys(patch).length > 0) {
patch.version = (current.version || 0) + 1
}
const r = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`, {
method: 'PUT', body: JSON.stringify(patch),
})
if (r.status !== 200) return json(res, r.status, { error: 'Update failed', detail: r.data })
log(`Flow Template updated: ${name} -> v${patch.version}`)
return json(res, 200, { template: hydrate(r.data.data) })
}
async function deleteTemplate (req, res, name) {
// Check is_system
const cur = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
if (cur.status !== 200) return json(res, 404, { error: 'Not found' })
if (cur.data.data.is_system) {
return json(res, 403, {
error: 'System template cannot be deleted',
hint: 'Use is_active=0 to disable, or duplicate then modify the copy.',
})
}
const r = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (r.status !== 200 && r.status !== 202) {
return json(res, r.status, { error: 'Delete failed', detail: r.data })
}
log(`Flow Template deleted: ${name}`)
return json(res, 200, { ok: true })
}
async function duplicateTemplate (req, res, name) {
const body = await parseBody(req).catch(() => ({}))
const cur = await erpFetch(`/api/resource/${ENC_DOC}/${encodeURIComponent(name)}`)
if (cur.status !== 200) return json(res, 404, { error: 'Not found' })
const src = cur.data.data
const def = parseFlowDefinition(src.flow_definition)
const newName = body.template_name || `${src.template_name} (copie)`
const payload = {
doctype: DOCTYPE,
template_name: newName,
category: src.category,
applies_to: src.applies_to,
icon: src.icon,
description: src.description,
trigger_event: src.trigger_event,
trigger_condition: src.trigger_condition,
is_active: 0, // cloned = inactive until user reviews
is_system: 0,
version: 1,
flow_definition: serializeFlowDefinition(def),
step_count: def.steps?.length || 0,
tags: src.tags || '',
notes: `(Copie de ${src.template_name})${src.notes ? '\n' + src.notes : ''}`,
}
const r = await erpFetch(`/api/resource/${ENC_DOC}`, {
method: 'POST', body: JSON.stringify(payload),
})
if (r.status !== 200 && r.status !== 201) {
return json(res, r.status, { error: 'Duplicate failed', detail: r.data })
}
log(`Flow Template duplicated: ${name} -> ${r.data.data?.name}`)
return json(res, 201, { template: hydrate(r.data.data) })
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
async function handle (req, res, method, urlPath, urlObj) {
// /flow/templates/:name/duplicate
const duplicateMatch = urlPath.match(/^\/flow\/templates\/([^/]+)\/duplicate$/)
if (duplicateMatch && method === 'POST') {
return duplicateTemplate(req, res, decodeURIComponent(duplicateMatch[1]))
}
// /flow/templates/:name
const oneMatch = urlPath.match(/^\/flow\/templates\/([^/]+)$/)
if (oneMatch) {
const name = decodeURIComponent(oneMatch[1])
if (method === 'GET') return getTemplate(req, res, name)
if (method === 'PUT') return updateTemplate(req, res, name)
if (method === 'DELETE') return deleteTemplate(req, res, name)
}
// /flow/templates
if (urlPath === '/flow/templates') {
if (method === 'GET') {
const params = {}
if (urlObj && urlObj.searchParams) {
for (const [k, v] of urlObj.searchParams) params[k] = v
}
return listTemplates(req, res, params)
}
if (method === 'POST') return createTemplate(req, res)
}
return json(res, 404, { error: 'Flow template endpoint not found' })
}
module.exports = { handle, parseFlowDefinition, validateFlowDefinition }