@aglyn/plugins-workflows 1.0.0-beta.143

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 (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -0
  3. package/package.json +53 -0
  4. package/src/index.d.ts +18 -0
  5. package/src/index.js +19 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/automation-step-fields.component.d.ts +74 -0
  8. package/src/lib/components/automation-step-fields.component.js +1210 -0
  9. package/src/lib/components/automation-step-fields.component.js.map +1 -0
  10. package/src/lib/components/host-actions-card.component.d.ts +15 -0
  11. package/src/lib/components/host-actions-card.component.js +1229 -0
  12. package/src/lib/components/host-actions-card.component.js.map +1 -0
  13. package/src/lib/components/host-activity-card.component.d.ts +84 -0
  14. package/src/lib/components/host-activity-card.component.js +280 -0
  15. package/src/lib/components/host-activity-card.component.js.map +1 -0
  16. package/src/lib/components/host-run-history-card.component.d.ts +40 -0
  17. package/src/lib/components/host-run-history-card.component.js +284 -0
  18. package/src/lib/components/host-run-history-card.component.js.map +1 -0
  19. package/src/lib/components/host-webhooks-card.component.d.ts +15 -0
  20. package/src/lib/components/host-webhooks-card.component.js +421 -0
  21. package/src/lib/components/host-webhooks-card.component.js.map +1 -0
  22. package/src/lib/components/host-workflows-card.component.d.ts +18 -0
  23. package/src/lib/components/host-workflows-card.component.js +952 -0
  24. package/src/lib/components/host-workflows-card.component.js.map +1 -0
  25. package/src/lib/components/run-quota-line.component.d.ts +27 -0
  26. package/src/lib/components/run-quota-line.component.js +72 -0
  27. package/src/lib/components/run-quota-line.component.js.map +1 -0
  28. package/src/lib/components/use-automation-step-pickers.d.ts +27 -0
  29. package/src/lib/components/use-automation-step-pickers.js +205 -0
  30. package/src/lib/components/use-automation-step-pickers.js.map +1 -0
  31. package/src/lib/components/workflow-zones.d.ts +36 -0
  32. package/src/lib/components/workflow-zones.js +19 -0
  33. package/src/lib/components/workflow-zones.js.map +1 -0
  34. package/src/lib/components/workflows-console-page.d.ts +25 -0
  35. package/src/lib/components/workflows-console-page.js +112 -0
  36. package/src/lib/components/workflows-console-page.js.map +1 -0
  37. package/src/lib/components/workflows-console-sections.d.ts +34 -0
  38. package/src/lib/components/workflows-console-sections.js +50 -0
  39. package/src/lib/components/workflows-console-sections.js.map +1 -0
  40. package/src/lib/constants/bundle-common.d.ts +8 -0
  41. package/src/lib/constants/bundle-common.js +9 -0
  42. package/src/lib/constants/bundle-common.js.map +1 -0
  43. package/src/lib/declarations.server.d.ts +41 -0
  44. package/src/lib/declarations.server.js +54 -0
  45. package/src/lib/declarations.server.js.map +1 -0
  46. package/src/lib/engine/crm-action-steps.d.ts +129 -0
  47. package/src/lib/engine/crm-action-steps.js +432 -0
  48. package/src/lib/engine/crm-action-steps.js.map +1 -0
  49. package/src/lib/engine/flow-enrollments.d.ts +264 -0
  50. package/src/lib/engine/flow-enrollments.js +386 -0
  51. package/src/lib/engine/flow-enrollments.js.map +1 -0
  52. package/src/lib/engine/run-event-actions.d.ts +232 -0
  53. package/src/lib/engine/run-event-actions.js +1392 -0
  54. package/src/lib/engine/run-event-actions.js.map +1 -0
  55. package/src/lib/engine/run-event-automations.d.ts +28 -0
  56. package/src/lib/engine/run-event-automations.js +37 -0
  57. package/src/lib/engine/run-event-automations.js.map +1 -0
  58. package/src/lib/engine/run-event-workflows.d.ts +75 -0
  59. package/src/lib/engine/run-event-workflows.js +311 -0
  60. package/src/lib/engine/run-event-workflows.js.map +1 -0
  61. package/src/lib/engine/workflow-steps.d.ts +107 -0
  62. package/src/lib/engine/workflow-steps.js +124 -0
  63. package/src/lib/engine/workflow-steps.js.map +1 -0
  64. package/src/lib/plugin.d.ts +33 -0
  65. package/src/lib/plugin.js +94 -0
  66. package/src/lib/plugin.js.map +1 -0
  67. package/src/lib/server-automation-drafts.d.ts +90 -0
  68. package/src/lib/server-automation-drafts.js +429 -0
  69. package/src/lib/server-automation-drafts.js.map +1 -0
  70. package/src/lib/server-console.d.ts +27 -0
  71. package/src/lib/server-console.js +35 -0
  72. package/src/lib/server-console.js.map +1 -0
  73. package/src/lib/server.d.ts +29 -0
  74. package/src/lib/server.js +298 -0
  75. package/src/lib/server.js.map +1 -0
@@ -0,0 +1,1392 @@
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 { ACTION_MAX_EVENT_DEPTH, ACTION_MAX_STEPS, checkEntitlement, planLabelGrantingFeature, checkQuota, WEBHOOK_URL_PATTERN, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, FLOW_TIMED_OUT_FIELD, flowEmailTopicId, hostPublicOrigin, isClientActionStep, isCrmActionStep, isFlowSuspendingStep, buildDatasetRecordValues, datasetDisplayName, contactCampaignFieldPath, datasetIntegrityFields, datasetIntegrityUpdate, describeStepOutcome, effectiveDatasetModel, normalizeTriggerConditions, resolveOrgEntitlements, runWorkflow, WORKFLOW_MAX_STEPS } from "@aglyn/aglyn/server";
18
+ import { isDeferrableSendResult, isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
19
+ import { dataStorageRefusal, enrollListMember, firebaseAdmin, flowEmailRefusal, getOrgForHost, hostSendingIdentity, meterHostEmail, notifyHostManagers, consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
20
+ import { announceDatasetRecordChange } from "@aglyn/tenant-data-admin/server/dataset-live-pages";
21
+ // The leaf, not the barrel: this library's specs substitute the barrel
22
+ // wholesale, and the lookup must reach the real index logic under them.
23
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
24
+ import { createHmac } from "crypto";
25
+ import { FieldValue } from "firebase-admin/firestore";
26
+ import { advanceFlowEnrollment, claimFlowEnrollment, deferFlowEnrollment, endFlowEnrollment, enrollInFlow, findFlowEnrollmentsAwaiting, sweepDueFlowEnrollments } from "./flow-enrollments.js";
27
+ import { logCrmEmailActivity, prepareCrmEmailActivity, runCrmActionStep } from "./crm-action-steps.js";
28
+ import { resolveDatasetDoc } from "@aglyn/tenant-runtime/resolve-dataset";
29
+ import { resumeWorkflowEnrollment, runEventWorkflows } from "./run-event-workflows.js";
30
+ import { isWorkflowActionStep, workflowActionStepRefusal, workflowHasActionSteps, workflowStepTypeLabel } from "./workflow-steps.js";
31
+ /** Bounded fan-out per event, mirroring the workflow runner. */ const MAX_TRIGGERED_ACTIONS = 10;
32
+ /**
33
+ * The run environment for one automation run: the org's plan gates resolved
34
+ * once, from the org document the caller already read.
35
+ */ export function automationRunEnv(input) {
36
+ var _ref, _input_alerts, _ref1, _input_loadWorkflowContext;
37
+ var _input_owner, _input_owner1;
38
+ const org = (_ref = (_input_owner = input.owner) == null ? void 0 : _input_owner.org) != null ? _ref : null;
39
+ return {
40
+ hostId: input.hostId,
41
+ hostRef: input.hostRef,
42
+ alerts: (_input_alerts = input.alerts) != null ? _input_alerts : [],
43
+ actionsAllowed: checkEntitlement(org, 'actions'),
44
+ webhooksAllowed: checkEntitlement(org, 'webhooks'),
45
+ crmAllowed: checkEntitlement(org, 'crm'),
46
+ depth: input.depth,
47
+ org,
48
+ orgId: (_ref1 = (_input_owner1 = input.owner) == null ? void 0 : _input_owner1.orgId) != null ? _ref1 : null,
49
+ loadWorkflowContext: (_input_loadWorkflowContext = input.loadWorkflowContext) != null ? _input_loadWorkflowContext : makeWorkflowContextLoader(input.hostRef)
50
+ };
51
+ }
52
+ export function makeWorkflowContextLoader(hostRef) {
53
+ let workflowContext = null;
54
+ return async ()=>{
55
+ if (workflowContext) return workflowContext;
56
+ const [functionDocs, variableDocs, workflowDocs] = await Promise.all([
57
+ hostRef.collection('functions').limit(100).get(),
58
+ hostRef.collection('variables').limit(100).get(),
59
+ hostRef.collection('workflows').limit(100).get()
60
+ ]);
61
+ workflowContext = workflowContextFromDocs(functionDocs.docs, variableDocs.docs, workflowDocs.docs);
62
+ return workflowContext;
63
+ };
64
+ }
65
+ /**
66
+ * The working set a run reads, from documents already in hand — for a door
67
+ * that read them itself and must not pay for them twice.
68
+ */ export function workflowContextFromDocs(functionDocs, variableDocs, workflowDocs) {
69
+ // Double-keyed by doc id AND name (AGL-261): id references are
70
+ // rename-safe; legacy name references keep resolving.
71
+ const byName = (docs)=>{
72
+ const map = {};
73
+ for (const doc of docs){
74
+ const data = doc.data();
75
+ if (data.deletedAt) continue;
76
+ map[doc.id] = data;
77
+ if (data == null ? void 0 : data.name) map[data.name] = data;
78
+ }
79
+ return map;
80
+ };
81
+ // A workflow is carried with its id so a step naming it by its legacy
82
+ // name still runs it as the automation it is (see `WorkflowContext`).
83
+ const workflows = {};
84
+ for (const doc of workflowDocs){
85
+ const data = doc.data();
86
+ if (data.deletedAt) continue;
87
+ const withId = _extends({}, data, {
88
+ $id: doc.id
89
+ });
90
+ workflows[doc.id] = withId;
91
+ if (data == null ? void 0 : data.name) workflows[data.name] = withId;
92
+ }
93
+ return {
94
+ functions: byName(functionDocs),
95
+ variables: byName(variableDocs),
96
+ workflows
97
+ };
98
+ }
99
+ /**
100
+ * Whether this dataset may take ANOTHER record, on the plan of the org that
101
+ * owns the site — the row band and the byte band, in that order. Null when
102
+ * the append may proceed; a reason string when it may not.
103
+ *
104
+ * ## Why an append needs a gate at all
105
+ *
106
+ * Every other door onto `datasets/{id}/records` already has one: the console
107
+ * route re-checks `recordsPerDataset` inside the creating transaction, the
108
+ * `/v1` record route checks it below its idempotency claim, and the public
109
+ * form-submission leg checks the rows and the bytes. A workflow step wrote
110
+ * with no check of either — and it is the door a visitor drives hardest,
111
+ * because an action fires per event on a published site. A cap enforced at
112
+ * three of four doors is not a cap; it is the shape of the one that is left.
113
+ *
114
+ * ## What it does NOT do
115
+ *
116
+ * It refuses the WRITE, never the dataset. A dataset already holding more
117
+ * rows than the plan includes keeps every row it has and keeps being read —
118
+ * nothing here deletes, truncates, or hides anything, and nothing may be
119
+ * added that does. What is refused is the next row, which is the same
120
+ * boundary the other three doors draw, and the reason a plan change cannot
121
+ * cost a customer data they already have.
122
+ *
123
+ * The update leg of `updateDataset` is deliberately NOT gated: merging fields
124
+ * into a record that already exists adds no row, so refusing it would refuse
125
+ * the state of being over rather than the raise.
126
+ *
127
+ * ## What it costs
128
+ *
129
+ * Nothing on the plans that sell the data store. The row count is read only
130
+ * when `recordsPerDataset` is FINITE, so an uncapped plan pays nothing; and
131
+ * `dataStorageRefusal` answers null with no read at all whenever the plan
132
+ * carries an `extraDataGbMonthlyUsd` rate, which every metered plan does. The
133
+ * reads are paid on the shapes that can actually refuse.
134
+ */ /**
135
+ * Refresh the live pages showing the dataset this step just wrote to
136
+ * (AGL-3113).
137
+ *
138
+ * An automation that appends a row is the same change to a visitor as a form
139
+ * submission or a console edit: the pages repeating over the dataset go on
140
+ * serving the rows they were built from. Announced from the step rather than
141
+ * from the run, so a workflow whose steps write two different datasets
142
+ * refreshes both — the announce coalesces repeats of the SAME dataset itself,
143
+ * which is what a run hitting one dataset several times needs.
144
+ *
145
+ * Silent without an org: datasets are org-scoped, so a host with no resolvable
146
+ * org has no dataset to have written to. Best effort, and never thrown: the
147
+ * row is already stored.
148
+ */ async function announceDatasetStepWrite(env, datasetId) {
149
+ if (!env.orgId) return;
150
+ await announceDatasetRecordChange({
151
+ firestore: firebaseAdmin.app().firestore(),
152
+ orgId: env.orgId,
153
+ datasetId
154
+ });
155
+ }
156
+ async function datasetAppendRefusal(env, datasetRef) {
157
+ const limit = resolveOrgEntitlements(env.org).recordsPerDataset;
158
+ if (Number.isFinite(limit)) {
159
+ const used = (await datasetRef.collection('records').count().get()).data().count;
160
+ if (!checkQuota(env.org, 'recordsPerDataset', used).allowed) {
161
+ return `dataset is full (${limit} records on this plan)`;
162
+ }
163
+ }
164
+ if (!env.orgId) return null;
165
+ const bytes = await dataStorageRefusal(env.org, firebaseAdmin.app().firestore().collection('orgs').doc(env.orgId));
166
+ if (!bytes) return null;
167
+ return `dataset storage is full (${bytes.includedMb} MB on this plan)`;
168
+ }
169
+ /**
170
+ * Folds one step's verdict into its run's tally, in the run-history phrasing
171
+ * every automation shares; true when the run stops at this step.
172
+ */ function tallyStep(tally, type, verdict) {
173
+ switch(verdict.kind){
174
+ case 'skipped':
175
+ return false;
176
+ case 'done':
177
+ tally.outcomes.push(describeStepOutcome(type, verdict.detail));
178
+ return false;
179
+ case 'failed':
180
+ tally.errors.push(verdict.error);
181
+ return false;
182
+ case 'exited':
183
+ tally.ending = 'exited';
184
+ tally.outcomes.push(describeStepOutcome(type));
185
+ return true;
186
+ case 'waiting':
187
+ tally.ending = 'waiting';
188
+ tally.outcomes.push(describeStepOutcome(type, verdict.detail));
189
+ return true;
190
+ case 'halted':
191
+ tally.errors.push(verdict.error);
192
+ return true;
193
+ case 'deferred':
194
+ tally.ending = 'deferred';
195
+ return true;
196
+ }
197
+ }
198
+ /**
199
+ * Runs ONE Actions step on the server: the executor every automation shares.
200
+ *
201
+ * An action's step list runs through it one step at a time, and so does
202
+ * every Actions step inside a workflow — so the two engines cannot disagree
203
+ * about what `sendEmail` or `datasetAppend` does, what a step is gated on, or
204
+ * how its outcome reads in the run history. There is no second copy of any
205
+ * branch below.
206
+ *
207
+ * Client-side steps (AGL-257) are skipped — the tenant page runtime runs
208
+ * those in the visitor's browser.
209
+ *
210
+ * Never throws: a step that throws is a failed step, and the run continues.
211
+ */ async function runServerStep(env, run, step, context) {
212
+ const { hostId, hostRef, alerts, depth } = env;
213
+ const { event, payload, enrollmentRef } = context;
214
+ /**
215
+ * The one fact worth carrying into the summary — the dataset's name,
216
+ * the webhook's status. Set by the branch that knows it.
217
+ */ let detail;
218
+ const failed = (error)=>({
219
+ kind: 'failed',
220
+ error
221
+ });
222
+ try {
223
+ /*
224
+ * BRANCHING INSIDE A FLOW: the step's own condition, evaluated against
225
+ * the same scope the trigger's is. An unmet guard skips this step and
226
+ * only this step — the run continues, which is what makes "wait three
227
+ * days, then, only if they have not ordered, send the reminder" a thing
228
+ * an author can write without a second action.
229
+ */ if (!evaluateStepGuard(step.when, context.scope)) return {
230
+ kind: 'skipped'
231
+ };
232
+ if (isClientActionStep(step) && step.type !== 'siteAlert') {
233
+ return {
234
+ kind: 'skipped'
235
+ } // Runs in the visitor's page (AGL-257).
236
+ ;
237
+ }
238
+ if (step.type === 'exitFlow') return {
239
+ kind: 'exited'
240
+ };
241
+ if (isFlowSuspendingStep(step)) {
242
+ // A run inside another automation's run finishes inside it: there is
243
+ // no enrollment of its own for the person to wait in.
244
+ if (context.nested) {
245
+ return {
246
+ kind: 'halted',
247
+ error: 'a workflow run as a step of another automation cannot wait — ' + 'give it its own trigger instead'
248
+ };
249
+ }
250
+ const suspended = await suspendFlow(env, run, {
251
+ step,
252
+ steps: context.steps,
253
+ nextStepIndex: context.index + 1,
254
+ event,
255
+ payload,
256
+ enrollmentRef
257
+ });
258
+ if (suspended.error) return {
259
+ kind: 'halted',
260
+ error: suspended.error
261
+ };
262
+ return {
263
+ kind: 'waiting',
264
+ detail: suspended.detail
265
+ };
266
+ }
267
+ if (step.type === 'siteAlert') {
268
+ var _step_message, _step_severity;
269
+ alerts.push({
270
+ message: String((_step_message = step.message) != null ? _step_message : '').slice(0, 300),
271
+ severity: (_step_severity = step.severity) != null ? _step_severity : 'info'
272
+ });
273
+ } else if (step.type === 'runWorkflow') {
274
+ var _workflowContext_workflows_, _ref, _ref1;
275
+ var _step_workflowId, _step_workflowName;
276
+ const workflowContext = await env.loadWorkflowContext();
277
+ const workflow = (_workflowContext_workflows_ = workflowContext.workflows[(_ref = (_step_workflowId = step.workflowId) == null ? void 0 : _step_workflowId.trim()) != null ? _ref : '']) != null ? _workflowContext_workflows_ : workflowContext.workflows[(_ref1 = (_step_workflowName = step.workflowName) == null ? void 0 : _step_workflowName.trim()) != null ? _ref1 : ''];
278
+ if (!workflow) {
279
+ return failed(`unknown workflow "${step.workflowName || step.workflowId}"`);
280
+ }
281
+ if (workflowHasActionSteps(workflow)) {
282
+ var _ref2, _workflow_$id, _workflow_name;
283
+ var _step_workflowId1;
284
+ /*
285
+ * A workflow with Actions steps is PERFORMED, not evaluated: its
286
+ * steps run here, inside this run, one level deeper under the same
287
+ * guard a custom-event chain runs under. It is part of this run, so
288
+ * it is not metered or recorded as a run of its own.
289
+ */ if (depth + 1 > ACTION_MAX_EVENT_DEPTH) {
290
+ return failed(`workflow "${workflow.name}" is nested too deeply`);
291
+ }
292
+ const nested = await executeWorkflow(_extends({}, env, {
293
+ depth: depth + 1
294
+ }), {
295
+ kind: 'workflow',
296
+ id: (_ref2 = (_workflow_$id = workflow.$id) != null ? _workflow_$id : (_step_workflowId1 = step.workflowId) == null ? void 0 : _step_workflowId1.trim()) != null ? _ref2 : '',
297
+ name: (_workflow_name = workflow.name) != null ? _workflow_name : ''
298
+ }, workflow, event, payload, {
299
+ nested: true
300
+ });
301
+ if (nested.errors.length) {
302
+ return failed(`workflow "${workflow.name}": ${nested.errors.join('; ')}`.slice(0, 300));
303
+ }
304
+ } else {
305
+ const evaluated = runWorkflow(workflow, workflowContext.functions, workflowContext.variables, _extends({
306
+ event
307
+ }, payload), {
308
+ workflows: workflowContext.workflows
309
+ });
310
+ if (evaluated.ok === false) return failed(evaluated.error);
311
+ }
312
+ } else if (step.type === 'customEvent') {
313
+ const nested = await runEventActions(hostId, step.eventName.trim(), payload, depth + 1);
314
+ alerts.push(...nested);
315
+ } else if (step.type === 'webhookPost') {
316
+ var _ref3;
317
+ var _step_webhookId, _step_webhookName;
318
+ if (!env.webhooksAllowed) return failed('webhooks require a Business plan');
319
+ // Id-first lookup (AGL-261); the name query is the legacy path.
320
+ const hookDoc = ((_step_webhookId = step.webhookId) == null ? void 0 : _step_webhookId.trim()) ? await hostRef.collection('webhooks').doc(step.webhookId.trim()).get() : (await hostRef.collection('webhooks').where('name', '==', (_ref3 = (_step_webhookName = step.webhookName) == null ? void 0 : _step_webhookName.trim()) != null ? _ref3 : '').limit(1).get()).docs[0];
321
+ const hook = (hookDoc == null ? void 0 : hookDoc.exists) ? hookDoc.data() : undefined;
322
+ if (!hook || hookDoc.get('deletedAt') || hook.enabled === false || hook.direction !== 'outbound' || !hook.url || !WEBHOOK_URL_PATTERN.test(hook.url)) {
323
+ return failed(`unknown webhook "${step.webhookName || step.webhookId}"`);
324
+ }
325
+ const body = JSON.stringify({
326
+ event,
327
+ payload,
328
+ sentAt: new Date().toISOString()
329
+ });
330
+ const signature = hook.secret ? createHmac('sha256', hook.secret).update(body).digest('hex') : '';
331
+ // Two quick retries — serverless-friendly; longer retry queues
332
+ // are a follow-up.
333
+ let delivered = false;
334
+ let lastStatus;
335
+ for(let attempt = 0; attempt < 3 && !delivered; attempt += 1){
336
+ try {
337
+ const response = await fetch(hook.url, {
338
+ method: 'POST',
339
+ headers: _extends({
340
+ 'Content-Type': 'application/json'
341
+ }, signature && {
342
+ 'X-Aglyn-Signature': signature
343
+ }),
344
+ body,
345
+ signal: AbortSignal.timeout(5000)
346
+ });
347
+ lastStatus = response.status;
348
+ delivered = response.ok;
349
+ } catch (unused) {
350
+ // Retry below.
351
+ }
352
+ if (!delivered && attempt < 2) {
353
+ await new Promise((resolve)=>setTimeout(resolve, 500 * (attempt + 1)));
354
+ }
355
+ }
356
+ if (!delivered) {
357
+ return failed(`webhook "${step.webhookName || step.webhookId}" delivery failed`);
358
+ }
359
+ // The status the mockup prints — discarded on the line it arrived
360
+ // until AGL-2171. A 200 and a 204 are both `ok`, and knowing which is
361
+ // the whole reason anyone opens a run history after a webhook.
362
+ detail = String(lastStatus != null ? lastStatus : '');
363
+ } else if (step.type === 'datasetAppend') {
364
+ // Id-first lookup (AGL-261/556); the name query is the legacy path.
365
+ const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
366
+ const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId);
367
+ if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) {
368
+ return failed(`unknown dataset "${step.datasetName || step.datasetId}"`);
369
+ }
370
+ // Restrict to the model's field ids (AGL-556) — covers model-only
371
+ // datasets whose flat v1 `fields` mirror is absent.
372
+ const appendDataset = {
373
+ model: datasetDoc.get('model'),
374
+ fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
375
+ };
376
+ const values = buildDatasetRecordValues(appendDataset, payload);
377
+ // Same name precedence `findDatasetByName` resolves in.
378
+ const appendLabel = (datasetDisplayName({
379
+ displayName: datasetDoc.get('displayName'),
380
+ name: datasetDoc.get('name')
381
+ }) || step.datasetName || '').slice(0, 60);
382
+ // No event field matched a field of the dataset, so there is nothing
383
+ // to write. An error rather than a quiet success: a run history that
384
+ // says `saved to Leads` while nothing saves is how a mismatched field
385
+ // name goes unnoticed.
386
+ if (!Object.keys(values).length) {
387
+ return failed(`no event field matches a field in dataset "${appendLabel || step.datasetId}"`);
388
+ }
389
+ const refusal = await datasetAppendRefusal(env, datasetDoc.ref);
390
+ if (refusal) return failed(refusal);
391
+ await datasetDoc.ref.collection('records').add(_extends({
392
+ values
393
+ }, datasetIntegrityFields(effectiveDatasetModel(appendDataset), values), {
394
+ createdAt: FieldValue.serverTimestamp()
395
+ }));
396
+ await announceDatasetStepWrite(env, datasetDoc.id);
397
+ // `saved to Leads` beats `saved to dataset` (AGL-2171).
398
+ detail = appendLabel;
399
+ } else if (step.type === 'updateDataset') {
400
+ var _payload_email;
401
+ // Update-or-append (AGL-257): matches the record whose `email`
402
+ // field equals the payload's email; appends when nothing matches.
403
+ const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
404
+ const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId);
405
+ if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) {
406
+ return failed(`unknown dataset "${step.datasetName || step.datasetId}"`);
407
+ }
408
+ const updateDataset = {
409
+ model: datasetDoc.get('model'),
410
+ fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
411
+ };
412
+ const updateModel = effectiveDatasetModel(updateDataset);
413
+ const values = buildDatasetRecordValues(updateDataset, payload);
414
+ // Nothing to merge or append — an error, for the reason the append
415
+ // branch above gives.
416
+ if (!Object.keys(values).length) {
417
+ const updateLabel = datasetDisplayName({
418
+ displayName: datasetDoc.get('displayName'),
419
+ name: datasetDoc.get('name')
420
+ }) || step.datasetName || step.datasetId;
421
+ return failed(`no event field matches a field in dataset "${String(updateLabel != null ? updateLabel : '').slice(0, 60)}"`);
422
+ }
423
+ const email = String((_payload_email = payload.email) != null ? _payload_email : '').trim();
424
+ // `records.values` is exempt from indexing, so this lookup is served
425
+ // only by the `values.email` field override in
426
+ // cloud/firebase-firestore.indexes.json. Without that override
427
+ // production refuses the query and neither leg below runs.
428
+ const existing = email ? await datasetDoc.ref.collection('records').where('values.email', '==', email).limit(1).get() : null;
429
+ if (existing && !existing.empty) {
430
+ var _existing_docs__get;
431
+ const merged = _extends({}, (_existing_docs__get = existing.docs[0].get('values')) != null ? _existing_docs__get : {}, values);
432
+ await existing.docs[0].ref.set(_extends({
433
+ values: merged
434
+ }, datasetIntegrityUpdate(updateModel, merged, FieldValue.delete()), {
435
+ updatedAt: FieldValue.serverTimestamp()
436
+ }), {
437
+ merge: true
438
+ });
439
+ } else {
440
+ // The APPEND leg of update-or-append, and the only one of the two
441
+ // that adds a row — the merge above rewrites a record that already
442
+ // counts against the band.
443
+ const refusal = await datasetAppendRefusal(env, datasetDoc.ref);
444
+ if (refusal) return failed(refusal);
445
+ await datasetDoc.ref.collection('records').add(_extends({
446
+ values
447
+ }, datasetIntegrityFields(updateModel, values), {
448
+ createdAt: FieldValue.serverTimestamp()
449
+ }));
450
+ }
451
+ // Both legs changed a row, so both make the same pages stale — an edited
452
+ // record reads no differently from a new one on a page that lists them.
453
+ await announceDatasetStepWrite(env, datasetDoc.id);
454
+ } else if (step.type === 'notifyAdmins') {
455
+ var _step_title;
456
+ await notifyHostManagers(hostId, _extends({
457
+ type: 'system.announcement',
458
+ title: String((_step_title = step.title) != null ? _step_title : '').slice(0, 200)
459
+ }, step.body ? {
460
+ body: String(step.body).slice(0, 500)
461
+ } : {}, {
462
+ link: `/${hostId}`
463
+ }));
464
+ } else if (step.type === 'sendEmail') {
465
+ var _payload_, _hostPublicOrigin, _step_topicId, _step_subject, _step_body;
466
+ var _step_toField, _this;
467
+ const to = String((_payload_ = payload[((_step_toField = step.toField) == null ? void 0 : _step_toField.trim()) || 'email']) != null ? _payload_ : '').trim();
468
+ if (!isEmailConfigured()) return failed('email is not configured');
469
+ if (!to || !to.includes('@')) {
470
+ return failed('no recipient email in the event payload');
471
+ }
472
+ // The site's own origin, for the unsubscribe link. Read here rather
473
+ // than carried on the run env because most action runs send no email
474
+ // at all, and a document read every workflow pays for is a read on
475
+ // the hot path for a link nine runs in ten never need.
476
+ const siteBase = (_hostPublicOrigin = hostPublicOrigin((_this = await hostRef.get().catch(()=>null)) == null ? void 0 : _this.data())) != null ? _hostPublicOrigin : '';
477
+ /*
478
+ * MARKETING. The subject and body are merchant-authored and the
479
+ * recipient comes out of the event payload — which, for the collect
480
+ * route, is a write triggered by an anonymous visitor. So this is a
481
+ * site mailing an address on the merchant's say-so, and it owes what
482
+ * every other such message owes: the unsubscribe header pair and a
483
+ * visible link, both suppression lists, and a share of the ceiling on
484
+ * how much one person receives from this site.
485
+ *
486
+ * Priority stays transactional. An action run is not resumable — the
487
+ * event has already happened and there is no beat that comes back for
488
+ * it — and the rule on `'bulk'` is that only a resumable sweep may
489
+ * refuse in a way the recipient survives.
490
+ *
491
+ * A merchant who wants an internal alert that no suppression can stop
492
+ * uses the `notifyAdmins` step beside this one: it reaches managers
493
+ * in the console rather than the shared sending domain, which is the
494
+ * right instrument for a notification nobody consented to receive.
495
+ */ /*
496
+ * A STEP THAT RUNS AFTER A WAIT IS A CAMPAIGN, not a reply.
497
+ *
498
+ * The paragraph above is exactly right about an IMMEDIATE step: the
499
+ * event has already happened, the recipient just did something, and
500
+ * the message is the response to it. None of that survives a three-day
501
+ * delay. Everything after a wait goes out on the merchant's schedule,
502
+ * to somebody who did one thing once — which is `marketing-send.ts`'s
503
+ * own definition of marketing mail, and it earns the full consent
504
+ * split and the default stream, exactly as a campaign does.
505
+ *
506
+ * An immediate step is not ungated, which it used to be. `to` is read
507
+ * out of the event payload — an anonymous visitor's write on the
508
+ * collect route — so nothing here establishes that whoever typed the
509
+ * address is whoever receives the mail, and a person with a RECORDED
510
+ * REFUSAL on this site was mailed merchant-authored content because a
511
+ * third party entered their address in a form. `'immediate'` asks the
512
+ * narrower question that catches exactly that and cannot refuse a
513
+ * new visitor their auto-response: see `FlowEmailScope`.
514
+ *
515
+ * Refused BEFORE `sendEmail` rather than inside it, because these two
516
+ * are the merchant's own policy over their own audience, where the
517
+ * ones the seam asks are platform controls over the shared sending
518
+ * domain. Both refusals are permanent for this message, so the
519
+ * enrollment moves on rather than retrying.
520
+ */ const scope = enrollmentRef ? 'scheduled' : 'immediate';
521
+ /*
522
+ * The stream this message belongs to, resolved ONCE and read twice:
523
+ * the gate below filters on it, and it rides the `marketing` context
524
+ * so the opt-out link the seam mints names it. Without that the
525
+ * preference page opens on a list of every stream the site has, and
526
+ * the recipient has to find the one they were trying to leave.
527
+ */ const topicId = flowEmailTopicId(step.topicId, scope);
528
+ const gate = await flowEmailRefusal({
529
+ hostId,
530
+ email: to,
531
+ topicId: (_step_topicId = step.topicId) != null ? _step_topicId : null,
532
+ org: env.org,
533
+ scope
534
+ });
535
+ if (gate) {
536
+ return failed(gate !== 'consent-withheld' ? 'the recipient has left this email topic' : // only on a stated refusal, so reporting it as a missing
537
+ // record would send a merchant looking for a consent field to
538
+ // fill in that would change nothing.
539
+ enrollmentRef ? 'the recipient has no marketing consent record on this site' : 'the recipient has declined marketing from this site');
540
+ }
541
+ const emailSubject = String((_step_subject = step.subject) != null ? _step_subject : '').slice(0, 200);
542
+ const emailText = String((_step_body = step.body) != null ? _step_body : '').slice(0, 5000);
543
+ /*
544
+ * THE TIMELINE ENTRY (AGL-2615). A message addressed to the contact
545
+ * the event is about is logged on that contact's timeline as an
546
+ * email activity — the same row the console's own send logs — so an
547
+ * automated welcome shows beside the calls a rep made, with its
548
+ * delivery state. Prepared first because the row's id has to be on
549
+ * the message for the webhook to find it; written only after the
550
+ * provider accepted. Behind the suite gate like every other CRM
551
+ * write an action makes.
552
+ */ const emailActivity = env.crmAllowed ? await prepareCrmEmailActivity({
553
+ hostId,
554
+ org: env.org,
555
+ orgId: env.orgId
556
+ }, to, payload) : null;
557
+ const result = await sendEmail(_extends({
558
+ to,
559
+ subject: emailSubject,
560
+ text: emailText,
561
+ sendingIdentity: await hostSendingIdentity(hostId)
562
+ }, emailActivity ? {
563
+ tags: emailActivity.tags
564
+ } : {}, {
565
+ audience: 'tenant',
566
+ context: enrollmentRef ? 'flow step' : 'event action'
567
+ }, enrollmentRef ? {
568
+ priority: 'bulk'
569
+ } : {}, {
570
+ // `topicId` is `''` for a step that belongs to no stream, which
571
+ // every reader of it treats as absent — see `flowEmailTopicId`.
572
+ marketing: {
573
+ hostId,
574
+ siteBase,
575
+ topicId
576
+ }
577
+ }));
578
+ /*
579
+ * DEFERRED IS NOT FAILED, and it is not SENT either.
580
+ *
581
+ * The platform's hourly ceiling and this person's own frequency window
582
+ * are both refusals a later beat can pass. Advancing past this step
583
+ * would turn "not this hour" into an email nobody ever receives, which
584
+ * is the defect the campaign processor and the cart sweep each name.
585
+ * So the enrollment is put back with the SAME `nextStepIndex` and the
586
+ * run ends here.
587
+ */ if (enrollmentRef && isDeferrableSendResult(result)) {
588
+ return {
589
+ kind: 'deferred'
590
+ };
591
+ }
592
+ // Named rather than lumped into "delivery failed": a suppression and
593
+ // a frequency ceiling are the controls working, and a merchant
594
+ // reading the run's alerts has a different thing to do about each.
595
+ const refusal = sendFailureReason(result);
596
+ const sendError = refusal ? refusal === 'suppressed' ? 'the recipient is unsubscribed or suppressed' : refusal === 'frequency-capped' ? 'the recipient has already had today’s limit of email ' + 'from this site' : 'email delivery failed' : null;
597
+ // Cost meter (AGL-1438). A workflow notification is transactional:
598
+ // counted, never capped. `sent` is false when Resend refused or the
599
+ // environment is unconfigured, and an email that never left is not a
600
+ // cost.
601
+ if (result.sent) {
602
+ await meterHostEmail(hostId);
603
+ if (emailActivity) {
604
+ await logCrmEmailActivity({
605
+ hostId,
606
+ org: env.org,
607
+ orgId: env.orgId
608
+ }, emailActivity, {
609
+ subject: emailSubject,
610
+ body: emailText,
611
+ to
612
+ }, run.id);
613
+ }
614
+ }
615
+ if (sendError) return failed(sendError);
616
+ } else if (step.type === 'enrollList') {
617
+ var _payload_email1, _ref4;
618
+ var _step_listId, _step_listName;
619
+ const orgId = await resolveOrgIdForHost(hostId);
620
+ const email = String((_payload_email1 = payload.email) != null ? _payload_email1 : '').trim().toLowerCase();
621
+ if (!orgId || !email || !email.includes('@')) {
622
+ return failed('no email to enroll');
623
+ }
624
+ const listsRef = firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('lists');
625
+ const listDoc = ((_step_listId = step.listId) == null ? void 0 : _step_listId.trim()) ? await listsRef.doc(step.listId.trim()).get() : (await listsRef.where('name', '==', (_ref4 = (_step_listName = step.listName) == null ? void 0 : _step_listName.trim()) != null ? _ref4 : '').limit(1).get()).docs[0];
626
+ if (!(listDoc == null ? void 0 : listDoc.exists)) {
627
+ return failed(`unknown list "${step.listName || step.listId}"`);
628
+ }
629
+ // `enrollListMember` owns the document id: the commerce newsletter
630
+ // handler enrolls into the same collection, and an id derived here
631
+ // would be a second answer to which document describes which person.
632
+ await enrollListMember({
633
+ listRef: listDoc.ref,
634
+ group: await consentGroupForSite(hostId),
635
+ email,
636
+ // Which automation enrolled them: `action:<id>` or `workflow:<id>`.
637
+ source: `${run.kind}:${run.id}`
638
+ });
639
+ } else if (step.type === 'assignCampaign') {
640
+ var _payload_email2, _ref5, _ref6;
641
+ var _step_campaignId, _step_campaignName;
642
+ const email = String((_payload_email2 = payload.email) != null ? _payload_email2 : '').trim().toLowerCase();
643
+ if (!email || !email.includes('@')) {
644
+ return failed('no contact email to assign');
645
+ }
646
+ // Scoped to this host (AGL-1039): a site must not reach a contact
647
+ // it cannot see, even to tag it onto a campaign. Through the org's
648
+ // address index (AGL-2633), so an address a merge folded into
649
+ // another record still names the person who now holds it.
650
+ const { ref: contactsRef } = await orgDataQueryForHost(hostId, 'contacts');
651
+ const contact = await findContactByEmail(contactsRef, email, {
652
+ hostId
653
+ });
654
+ if (!contact) return failed(`no contact for ${email}`);
655
+ /*
656
+ * THE CAMPAIGN IS RESOLVED TO A DOCUMENT, exactly as `enrollList`
657
+ * resolves a list one branch above.
658
+ *
659
+ * A step may name a campaign by id or by name — the picker writes the
660
+ * id, an imported automation may carry only the name — and what gets
661
+ * stored is the id either way. Storing whichever of the two the step
662
+ * happened to hold would put names and ids in one array, and every
663
+ * reader of that array resolves ids: a name in it renders as a chip
664
+ * nobody can click and matches no campaign the console can find.
665
+ *
666
+ * An unknown campaign is an ERROR rather than a stored string. The
667
+ * reference audit already reports a step pointing at a campaign that
668
+ * does not exist; a run that wrote the dangling name anyway would
669
+ * make the audit's finding untrue the moment it fired.
670
+ */ const campaignsRef = hostRef.collection('emailCampaigns');
671
+ const namedId = (_ref5 = (_step_campaignId = step.campaignId) == null ? void 0 : _step_campaignId.trim()) != null ? _ref5 : '';
672
+ const campaignDoc = namedId ? await campaignsRef.doc(namedId).get() : (await campaignsRef.where('name', '==', (_ref6 = (_step_campaignName = step.campaignName) == null ? void 0 : _step_campaignName.trim()) != null ? _ref6 : '').limit(1).get()).docs[0];
673
+ if (!(campaignDoc == null ? void 0 : campaignDoc.exists)) {
674
+ return failed(`unknown campaign "${step.campaignName || step.campaignId}"`);
675
+ }
676
+ /*
677
+ * INSIDE THIS SITE'S FACET, not at the top of the document.
678
+ *
679
+ * A contact is one row shared by every site in the org, and which
680
+ * campaigns a merchant has filed somebody under is that merchant's
681
+ * business record on the same footing as their notes and their tags.
682
+ * Written at the top it would be readable by every other site in an
683
+ * agency's account.
684
+ *
685
+ * `update` with a dotted path, never `set({merge:true})`: a `set`
686
+ * treats the string as a literal field NAME and would mint a
687
+ * top-level key with dots in it. The document was just read, so the
688
+ * update cannot fail for absence.
689
+ */ const group = await consentGroupForSite(hostId);
690
+ await contact.ref.update({
691
+ [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(campaignDoc.id),
692
+ updatedAt: FieldValue.serverTimestamp()
693
+ });
694
+ } else if (isCrmActionStep(step)) {
695
+ // The plan gate, the way `webhookPost` takes the `webhooks` one:
696
+ // refused into the run history with the tier that carries it, so
697
+ // a Free workspace whose flow names a CRM step reads why the step
698
+ // did nothing rather than a log that says it ran.
699
+ if (!env.crmAllowed) {
700
+ return failed(`CRM steps require the ${planLabelGrantingFeature('crm')} plan`);
701
+ }
702
+ // The five CRM steps (AGL-2605) share a resolver and a scope, so
703
+ // they share a module; see `crm-action-steps.ts`.
704
+ const outcome = await runCrmActionStep({
705
+ hostId,
706
+ org: env.org,
707
+ orgId: env.orgId
708
+ }, run.id, step, payload);
709
+ if (outcome.error) return failed(outcome.error);
710
+ detail = outcome.detail;
711
+ /*
712
+ * A stage set by an automation IS a stage change, and whatever
713
+ * listens for one must hear it — fanned out here, under the same
714
+ * depth guard a `customEvent` chain runs under, rather than through
715
+ * `emitHostEvent`, which starts every chain at depth zero and would
716
+ * let an automation that sets the stage it listens for run forever.
717
+ * Workflows take the guard too, now that a workflow can set a stage.
718
+ */ if (outcome.emit) {
719
+ const [fromWorkflows, fromActions] = await Promise.all([
720
+ runEventWorkflows(hostId, outcome.emit.event, outcome.emit.payload, depth + 1),
721
+ runEventActions(hostId, outcome.emit.event, outcome.emit.payload, depth + 1)
722
+ ]);
723
+ alerts.push(...fromActions, ...fromWorkflows);
724
+ }
725
+ }
726
+ } catch (error) {
727
+ return failed(error.message);
728
+ }
729
+ return {
730
+ kind: 'done',
731
+ detail
732
+ };
733
+ }
734
+ /**
735
+ * Executes one action's SERVER steps in order, collecting per-step errors
736
+ * into the activity summary. Each step runs through {@link runServerStep}.
737
+ *
738
+ * A `wait` step ENDS this call and hands the rest of the list to the job
739
+ * beat: everything after the wait belongs to a run that has not happened yet,
740
+ * so nothing below the wait may execute in this request.
741
+ */ async function executeAction(env, actionId, action, event, payload, options = {}) {
742
+ var _action_name, _options_enrollmentRef, _ref, _options_steps, _options_startIndex, _action_name1;
743
+ const { hostRef } = env;
744
+ const run = {
745
+ kind: 'action',
746
+ id: actionId,
747
+ name: (_action_name = action.name) != null ? _action_name : ''
748
+ };
749
+ const enrollmentRef = (_options_enrollmentRef = options.enrollmentRef) != null ? _options_enrollmentRef : null;
750
+ const steps = ((_ref = (_options_steps = options.steps) != null ? _options_steps : action.steps) != null ? _ref : []).slice(0, ACTION_MAX_STEPS);
751
+ const startIndex = Math.max(0, (_options_startIndex = options.startIndex) != null ? _options_startIndex : 0);
752
+ /**
753
+ * What each step actually DID (AGL-2171). Only failures were recorded,
754
+ * so a run that sent an email, wrote a row and posted a webhook logged
755
+ * the same eight words as a run that did nothing — and
756
+ * `/product/workflows` advertises a `What happened` column reading
757
+ * `Sent email · saved to Leads · webhook 200`.
758
+ */ const tally = {
759
+ ending: 'ran',
760
+ errors: [],
761
+ outcomes: []
762
+ };
763
+ const scope = _extends({
764
+ event
765
+ }, payload);
766
+ for(let index = startIndex; index < steps.length; index += 1){
767
+ const step = steps[index];
768
+ const verdict = await runServerStep(env, run, step, {
769
+ index,
770
+ steps,
771
+ event,
772
+ payload,
773
+ scope,
774
+ enrollmentRef
775
+ });
776
+ if (tallyStep(tally, step.type, verdict)) break;
777
+ }
778
+ const { ending, errors: stepErrors, outcomes } = tally;
779
+ /*
780
+ * A DEFERRED run writes no history line and leaves the enrollment where it
781
+ * was. Nothing happened that a merchant should read as a run: the step is
782
+ * still ahead of this person, and a row per refused beat would bury the
783
+ * runs that did something under a log of the ceiling working.
784
+ */ if (ending === 'deferred') return ending;
785
+ const summary = stepErrors.length ? `Action ran on ${event} with errors: ${stepErrors.join('; ')}`.slice(0, 300) : ending === 'waiting' ? `Action is waiting, on ${event}` : `Action ran on ${event}`;
786
+ await hostRef.collection('activity').add({
787
+ actorId: null,
788
+ actorEmail: null,
789
+ // The prose line stays exactly as it was: `activityPrimaryText` and
790
+ // three other renderers read it, and the run table is not the only
791
+ // thing this collection feeds.
792
+ action: summary,
793
+ // The structured half (AGL-2171) — the two columns the advertised
794
+ // run-history table could not otherwise fill.
795
+ result: stepErrors.length ? 'failed' : 'succeeded',
796
+ trigger: event,
797
+ summary: (outcomes.length ? outcomes.join(' · ') : 'Ran').slice(0, 300),
798
+ target: {
799
+ type: 'workflow',
800
+ id: actionId,
801
+ name: (_action_name1 = action.name) != null ? _action_name1 : ''
802
+ },
803
+ createdAt: FieldValue.serverTimestamp()
804
+ }).catch(()=>undefined);
805
+ return ending;
806
+ }
807
+ /**
808
+ * Runs a workflow whose steps include Actions steps: function calls and
809
+ * Actions steps, in order, in one scope.
810
+ *
811
+ * - A FUNCTION CALL is evaluated by the platform's pure evaluator,
812
+ * `runWorkflow`, one call at a time, so its expressions see the event, the
813
+ * site's variables and every result bound before it — exactly the scope a
814
+ * function-only workflow gives it. A call that fails ends the run.
815
+ * - An ACTIONS STEP runs through {@link runServerStep}, the executor actions
816
+ * use, and reads the event payload together with every result bound so
817
+ * far: a workflow can compute a score and write it to a dataset. Each one
818
+ * takes the Actions tier gate (`actions`, Pro and up) on top of its own —
819
+ * `webhookPost` on Business, the CRM steps on the CRM suite — and a step
820
+ * only the visitor's browser can run is refused.
821
+ * - `wait` and `waitForEvent` enroll the person and end this call; the rest
822
+ * of the list, results included, continues from the beat. `exitFlow` ends
823
+ * the run.
824
+ *
825
+ * Records nothing and meters nothing: the caller that admitted the run does
826
+ * both, once.
827
+ */ export async function executeWorkflow(env, run, workflow, event, payload, options = {}) {
828
+ var _ref, _options_steps, _options_enrollmentRef, _options_startIndex, _Object_values_at;
829
+ var _workflow_returnValue;
830
+ const steps = (_ref = (_options_steps = options.steps) != null ? _options_steps : workflow.steps) != null ? _ref : [];
831
+ const tally = {
832
+ ending: 'ran',
833
+ errors: [],
834
+ outcomes: []
835
+ };
836
+ const results = {};
837
+ if (steps.length > WORKFLOW_MAX_STEPS) {
838
+ return _extends({}, tally, {
839
+ errors: [
840
+ `Workflows are capped at ${WORKFLOW_MAX_STEPS} steps`
841
+ ],
842
+ value: '',
843
+ results
844
+ });
845
+ }
846
+ const context = await env.loadWorkflowContext();
847
+ const enrollmentRef = (_options_enrollmentRef = options.enrollmentRef) != null ? _options_enrollmentRef : null;
848
+ const startIndex = Math.max(0, (_options_startIndex = options.startIndex) != null ? _options_startIndex : 0);
849
+ /** The payload an Actions step reads: the event's, then every result. */ const stepPayload = ()=>_extends({}, payload, results);
850
+ for(let index = startIndex; index < steps.length; index += 1){
851
+ const step = steps[index];
852
+ if (!isWorkflowActionStep(step)) {
853
+ var _call_resultName;
854
+ const call = step;
855
+ const resultName = ((_call_resultName = call.resultName) == null ? void 0 : _call_resultName.trim()) || `step${index + 1}`;
856
+ const evaluated = runWorkflow({
857
+ name: workflow.name,
858
+ steps: [
859
+ _extends({}, call, {
860
+ resultName
861
+ })
862
+ ]
863
+ }, context.functions, context.variables, _extends({
864
+ event
865
+ }, stepPayload()), {
866
+ workflows: context.workflows
867
+ });
868
+ if (evaluated.ok === false) {
869
+ // The evaluator numbers the one step it was handed; the author
870
+ // numbers it by its place in the workflow.
871
+ tally.errors.push(evaluated.error.replace(/^Step 1\b/, `Step ${index + 1}`));
872
+ break;
873
+ }
874
+ results[resultName] = evaluated.results[resultName];
875
+ tally.outcomes.push(`ran ${String(call.functionName || call.functionId || 'a function').trim()}`);
876
+ continue;
877
+ }
878
+ const refusal = workflowActionStepRefusal(step);
879
+ if (refusal) {
880
+ tally.errors.push(refusal);
881
+ continue;
882
+ }
883
+ if (!env.actionsAllowed) {
884
+ tally.errors.push(`“${workflowStepTypeLabel(step.type)}” needs the ` + `${planLabelGrantingFeature('actions')} plan`);
885
+ continue;
886
+ }
887
+ const verdict = await runServerStep(env, run, step, {
888
+ index,
889
+ steps,
890
+ event,
891
+ payload: stepPayload(),
892
+ scope: _extends({
893
+ event
894
+ }, stepPayload()),
895
+ enrollmentRef,
896
+ nested: options.nested
897
+ });
898
+ if (tallyStep(tally, step.type, verdict)) break;
899
+ }
900
+ /*
901
+ * The return value the pure evaluator would give: the named scope entry —
902
+ * a result, a payload field or a variable — when the workflow names one,
903
+ * and the last result otherwise. Asked of the evaluator itself, with no
904
+ * steps, so a variable resolves by the evaluator's own rules.
905
+ */ const returnName = (_workflow_returnValue = workflow.returnValue) == null ? void 0 : _workflow_returnValue.trim();
906
+ const value = returnName ? (()=>{
907
+ const named = runWorkflow({
908
+ name: workflow.name,
909
+ steps: [],
910
+ returnValue: returnName
911
+ }, context.functions, context.variables, _extends({
912
+ event
913
+ }, stepPayload()));
914
+ return named.ok === false ? '' : named.value;
915
+ })() : (_Object_values_at = Object.values(results).at(-1)) != null ? _Object_values_at : '';
916
+ return _extends({}, tally, {
917
+ value,
918
+ results
919
+ });
920
+ }
921
+ /**
922
+ * Suspends the run at a `wait` or `waitForEvent` step.
923
+ *
924
+ * One function for both the first suspension and every later one, because the
925
+ * two differ only in whether a row already exists — and writing "create here,
926
+ * update there" twice is how the two drift into disagreeing about which
927
+ * fields a waiting enrollment carries.
928
+ */ async function suspendFlow(env, run, request) {
929
+ var _ref, _request_payload;
930
+ var _request_payload1;
931
+ const { step } = request;
932
+ const minutes = step.type === 'wait' ? Number(step.delayMinutes) : step.type === 'waitForEvent' ? Number(step.timeoutMinutes) : 0;
933
+ if (!Number.isFinite(minutes) || minutes <= 0) {
934
+ return {
935
+ error: 'the wait has no duration'
936
+ };
937
+ }
938
+ const nowMs = Date.now();
939
+ const resumeAtMs = nowMs + minutes * 60000;
940
+ const awaitingEvent = step.type === 'waitForEvent' ? step.eventName.trim() : null;
941
+ const detail = step.type === 'waitForEvent' ? `${awaitingEvent} (up to ${minutes}m)` : `${minutes}m`;
942
+ if (request.enrollmentRef) {
943
+ await advanceFlowEnrollment(request.enrollmentRef, {
944
+ nextStepIndex: request.nextStepIndex,
945
+ resumeAtMs,
946
+ awaitingEvent,
947
+ payload: request.payload
948
+ }, nowMs);
949
+ return {
950
+ detail
951
+ };
952
+ }
953
+ const enrolled = await enrollInFlow(_extends({
954
+ hostId: env.hostId,
955
+ actionId: run.id
956
+ }, run.kind === 'workflow' ? {
957
+ automation: 'workflow'
958
+ } : {}, {
959
+ // The SNAPSHOT is the list this run is executing, which on a first run is
960
+ // the automation's own — so one edited later cannot move this person's
961
+ // remaining steps out from under them.
962
+ action: {
963
+ name: run.name,
964
+ steps: [
965
+ ...request.steps
966
+ ]
967
+ },
968
+ email: String((_ref = (_request_payload1 = request.payload) == null ? void 0 : _request_payload1['email']) != null ? _ref : ''),
969
+ event: request.event,
970
+ payload: (_request_payload = request.payload) != null ? _request_payload : {},
971
+ nextStepIndex: request.nextStepIndex,
972
+ resumeAtMs,
973
+ awaitingEvent,
974
+ nowMs
975
+ }));
976
+ if (enrolled.enrolled === true) return {
977
+ detail
978
+ };
979
+ /*
980
+ * Both refusals are stated rather than swallowed, because both are things
981
+ * an author can act on: a flow that waits needs an address in its trigger
982
+ * payload, and a person already inside this flow is not put through it
983
+ * twice concurrently.
984
+ */ return {
985
+ error: enrolled.reason === 'no-person' ? 'a flow that waits needs the person’s email in the event payload' : 'this person is already waiting inside this flow'
986
+ };
987
+ }
988
+ /**
989
+ * Events too frequent to log a skip for.
990
+ *
991
+ * `runEventActions` fires on EVERY page view of every published site. A
992
+ * Firestore write per visitor per non-matching action is not a run
993
+ * history, it is an outage — and a page-view condition is one an author
994
+ * tunes by watching the site, not by reading a log. The events people
995
+ * actually debug are the server-emitted ones (a form submission, a
996
+ * booking, a sign-up), and those are low-volume by construction.
997
+ */ const SKIP_LOG_EXCLUDED_EVENTS = new Set([
998
+ 'pageView'
999
+ ]);
1000
+ /**
1001
+ * Writes the `Skipped` row (AGL-2171): which condition stopped the run,
1002
+ * so the answer to "why didn't it fire?" is in the same place as every
1003
+ * run that did.
1004
+ *
1005
+ * Never counts against `actionRunsPerMonth` — nothing executed, and
1006
+ * charging for a condition that said no would be its own bug.
1007
+ */ async function recordSkippedRun(hostRef, actionId, action, event) {
1008
+ var _action_name;
1009
+ if (SKIP_LOG_EXCLUDED_EVENTS.has(event)) return;
1010
+ // `normalizeTriggerConditions`, not `trigger.conditions` — a pre-AGL-565
1011
+ // action carries a single `condition` and reading only the array would
1012
+ // give every one of them the nameless fallback.
1013
+ const named = normalizeTriggerConditions(action.trigger).map((condition)=>{
1014
+ var _ref;
1015
+ return String((_ref = condition == null ? void 0 : condition.field) != null ? _ref : '').trim();
1016
+ }).filter(Boolean);
1017
+ const reason = named.length ? `Condition on ${named.slice(0, 3).join(', ')} not met` : 'Trigger condition not met';
1018
+ await hostRef.collection('activity').add({
1019
+ actorId: null,
1020
+ actorEmail: null,
1021
+ action: `Action skipped on ${event}`,
1022
+ result: 'skipped',
1023
+ trigger: event,
1024
+ summary: reason.slice(0, 300),
1025
+ target: {
1026
+ type: 'workflow',
1027
+ id: actionId,
1028
+ name: (_action_name = action.name) != null ? _action_name : ''
1029
+ },
1030
+ createdAt: FieldValue.serverTimestamp()
1031
+ }).catch(()=>undefined);
1032
+ }
1033
+ /**
1034
+ * Event-triggered action runner (AGL-148): loads enabled actions whose
1035
+ * `trigger.event` matches (built-in, site event, or custom), evaluates
1036
+ * optional filters over the payload, and executes each step list in
1037
+ * order. Never throws into the emitting request. Paid feature: the
1038
+ * `actions` flag gates and `actionRunsPerMonth` meters runs.
1039
+ */ export async function runEventActions(hostId, event, payload = {}, depth = 0) {
1040
+ const alerts = [];
1041
+ if (depth > ACTION_MAX_EVENT_DEPTH) return alerts;
1042
+ /*
1043
+ * A flow waiting for THIS event, for THIS person, resumes here.
1044
+ *
1045
+ * Above the action lookup and outside its try, because the two are
1046
+ * unrelated: an event that matches no action can still be the one a flow
1047
+ * has been waiting a week for, and a site with no actions at all can hold
1048
+ * enrollments from an action that has since been rewritten.
1049
+ */ await wakeFlowsAwaitingEvent(hostId, event, payload).catch(()=>undefined);
1050
+ try {
1051
+ var _ref, _ref1;
1052
+ const firestore = firebaseAdmin.app().firestore();
1053
+ const hostRef = firestore.collection('hosts').doc(hostId);
1054
+ const triggered = await hostRef.collection('actions').where('trigger.event', '==', event).limit(MAX_TRIGGERED_ACTIONS).get();
1055
+ const actions = triggered.docs.filter((doc)=>!doc.get('deletedAt') && doc.get('enabled') !== false);
1056
+ if (!actions.length) return alerts;
1057
+ const monthKey = new Date().toISOString().slice(0, 7);
1058
+ const runCounterRef = hostRef.collection('counters').doc('actionRuns');
1059
+ // Webhook steps take the higher `webhooks` gate (AGL-149); plan gates
1060
+ // ride the owning org's doc (AGL-238).
1061
+ let webhooksAllowed = true;
1062
+ let crmAllowed = true;
1063
+ // Plan-less orgs resolve as free (AGL-247) — gates always run. Held for
1064
+ // the rest of the run so the dataset caps below cost no second read.
1065
+ const owner = await getOrgForHost(hostId);
1066
+ {
1067
+ var _counterSnapshot_get;
1068
+ const org = owner == null ? void 0 : owner.org;
1069
+ if (!checkEntitlement(org, 'actions')) return alerts;
1070
+ webhooksAllowed = checkEntitlement(org, 'webhooks');
1071
+ crmAllowed = checkEntitlement(org, 'crm');
1072
+ const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
1073
+ const counterSnapshot = await runCounterRef.get();
1074
+ const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
1075
+ if (used + actions.length > limit) return alerts;
1076
+ }
1077
+ const env = {
1078
+ hostId,
1079
+ hostRef,
1080
+ alerts,
1081
+ // Admitted by the `actions` gate above.
1082
+ actionsAllowed: true,
1083
+ webhooksAllowed,
1084
+ crmAllowed,
1085
+ depth,
1086
+ org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
1087
+ orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null,
1088
+ loadWorkflowContext: makeWorkflowContextLoader(hostRef)
1089
+ };
1090
+ let executed = 0;
1091
+ for (const doc of actions){
1092
+ var _action_trigger_filter, _action_trigger;
1093
+ const action = doc.data();
1094
+ const filter = (_action_trigger = action.trigger) == null ? void 0 : (_action_trigger_filter = _action_trigger.filter) == null ? void 0 : _action_trigger_filter.trim();
1095
+ if (filter) {
1096
+ try {
1097
+ if (!evaluateExpression(filter, _extends({
1098
+ event
1099
+ }, payload))) continue;
1100
+ } catch (unused) {
1101
+ continue; // A broken filter never fires.
1102
+ }
1103
+ }
1104
+ // Structured payload conditions (AGL-557; AND/OR chaining AGL-565):
1105
+ // same scope as the filter; unmet conditions skip the action and
1106
+ // never count as a run against the quota.
1107
+ if (!evaluateTriggerConditions(action.trigger, _extends({
1108
+ event
1109
+ }, payload))) {
1110
+ // …but they are RECORDED now (AGL-2171). This was a bare
1111
+ // `continue`, so "why didn't my automation fire?" — the most
1112
+ // common support question about automations — had no answer
1113
+ // anywhere in the product, while `/product/workflows` advertises
1114
+ // an amber `Skipped` row that answers it.
1115
+ await recordSkippedRun(hostRef, doc.id, action, event);
1116
+ continue;
1117
+ }
1118
+ executed += 1;
1119
+ await executeAction(env, doc.id, action, event, payload);
1120
+ }
1121
+ if (executed > 0) {
1122
+ await runCounterRef.set({
1123
+ [monthKey]: FieldValue.increment(executed)
1124
+ }, {
1125
+ merge: true
1126
+ }).catch(()=>undefined);
1127
+ }
1128
+ } catch (error) {
1129
+ console.error('runEventActions failed', hostId, event, error);
1130
+ }
1131
+ return alerts;
1132
+ }
1133
+ /**
1134
+ * Runs ONE action's server steps (AGL-256): the tenant page runtime
1135
+ * evaluates site-event trigger conditions (scroll thresholds, selectors)
1136
+ * client-side and dispatches the specific action here — re-matching by
1137
+ * event name would wrongly fire sibling actions with different
1138
+ * thresholds. Same gates and metering as the event runner.
1139
+ */ export async function runSingleAction(hostId, actionId, event, payload = {}) {
1140
+ const alerts = [];
1141
+ try {
1142
+ var _ref, _ref1, _ref2;
1143
+ var _action_trigger;
1144
+ const firestore = firebaseAdmin.app().firestore();
1145
+ const hostRef = firestore.collection('hosts').doc(hostId);
1146
+ const doc = await hostRef.collection('actions').doc(actionId).get();
1147
+ if (!doc.exists || doc.get('deletedAt') || doc.get('enabled') === false) {
1148
+ return alerts;
1149
+ }
1150
+ const action = doc.data();
1151
+ // Only site-event actions may be dispatched externally — server
1152
+ // events flow through their own emitters.
1153
+ if (String((_ref = (_action_trigger = action.trigger) == null ? void 0 : _action_trigger.event) != null ? _ref : '') !== String(event)) return alerts;
1154
+ // Structured payload conditions (AGL-557; AND/OR chaining AGL-565):
1155
+ // the single-action dispatch path honors them too, so
1156
+ // client-evaluated triggers can't bypass them.
1157
+ if (!evaluateTriggerConditions(action.trigger, _extends({
1158
+ event
1159
+ }, payload))) {
1160
+ return alerts;
1161
+ }
1162
+ const monthKey = new Date().toISOString().slice(0, 7);
1163
+ const runCounterRef = hostRef.collection('counters').doc('actionRuns');
1164
+ let webhooksAllowed = true;
1165
+ let crmAllowed = true;
1166
+ // Held for the rest of the run, as in `runEventActions` above.
1167
+ const owner = await getOrgForHost(hostId);
1168
+ {
1169
+ var _counterSnapshot_get;
1170
+ const org = owner == null ? void 0 : owner.org;
1171
+ if (!checkEntitlement(org, 'actions')) return alerts;
1172
+ webhooksAllowed = checkEntitlement(org, 'webhooks');
1173
+ crmAllowed = checkEntitlement(org, 'crm');
1174
+ const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
1175
+ const counterSnapshot = await runCounterRef.get();
1176
+ const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
1177
+ if (used + 1 > limit) return alerts;
1178
+ }
1179
+ const env = {
1180
+ hostId,
1181
+ hostRef,
1182
+ alerts,
1183
+ // Admitted by the `actions` gate above.
1184
+ actionsAllowed: true,
1185
+ webhooksAllowed,
1186
+ crmAllowed,
1187
+ depth: 0,
1188
+ org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
1189
+ orgId: (_ref2 = owner == null ? void 0 : owner.orgId) != null ? _ref2 : null,
1190
+ loadWorkflowContext: makeWorkflowContextLoader(hostRef)
1191
+ };
1192
+ await executeAction(env, doc.id, action, event, payload);
1193
+ await runCounterRef.set({
1194
+ [monthKey]: FieldValue.increment(1)
1195
+ }, {
1196
+ merge: true
1197
+ }).catch(()=>undefined);
1198
+ } catch (error) {
1199
+ console.error('runSingleAction failed', hostId, actionId, error);
1200
+ }
1201
+ return alerts;
1202
+ }
1203
+ /**
1204
+ * Ends an enrollment its automation may no longer run, and says why in the
1205
+ * run history — the automation was deleted or switched off, or the plan that
1206
+ * carried it lapsed. Shared by both kinds of enrollment, so "stopped mid-wait"
1207
+ * reads the same for an action and a workflow.
1208
+ */ export async function stopFlowEnrollment(enrollment, ref, reason) {
1209
+ var _enrollment_actionName;
1210
+ const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(enrollment.hostId);
1211
+ await endFlowEnrollment(ref);
1212
+ await hostRef.collection('activity').add({
1213
+ actorId: null,
1214
+ actorEmail: null,
1215
+ action: `Flow stopped mid-wait: ${reason}`.slice(0, 300),
1216
+ result: 'skipped',
1217
+ trigger: enrollment.event,
1218
+ summary: reason.slice(0, 300),
1219
+ target: {
1220
+ type: 'workflow',
1221
+ id: enrollment.actionId,
1222
+ name: (_enrollment_actionName = enrollment.actionName) != null ? _enrollment_actionName : ''
1223
+ },
1224
+ createdAt: FieldValue.serverTimestamp()
1225
+ }).catch(()=>undefined);
1226
+ return 'stopped';
1227
+ }
1228
+ /**
1229
+ * Continues one enrollment from where its wait ended.
1230
+ *
1231
+ * The enrollment carries everything the run needs except the gates: the step
1232
+ * list it entered with, the position inside it, the payload the trigger
1233
+ * produced, and who it is about. What it deliberately does NOT carry is
1234
+ * permission — the `actions` entitlement, the site's lockdown and the
1235
+ * action's own enabled flag are all re-asked here, because a flow that waits
1236
+ * three days is a flow that can outlive the plan, the site and the merchant's
1237
+ * decision to run it.
1238
+ */ export async function resumeFlowEnrollment(enrollment, ref, options) {
1239
+ var _ref, _ref1, _ref2, _enrollment_payload;
1240
+ const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
1241
+ // A workflow's enrollment continues the workflow, against the workflow's
1242
+ // own kill switch and meter; every enrollment without the field is an
1243
+ // action's, which is every one written before a workflow could wait.
1244
+ if (enrollment.automation === 'workflow') {
1245
+ return await resumeWorkflowEnrollment(enrollment, ref, _extends({}, options, {
1246
+ nowMs
1247
+ }));
1248
+ }
1249
+ const hostId = enrollment.hostId;
1250
+ const firestore = firebaseAdmin.app().firestore();
1251
+ const hostRef = firestore.collection('hosts').doc(hostId);
1252
+ const stop = (reason)=>stopFlowEnrollment(enrollment, ref, reason);
1253
+ const doc = await hostRef.collection('actions').doc(enrollment.actionId).get().catch(()=>null);
1254
+ /*
1255
+ * A KILL SWITCH HAS TO KILL, including for the people already inside.
1256
+ *
1257
+ * Editing a flow leaves an enrollment alone — it runs the snapshot it
1258
+ * entered with. DELETING or DISABLING one does not: "off" that keeps mailing
1259
+ * the queue for the next three days is not off, and it is the control a
1260
+ * merchant reaches for when a flow is doing something wrong. So the two
1261
+ * cases are deliberately different, and this is the one that stops.
1262
+ */ if (!(doc == null ? void 0 : doc.exists) || doc.get('deletedAt') || doc.get('enabled') === false) {
1263
+ return await stop('the automation was turned off or deleted');
1264
+ }
1265
+ const action = doc.data();
1266
+ const owner = await getOrgForHost(hostId).catch(()=>null);
1267
+ if (!checkEntitlement(owner == null ? void 0 : owner.org, 'actions')) {
1268
+ return await stop('this site’s plan no longer includes automations');
1269
+ }
1270
+ const env = {
1271
+ hostId,
1272
+ hostRef,
1273
+ alerts: [],
1274
+ // Admitted by the `actions` gate above.
1275
+ actionsAllowed: true,
1276
+ webhooksAllowed: checkEntitlement(owner == null ? void 0 : owner.org, 'webhooks'),
1277
+ crmAllowed: checkEntitlement(owner == null ? void 0 : owner.org, 'crm'),
1278
+ depth: 0,
1279
+ org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
1280
+ orgId: (_ref2 = owner == null ? void 0 : owner.orgId) != null ? _ref2 : null,
1281
+ loadWorkflowContext: makeWorkflowContextLoader(hostRef)
1282
+ };
1283
+ const payload = _extends({}, (_enrollment_payload = enrollment.payload) != null ? _enrollment_payload : {}, (options == null ? void 0 : options.timedOut) ? {
1284
+ [FLOW_TIMED_OUT_FIELD]: true
1285
+ } : {});
1286
+ const ending = await executeAction(env, enrollment.actionId, action, enrollment.event, payload, {
1287
+ startIndex: enrollment.nextStepIndex,
1288
+ // An action's enrollment holds only Actions steps: its snapshot is the
1289
+ // action's own list.
1290
+ steps: enrollment.steps,
1291
+ enrollmentRef: ref
1292
+ });
1293
+ if (ending === 'deferred') {
1294
+ /*
1295
+ * Pushed a beat down the queue rather than retried immediately: the two
1296
+ * refusals that reach here are an hour-long platform ceiling and a
1297
+ * day-long per-person window, so retrying in sixty seconds would spend a
1298
+ * read to be told the same thing sixty more times.
1299
+ */ await deferFlowEnrollment(ref, nowMs + FLOW_CLAIM_RETRY_MS, nowMs);
1300
+ return ending;
1301
+ }
1302
+ if (ending !== 'waiting') await endFlowEnrollment(ref);
1303
+ /*
1304
+ * COUNTED, NEVER REFUSED.
1305
+ *
1306
+ * A resume is real work and belongs on the run meter, so the usage card and
1307
+ * the COGS rollup see it. It is not GATED on `actionRunsPerMonth` the way a
1308
+ * new run is, because the person is already inside the flow: refusing here
1309
+ * would abandon somebody half-way through a sequence, which is a capacity
1310
+ * limit enforced against a person rather than against the decision that
1311
+ * added them. The gate belongs at enrollment, and that is where it is.
1312
+ */ const monthKey = new Date(nowMs).toISOString().slice(0, 7);
1313
+ await hostRef.collection('counters').doc('actionRuns').set({
1314
+ [monthKey]: FieldValue.increment(1)
1315
+ }, {
1316
+ merge: true
1317
+ }).catch(()=>undefined);
1318
+ return ending;
1319
+ }
1320
+ /** How long a deferred enrollment waits before the next attempt. */ export const FLOW_CLAIM_RETRY_MS = 15 * 60000;
1321
+ /**
1322
+ * The job beat's entry point: resume every flow whose wait has ended.
1323
+ *
1324
+ * Thin on purpose. The scheduling contract — due-ness, the transactional
1325
+ * claim, the scan budget, the lockdown skip — is `sweepDueFlowEnrollments`,
1326
+ * and the work is `resumeFlowEnrollment`; this is the wire between them, so
1327
+ * neither has to import the other's dependencies to be tested.
1328
+ */ export async function runDueFlowEnrollments(gate, options) {
1329
+ return await sweepDueFlowEnrollments(gate, _extends({}, (options == null ? void 0 : options.nowMs) !== undefined ? {
1330
+ nowMs: options.nowMs
1331
+ } : {}, (options == null ? void 0 : options.scanBudget) !== undefined ? {
1332
+ scanBudget: options.scanBudget
1333
+ } : {}, (options == null ? void 0 : options.cursor) ? {
1334
+ cursor: options.cursor
1335
+ } : {}, {
1336
+ resume: async (enrollment, ref)=>{
1337
+ await resumeFlowEnrollment(enrollment, ref, _extends({
1338
+ // Reaching the sweep IS the timeout for a `waitForEvent`: the event
1339
+ // it was watching for never arrived before `resumeAtMs`. A plain
1340
+ // `wait` has no timeout to report, so the flag rides the presence of
1341
+ // an awaited event rather than the fact of being swept.
1342
+ timedOut: Boolean(enrollment.awaitingEvent)
1343
+ }, (options == null ? void 0 : options.nowMs) !== undefined ? {
1344
+ nowMs: options.nowMs
1345
+ } : {}));
1346
+ }
1347
+ }));
1348
+ }
1349
+ /**
1350
+ * Wakes the flows this person's event was being waited for.
1351
+ *
1352
+ * NOT A POLL. Nothing here scans the enrolled population asking whose turn it
1353
+ * is — the event arrives already naming a person, and the lookup is three
1354
+ * equality filters against that person's key. A site with ten thousand people
1355
+ * waiting costs the same as one with ten.
1356
+ *
1357
+ * The COST GUARD is the caller's, and it is why this takes an email rather
1358
+ * than reading one: `runEventActions` fires on every page view of every
1359
+ * published site, so it asks only for events that name a person. A page view
1360
+ * does not, and pays nothing.
1361
+ */ async function wakeFlowsAwaitingEvent(hostId, event, payload) {
1362
+ var _ref;
1363
+ const email = String((_ref = payload == null ? void 0 : payload['email']) != null ? _ref : '').trim();
1364
+ if (!email || !email.includes('@')) return;
1365
+ const waiting = await findFlowEnrollmentsAwaiting({
1366
+ hostId,
1367
+ event,
1368
+ email
1369
+ });
1370
+ for (const doc of waiting){
1371
+ const claimed = await claimFlowEnrollment(doc.ref).catch(()=>null);
1372
+ if (!claimed) continue;
1373
+ try {
1374
+ var _claimed_payload;
1375
+ /*
1376
+ * The awaited event ARRIVED, so this is not the timeout branch — and
1377
+ * the arriving payload joins the carried one, so a step after the wait
1378
+ * can read the order total the flow was waiting for.
1379
+ */ await resumeFlowEnrollment(_extends({}, claimed, {
1380
+ payload: _extends({}, (_claimed_payload = claimed.payload) != null ? _claimed_payload : {}, payload != null ? payload : {})
1381
+ }), doc.ref, {
1382
+ timedOut: false
1383
+ });
1384
+ } catch (error) {
1385
+ console.error('[flow] event wake failed', doc.ref.path, error);
1386
+ await deferFlowEnrollment(doc.ref, Date.now() + FLOW_CLAIM_RETRY_MS);
1387
+ }
1388
+ }
1389
+ }
1390
+ export default runEventActions;
1391
+
1392
+ //# sourceMappingURL=run-event-actions.js.map