fix(workflow): sendNotification skip-on-empty-toUserId + error precision + doc unification

- toUserId 解析为空时静默跳过 (succeeded, sentCount=0, skipped:true) 而非 failed
  → 支持'仅有重复订单时才通知'的条件通知编排
- 必填校验只列出实际缺失字段 (message),不再误报已解析的字段
- 文档统一变量格式: {{nodeId.path}} 为主,{{index.path}} 兼容
- spec 6/6,线上实测: 空收件人 skip + 非空正常发送均验证
This commit is contained in:
yeuimu
2026-08-17 10:11:25 +00:00
parent 146d62c80b
commit 80801346b5
3 changed files with 26 additions and 19 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ These differ from official teable and from the (stale) `docs/automation/triggers
2. **Read [trigger-types.md](api-reference/trigger-types.md) before configuring a trigger** — contains exact config keys per trigger; guessing leads to silent misfires. 2. **Read [trigger-types.md](api-reference/trigger-types.md) before configuring a trigger** — contains exact config keys per trigger; guessing leads to silent misfires.
3. **Build the full chain then activate** — `workflow create` → `trigger set` → `action add` → `activate`. Editing nodes after activation requires re-`activate` to snapshot. 3. **Build the full chain then activate** — `workflow create` → `trigger set` → `action add` → `activate`. Editing nodes after activation requires re-`activate` to snapshot.
4. **For `buttonClick`, ensure the field is a Button field bound to this workflow** — create via `field create --type button` with `options.workflow.id` (the `workflow` value is an **object** `{id, name?}`, NOT a bare `workflowId` string — matching the DB shape), or the field won't render/click. Note: field `options` captures a stale `isActive` snapshot at creation; the API validates the DB `isActive` at click time instead, so a deactivated workflow returns 400. 4. **For `buttonClick`, ensure the field is a Button field bound to this workflow** — create via `field create --type button` with `options.workflow.id` (the `workflow` value is an **object** `{id, name?}`, NOT a bare `workflowId` string — matching the DB shape), or the field won't render/click. Note: field `options` captures a stale `isActive` snapshot at creation; the API validates the DB `isActive` at click time instead, so a deactivated workflow returns 400.
5. **Read [action-types.md](api-reference/action-types.md) before writing scripts** — covers sandbox globals, variable references `{{nodeIndex.path}}`, and the `_accessToken`/`_apiBaseUrl` back-channel. 5. **Read [action-types.md](api-reference/action-types.md) before writing scripts** — covers sandbox globals, variable references `{{nodeId.path}}`, and the `_accessToken`/`_apiBaseUrl` back-channel.
## 5. Common Errors & Recovery ## 5. Common Errors & Recovery
@@ -132,7 +132,7 @@ inkreach-teable action add <wfId> --type sendEmail \
**Output:** `{ "sent": true, "to": "..." }` **Output:** `{ "sent": true, "to": "..." }`
Variable reference: `--body '{{1.record.fields.Name}}'` injects upstream data. Variable reference: `--body '{{n0.record.fields.Name}}'` injects upstream data.
--- ---
@@ -180,14 +180,16 @@ inkreach-teable action add <wfId> --type sendNotification \
"severity": "info" "severity": "info"
} }
``` ```
- `toUserId`: recipient user ID (required). Can reference the trigger user with `{{n0.user.id}}` (submitter/operator — only populated on user-initiated triggers; scheduled/webhook resolve to empty). - `toUserId`: recipient user ID. Can reference the trigger user with `{{n0.user.id}}` (submitter/operator — only populated on user-initiated triggers; scheduled/webhook resolve to empty).
- `title` + `message`: notification text; both support `{{nodeId.path}}` variables. If title set, rendered as `title\nmessage`. - `title` + `message`: notification text; both support `{{nodeId.path}}` variables (or `{{index.path}}` — both formats resolve identically). If title set, rendered as `title\nmessage`.
- `path` (optional): click-through URL in the notification center. - `path` (optional): click-through URL in the notification center.
- `severity`: `info` (default) | `warning` | `critical`. - `severity`: `info` (default) | `warning` | `critical`.
**Empty-value semantics**: `toUserId` is required, but if it resolves to an **empty string** the node **silently skips** — status `succeeded`, `sentCount: 0`, output `{ sentCount: 0, invalidUserIds: [], toUserId: '', skipped: true, skipReason: 'toUserId is empty' }`, and `sendCommonNotify` is NOT called. This enables conditional-notify patterns: a script action that returns an empty recipient implements "notify only when X". An empty `message` still fails the node with `Missing required fields: message`.
**IMPORTANT**: this is a **system in-app notification** (bell badge + notification center + realtime socket push). It calls `NotificationService.sendCommonNotify` with `type=system` and **no email config — no email is sent**. Prefer it over `sendEmail` unless an actual email is required. **IMPORTANT**: this is a **system in-app notification** (bell badge + notification center + realtime socket push). It calls `NotificationService.sendCommonNotify` with `type=system` and **no email config — no email is sent**. Prefer it over `sendEmail` unless an actual email is required.
**Output:** `{ "sentCount": 1, "invalidUserIds": [], "toUserId": "usr..." }` **Output:** `{ "sentCount": 1, "invalidUserIds": [], "toUserId": "usr..." }` (skipped case: see empty-value semantics above)
--- ---
@@ -196,7 +198,7 @@ inkreach-teable action add <wfId> --type sendNotification \
```bash ```bash
inkreach-teable action add <wfId> --type createRecord \ inkreach-teable action add <wfId> --type createRecord \
--target-table tblXXX \ --target-table tblXXX \
--records '[{"fields":{"Name":"New","Source":"{{1.record.id}}"}}]' --records '[{"fields":{"Name":"New","Source":"{{n0.record.id}}"}}]'
``` ```
**Config:** **Config:**
@@ -264,17 +266,20 @@ inkreach-teable action add <wfId> --type getRecords \
## Variable References ## Variable References
In any action's **string** config field, use `{{nodeIndex.path}}` to inject upstream output: In any action's **string** config field, use `{{nodeId.path}}` to inject upstream output. The positional `{{index.path}}` form is also accepted — both resolve identically:
| Reference | Resolves to | | Reference | Resolves to |
|-----------|-------------| |-----------|-------------|
| `{{1.record.id}}` | trigger record id (trigger = node 1) | | `{{n0.record.id}}` (≡ `{{1.record.id}}`) | trigger record id (trigger = node 1) |
| `{{1.record.fields.fldStatus}}` | trigger record's Status field | | `{{n0.record.fields.fldStatus}}` | trigger record's Status field |
| `{{2.result}}` | script action's return value (node 2) | | `{{n2.result}}` (≡ `{{2.result}}`) | script action's return value |
| `{{3.records}}` | getRecords output array (node 3) | | `{{n3.records}}` (≡ `{{3.records}}`) | getRecords output array |
| `{{2.statusCode}}` | httpRequest status (node 2) | | `{{n2.statusCode}}` (≡ `{{2.statusCode}}`) | httpRequest status |
**Node index** = topological execution order (trigger is always 1, then actions in chain order). - **`{{nodeId.path}}`** — node ID form (teable generates ids like `n0`, `n1`, `n2`…). Preferred for clarity.
- **`{{index.path}}`** — numeric form; **index = topological execution order** (trigger is always 1, then actions in chain order).
**Empty resolution**: when a referenced value is missing/undefined, the variable resolves to `''` (see sendNotification §4 for skip semantics).
Script actions can also read everything via `triggerInput` (the merged upstream output): Script actions can also read everything via `triggerInput` (the merged upstream output):
```js ```js
@@ -163,13 +163,15 @@ trigger or use the queue. Two caveats:
## Variable References (cross-node data) ## Variable References (cross-node data)
An action can reference upstream nodes' output in string fields via `{{nodeIndex.path}}`: An action can reference upstream nodes' output in string fields via `{{nodeId.path}}` (or the compatible `{{index.path}}` positional form):
- `{{1.record.id}}` — trigger record's id (trigger = node 1) - `{{n0.record.id}}` — trigger record's id (n0 = trigger node)
- `{{1.record.fields.Status}}` — trigger record's Status field - `{{n0.record.fields.Status}}` — trigger record's Status field
- `{{2.records}}` — output of the first action (node 2) - `{{2.result}}` — output of the first action (numeric index form; index = topological execution order, trigger is always 1)
Node index follows **topological execution order** (trigger is always 1). Script actions can also read the merged upstream output directly via `triggerInput`. Both forms resolve to the same value — `{{n0.record.id}}` ≡ `{{1.record.id}}`. Use `{{nodeId.path}}` in new workflows for clarity.
> **Empty-value semantics**: if a variable resolves to an empty string, the node sees the literal empty value. For `sendNotification`, an empty `toUserId` makes the node **silently skip** (status `succeeded`, `sentCount: 0`) instead of failing — so a script action that returns an empty recipient implements "notify only when X" conditional patterns. An empty `message` still fails with `Missing required fields: message`.
## Common Patterns ## Common Patterns
@@ -179,7 +181,7 @@ inkreach-teable workflow create --name "Status→email"
inkreach-teable trigger set wflXXX --type recordUpdated --table-id tblXXX --field-ids '["fldStatus"]' inkreach-teable trigger set wflXXX --type recordUpdated --table-id tblXXX --field-ids '["fldStatus"]'
inkreach-teable action add wflXXX --type sendEmail \ inkreach-teable action add wflXXX --type sendEmail \
--to ops@x.com --subject 'Status changed' \ --to ops@x.com --subject 'Status changed' \
--body '{{1.record.fields.Status}}' --body '{{n0.record.fields.Status}}'
inkreach-teable workflow activate wflXXX inkreach-teable workflow activate wflXXX
``` ```