@aglyn/plugins-workflows 1.0.0-beta.222 → 1.0.0-beta.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +12 -12
  2. package/src/lib/components/automation-step-fields.component.d.ts +16 -2
  3. package/src/lib/components/automation-step-fields.component.js +53 -2
  4. package/src/lib/components/automation-step-fields.component.js.map +1 -1
  5. package/src/lib/components/automation-trigger-conditions.component.d.ts +23 -2
  6. package/src/lib/components/automation-trigger-conditions.component.js +89 -10
  7. package/src/lib/components/automation-trigger-conditions.component.js.map +1 -1
  8. package/src/lib/components/host-actions-card.component.js +64 -30
  9. package/src/lib/components/host-actions-card.component.js.map +1 -1
  10. package/src/lib/components/host-workflows-card.component.js +31 -10
  11. package/src/lib/components/host-workflows-card.component.js.map +1 -1
  12. package/src/lib/components/org-automation-editor.component.d.ts +1 -1
  13. package/src/lib/components/org-automation-editor.component.js +20 -11
  14. package/src/lib/components/org-automation-editor.component.js.map +1 -1
  15. package/src/lib/engine/email-merge.d.ts +69 -0
  16. package/src/lib/engine/email-merge.js +98 -0
  17. package/src/lib/engine/email-merge.js.map +1 -0
  18. package/src/lib/engine/run-event-actions.d.ts +30 -0
  19. package/src/lib/engine/run-event-actions.js +158 -54
  20. package/src/lib/engine/run-event-actions.js.map +1 -1
  21. package/src/lib/engine/run-event-workflows.js +44 -8
  22. package/src/lib/engine/run-event-workflows.js.map +1 -1
  23. package/src/lib/engine/run-org-automations.js +3 -1
  24. package/src/lib/engine/run-org-automations.js.map +1 -1
  25. package/src/lib/engine/triggered-docs.d.ts +39 -0
  26. package/src/lib/engine/triggered-docs.js +47 -0
  27. package/src/lib/engine/triggered-docs.js.map +1 -0
  28. package/src/lib/model/host-actions.d.ts +53 -0
  29. package/src/lib/model/host-actions.js +50 -2
  30. package/src/lib/model/host-actions.js.map +1 -1
  31. package/src/lib/model/org-automations.d.ts +6 -4
  32. package/src/lib/model/org-automations.js +8 -5
  33. package/src/lib/model/org-automations.js.map +1 -1
  34. package/src/lib/server-automation-drafts.js +2 -1
  35. package/src/lib/server-automation-drafts.js.map +1 -1
  36. package/src/lib/testing/stand-in-crm-steps.js +1 -1
  37. package/src/lib/testing/stand-in-crm-steps.js.map +1 -1
  38. package/src/lib/testing/stand-in-recipes.d.ts +3 -2
  39. package/src/lib/testing/stand-in-recipes.js +17 -12
  40. package/src/lib/testing/stand-in-recipes.js.map +1 -1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmMergeContext, type CrmMergeResult } from '@aglyn/aglyn/app-utils/crm-email-templates';
