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>
679 lines
31 KiB
Markdown
679 lines
31 KiB
Markdown
# Flow Editor — Architecture & Integration Guide
|
||
|
||
> Authoritative reference for the Flow Editor subsystem: visual builder in the
|
||
> Ops PWA, JSON flow-definition language, runtime engine in `targo-hub`, and
|
||
> the Frappe scheduler that wakes delayed steps. Companion document to
|
||
> `ARCHITECTURE.md` and `DATA_AND_FLOWS.md`.
|
||
|
||
---
|
||
|
||
## 1. Goals & Principles
|
||
|
||
The Flow Editor lets operators compose automations — onboarding,
|
||
follow-ups, SLA escalations, billing side-effects — without writing code or
|
||
leaving the ERPNext/Ops perimeter. It behaves like n8n or Shopify Flow, but
|
||
is **natively embedded** in the Ops PWA and talks directly to ERPNext via the
|
||
`targo-hub`.
|
||
|
||
Design tenets:
|
||
|
||
1. **One UI, many domains.** A single `FlowEditor.vue` tree is parameterised
|
||
by a *kind catalog* (`PROJECT_KINDS`, `AGENT_KINDS`, …). Adding a new
|
||
catalog adds a new editor, no UI fork.
|
||
2. **Inline-editable from anywhere** (Odoo pattern). `FlowQuickButton` +
|
||
`useFlowEditor()` let any page pop the editor with preset filters
|
||
(category/applies-to/trigger) — the user never has to route to Settings
|
||
to create a flow.
|
||
3. **Pluggable runtime via a kind dispatcher.** `KIND_HANDLERS` in
|
||
`flow-runtime.js` is a plain map — no `if/else` chain. Adding a step
|
||
kind = write one async function.
|
||
4. **No code-as-config.** Predicates go through a pure evaluator
|
||
(`evalCondition`) — never `eval()` / `Function()`. Templates are rendered
|
||
with a regex (`{{a.b.c}}`), no Mustache lib.
|
||
5. **Persist decisions, not heartbeats.** Delayed steps become
|
||
`Flow Step Pending` rows with a `trigger_at`. The scheduler never
|
||
re-executes logic itself — it just nudges the Hub.
|
||
6. **Seeded templates are protected** (`is_system=1`). Operators can
|
||
duplicate them but not delete or rename.
|
||
|
||
---
|
||
|
||
## 2. File Inventory
|
||
|
||
### Front-end — `apps/ops/src/`
|
||
|
||
| Path | Responsibility |
|
||
|-------------------------------------------------------|-----------------------------------------------------------------|
|
||
| `components/flow-editor/FlowEditor.vue` | Vertical-tree view + drag-to-reorder, consumes a kind catalog |
|
||
| `components/flow-editor/FlowNode.vue` | Single step card (icon, label, menu, branch connectors) |
|
||
| `components/flow-editor/StepEditorModal.vue` | Modal that edits one step's payload (trigger + kind fields) |
|
||
| `components/flow-editor/FieldInput.vue` | Polymorphic field widget (text/number/select/datetime/…) |
|
||
| `components/flow-editor/FlowEditorDialog.vue` | Full-screen dialog for template-level edits (meta + tree) |
|
||
| `components/flow-editor/FlowTemplatesSection.vue` | Settings list: filter, duplicate, delete, toggle active |
|
||
| `components/flow-editor/FlowQuickButton.vue` | Drop-in "New flow / Edit flow" button |
|
||
| `components/flow-editor/kind-catalogs.js` | `PROJECT_KINDS`, `AGENT_KINDS`, `TRIGGER_TYPES`, `buildEmptyStep` |
|
||
| `components/flow-editor/index.js` | Barrel export |
|
||
| `composables/useFlowEditor.js` | Module-level singleton reactive store |
|
||
| `api/flow-templates.js` | REST client for `/flow/templates/*` |
|
||
|
||
### Back-end — `services/targo-hub/lib/`
|
||
|
||
| Path | Responsibility |
|
||
|------------------------|-------------------------------------------------------------------|
|
||
| `flow-templates.js` | REST API: CRUD + duplicate + validation |
|
||
| `flow-runtime.js` | Execution engine: kind handlers, wave loop, template render |
|
||
| `flow-api.js` | REST API: `/flow/start`, `/flow/advance`, `/flow/complete`, `/flow/event`, `/flow/runs` |
|
||
| `contracts.js` | Fires `on_contract_signed` on JWT confirm |
|
||
| `payments.js` | Fires `on_payment_received` on Stripe webhook |
|
||
| `acceptance.js` | Fires `on_quotation_accepted` on accept/confirm |
|
||
|
||
### ERPNext — `erpnext/`
|
||
|
||
| Path | Responsibility |
|
||
|------------------------------|-------------------------------------------------------------|
|
||
| `flow_scheduler.py` | Frappe cron hook (every minute) — claims due pending steps |
|
||
| `seed_flow_templates.py` | Seeds the 4 project templates + `residential_onboarding` + `quotation_follow_up` |
|
||
| `setup_flow_templates.py` | DocType creation (Flow Template / Flow Run / Flow Step Pending) |
|
||
|
||
---
|
||
|
||
## 3. Data Model (ERPNext doctypes)
|
||
|
||
Three custom doctypes live under `ERPNext`. All named by series
|
||
(`FT-NNNNN`, `FR-NNNNN`, `FSP-NNNNN`).
|
||
|
||
### 3.1 `Flow Template`
|
||
|
||
Authoritative definition of an automation. Edited via the Flow Editor.
|
||
|
||
| Field | Type | Notes |
|
||
|----------------------|----------|---------------------------------------------------------------|
|
||
| `template_name` | Data | Human label (unique within category) |
|
||
| `category` | Select | `residential` / `commercial` / `incident` / `billing` / `other` |
|
||
| `applies_to` | Link | Target DocType (Service Contract, Quotation, Issue, …) |
|
||
| `icon` | Data | Material icon name |
|
||
| `description` | Small Text | One-liner |
|
||
| `is_active` | Check | Only active templates dispatch on events |
|
||
| `is_system` | Check | Seeded by code — cannot be deleted; duplicating is allowed |
|
||
| `version` | Int | Bumped on every save (used for audit trail) |
|
||
| `trigger_event` | Select | `on_contract_signed`, `on_payment_received`, `on_subscription_active`, `on_quotation_created`, `on_quotation_accepted`, `on_issue_opened`, `on_customer_created`, `on_dispatch_completed`, `manual` |
|
||
| `trigger_condition` | Code | Optional JSONLogic-ish gate — today only `==` is wired |
|
||
| `flow_definition` | Long Text (JSON) | The whole graph — see §4 |
|
||
| `step_count` | Int | Computed on save for list display |
|
||
| `tags`, `notes` | Data / Text | Free-form metadata |
|
||
|
||
### 3.2 `Flow Run`
|
||
|
||
One execution of a template against a specific trigger doc.
|
||
|
||
| Field | Type | Notes |
|
||
|----------------------|---------------|--------------------------------------------------------|
|
||
| `flow_template` | Link | The FT we're executing |
|
||
| `template_version` | Int | Snapshot at start — template edits don't retro-affect |
|
||
| `status` | Select | `running` / `completed` / `failed` / `cancelled` |
|
||
| `trigger_event` | Data | What caused this run (for audit) |
|
||
| `context_doctype` | Data | Trigger doc's doctype |
|
||
| `context_docname` | Data | Trigger doc's name |
|
||
| `customer` | Link Customer | Resolved customer for template rendering |
|
||
| `variables` | Long Text (JSON) | Runtime bag passed by `dispatchEvent` (amount, signed_at, …) |
|
||
| `step_state` | Long Text (JSON) | `{ [stepId]: { status, started_at, completed_at, result, error } }` |
|
||
| `started_at`, `completed_at` | Datetime | Timestamps |
|
||
|
||
### 3.3 `Flow Step Pending`
|
||
|
||
One row per delayed step. The scheduler processes these.
|
||
|
||
| Field | Type | Notes |
|
||
|-------------------|--------------|---------------------------------------------------------|
|
||
| `flow_run` | Link | Parent run |
|
||
| `step_id` | Data | The step within `flow_definition.steps` |
|
||
| `trigger_at` | Datetime | When to fire (indexed — scheduler queries this) |
|
||
| `status` | Select | `pending` → `running` → `completed` or `failed` |
|
||
| `retry_count` | Int | Increments on scheduler failures (caps at 5) |
|
||
| `last_error` | Small Text | Truncated error from last attempt |
|
||
| `context_snapshot`| Long Text (JSON) | Step payload captured at schedule time (optional) |
|
||
| `executed_at` | Datetime | Set when scheduler successfully nudges the Hub |
|
||
|
||
---
|
||
|
||
## 4. Flow Definition JSON Schema
|
||
|
||
Stored as the `flow_definition` field of `Flow Template` and as `{step}` in
|
||
`Flow Step Pending.context_snapshot`.
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"trigger": { "event": "on_contract_signed", "condition": "" },
|
||
"variables": { "default_group": "Tech Targo" },
|
||
"steps": [ /* Step[] */ ]
|
||
}
|
||
```
|
||
|
||
### 4.1 Step shape
|
||
|
||
```json
|
||
{
|
||
"id": "step_abc123",
|
||
"kind": "dispatch_job",
|
||
"label": "Installation fibre",
|
||
"parent_id": null,
|
||
"branch": null,
|
||
"depends_on": [],
|
||
"trigger": { "type": "on_flow_start" },
|
||
"payload": {
|
||
"subject": "Installation {{customer.customer_name}}",
|
||
"job_type": "Installation",
|
||
"priority": "medium",
|
||
"duration_h": 2,
|
||
"assigned_group": "Tech Targo"
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Type | Notes |
|
||
|--------------|----------------|---------------------------------------------------------|
|
||
| `id` | string | Stable UID, generated client-side (`step_xxxxxx`) |
|
||
| `kind` | string | Must exist in `KIND_HANDLERS` |
|
||
| `label` | string | Free text shown in the editor + timeline |
|
||
| `parent_id` | `string\|null` | If set, this step is inside the parent's branch |
|
||
| `branch` | `string\|null` | Matches `parent.result.branch` (e.g. `"yes"` / `"no"`) |
|
||
| `depends_on` | `string[]` | Must all be `done` before this step becomes ready |
|
||
| `trigger` | object | When to fire — see §4.2 |
|
||
| `payload` | object | Kind-specific fields (see §4.3) |
|
||
|
||
### 4.2 Trigger types
|
||
|
||
| `trigger.type` | Extra fields | Semantics |
|
||
|---------------------|---------------------------|----------------------------------------------------------|
|
||
| `on_flow_start` | — | Fires in the very first wave of `advanceFlow` |
|
||
| `on_prev_complete` | — | Fires when all `depends_on` are `done` |
|
||
| `after_delay` | `delay_hours`, `delay_days` | Scheduled — creates a `Flow Step Pending` row |
|
||
| `on_date` | `at` (ISO) | Scheduled — creates a `Flow Step Pending` row |
|
||
| `on_webhook` | — | Stays `pending`, external POST to `/flow/complete` fires it |
|
||
| `manual` | — | Stays `pending`, user clicks a button to fire |
|
||
|
||
### 4.3 Payloads per kind
|
||
|
||
The authoritative list lives in `apps/ops/src/components/flow-editor/kind-catalogs.js`
|
||
(PROJECT_KINDS). The runtime map in `flow-runtime.js` must be kept in sync.
|
||
|
||
| Kind | Payload |
|
||
|--------------------------|----------------------------------------------------------------------|
|
||
| `dispatch_job` | `subject`, `job_type`, `priority`, `duration_h`, `assigned_group`, `on_open_webhook`, `on_close_webhook`, `merge_key` |
|
||
| `issue` | `subject`, `description`, `priority`, `issue_type` |
|
||
| `notify` | `channel` (sms/email), `to`, `template_id`, `subject`, `body` |
|
||
| `webhook` | `url`, `method`, `body_template` (JSON string) |
|
||
| `erp_update` | `doctype`, `docname_ref`, `fields_json` (JSON string) |
|
||
| `wait` | — (delay controlled by `trigger.type=after_delay`) |
|
||
| `condition` | `field`, `op`, `value` → exposes `result.branch = "yes"\|"no"` |
|
||
| `subscription_activate` | `subscription_ref` |
|
||
|
||
### 4.4 Template interpolation
|
||
|
||
Strings in `payload` support `{{a.b.c}}` dotted paths. The context is:
|
||
|
||
```js
|
||
{
|
||
doc, // the trigger doc (contract, quotation, issue)
|
||
customer, // resolved Customer doc
|
||
run, // the Flow Run (name, variables, step_state)
|
||
template, // the Flow Template (name, version)
|
||
variables, // run-level variable bag
|
||
now // current ISO datetime
|
||
}
|
||
```
|
||
|
||
Unknown paths render as empty string — rendering never throws.
|
||
|
||
---
|
||
|
||
## 5. Front-end Architecture
|
||
|
||
### 5.1 Component hierarchy
|
||
|
||
```
|
||
MainLayout.vue
|
||
└─ FlowEditorDialog.vue (mounted once, globally)
|
||
└─ FlowEditor.vue
|
||
└─ FlowNode.vue (xN)
|
||
└─ StepEditorModal.vue
|
||
└─ FieldInput.vue (xN)
|
||
|
||
Any page
|
||
└─ FlowQuickButton.vue (opens the global dialog)
|
||
|
||
SettingsPage.vue
|
||
└─ FlowTemplatesSection.vue (list/filter/toggle/duplicate)
|
||
```
|
||
|
||
### 5.2 `useFlowEditor()` — singleton reactive store
|
||
|
||
Module-level `ref`s shared across every call to `useFlowEditor()` — no
|
||
provide/inject, no Pinia store. Any component can call `openTemplate()` and
|
||
the globally-mounted `<FlowEditorDialog>` reacts automatically.
|
||
|
||
Read-only exposed refs: `isOpen`, `loading`, `saving`, `error`, `dirty`,
|
||
`mode`, `templateName`. Mutable: `template` (the draft body).
|
||
|
||
Actions:
|
||
|
||
| Method | Behaviour |
|
||
|----------------------------|-----------------------------------------------------------------|
|
||
| `openTemplate(name, opts)` | Fetches FT, populates `template`, `isOpen = true`, mode=`edit` |
|
||
| `openNew(opts)` | Blank draft with `category`/`applies_to`/`trigger_event` presets |
|
||
| `close(force?)` | Confirms unsaved changes unless `force=true` |
|
||
| `save()` | Create or patch — bumps version, invokes `onSaved` callbacks |
|
||
| `duplicate(newName)` | Clones current, loads the clone |
|
||
| `remove()` | Deletes current (forbidden on `is_system=1`) |
|
||
| `markDirty()` | Called on every field mutation — O(1), avoids deep-watch cost |
|
||
|
||
### 5.3 Pluggable kind catalogs
|
||
|
||
`FlowEditor` takes a `kindCatalog` prop. Two are shipped:
|
||
|
||
* `PROJECT_KINDS` — 8 kinds for service-delivery automations
|
||
* `AGENT_KINDS` — 6 kinds for conversational agent flows (preserves the old
|
||
`AgentFlowsPage` semantics for a later drop-in refactor)
|
||
|
||
Each catalog entry defines: `label`, `icon`, `color`, optional
|
||
`hasBranches`/`branchLabels`, and a `fields[]` array of field descriptors.
|
||
Descriptor shape is documented at the top of `kind-catalogs.js`. Adding a
|
||
field descriptor = the modal renders the right widget automatically.
|
||
|
||
### 5.4 `FlowQuickButton` — inline entry points
|
||
|
||
Drop-in button with two modes:
|
||
|
||
```vue
|
||
<!-- New flow, pre-filtered -->
|
||
<FlowQuickButton category="residential" applies-to="Service Contract" />
|
||
|
||
<!-- Edit an existing template -->
|
||
<FlowQuickButton template-name="FT-00005" label="Modifier le flow" />
|
||
```
|
||
|
||
Used in: `ProjectWizard.vue`, `TicketsPage.vue`, `IssueDetail.vue`,
|
||
`ClientDetailPage.vue` (Contrats de service header). Add it wherever a
|
||
contextual "spin up an automation from here" shortcut makes sense.
|
||
|
||
---
|
||
|
||
## 6. Runtime (`flow-runtime.js`)
|
||
|
||
### 6.1 Wave loop — `advanceFlow(runName)`
|
||
|
||
1. Fetch run + template + build context (`_buildContext`).
|
||
2. Loop up to 50 waves:
|
||
1. For each step, `isStepReady(step, state, def)`:
|
||
- all `depends_on` done?
|
||
- parent done + `branch` matches parent's result?
|
||
2. If ready:
|
||
- `after_delay`/`on_date` → schedule `Flow Step Pending`, state=`scheduled`
|
||
- `on_webhook`/`manual` → leave `pending`, caller resumes later
|
||
- else → inline execute via `KIND_HANDLERS[step.kind]`, mutate state
|
||
3. If no step progressed, break.
|
||
3. Compute run-level status: `completed` if all done, `failed` if any failed.
|
||
4. Persist `step_state` + patch once (single PUT).
|
||
|
||
The wave loop means a single `advanceFlow` call collapses an entire chain of
|
||
instant steps into one write — the scheduler's job is just to kick the first
|
||
domino after a delay.
|
||
|
||
### 6.2 Kind dispatcher
|
||
|
||
```js
|
||
const KIND_HANDLERS = {
|
||
dispatch_job, issue, notify, webhook, erp_update,
|
||
subscription_activate, wait, condition,
|
||
}
|
||
```
|
||
|
||
Every handler has the same signature:
|
||
|
||
```js
|
||
async function handleXxx(step, ctx) { … return { status, result?, error? } }
|
||
```
|
||
|
||
`status` is one of `STATUS.DONE`, `STATUS.FAILED`, or `STATUS.SCHEDULED`.
|
||
|
||
### 6.3 Condition evaluator
|
||
|
||
```js
|
||
evalCondition({ field, op, value }, ctx) // → boolean
|
||
```
|
||
|
||
Supported ops: `== != < > <= >= in not_in empty not_empty contains
|
||
starts_with ends_with`. No `eval`, no `Function`, no prototype access.
|
||
`condition` steps return `{ branch: ok ? 'yes' : 'no' }` so child steps can
|
||
filter on `branch`.
|
||
|
||
### 6.4 `dispatchEvent(eventName, opts)`
|
||
|
||
The public event hook used by `contracts.js`, `payments.js`,
|
||
`acceptance.js`, etc. It:
|
||
|
||
1. Queries `Flow Template` with `trigger_event = eventName AND is_active = 1`.
|
||
2. For each, evaluates `trigger_condition` (simple `==` JSON gate today).
|
||
3. Calls `startFlow` for matching templates.
|
||
|
||
Callers use it non-blocking (`.catch(()=>{})`) so automation failures never
|
||
break the primary flow (contract signature, payment, etc.).
|
||
|
||
### 6.5 Scheduler (`flow_scheduler.py`)
|
||
|
||
Runs every minute via Frappe `hooks.py`:
|
||
|
||
```python
|
||
scheduler_events = {
|
||
"cron": { "* * * * *": ["flow_scheduler.tick"] }
|
||
}
|
||
```
|
||
|
||
Algorithm (`_claim_due_rows` → `_fire_row`):
|
||
|
||
1. Select `Flow Step Pending` where `status='pending' AND trigger_at <= now()`
|
||
(limited to 50 per tick).
|
||
2. Atomically flip them to `status='running'` in one SQL UPDATE.
|
||
3. For each, POST `{run, step_id}` to `HUB_URL/flow/complete`.
|
||
4. On success: `status='completed'`, `executed_at=now()`.
|
||
5. On failure: increment `retry_count`, set `status='pending'` (or
|
||
`'failed'` after 5 retries), store truncated error in `last_error`.
|
||
|
||
The claim-then-fire split guarantees at-most-once execution even under
|
||
concurrent ticks. `retry_count` gives durable back-off without polling logic.
|
||
|
||
---
|
||
|
||
## 7. HTTP API
|
||
|
||
### 7.1 `/flow/templates` (mounted in `flow-templates.js`)
|
||
|
||
| Verb | Path | Body / Query | Response |
|
||
|------|--------------------------------|---------------------------|-----------------------|
|
||
| GET | `/flow/templates` | `?category=&applies_to=&is_active=&trigger_event=` | `{templates: FT[]}` |
|
||
| GET | `/flow/templates/:name` | — | `{template: FT}` |
|
||
| POST | `/flow/templates` | `{template_name, …}` | `{template}` |
|
||
| PUT | `/flow/templates/:name` | partial patch | `{template}` |
|
||
| DELETE | `/flow/templates/:name` | — | `{ok: true}` (blocks `is_system`) |
|
||
| POST | `/flow/templates/:name/duplicate` | `{new_name?}` | `{template}` |
|
||
|
||
Validation runs on every write: unique step IDs, known kinds, referential
|
||
integrity of `depends_on`/`parent_id`.
|
||
|
||
### 7.2 `/flow/*` (mounted in `flow-api.js`)
|
||
|
||
| Verb | Path | Body | Response |
|
||
|------|----------------------|---------------------------------------------------|-------------------------------|
|
||
| POST | `/flow/start` | `{template, doctype, docname, customer, variables}` | `{run, executed, scheduled}` |
|
||
| POST | `/flow/advance` | `{run}` | `{run, executed, scheduled}` |
|
||
| POST | `/flow/complete` | `{run, step_id, result?}` | `{run, …}` |
|
||
| POST | `/flow/event` | `{event, doctype, docname, customer, variables}` | `{results: [...]}` |
|
||
| GET | `/flow/runs` | `?status=&template=&customer=` | `{runs: FR[]}` |
|
||
| GET | `/flow/runs/:name` | — | `{run: FR}` |
|
||
|
||
All endpoints support optional `Authorization: Bearer $HUB_INTERNAL_TOKEN`
|
||
(controlled via `INTERNAL_TOKEN` env). Used by the Frappe scheduler.
|
||
|
||
---
|
||
|
||
## 8. Trigger Wiring
|
||
|
||
Event emitters are intentionally thin — two lines each, failure-tolerant.
|
||
|
||
### 8.1 `on_contract_signed` (in `contracts.js`)
|
||
|
||
Fired by the JWT accept flow (residential) on `POST /contract/confirm`:
|
||
|
||
```js
|
||
_fireFlowTrigger('on_contract_signed', {
|
||
doctype: 'Service Contract',
|
||
docname: contractName,
|
||
customer: payload.sub,
|
||
variables: { contract_type, signed_at },
|
||
})
|
||
```
|
||
|
||
`_fireFlowTrigger` lazy-requires `./flow-runtime` so the cost is paid only
|
||
when a trigger actually fires.
|
||
|
||
### 8.2 `on_payment_received` (in `payments.js`)
|
||
|
||
Fired from the Stripe `checkout.session.completed` webhook handler, after
|
||
the Sales Invoice is marked paid:
|
||
|
||
```js
|
||
require('./flow-runtime').dispatchEvent('on_payment_received', {
|
||
doctype: 'Sales Invoice',
|
||
docname: invoiceName,
|
||
customer,
|
||
variables: { amount, payment_intent: piId },
|
||
})
|
||
```
|
||
|
||
Wrapped in `try/catch` so a flow bug never blocks a payment.
|
||
|
||
### 8.3 `on_quotation_accepted` (in `acceptance.js`)
|
||
|
||
Fired by the commercial accept flow (DocuSeal/JWT) on `POST /accept/confirm`:
|
||
|
||
```js
|
||
require('./flow-runtime').dispatchEvent('on_quotation_accepted', {
|
||
doctype: 'Quotation',
|
||
docname: payload.doc,
|
||
customer: payload.sub,
|
||
variables: { accepted_at },
|
||
})
|
||
```
|
||
|
||
### 8.4 Future wiring
|
||
|
||
To hook another event (e.g. `on_ticket_resolved`):
|
||
|
||
1. Pick the source of truth (ERPNext Issue webhook, Ops inline save, etc.).
|
||
2. From that handler, call `require('./flow-runtime').dispatchEvent(...)`.
|
||
3. Add the event name to `TRIGGER_EVENT_OPTIONS` in
|
||
`FlowEditorDialog.vue` so operators can pick it from the UI.
|
||
4. (Optional) add a pre-seeded template in `seed_flow_templates.py`.
|
||
|
||
---
|
||
|
||
## 9. How-To Recipes
|
||
|
||
### 9.1 Add a new step kind
|
||
|
||
1. **Front-end** — in `kind-catalogs.js`, extend `PROJECT_KINDS` with a new
|
||
entry:
|
||
```js
|
||
send_portal_invite: {
|
||
label: 'Invitation portail',
|
||
icon: 'mail_lock',
|
||
color: '#0ea5e9',
|
||
fields: [
|
||
{ name: 'to', type: 'text', label: 'Email', required: true },
|
||
{ name: 'role', type: 'select', label: 'Rôle', options: ['customer', 'tech'] },
|
||
],
|
||
}
|
||
```
|
||
2. **Back-end** — in `flow-runtime.js`, implement the handler:
|
||
```js
|
||
async function handlePortalInvite (step, ctx) {
|
||
const p = renderDeep(step.payload || {}, ctx)
|
||
// … call the existing portal-invite endpoint …
|
||
return { status: STATUS.DONE, result: { … } }
|
||
}
|
||
```
|
||
3. Register it in `KIND_HANDLERS`:
|
||
```js
|
||
const KIND_HANDLERS = { …, send_portal_invite: handlePortalInvite }
|
||
```
|
||
4. Add the kind name to `flow-templates.js → validateFlowDefinition.validKinds`.
|
||
5. Rebuild + deploy Hub; rebuild + deploy Ops SPA.
|
||
|
||
No UI changes: the new kind renders its fields automatically.
|
||
|
||
### 9.2 Add a new trigger event
|
||
|
||
1. Emit it: `require('./flow-runtime').dispatchEvent('on_foo', {…})` from
|
||
wherever the event occurs.
|
||
2. Add the label in `FlowEditorDialog.vue` `TRIGGER_EVENT_OPTIONS`.
|
||
3. (Optional) pre-seed a template in `seed_flow_templates.py`.
|
||
|
||
### 9.3 Add a new kind catalog (e.g. for billing flows)
|
||
|
||
1. Export a new const from `kind-catalogs.js`: `export const BILLING_KINDS = {...}`.
|
||
2. Pass it into any `<FlowEditor :kind-catalog="BILLING_KINDS" />` instance.
|
||
3. Reuse the same `FlowEditorDialog` if the kinds live side by side; fork it
|
||
only if you need dramatically different metadata panels.
|
||
|
||
### 9.4 Add a trigger condition
|
||
|
||
Today `trigger_condition` supports a simple `{ "==": [{"var": "path"}, "value"] }`
|
||
JSON gate. To extend to full JSONLogic:
|
||
|
||
1. Replace `_matchCondition` in `flow-runtime.js` with the `json-logic-js`
|
||
library (already a stable dep elsewhere in Hub — check first).
|
||
2. Update `validateFlowDefinition` to reject unknown operators.
|
||
3. Document the new operators in this file.
|
||
|
||
---
|
||
|
||
## 10. Debugging Guide
|
||
|
||
### 10.1 Flow didn't run when expected
|
||
|
||
1. **Check the event was emitted.** `docker logs targo-hub | grep '\[flow\]'`.
|
||
You should see `[flow] started FR-XXXXX from FT-YYYYY`.
|
||
2. **Check the template is active + matches.** In Ops → Settings →
|
||
Flow Templates, confirm `is_active`, `trigger_event`, and
|
||
`trigger_condition` (if set) match the event.
|
||
3. **Inspect the Flow Run.** `GET /flow/runs/:name` returns full state.
|
||
`step_state[stepId].status = 'failed'` + `.error` pinpoints the cause.
|
||
|
||
### 10.2 Delayed step never fires
|
||
|
||
1. **Check the scheduler is ticking.** ERPNext backend logs:
|
||
`docker logs erpnext-backend-1 | grep flow_scheduler`. If silent,
|
||
confirm the cron entry in `hooks.py` and the Frappe scheduler worker is
|
||
up (`bench doctor`).
|
||
2. **Check the pending row.** ERPNext → Flow Step Pending list:
|
||
`status`, `trigger_at`, `retry_count`, `last_error`.
|
||
3. **Manually fire it:** `POST /flow/complete` with `{run, step_id}` from
|
||
any host with access to the Hub.
|
||
|
||
### 10.3 Template renders empty strings
|
||
|
||
`{{foo.bar}}` returned empty means the path is absent from the context. Add
|
||
a `console.log(ctx)` in the handler, or use a `condition` step upstream to
|
||
verify the data is populated before the branch that uses it. Remember that
|
||
`customer` is only loaded if `run.customer` is set *or* the trigger doc has
|
||
a `customer` field.
|
||
|
||
### 10.4 "Step has no handler" error
|
||
|
||
The kind name in the template no longer matches `KIND_HANDLERS`. Either a
|
||
deploy mismatch (front-end ahead of Hub) or a kind was removed. Revert the
|
||
Hub or update the Flow Template.
|
||
|
||
### 10.5 Retry loop stuck
|
||
|
||
`Flow Step Pending.retry_count` maxes at 5 then flips to `failed`. To retry
|
||
manually: set `status='pending'`, `retry_count=0` on the row, wait a minute.
|
||
Fix root cause before retrying — the scheduler won't back off past 5.
|
||
|
||
---
|
||
|
||
## 11. Seeded Templates
|
||
|
||
Living in `erpnext/seed_flow_templates.py`. All created with `is_system=1`.
|
||
|
||
| FT Name | Category | Trigger event | Purpose |
|
||
|----------------------|--------------|--------------------------|------------------------------------------------------------|
|
||
| fiber_install | residential | manual | 4-step install (dispatch install + notify + activate sub + 24h survey) |
|
||
| phone_service | residential | manual | Phone-only activation |
|
||
| move_service | residential | manual | Move existing service to new address |
|
||
| repair_service | incident | on_issue_opened | Triage + dispatch repair |
|
||
| residential_onboarding | residential | on_contract_signed | Install + welcome SMS + sub activate + 24h survey + 11-month renewal reminder |
|
||
| quotation_follow_up | commercial | on_quotation_accepted | 3-touch email sequence (D+3, D+7, D+14) |
|
||
|
||
Run the seeder once after deploying the scheduler:
|
||
|
||
```bash
|
||
docker exec -u frappe erpnext-backend-1 bash -c \
|
||
'cd /home/frappe/frappe-bench/sites && \
|
||
/home/frappe/frappe-bench/env/bin/python -c \
|
||
"import frappe; frappe.init(site=\"erp.gigafibre.ca\"); frappe.connect(); \
|
||
from seed_flow_templates import seed_all; seed_all()"'
|
||
```
|
||
|
||
---
|
||
|
||
## 12. Security & Governance
|
||
|
||
* **Seeded templates** (`is_system=1`) cannot be deleted — only duplicated.
|
||
This preserves the baseline automations even after operator mistakes.
|
||
* **Internal token**: all `/flow/*` endpoints honour
|
||
`Authorization: Bearer $HUB_INTERNAL_TOKEN`. Set it in both Hub and
|
||
ERPNext environment to lock down the API surface between containers.
|
||
* **No `eval`** anywhere: conditions run through a fixed predicate table,
|
||
templates through a regex. Adding arbitrary code to a flow is not a
|
||
supported feature — if a use case needs it, the answer is "add a new
|
||
kind".
|
||
* **Validation is mandatory** on every write: duplicate IDs, unknown kinds,
|
||
dangling `depends_on`, orphan `parent_id` all reject at the API.
|
||
* **Audit**: every Flow Run carries `template_version` so historical runs
|
||
stay traceable even after the template is edited.
|
||
|
||
---
|
||
|
||
## 13. Deployment Topology
|
||
|
||
```
|
||
ERPNext backend ──cron every 60s──► flow_scheduler.tick()
|
||
│
|
||
│ (INTERNAL_TOKEN)
|
||
▼
|
||
Ops PWA ───REST───► targo-hub ─PUT/POST─► ERPNext (Flow Template, Flow Run, Flow Step Pending)
|
||
FlowEditorDialog flow-api.js (+ Dispatch Job, Issue, Customer, Subscription, …)
|
||
flow-runtime.js
|
||
flow-templates.js
|
||
▲ │
|
||
└── dispatchEvent ◄──────┘
|
||
(contracts.js, payments.js, acceptance.js)
|
||
```
|
||
|
||
Deploy order (important):
|
||
|
||
1. ERPNext: create doctypes via `setup_flow_templates.py`, wire
|
||
`flow_scheduler.tick` in `hooks.py`, restart scheduler.
|
||
2. targo-hub: ship `flow-runtime.js`, `flow-api.js`, `flow-templates.js` +
|
||
the trigger emitters in `contracts.js` / `payments.js` / `acceptance.js`.
|
||
Restart container.
|
||
3. Ops SPA: build + deploy. The editor is usable instantly; it just can't
|
||
dispatch until the Hub is up.
|
||
4. Seed the templates (`seed_flow_templates.py`).
|
||
|
||
---
|
||
|
||
## 14. Glossary
|
||
|
||
- **Flow Template (FT)** — the definition, reusable, edited by operators.
|
||
- **Flow Run (FR)** — one execution against one trigger doc.
|
||
- **Flow Step Pending (FSP)** — one row per delayed step, the scheduler's inbox.
|
||
- **Kind** — a step type (`dispatch_job`, `notify`, …). Runtime-dispatched
|
||
via `KIND_HANDLERS`.
|
||
- **Catalog** — a set of kinds exposed in one editor instance
|
||
(`PROJECT_KINDS`, `AGENT_KINDS`).
|
||
- **Wave** — one iteration of `advanceFlow`'s inner loop. A flow typically
|
||
needs 1–3 waves; the 50 cap is a safety net against cycles.
|
||
- **Trigger event** — the incoming signal that kicks off flows
|
||
(`on_contract_signed`, …).
|
||
- **Trigger type** — the per-step firing rule inside a flow
|
||
(`on_prev_complete`, `after_delay`, …).
|
||
|
||
---
|
||
|
||
*Last updated: 2026-04-21. Owners: Targo Platform team
|
||
(<louis@targo.ca>). See `ROADMAP.md` for upcoming Flow Editor v2 items
|
||
(JSONLogic conditions, sub-flows, parallel branches, retry policies).*
|