'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 }