18
+ /**
19
+ * The person a step's email is about, as the payload names them when no
20
+ * record does: the `name` and `email` the event carried.
21
+ */
22
+ export interface EmailMergePerson {
23
+ name?: string | null;
24
+ email?: string | null;
25
+ }
26
+ /**
27
+ * A `sendEmail` step's subject or body with its merge tags filled from the
28
+ * contact or the lead the run is about (AGL-3458), so a reply greets the
29
+ * person by name rather than as `{{firstName|there}}`.
30
+ *
31
+ * Both spellings a merchant has been shown resolve, because a workflow email
32
+ * is written by the same people who write the other two:
33
+ *
34
+ * - the CRM's fields, `{{contact.firstName}}`, `{{lead.company}}`,
35
+ * `{{site.name}}` — `resolveCrmMergeFields`, the one-to-one email's
36
+ * resolver, run over the record the run resolved;
37
+ * - the campaign's short tags, `{{firstName|there}}`, `{{name}}`,
38
+ * `{{email}}` — `resolveMergeTags`, the campaign sender's own resolver, run
39
+ * over the same person: the contact's name as this site knows it, else
40
+ * the lead's, else the name the event carried.
41
+ *
42
+ * Both honor `|fallback`, and an unknown tag prints its fallback or nothing,
43
+ * never its braces — a typo must not reach a customer as `{{frstName}}`.
44
+ * Pure; the caller hands over what it read.
45
+ */
46
+ export declare function resolveStepEmailMerge(text: string, context: CrmMergeContext, person?: EmailMergePerson | null): CrmMergeResult;
47
+ /**
48
+ * The records a step's email is about, for its merge tags: the person the
49
+ * event names, as the plugin that keeps people holds them — a contact, else
50
+ * the lead a lead-routed form filed — and the site's name, in the shape the
51
+ * one-to-one email's resolver takes (`resolveStepEmailMerge`).
52
+ *
53
+ * Asked through the person-records seam (`findPluginPerson`), narrowed to
54
+ * what this site may see, so the engine opens no record system's storage. A
55
+ * workspace with no record system, or a person nobody holds yet, leaves both
56
+ * records out, and the tags fall back to the name and address the event
57
+ * carried.
58
+ *
59
+ * **Never throws.** A lookup that fails is an email with its fallbacks, never
60
+ * an email that did not leave.
61
+ */
62
+ export declare function stepEmailMergeContext(input: {
63
+ hostId: string;
64
+ /** The address of the person the event is about. */
65
+ email: unknown;
66
+ /** The holder group whose facet names a contact: the sending site's. */
67
+ contactGroupId: string;
68
+ siteName: string;
69
+ }): Promise<CrmMergeContext>;
@@ -0,0 +1,98 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { resolveCrmMergeFields } from "@aglyn/aglyn/app-utils/crm-email-templates";
19
+ import { findPluginPerson } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
20
+ import { resolveMergeTags } from "@aglyn/shared-util-email/email-merge";
21
+ /** One CRM field's value, through the one-to-one email's own resolver. */ const field = (key, context)=>resolveCrmMergeFields(`{{${key}}}`, context).text.trim();
22
+ /**
23
+ * A `sendEmail` step's subject or body with its merge tags filled from the
24
+ * contact or the lead the run is about (AGL-3458), so a reply greets the
25
+ * person by name rather than as `{{firstName|there}}`.
26
+ *
27
+ * Both spellings a merchant has been shown resolve, because a workflow email
28
+ * is written by the same people who write the other two:
29
+ *
30
+ * - the CRM's fields, `{{contact.firstName}}`, `{{lead.company}}`,
31
+ * `{{site.name}}` — `resolveCrmMergeFields`, the one-to-one email's
32
+ * resolver, run over the record the run resolved;
33
+ * - the campaign's short tags, `{{firstName|there}}`, `{{name}}`,
34
+ * `{{email}}` — `resolveMergeTags`, the campaign sender's own resolver, run
35
+ * over the same person: the contact's name as this site knows it, else
36
+ * the lead's, else the name the event carried.
37
+ *
38
+ * Both honor `|fallback`, and an unknown tag prints its fallback or nothing,
39
+ * never its braces — a typo must not reach a customer as `{{frstName}}`.
40
+ * Pure; the caller hands over what it read.
41
+ */ export function resolveStepEmailMerge(text, context, person = null) {
42
+ var _normalizeContactEmail;
43
+ const crm = resolveCrmMergeFields(text, context);
44
+ const name = field('contact.name', context) || field('lead.name', context) || (typeof (person == null ? void 0 : person.name) === 'string' ? person.name.trim() : '');
45
+ const email = field('contact.email', context) || field('lead.email', context) || ((_normalizeContactEmail = normalizeContactEmail(person == null ? void 0 : person.email)) != null ? _normalizeContactEmail : '');
46
+ return {
47
+ text: resolveMergeTags(crm.text, _extends({
48
+ email
49
+ }, name ? {
50
+ name
51
+ } : {})),
52
+ unresolved: crm.unresolved
53
+ };
54
+ }
55
+ /**
56
+ * The records a step's email is about, for its merge tags: the person the
57
+ * event names, as the plugin that keeps people holds them — a contact, else
58
+ * the lead a lead-routed form filed — and the site's name, in the shape the
59
+ * one-to-one email's resolver takes (`resolveStepEmailMerge`).
60
+ *
61
+ * Asked through the person-records seam (`findPluginPerson`), narrowed to
62
+ * what this site may see, so the engine opens no record system's storage. A
63
+ * workspace with no record system, or a person nobody holds yet, leaves both
64
+ * records out, and the tags fall back to the name and address the event
65
+ * carried.
66
+ *
67
+ * **Never throws.** A lookup that fails is an email with its fallbacks, never
68
+ * an email that did not leave.
69
+ */ export async function stepEmailMergeContext(input) {
70
+ const context = {
71
+ site: {
72
+ name: input.siteName
73
+ }
74
+ };
75
+ try {
76
+ const person = await findPluginPerson({
77
+ hostId: input.hostId,
78
+ email: input.email,
79
+ onlyVisibleToSite: true,
80
+ anyKind: true
81
+ });
82
+ const data = person ? _extends({}, person.data) : null;
83
+ if ((person == null ? void 0 : person.kind) === 'contact') {
84
+ return _extends({}, context, {
85
+ contact: data,
86
+ contactGroupId: input.contactGroupId
87
+ });
88
+ }
89
+ if ((person == null ? void 0 : person.kind) === 'lead') return _extends({}, context, {
90
+ lead: data
91
+ });
92
+ } catch (error) {
93
+ console.error('[workflow] email merge context could not be read', input.hostId, error);
94
+ }
95
+ return context;
96
+ }
97
+
98
+ //# sourceMappingURL=email-merge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/workflows/src/lib/engine/email-merge.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmMergeContext,\n type CrmMergeResult,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { findPluginPerson } from '@aglyn/aglyn/plugin-manager/plugin-person-records'\nimport { resolveMergeTags } from '@aglyn/shared-util-email/email-merge'\n\n/**\n * The person a step's email is about, as the payload names them when no\n * record does: the `name` and `email` the event carried.\n */\nexport interface EmailMergePerson {\n name?: string | null\n email?: string | null\n}\n\n/** One CRM field's value, through the one-to-one email's own resolver. */\nconst field = (key: string, context: CrmMergeContext): string =>\n resolveCrmMergeFields(`{{${key}}}`, context).text.trim()\n\n/**\n * A `sendEmail` step's subject or body with its merge tags filled from the\n * contact or the lead the run is about (AGL-3458), so a reply greets the\n * person by name rather than as `{{firstName|there}}`.\n *\n * Both spellings a merchant has been shown resolve, because a workflow email\n * is written by the same people who write the other two:\n *\n * - the CRM's fields, `{{contact.firstName}}`, `{{lead.company}}`,\n * `{{site.name}}` — `resolveCrmMergeFields`, the one-to-one email's\n * resolver, run over the record the run resolved;\n * - the campaign's short tags, `{{firstName|there}}`, `{{name}}`,\n * `{{email}}` — `resolveMergeTags`, the campaign sender's own resolver, run\n * over the same person: the contact's name as this site knows it, else\n * the lead's, else the name the event carried.\n *\n * Both honor `|fallback`, and an unknown tag prints its fallback or nothing,\n * never its braces — a typo must not reach a customer as `{{frstName}}`.\n * Pure; the caller hands over what it read.\n */\nexport function resolveStepEmailMerge(\n text: string,\n context: CrmMergeContext,\n person: EmailMergePerson | null = null,\n): CrmMergeResult {\n const crm = resolveCrmMergeFields(text, context)\n const name =\n field('contact.name', context) ||\n field('lead.name', context) ||\n (typeof person?.name === 'string' ? person.name.trim() : '')\n const email =\n field('contact.email', context) ||\n field('lead.email', context) ||\n (normalizeContactEmail(person?.email) ?? '')\n return {\n text: resolveMergeTags(crm.text, { email, ...(name ? { name } : {}) }),\n unresolved: crm.unresolved,\n }\n}\n\n/**\n * The records a step's email is about, for its merge tags: the person the\n * event names, as the plugin that keeps people holds them — a contact, else\n * the lead a lead-routed form filed — and the site's name, in the shape the\n * one-to-one email's resolver takes (`resolveStepEmailMerge`).\n *\n * Asked through the person-records seam (`findPluginPerson`), narrowed to\n * what this site may see, so the engine opens no record system's storage. A\n * workspace with no record system, or a person nobody holds yet, leaves both\n * records out, and the tags fall back to the name and address the event\n * carried.\n *\n * **Never throws.** A lookup that fails is an email with its fallbacks, never\n * an email that did not leave.\n */\nexport async function stepEmailMergeContext(input: {\n hostId: string\n /** The address of the person the event is about. */\n email: unknown\n /** The holder group whose facet names a contact: the sending site's. */\n contactGroupId: string\n siteName: string\n}): Promise<CrmMergeContext> {\n const context: CrmMergeContext = { site: { name: input.siteName } }\n try {\n const person = await findPluginPerson({\n hostId: input.hostId,\n email: input.email,\n onlyVisibleToSite: true,\n anyKind: true,\n })\n const data = person ? { ...person.data } : null\n if (person?.kind === 'contact') {\n return { ...context, contact: data, contactGroupId: input.contactGroupId }\n }\n if (person?.kind === 'lead') return { ...context, lead: data }\n } catch (error) {\n console.error('[workflow] email merge context could not be read', input.hostId, error)\n }\n return context\n}\n"],"names":["normalizeContactEmail","resolveCrmMergeFields","findPluginPerson","resolveMergeTags","field","key","context","text","trim","resolveStepEmailMerge","person","crm","name","email","unresolved","stepEmailMergeContext","input","site","siteName","hostId","onlyVisibleToSite","anyKind","data","kind","contact","contactGroupId","lead","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAAQ,kCAAiC;AACvE,SAGEC,qBAAqB,QAChB,6CAA4C;AACnD,SAASC,gBAAgB,QAAQ,oDAAmD;AACpF,SAASC,gBAAgB,QAAQ,uCAAsC;AAWvE,wEAAwE,GACxE,MAAMC,QAAQ,CAACC,KAAaC,UAC1BL,sBAAsB,CAAC,EAAE,EAAEI,IAAI,EAAE,CAAC,EAAEC,SAASC,IAAI,CAACC,IAAI;AAExD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,sBACdF,IAAY,EACZD,OAAwB,EACxBI,SAAkC,IAAI;QAUnCV;IARH,MAAMW,MAAMV,sBAAsBM,MAAMD;IACxC,MAAMM,OACJR,MAAM,gBAAgBE,YACtBF,MAAM,aAAaE,YAClB,CAAA,QAAOI,0BAAAA,OAAQE,IAAI,MAAK,WAAWF,OAAOE,IAAI,CAACJ,IAAI,KAAK,EAAC;IAC5D,MAAMK,QACJT,MAAM,iBAAiBE,YACvBF,MAAM,cAAcE,cACnBN,yBAAAA,sBAAsBU,0BAAAA,OAAQG,KAAK,aAAnCb,yBAAwC;IAC3C,OAAO;QACLO,MAAMJ,iBAAiBQ,IAAIJ,IAAI,EAAE;YAAEM;WAAWD,OAAO;YAAEA;QAAK,IAAI,CAAC;QACjEE,YAAYH,IAAIG,UAAU;IAC5B;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeC,sBAAsBC,KAO3C;IACC,MAAMV,UAA2B;QAAEW,MAAM;YAAEL,MAAMI,MAAME,QAAQ;QAAC;IAAE;IAClE,IAAI;QACF,MAAMR,SAAS,MAAMR,iBAAiB;YACpCiB,QAAQH,MAAMG,MAAM;YACpBN,OAAOG,MAAMH,KAAK;YAClBO,mBAAmB;YACnBC,SAAS;QACX;QACA,MAAMC,OAAOZ,SAAS,aAAKA,OAAOY,IAAI,IAAK;QAC3C,IAAIZ,CAAAA,0BAAAA,OAAQa,IAAI,MAAK,WAAW;YAC9B,OAAO,aAAKjB;gBAASkB,SAASF;gBAAMG,gBAAgBT,MAAMS,cAAc;;QAC1E;QACA,IAAIf,CAAAA,0BAAAA,OAAQa,IAAI,MAAK,QAAQ,OAAO,aAAKjB;YAASoB,MAAMJ;;IAC1D,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,oDAAoDX,MAAMG,MAAM,EAAEQ;IAClF;IACA,OAAOrB;AACT"}
@@ -20,6 +20,8 @@ import { type FlowEnrollment, type FlowSweepCursor, type FlowSweepResult } from
20
20
  import type { HostEventPayload } from '@aglyn/tenant-runtime/host-event-listeners';
