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>
279 lines
11 KiB
JavaScript
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 }
|