21
21
  import { type AutomationWorkflow, type WorkflowStep } from './workflow-steps';
22
22
  import { type HostAction, type HostActionAlert, type HostActionStep } from '../model/host-actions';
23
+ /** The live, switched-on actions a site holds for `event`, in document-id order. */
24
+ export declare function liveActionsForEvent(hostRef: FirebaseFirestore.DocumentReference, event: string): Promise<FirebaseFirestore.QueryDocumentSnapshot[]>;
23
25
  export interface ActionRunEnv {
24
26
  hostId: string;
25
27
  hostRef: FirebaseFirestore.DocumentReference;
@@ -221,6 +223,34 @@ interface ExecuteWorkflowOptions {
221
223
  * both, once.
222
224
  */
223
225
  export declare function executeWorkflow(env: ActionRunEnv, run: AutomationRun, workflow: AutomationWorkflow, event: string, payload: HostEventPayload, options?: ExecuteWorkflowOptions): Promise<WorkflowExecution>;
226
+ /**
227
+ * Events too frequent to log a skip for.
228
+ *
229
+ * `runEventActions` fires on EVERY page view of every published site. A
230
+ * Firestore write per visitor per non-matching action is not a run
231
+ * history, it is an outage — and a page-view condition is one an author
232
+ * tunes by watching the site, not by reading a log. The events people
233
+ * actually debug are the server-emitted ones (a form submission, a
234
+ * booking, a sign-up), and those are low-volume by construction.
235
+ */
236
+ export declare const SKIP_LOG_EXCLUDED_EVENTS: ReadonlySet<string>;
237
+ /**
238
+ * Whether an action's unmet conditions only say it is ANOTHER FORM'S
239
+ * (AGL-3458) — the case that is noise rather than an answer.
240
+ *
241
+ * A site with one auto-reply per form keyed on `formId equals …` would write
242
+ * a `Skipped` row for every other form's reply on every submission: eight
243
+ * rows a fill on a nine-form site, burying the one row that explains a real
244
+ * miss. A form picked by id cannot be mistyped, so its mismatch against a
245
+ * submission that names a DIFFERENT form says only "not this form", and it
246
+ * is not recorded. Every other unmet condition still is: a hand-typed
247
+ * `formName`, a field condition, a submission that names no form at all.
248
+ *
249
+ * With `and`, one such clause is the whole answer; with `or`, only when every
250
+ * clause is about which form it was — `formId` or `formName` equals — since
251
+ * any other clause could have been the one that was meant to match.
252
+ */
253
+ export declare function conditionsNameAnotherForm(trigger: HostAction['trigger'] | null | undefined, payload: HostEventPayload): boolean;
224
254
  /**
225
255
  * Event-triggered action runner (AGL-148): loads enabled actions whose
226
256
  * `trigger.event` matches (built-in, site event, or custom), evaluates
@@ -14,11 +14,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { ACTION_MAX_STEPS, checkEntitlement, consentGroupForHost, planLabelGrantingFeature, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, flowSubscriptionTopicId, hostPublicOrigin, isClientActionStep, normalizeTriggerConditions, resolveOrgEntitlements } from "@aglyn/aglyn/server";
17
+ */ import { ACTION_MAX_STEPS, ACTIONS_MAX_PER_HOST, checkEntitlement, consentGroupForHost, planLabelGrantingFeature, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, flowSubscriptionTopicId, hostPublicOrigin, isClientActionStep, normalizeTriggerConditions, resolveOrgEntitlements } from "@aglyn/aglyn/server";
18
18
  import { isDeferrableSendResult, isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
19
- import { enrollListMember, firebaseAdmin, flowEmailRefusal, getOrgForHost, hostSendingIdentity, meterHostEmail, notifyHostManagers, consentGroupForSite, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
19
+ import { enrollListMember, filterSendableForHost, firebaseAdmin, flowEmailRefusal, getOrgForHost, hostDisplayName, hostSendingIdentity, meterHostEmail, notifyHostManagers, consentGroupForSite, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
20
20
  import { activitySearchTokens } from "@aglyn/aglyn/app-utils/activity-search";
21
21
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
22
+ import { triggerFilterProblem } from "@aglyn/aglyn/app-utils/site-interactions";
22
23
  // The person behind an address, and their filing under a campaign, through
23
24
  // the plugin that keeps people (AGL-3080): the engine opens none of its
24
25
  // collections.
@@ -35,6 +36,8 @@ import { ACTION_MAX_EVENT_DEPTH, runWorkflow, WORKFLOW_MAX_STEPS } from "../mode
35
36
  import { describeStepOutcome } from "../model/step-outcomes.js";
36
37
  import { WEBHOOK_URL_PATTERN } from "../model/webhooks.js";
37
38
  import { eventRunSuspension } from "./site-suspension.js";
39
+ import { triggeredDocsForEvent } from "./triggered-docs.js";
40
+ import { resolveStepEmailMerge, stepEmailMergeContext } from "./email-merge.js";
38
41
  import { recordRuns, runMonthKey, runsUsedThisMonth } from "./run-meter.js";
39
42
  import { advanceFlowEnrollment, claimFlowEnrollment, deferFlowEnrollment, endFlowEnrollment, enrollInFlow, findFlowEnrollmentsAwaiting, sweepDueFlowEnrollments } from "./flow-enrollments.js";
40
43
  import { resumeWorkflowEnrollment, runEventWorkflows } from "./run-event-workflows.js";
@@ -45,8 +48,19 @@ import { runTriggeredByFields } from "./run-trigger-actor.js";
45
48
  // By path, not the barrel: only a server run asks.
46
49
  import { pluginServerStepExecutor } from "@aglyn/aglyn/plugin-manager/plugin-server-steps";
47
50
  import { preparePluginRecordEmail } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
48
- import { FLOW_TIMED_OUT_FIELD, isFlowSuspendingStep } from "../model/host-actions.js";
49
- /** Bounded fan-out per event, mirroring the workflow runner. */ const MAX_TRIGGERED_ACTIONS = 10;
51
+ import { FLOW_TIMED_OUT_FIELD, isFlowSuspendingStep, sendEmailIsTransactionalReply } from "../model/host-actions.js";
52
+ /**
53
+ * Every live, switched-on action a site holds for an event runs, in
54
+ * document-id order (AGL-3458) — up to the most live actions a site may hold
55
+ * at all, so no matching action is ever left out. See `triggered-docs.ts`.
56
+ */ const MAX_TRIGGERED_ACTIONS = ACTIONS_MAX_PER_HOST;
57
+ /** The live, switched-on actions a site holds for `event`, in document-id order. */ export function liveActionsForEvent(hostRef, event) {
58
+ return triggeredDocsForEvent(hostRef.collection('actions'), event, {
59
+ keep: (doc)=>!doc.get('deletedAt') && doc.get('enabled') !== false,
60
+ max: MAX_TRIGGERED_ACTIONS,
61
+ maxReads: MAX_TRIGGERED_ACTIONS * 4
62
+ });
63
+ }
50
64
  /**
51
65
  * The run environment for one automation run: the org's plan gates resolved
52
66
  * once, from the org document the caller already read.
@@ -335,7 +349,7 @@ export function makeWorkflowContextLoader(hostRef) {
335
349
  link: `/${hostId}`
336
350
  }));
337
351
  } else if (step.type === 'sendEmail') {
338
- var _payload_, _ref4, _hostPublicOrigin, _step_topicId, _step_subject, _step_body, _ref5, _run_orgId, _env_org, _env_org1, _payload_contactId, _payload_email, _screened_releasedReviewId;
352
+ var _payload_, _ref4, _hostPublicOrigin, _env_org, _step_subject, _step_body, _payload_email, _ref5, _payload_name, _ref6, _run_orgId, _env_org1, _payload_contactId, _payload_email1, _screened_releasedReviewId;
339
353
  var _step_toField, _this;
340
354
  const to = String((_payload_ = payload[((_step_toField = step.toField) == null ? void 0 : _step_toField.trim()) || 'email']) != null ? _payload_ : '').trim();
341
355
  if (!isEmailConfigured()) return failed('email is not configured');
@@ -349,13 +363,14 @@ export function makeWorkflowContextLoader(hostRef) {
349
363
  const hostData = (_ref4 = (_this = await hostRef.get().catch(()=>null)) == null ? void 0 : _this.data()) != null ? _ref4 : null;
350
364
  const siteBase = (_hostPublicOrigin = hostPublicOrigin(hostData)) != null ? _hostPublicOrigin : '';
351
365
  /*
352
- * MARKETING. The subject and body are merchant-authored and the
353
- * recipient comes out of the event payload — which, for the collect
354
- * route, is a write triggered by an anonymous visitor. So this is a
355
- * site mailing an address on the merchant's say-so, and it owes what
356
- * every other such message owes: the unsubscribe header pair and a
357
- * visible link, both suppression lists, and a share of the ceiling on
358
- * how much one person receives from this site.
366
+ * MARKETING, unless it is a transactional reply (below). The subject
367
+ * and body are merchant-authored and the recipient comes out of the
368
+ * event payload — which, for the collect route, is a write triggered
369
+ * by an anonymous visitor. So a step that is not a reply is a site
370
+ * mailing an address on the merchant's say-so, and it owes what every
371
+ * other such message owes: the unsubscribe header pair and a visible
372
+ * link, both suppression lists, and a share of the ceiling on how much
373
+ * one person receives from this site.
359
374
  *
360
375
  * Priority stays transactional. An action run is not resumable — the
361
376
  * event has already happened and there is no beat that comes back for
@@ -393,27 +408,74 @@ export function makeWorkflowContextLoader(hostRef) {
393
408
  * enrollment moves on rather than retrying.
394
409
  */ const scope = enrollmentRef ? 'scheduled' : 'immediate';
395
410
  /*
411
+ * A TRANSACTIONAL REPLY (AGL-3458) — the answer to what the recipient
412
+ * just did: the run started on their own form submission, booking or
413
+ * sign-up, this step runs at once, names no topic, and goes to the
414
+ * address the event carries. Such a message is not a mailing, so it
415
+ * leaves with no unsubscribe header and no unsubscribe link, and asks
416
+ * no marketing-consent question — the reply the Inbox sends to a
417
+ * submission is the same kind of message, and CAN-SPAM exempts both.
418
+ * What it keeps is the suppression check below: a hard bounce, a spam
419
+ * complaint or a person who asked this site to stop is not mailed by
420
+ * anything. The author can switch a qualifying step back to a mailing
421
+ * (`transactional: false`); a step that does not qualify is a mailing
422
+ * whatever it says.
423
+ */ const transactional = sendEmailIsTransactionalReply(step, {
424
+ event,
425
+ afterWait: Boolean(enrollmentRef)
426
+ });
427
+ /*
396
428
  * The stream this message belongs to, resolved ONCE and read twice:
397
429
  * the gate below filters on it, and it rides the `marketing` context
398
430
  * so the opt-out link the seam mints names it. Without that the
399
431
  * preference page opens on a list of every stream the site has, and
400
432
  * the recipient has to find the one they were trying to leave.
401
433
  */ const topicId = flowSubscriptionTopicId(step.topicId, scope);
402
- const gate = await flowEmailRefusal({
434
+ const consentGroup = consentGroupForHost((_env_org = env.org) != null ? _env_org : null, hostId);
435
+ if (transactional) {
436
+ // Both suppression lists — the platform's bounces and complaints, and
437
+ // this site's (and its consent group's) opt-outs — fail CLOSED.
438
+ const sendable = await filterSendableForHost(hostId, [
439
+ to
440
+ ], undefined, consentGroup);
441
+ if (!sendable.length) return failed('the recipient is unsubscribed or suppressed');
442
+ } else {
443
+ var _step_topicId;
444
+ const gate = await flowEmailRefusal({
445
+ hostId,
446
+ email: to,
447
+ topicId: (_step_topicId = step.topicId) != null ? _step_topicId : null,
448
+ org: env.org,
449
+ scope
450
+ });
451
+ if (gate) {
452
+ return failed(gate !== 'consent-withheld' ? 'the recipient has left this email topic' : // only on a stated refusal, so reporting it as a missing
453
+ // record would send a merchant looking for a consent field to
454
+ // fill in that would change nothing.
455
+ enrollmentRef ? 'the recipient has no marketing consent record on this site' : 'the recipient has declined marketing from this site');
456
+ }
457
+ }
458
+ /*
459
+ * THE WORDS AS AUTHORED, and as sent (AGL-3458). The merge tags — the
460
+ * campaign's `{{firstName|there}}` and the CRM's `{{contact.firstName}}`
461
+ * — are filled from the contact or the lead the run is about, else from
462
+ * the name and address the event carried. The phishing screen below
463
+ * reads the AUTHORED words: it judges what the business wrote, once per
464
+ * automation, not one copy per recipient.
465
+ */ const authoredSubject = String((_step_subject = step.subject) != null ? _step_subject : '').slice(0, 200);
466
+ const authoredText = String((_step_body = step.body) != null ? _step_body : '').slice(0, 5000);
467
+ const mergeContext = await stepEmailMergeContext({
403
468
  hostId,
404
- email: to,
405
- topicId: (_step_topicId = step.topicId) != null ? _step_topicId : null,
406
- org: env.org,
407
- scope
469
+ email: String((_payload_email = payload['email']) != null ? _payload_email : '').trim() || to,
470
+ contactGroupId: consentGroup.groupId,
471
+ siteName: hostDisplayName(hostData != null ? hostData : undefined, hostId)
408
472
  });
409
- if (gate) {
410
- return failed(gate !== 'consent-withheld' ? 'the recipient has left this email topic' : // only on a stated refusal, so reporting it as a missing
411
- // record would send a merchant looking for a consent field to
412
- // fill in that would change nothing.
413
- enrollmentRef ? 'the recipient has no marketing consent record on this site' : 'the recipient has declined marketing from this site');
414
- }
415
- const emailSubject = String((_step_subject = step.subject) != null ? _step_subject : '').slice(0, 200);
416
- const emailText = String((_step_body = step.body) != null ? _step_body : '').slice(0, 5000);
473
+ const mergePerson = {
474
+ email: to,
475
+ name: String((_ref5 = (_payload_name = payload['name']) != null ? _payload_name : payload['fullName']) != null ? _ref5 : '').trim()
476
+ };
477
+ const emailSubject = resolveStepEmailMerge(authoredSubject, mergeContext, mergePerson).text.replace(/[\r\n]+/g, ' ').slice(0, 200);
478
+ const emailText = resolveStepEmailMerge(authoredText, mergeContext, mergePerson).text.slice(0, 5000);
417
479
  /*
418
480
  * THE PHISHING SCREEN, for a workspace in its first fortnight
419
481
  * (AGL-3356). The incident's second message was exactly this step: a
@@ -428,14 +490,14 @@ export function makeWorkflowContextLoader(hostRef) {
428
490
  * once released. See `outbound-send-review.ts`.
429
491
  */ const screened = await screenOutboundSend({
430
492
  kind: run.kind,
431
- path: run.kind === 'orgAutomation' ? `orgs/${(_ref5 = (_run_orgId = run.orgId) != null ? _run_orgId : env.orgId) != null ? _ref5 : ''}/automations/${run.id}` : `hosts/${hostId}/${run.kind === 'workflow' ? 'workflows' : 'actions'}/${run.id}`,
493
+ path: run.kind === 'orgAutomation' ? `orgs/${(_ref6 = (_run_orgId = run.orgId) != null ? _run_orgId : env.orgId) != null ? _ref6 : ''}/automations/${run.id}` : `hosts/${hostId}/${run.kind === 'workflow' ? 'workflows' : 'actions'}/${run.id}`,
432
494
  hostId,
433
495
  orgId: env.orgId,
434
- org: (_env_org = env.org) != null ? _env_org : null,
496
+ org: (_env_org1 = env.org) != null ? _env_org1 : null,
435
497
  host: hostData,
436
- subject: emailSubject,
498
+ subject: authoredSubject,
437
499
  bodies: [
438
- emailText
500
+ authoredText
439
501
  ]
440
502
  });
441
503
  if (screened.outcome === 'rejected') {
@@ -447,7 +509,6 @@ export function makeWorkflowContextLoader(hostRef) {
447
509
  };
448
510
  return failed('this email is held for staff review because it may impersonate ' + `another business (${screened.reference})`);
449
511
  }
450
- const consentGroup = consentGroupForHost((_env_org1 = env.org) != null ? _env_org1 : null, hostId);
451
512
  /*
452
513
  * THE TIMELINE ENTRY (AGL-2615). A message addressed to the person the
453
514
  * event is about is filed on that person's timeline — the same entry a
@@ -464,7 +525,7 @@ export function makeWorkflowContextLoader(hostRef) {
464
525
  to,
465
526
  link: _extends({}, String((_payload_contactId = payload['contactId']) != null ? _payload_contactId : '').trim() ? {
466
527
  contactId: String(payload['contactId']).trim()
467
- } : {}, String((_payload_email = payload['email']) != null ? _payload_email : '').trim() ? {
528
+ } : {}, String((_payload_email1 = payload['email']) != null ? _payload_email1 : '').trim() ? {
468
529
  email: String(payload['email']).trim()
469
530
  } : {}),
470
531
  org: env.org
@@ -495,13 +556,7 @@ export function makeWorkflowContextLoader(hostRef) {
495
556
  releasedReviewId: screened.outcome === 'send' ? (_screened_releasedReviewId = screened.releasedReviewId) != null ? _screened_releasedReviewId : null : null
496
557
  }, enrollmentRef ? {
497
558
  priority: 'bulk'
498
- } : {}, {
499
- // `topicId` is `''` for a step that belongs to no stream, which
500
- // every reader of it treats as absent — see `flowSubscriptionTopicId`.
501
- // The consent group comes off the org the run already holds, so the
502
- // gate honors an unsubscribe from any site of a declared group —
503
- // the sender this site mails as — and the org's confirmation switch,
504
- // at no extra read.
559
+ } : {}, transactional ? {} : {
505
560
  marketing: {
506
561
  hostId,
507
562
  siteBase,
@@ -544,15 +599,15 @@ export function makeWorkflowContextLoader(hostRef) {
544
599
  }
545
600
  if (sendError) return failed(sendError);
546
601
  } else if (step.type === 'enrollList') {
547
- var _payload_email1, _ref6;
602
+ var _payload_email2, _ref7;
548
603
  var _step_listId, _step_listName;
549
604
  const orgId = await resolveOrgIdForHost(hostId);
550
- const email = String((_payload_email1 = payload.email) != null ? _payload_email1 : '').trim().toLowerCase();
605
+ const email = String((_payload_email2 = payload.email) != null ? _payload_email2 : '').trim().toLowerCase();
551
606
  if (!orgId || !email || !email.includes('@')) {
552
607
  return failed('no email to enroll');
553
608
  }
554
609
  const listsRef = firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('lists');
555
- const listDoc = ((_step_listId = step.listId) == null ? void 0 : _step_listId.trim()) ? await listsRef.doc(step.listId.trim()).get() : (await listsRef.where('name', '==', (_ref6 = (_step_listName = step.listName) == null ? void 0 : _step_listName.trim()) != null ? _ref6 : '').limit(1).get()).docs[0];
610
+ const listDoc = ((_step_listId = step.listId) == null ? void 0 : _step_listId.trim()) ? await listsRef.doc(step.listId.trim()).get() : (await listsRef.where('name', '==', (_ref7 = (_step_listName = step.listName) == null ? void 0 : _step_listName.trim()) != null ? _ref7 : '').limit(1).get()).docs[0];
556
611
  if (!(listDoc == null ? void 0 : listDoc.exists)) {
557
612
  return failed(`unknown list "${step.listName || step.listId}"`);
558
613
  }
@@ -567,9 +622,9 @@ export function makeWorkflowContextLoader(hostRef) {
567
622
  source: `${run.kind}:${run.id}`
568
623
  });
569
624
  } else if (step.type === 'assignCampaign') {
570
- var _payload_email2, _env_orgId, _ref7, _ref8;
625
+ var _payload_email3, _env_orgId, _ref8, _ref9;
571
626
  var _step_campaignId, _step_campaignName;
572
- const email = String((_payload_email2 = payload.email) != null ? _payload_email2 : '').trim().toLowerCase();
627
+ const email = String((_payload_email3 = payload.email) != null ? _payload_email3 : '').trim().toLowerCase();
573
628
  if (!email || !email.includes('@')) {
574
629
  return failed('no contact email to assign');
575
630
  }
@@ -615,10 +670,10 @@ export function makeWorkflowContextLoader(hostRef) {
615
670
  // declaration says they are stored.
616
671
  const containerStore = firebaseAdmin.app().firestore();
617
672
  const usable = (container)=>Boolean((container == null ? void 0 : container.live) && visibleToHost(container.visibleTo, hostId));
618
- const namedId = (_ref7 = (_step_campaignId = step.campaignId) == null ? void 0 : _step_campaignId.trim()) != null ? _ref7 : '';
673
+ const namedId = (_ref8 = (_step_campaignId = step.campaignId) == null ? void 0 : _step_campaignId.trim()) != null ? _ref8 : '';
619
674
  const campaign = namedId ? (await readOrgContainers(containerStore, 'campaign', campaignOrgId, [
620
675
  namedId
621
- ]))[0] : (await findOrgContainersByName(containerStore, 'campaign', campaignOrgId, (_ref8 = (_step_campaignName = step.campaignName) == null ? void 0 : _step_campaignName.trim()) != null ? _ref8 : '', 10)).find(usable);
676
+ ]))[0] : (await findOrgContainersByName(containerStore, 'campaign', campaignOrgId, (_ref9 = (_step_campaignName = step.campaignName) == null ? void 0 : _step_campaignName.trim()) != null ? _ref9 : '', 10)).find(usable);
622
677
  if (!(campaign == null ? void 0 : campaign.exists)) {
623
678
  return failed(`unknown campaign "${campaignLabel}"`);
624
679
  }
@@ -979,9 +1034,45 @@ export function makeWorkflowContextLoader(hostRef) {
979
1034
  * tunes by watching the site, not by reading a log. The events people
980
1035
  * actually debug are the server-emitted ones (a form submission, a
981
1036
  * booking, a sign-up), and those are low-volume by construction.
982
- */ const SKIP_LOG_EXCLUDED_EVENTS = new Set([
1037
+ */ export const SKIP_LOG_EXCLUDED_EVENTS = new Set([
983
1038
  'pageView'
984
1039
  ]);
1040
+ /**
1041
+ * Whether an action's unmet conditions only say it is ANOTHER FORM'S
1042
+ * (AGL-3458) — the case that is noise rather than an answer.
1043
+ *
1044
+ * A site with one auto-reply per form keyed on `formId equals …` would write
1045
+ * a `Skipped` row for every other form's reply on every submission: eight
1046
+ * rows a fill on a nine-form site, burying the one row that explains a real
1047
+ * miss. A form picked by id cannot be mistyped, so its mismatch against a
1048
+ * submission that names a DIFFERENT form says only "not this form", and it
1049
+ * is not recorded. Every other unmet condition still is: a hand-typed
1050
+ * `formName`, a field condition, a submission that names no form at all.
1051
+ *
1052
+ * With `and`, one such clause is the whole answer; with `or`, only when every
1053
+ * clause is about which form it was — `formId` or `formName` equals — since
1054
+ * any other clause could have been the one that was meant to match.
1055
+ */ export function conditionsNameAnotherForm(trigger, payload) {
1056
+ var _payload_formId;
1057
+ const submitted = String((_payload_formId = payload['formId']) != null ? _payload_formId : '').trim();
1058
+ if (!submitted) return false;
1059
+ const clauses = normalizeTriggerConditions(trigger);
1060
+ const another = (clause)=>{
1061
+ var _clause_field, _clause_value, _clause_value1;
1062
+ return ((_clause_field = clause.field) == null ? void 0 : _clause_field.trim()) === 'formId' && clause.op === 'equals' && Boolean((_clause_value = clause.value) == null ? void 0 : _clause_value.trim()) && ((_clause_value1 = clause.value) == null ? void 0 : _clause_value1.trim()) !== submitted;
1063
+ };
1064
+ if ((trigger == null ? void 0 : trigger.combinator) === 'or') {
1065
+ return clauses.some(another) && clauses.every((clause)=>{
1066
+ var _ref;
1067
+ var _clause_field;
1068
+ return clause.op === 'equals' && [
1069
+ 'formId',
1070
+ 'formName'
1071
+ ].includes((_ref = (_clause_field = clause.field) == null ? void 0 : _clause_field.trim()) != null ? _ref : '');
1072
+ });
1073
+ }
1074
+ return clauses.some(another);
1075
+ }
985
1076
  /**
986
1077
  * Writes the `Skipped` row (AGL-2171): which condition stopped the run,
987
1078
  * so the answer to "why didn't it fire?" is in the same place as every
@@ -989,7 +1080,7 @@ export function makeWorkflowContextLoader(hostRef) {
989
1080
  *
990
1081
  * Never counts against `actionRunsPerMonth` — nothing executed, and
991
1082
  * charging for a condition that said no would be its own bug.
992
- */ async function recordSkippedRun(hostRef, actionId, action, event, kind = 'action') {
1083
+ */ async function recordSkippedRun(hostRef, actionId, action, event, kind = 'action', /** What stopped it, when it was not an unmet condition. */ because) {
993
1084
  var _action_name;
994
1085
  if (SKIP_LOG_EXCLUDED_EVENTS.has(event)) return;
995
1086
  // `normalizeTriggerConditions`, not `trigger.conditions` — a pre-AGL-565
@@ -999,7 +1090,7 @@ export function makeWorkflowContextLoader(hostRef) {
999
1090
  var _ref;
1000
1091
  return String((_ref = condition == null ? void 0 : condition.field) != null ? _ref : '').trim();
1001
1092
  }).filter(Boolean);
1002
- const reason = named.length ? `Condition on ${named.slice(0, 3).join(', ')} not met` : 'Trigger condition not met';
1093
+ const reason = because != null ? because : named.length ? `Condition on ${named.slice(0, 3).join(', ')} not met` : 'Trigger condition not met';
1003
1094
  await hostRef.collection('activity').add(_extends({
1004
1095
  actorId: null,
1005
1096
  actorEmail: null
@@ -1046,8 +1137,7 @@ export function makeWorkflowContextLoader(hostRef) {
1046
1137
  var _ref, _ref1;
1047
1138
  const firestore = firebaseAdmin.app().firestore();
1048
1139
  const hostRef = firestore.collection('hosts').doc(hostId);
1049
- const triggered = await hostRef.collection('actions').where('trigger.event', '==', event).limit(MAX_TRIGGERED_ACTIONS).get();
1050
- const actions = triggered.docs.filter((doc)=>!doc.get('deletedAt') && doc.get('enabled') !== false);
1140
+ const actions = await liveActionsForEvent(hostRef, event);
1051
1141
  /*
1052
1142
  * THE ORGANIZATION'S AUTOMATIONS placed on this site and not paused here.
1053
1143
  *
@@ -1123,12 +1213,17 @@ export function makeWorkflowContextLoader(hostRef) {
1123
1213
  const action = doc.data();
1124
1214
  const filter = (_action_trigger = action.trigger) == null ? void 0 : (_action_trigger_filter = _action_trigger.filter) == null ? void 0 : _action_trigger_filter.trim();
1125
1215
  if (filter) {
1216
+ const unrunnable = triggerFilterProblem(filter);
1217
+ if (unrunnable) {
1218
+ await recordSkippedRun(hostRef, doc.id, action, event, 'action', unrunnable);
1219
+ continue;
1220
+ }
1126
1221
  try {
1127
1222
  if (!evaluateExpression(filter, _extends({
1128
1223
  event
1129
1224
  }, payload))) continue;
1130
1225
  } catch (unused) {
1131
- continue; // A broken filter never fires.
1226
+ continue; // A field the filter names is not on this event.
1132
1227
  }
1133
1228
  }
1134
1229
  // Structured payload conditions (AGL-557; AND/OR chaining AGL-565):
@@ -1142,7 +1237,9 @@ export function makeWorkflowContextLoader(hostRef) {
1142
1237
  // common support question about automations — had no answer
1143
1238
  // anywhere in the product, while `/product/workflows` advertises
1144
1239
  // an amber `Skipped` row that answers it.
1145
- await recordSkippedRun(hostRef, doc.id, action, event);
1240
+ if (!conditionsNameAnotherForm(action.trigger, payload)) {
1241
+ await recordSkippedRun(hostRef, doc.id, action, event);
1242
+ }
1146
1243
  continue;
1147
1244
  }
1148
1245
  executed += 1;
@@ -1157,18 +1254,25 @@ export function makeWorkflowContextLoader(hostRef) {
1157
1254
  const automation = doc.data();
1158
1255
  const filter = (_automation_trigger = automation.trigger) == null ? void 0 : (_automation_trigger_filter = _automation_trigger.filter) == null ? void 0 : _automation_trigger_filter.trim();
1159
1256
  if (filter) {
1257
+ const unrunnable = triggerFilterProblem(filter);
1258
+ if (unrunnable) {
1259
+ await recordSkippedRun(hostRef, doc.id, automation, event, 'orgAutomation', unrunnable);
1260
+ continue;
1261
+ }
1160
1262
  try {
1161
1263
  if (!evaluateExpression(filter, _extends({
1162
1264
  event
1163
1265
  }, payload))) continue;
1164
1266
  } catch (unused) {
1165
- continue; // A broken filter never fires.
1267
+ continue; // A field the filter names is not on this event.
1166
1268
  }
1167
1269
  }
1168
1270
  if (!evaluateTriggerConditions(automation.trigger, _extends({
1169
1271
  event
1170
1272
  }, payload))) {
1171
- await recordSkippedRun(hostRef, doc.id, automation, event, 'orgAutomation');
1273
+ if (!conditionsNameAnotherForm(automation.trigger, payload)) {
1274
+ await recordSkippedRun(hostRef, doc.id, automation, event, 'orgAutomation');
1275
+ }
1172
1276
  continue;
1173
1277
  }
1174
1278
  executed += 1;