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

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 (92) hide show
  1. package/README.md +22 -14
  2. package/package.json +12 -12
  3. package/src/lib/components/automation-step-fields.component.d.ts +1 -15
  4. package/src/lib/components/automation-step-fields.component.js +93 -10
  5. package/src/lib/components/automation-step-fields.component.js.map +1 -1
  6. package/src/lib/components/automation-trigger-conditions.component.d.ts +2 -1
  7. package/src/lib/components/automation-trigger-conditions.component.js.map +1 -1
  8. package/src/lib/components/host-actions-card.component.js +85 -77
  9. package/src/lib/components/host-actions-card.component.js.map +1 -1
  10. package/src/lib/components/host-webhooks-card.component.js +2 -1
  11. package/src/lib/components/host-webhooks-card.component.js.map +1 -1
  12. package/src/lib/components/host-workflows-card.component.js.map +1 -1
  13. package/src/lib/components/org-automation-editor.component.d.ts +2 -1
  14. package/src/lib/components/org-automation-editor.component.js.map +1 -1
  15. package/src/lib/components/org-automations-card.component.js +2 -1
  16. package/src/lib/components/org-automations-card.component.js.map +1 -1
  17. package/src/lib/components/run-quota-line.component.d.ts +17 -4
  18. package/src/lib/components/run-quota-line.component.js +16 -7
  19. package/src/lib/components/run-quota-line.component.js.map +1 -1
  20. package/src/lib/components/site-org-automations-panel.component.js +2 -1
  21. package/src/lib/components/site-org-automations-panel.component.js.map +1 -1
  22. package/src/lib/components/workflows-console-page.js +18 -7
  23. package/src/lib/components/workflows-console-page.js.map +1 -1
  24. package/src/lib/declarations.d.ts +28 -0
  25. package/src/lib/declarations.js +37 -0
  26. package/src/lib/declarations.js.map +1 -0
  27. package/src/lib/declarations.server.js +17 -0
  28. package/src/lib/declarations.server.js.map +1 -1
  29. package/src/lib/engine/run-event-actions.d.ts +5 -10
  30. package/src/lib/engine/run-event-actions.js +103 -320
  31. package/src/lib/engine/run-event-actions.js.map +1 -1
  32. package/src/lib/engine/run-event-automations.d.ts +1 -1
  33. package/src/lib/engine/run-event-automations.js.map +1 -1
  34. package/src/lib/engine/run-event-workflows.d.ts +2 -1
  35. package/src/lib/engine/run-event-workflows.js +27 -22
  36. package/src/lib/engine/run-event-workflows.js.map +1 -1
  37. package/src/lib/engine/run-meter.d.ts +98 -0
  38. package/src/lib/engine/run-meter.js +121 -0
  39. package/src/lib/engine/run-meter.js.map +1 -0
  40. package/src/lib/engine/run-org-automations.d.ts +3 -2
  41. package/src/lib/engine/run-org-automations.js +14 -11
  42. package/src/lib/engine/run-org-automations.js.map +1 -1
  43. package/src/lib/engine/workflow-steps.d.ts +1 -1
  44. package/src/lib/engine/workflow-steps.js +3 -2
  45. package/src/lib/engine/workflow-steps.js.map +1 -1
  46. package/src/lib/model/host-actions.d.ts +231 -0
  47. package/src/lib/model/host-actions.js +268 -0
  48. package/src/lib/model/host-actions.js.map +1 -0
  49. package/src/lib/model/org-automations.d.ts +2 -1
  50. package/src/lib/model/org-automations.js +6 -3
  51. package/src/lib/model/org-automations.js.map +1 -1
  52. package/src/lib/model/step-outcomes.d.ts +2 -2
  53. package/src/lib/model/step-outcomes.js +1 -1
  54. package/src/lib/model/step-outcomes.js.map +1 -1
  55. package/src/lib/plugin.js +10 -0
  56. package/src/lib/plugin.js.map +1 -1
  57. package/src/lib/server/action-test-run-route.d.ts +3 -2
  58. package/src/lib/server/action-test-run-route.js +3 -2
  59. package/src/lib/server/action-test-run-route.js.map +1 -1
  60. package/src/lib/server/org-automations-routes.d.ts +42 -1
  61. package/src/lib/server/org-automations-routes.js +120 -72
  62. package/src/lib/server/org-automations-routes.js.map +1 -1
  63. package/src/lib/server-automation-drafts.d.ts +2 -2
  64. package/src/lib/server-automation-drafts.js +9 -6
  65. package/src/lib/server-automation-drafts.js.map +1 -1
  66. package/src/lib/server.js +19 -13
  67. package/src/lib/server.js.map +1 -1
  68. package/src/lib/testing/stand-in-crm-steps.d.ts +45 -0
  69. package/src/lib/testing/stand-in-crm-steps.js +97 -0
  70. package/src/lib/testing/stand-in-crm-steps.js.map +1 -0
  71. package/src/lib/testing/stand-in-dataset-list.js +11 -3
  72. package/src/lib/testing/stand-in-dataset-list.js.map +1 -1
  73. package/src/lib/testing/stand-in-dataset-steps.d.ts +43 -0
  74. package/src/lib/testing/stand-in-dataset-steps.js +65 -0
  75. package/src/lib/testing/stand-in-dataset-steps.js.map +1 -0
  76. package/src/lib/testing/stand-in-person-records.d.ts +35 -0
  77. package/src/lib/testing/stand-in-person-records.js +54 -0
  78. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  79. package/src/lib/testing/stand-in-recipes.d.ts +35 -0
  80. package/src/lib/testing/stand-in-recipes.js +201 -0
  81. package/src/lib/testing/stand-in-recipes.js.map +1 -0
  82. package/src/lib/transfer/org-automations-package.d.ts +54 -0
  83. package/src/lib/transfer/org-automations-package.js +167 -0
  84. package/src/lib/transfer/org-automations-package.js.map +1 -0
  85. package/src/lib/transfer/org-automations-package.server.d.ts +38 -0
  86. package/src/lib/transfer/org-automations-package.server.js +296 -0
  87. package/src/lib/transfer/org-automations-package.server.js.map +1 -0
  88. package/src/lib/usage-axes.js +5 -1
  89. package/src/lib/usage-axes.js.map +1 -1
  90. package/src/lib/engine/crm-action-steps.d.ts +0 -142
  91. package/src/lib/engine/crm-action-steps.js +0 -452
  92. package/src/lib/engine/crm-action-steps.js.map +0 -1
@@ -14,15 +14,15 @@ 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, checkQuota, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, FLOW_TIMED_OUT_FIELD, flowSubscriptionTopicId, hostPublicOrigin, isClientActionStep, isFlowSuspendingStep, datasetDisplayName, describeDatasetRecordErrors, contactContainerFieldPath, datasetIntegrityFields, datasetIntegrityUpdate, effectiveDatasetModel, ensureDeclaredCustomFieldTypes, normalizeTriggerConditions, prepareDatasetRecordWrite, resolveOrgEntitlements } from "@aglyn/aglyn/server";
17
+ */ import { ACTION_MAX_STEPS, 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 { 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";
19
+ import { enrollListMember, firebaseAdmin, flowEmailRefusal, getOrgForHost, hostSendingIdentity, meterHostEmail, notifyHostManagers, consentGroupForSite, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
21
20
  import { activitySearchTokens } from "@aglyn/aglyn/app-utils/activity-search";
22
21
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
23
- // The leaf, not the barrel: this library's specs substitute the barrel
24
- // wholesale, and the lookup must reach the real index logic under them.
25
- import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
22
+ // The person behind an address, and their filing under a campaign, through
23
+ // the plugin that keeps people (AGL-3080): the engine opens none of its
24
+ // collections.
25
+ import { filePluginPersonUnder, findPluginPerson } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
26
26
  import { findOrgContainersByName, readOrgContainers } from "@aglyn/tenant-data-admin/server/org-containers";
27
27
  // The leaf again, for the phishing screen's hold (AGL-3356): the control
28
28
  // that stops a send must be the real one under a spec that mocks the barrel.
@@ -35,9 +35,8 @@ import { ACTION_MAX_EVENT_DEPTH, runWorkflow, WORKFLOW_MAX_STEPS } from "../mode
35
35
  import { describeStepOutcome } from "../model/step-outcomes.js";
36
36
  import { WEBHOOK_URL_PATTERN } from "../model/webhooks.js";
37
37
  import { eventRunSuspension } from "./site-suspension.js";
38
+ import { recordRuns, runMonthKey, runsUsedThisMonth } from "./run-meter.js";
38
39
  import { advanceFlowEnrollment, claimFlowEnrollment, deferFlowEnrollment, endFlowEnrollment, enrollInFlow, findFlowEnrollmentsAwaiting, sweepDueFlowEnrollments } from "./flow-enrollments.js";
39
- import { isCrmActionStep, logCrmEmailActivity, prepareCrmEmailActivity, runCrmActionStep } from "./crm-action-steps.js";
40
- import { resolveDatasetDoc } from "@aglyn/tenant-runtime/resolve-dataset";
41
40
  import { resumeWorkflowEnrollment, runEventWorkflows } from "./run-event-workflows.js";
42
41
  import { ORG_AUTOMATION_RUN_TARGET, orgAutomationStepRefusal } from "../model/org-automations.js";
43
42
  import { findOrgAutomationsForEvent, resumeOrgAutomationEnrollment } from "./run-org-automations.js";
@@ -45,6 +44,8 @@ import { isWorkflowActionStep, workflowActionStepRefusal, workflowHasActionSteps
45
44
  import { runTriggeredByFields } from "./run-trigger-actor.js";
46
45
  // By path, not the barrel: only a server run asks.
47
46
  import { pluginServerStepExecutor } from "@aglyn/aglyn/plugin-manager/plugin-server-steps";
47
+ import { preparePluginRecordEmail } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
48
+ import { FLOW_TIMED_OUT_FIELD, isFlowSuspendingStep } from "../model/host-actions.js";
48
49
  /** Bounded fan-out per event, mirroring the workflow runner. */ const MAX_TRIGGERED_ACTIONS = 10;
49
50
  /**
50
51
  * The run environment for one automation run: the org's plan gates resolved
@@ -59,7 +60,6 @@ import { pluginServerStepExecutor } from "@aglyn/aglyn/plugin-manager/plugin-ser
59
60
  alerts: (_input_alerts = input.alerts) != null ? _input_alerts : [],
60
61
  actionsAllowed: checkEntitlement(org, 'actions'),
61
62
  webhooksAllowed: checkEntitlement(org, 'webhooks'),
62
- crmAllowed: checkEntitlement(org, 'crm'),
63
63
  depth: input.depth,
64
64
  org,
65
65
  orgId: (_ref1 = (_input_owner1 = input.owner) == null ? void 0 : _input_owner1.orgId) != null ? _ref1 : null,
@@ -113,76 +113,6 @@ export function makeWorkflowContextLoader(hostRef) {
113
113
  workflows
114
114
  };
115
115
  }
116
- /**
117
- * Whether this dataset may take ANOTHER record, on the plan of the org that
118
- * owns the site — the row band and the byte band, in that order. Null when
119
- * the append may proceed; a reason string when it may not.
120
- *
121
- * ## Why an append needs a gate at all
122
- *
123
- * Every other door onto `datasets/{id}/records` already has one: the console
124
- * route re-checks `recordsPerDataset` inside the creating transaction, the
125
- * `/v1` record route checks it below its idempotency claim, and the public
126
- * form-submission leg checks the rows and the bytes. A workflow step wrote
127
- * with no check of either — and it is the door a visitor drives hardest,
128
- * because an action fires per event on a published site. A cap enforced at
129
- * three of four doors is not a cap; it is the shape of the one that is left.
130
- *
131
- * ## What it does NOT do
132
- *
133
- * It refuses the WRITE, never the dataset. A dataset already holding more
134
- * rows than the plan includes keeps every row it has and keeps being read —
135
- * nothing here deletes, truncates, or hides anything, and nothing may be
136
- * added that does. What is refused is the next row, which is the same
137
- * boundary the other three doors draw, and the reason a plan change cannot
138
- * cost a customer data they already have.
139
- *
140
- * The update leg of `updateDataset` is deliberately NOT gated: merging fields
141
- * into a record that already exists adds no row, so refusing it would refuse
142
- * the state of being over rather than the raise.
143
- *
144
- * ## What it costs
145
- *
146
- * Nothing on the plans that sell the data store. The row count is read only
147
- * when `recordsPerDataset` is FINITE, so an uncapped plan pays nothing; and
148
- * `dataStorageRefusal` answers null with no read at all whenever the plan
149
- * carries an `extraDataGbMonthlyUsd` rate, which every metered plan does. The
150
- * reads are paid on the shapes that can actually refuse.
151
- */ /**
152
- * Refresh the live pages showing the dataset this step just wrote to
153
- * (AGL-3113).
154
- *
155
- * An automation that appends a row is the same change to a visitor as a form
156
- * submission or a console edit: the pages repeating over the dataset go on
157
- * serving the rows they were built from. Announced from the step rather than
158
- * from the run, so a workflow whose steps write two different datasets
159
- * refreshes both — the announce coalesces repeats of the SAME dataset itself,
160
- * which is what a run hitting one dataset several times needs.
161
- *
162
- * Silent without an org: datasets are org-scoped, so a host with no resolvable
163
- * org has no dataset to have written to. Best effort, and never thrown: the
164
- * row is already stored.
165
- */ async function announceDatasetStepWrite(env, datasetId) {
166
- if (!env.orgId) return;
167
- await announceDatasetRecordChange({
168
- firestore: firebaseAdmin.app().firestore(),
169
- orgId: env.orgId,
170
- datasetId
171
- });
172
- }
173
- async function datasetAppendRefusal(env, datasetRef) {
174
- const limit = resolveOrgEntitlements(env.org).recordsPerDataset;
175
- if (Number.isFinite(limit)) {
176
- const used = (await datasetRef.collection('records').count().get()).data().count;
177
- if (!checkQuota(env.org, 'recordsPerDataset', used).allowed) {
178
- return `dataset is full (${limit} records on this plan)`;
179
- }
180
- }
181
- if (!env.orgId) return null;
182
- const bytes = await dataStorageRefusal(env.org, firebaseAdmin.app().firestore().collection('orgs').doc(env.orgId));
183
- if (!bytes) return null;
184
- return `dataset storage is full (${bytes.includedMb} MB on this plan)`;
185
- }
186
116
  /**
187
117
  * Folds one step's verdict into its run's tally, in the run-history phrasing
188
118
  * every automation shares; true when the run stops at this step.
@@ -234,7 +164,7 @@ async function datasetAppendRefusal(env, datasetRef) {
234
164
  *
235
165
  * An action's step list runs through it one step at a time, and so does
236
166
  * every Actions step inside a workflow — so the two engines cannot disagree
237
- * about what `sendEmail` or `datasetAppend` does, what a step is gated on, or
167
+ * about what `sendEmail` or `webhookPost` does, what a step is gated on, or
238
168
  * how its outcome reads in the run history. There is no second copy of any
239
169
  * branch below.
240
170
  *
@@ -246,8 +176,8 @@ async function datasetAppendRefusal(env, datasetRef) {
246
176
  const { hostId, hostRef, alerts, depth } = env;
247
177
  const { event, payload, enrollmentRef } = context;
248
178
  /**
249
- * The one fact worth carrying into the summary — the dataset's name,
250
- * the webhook's status. Set by the branch that knows it.
179
+ * The one fact worth carrying into the summary — the webhook's status, or
180
+ * whatever a plugin's step answers. Set by the branch that knows it.
251
181
  */ let detail;
252
182
  const failed = (error)=>({
253
183
  kind: 'failed',
@@ -394,140 +324,6 @@ async function datasetAppendRefusal(env, datasetRef) {
394
324
  // until AGL-2171. A 200 and a 204 are both `ok`, and knowing which is
395
325
  // the whole reason anyone opens a run history after a webhook.
396
326
  detail = String(lastStatus != null ? lastStatus : '');
397
- } else if (step.type === 'datasetAppend') {
398
- // Id-first lookup (AGL-261/556); the name query is the legacy path.
399
- const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
400
- const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId);
401
- if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) {
402
- return failed(`unknown dataset "${step.datasetName || step.datasetId}"`);
403
- }
404
- // Restrict to the model's field ids (AGL-556) — covers model-only
405
- // datasets whose flat v1 `fields` mirror is absent.
406
- const appendDataset = {
407
- model: datasetDoc.get('model'),
408
- fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
409
- };
410
- const appendModel = effectiveDatasetModel(appendDataset);
411
- // A plugin's field validator runs only once its plugin registered it.
412
- await ensureDeclaredCustomFieldTypes(appendModel);
413
- const write = prepareDatasetRecordWrite(appendDataset, payload);
414
- const values = write.values;
415
- // Same name precedence `findDatasetByName` resolves in.
416
- const appendLabel = (datasetDisplayName({
417
- displayName: datasetDoc.get('displayName'),
418
- name: datasetDoc.get('name')
419
- }) || step.datasetName || '').slice(0, 60);
420
- // No event field matched a field of the dataset, so there is nothing
421
- // to write. An error rather than a quiet success: a run history that
422
- // says `saved to Leads` while nothing saves is how a mismatched field
423
- // name goes unnoticed.
424
- if (!write.matched.length) {
425
- return failed(`no event field matches a field in dataset "${appendLabel || step.datasetId}"`);
426
- }
427
- // Held to the model like every other record write (AGL-2773): a value
428
- // its field cannot hold refuses the whole record, and the run says
429
- // which field and why instead of `saved to Leads`.
430
- if (Object.keys(write.errors).length) {
431
- return failed(`record failed validation for dataset "${appendLabel || step.datasetId}": ${describeDatasetRecordErrors(write.errors)}`);
432
- }
433
- if (!Object.keys(values).length) {
434
- return failed(`every event field matching dataset "${appendLabel || step.datasetId}" is empty`);
435
- }
436
- const refusal = await datasetAppendRefusal(env, datasetDoc.ref);
437
- if (refusal) return failed(refusal);
438
- await datasetDoc.ref.collection('records').add(_extends({
439
- values
440
- }, datasetIntegrityFields(appendModel, values), {
441
- createdAt: FieldValue.serverTimestamp()
442
- }));
443
- await announceDatasetStepWrite(env, datasetDoc.id);
444
- // `saved to Leads` beats `saved to dataset` (AGL-2171).
445
- detail = appendLabel;
446
- } else if (step.type === 'updateDataset') {
447
- var _payload_email;
448
- // Update-or-append (AGL-257): matches the record whose `email`
449
- // field equals the payload's email; appends when nothing matches.
450
- const datasetsRef = await orgDataCollectionForHost(hostId, 'datasets');
451
- const datasetDoc = await resolveDatasetDoc(datasetsRef, step, hostId);
452
- if (!(datasetDoc == null ? void 0 : datasetDoc.exists) || datasetDoc.get('deletedAt')) {
453
- return failed(`unknown dataset "${step.datasetName || step.datasetId}"`);
454
- }
455
- const updateDataset = {
456
- model: datasetDoc.get('model'),
457
- fields: Array.isArray(datasetDoc.get('fields')) ? datasetDoc.get('fields') : []
458
- };
459
- const updateModel = effectiveDatasetModel(updateDataset);
460
- await ensureDeclaredCustomFieldTypes(updateModel);
461
- const updateLabel = String(datasetDisplayName({
462
- displayName: datasetDoc.get('displayName'),
463
- name: datasetDoc.get('name')
464
- }) || step.datasetName || step.datasetId || '').slice(0, 60);
465
- // Checked before the lookup, as an append: the fields this write
466
- // supplies are held to the model whichever leg runs below, and a
467
- // refusal costs no read.
468
- const incoming = prepareDatasetRecordWrite(updateDataset, payload);
469
- // Nothing to merge or append — an error, for the reason the append
470
- // branch above gives.
471
- if (!incoming.matched.length) {
472
- return failed(`no event field matches a field in dataset "${updateLabel}"`);
473
- }
474
- if (!Object.keys(incoming.values).length) {
475
- return failed(`every event field matching dataset "${updateLabel}" is empty`);
476
- }
477
- const email = String((_payload_email = payload.email) != null ? _payload_email : '').trim();
478
- // `records.values` is exempt from indexing, so this lookup is served
479
- // only by the `values.email` field override in
480
- // cloud/firebase-firestore.indexes.json. Without that override
481
- // production refuses the query and neither leg below runs.
482
- const existing = email ? await datasetDoc.ref.collection('records').where('values.email', '==', email).limit(1).get() : null;
483
- if (existing && !existing.empty) {
484
- var _existing_docs__get;
485
- // A merge holds only the fields this write sent to the model: a row
486
- // stored as text before AGL-2773 is not refused for a legacy value
487
- // this run never touched.
488
- const write = prepareDatasetRecordWrite(updateDataset, payload, {
489
- existing: (_existing_docs__get = existing.docs[0].get('values')) != null ? _existing_docs__get : {}
490
- });
491
- if (Object.keys(write.errors).length) {
492
- return failed(`record failed validation for dataset "${updateLabel}": ${describeDatasetRecordErrors(write.errors)}`);
493
- }
494
- const merged = write.values;
495
- await existing.docs[0].ref.set(_extends({
496
- values: merged
497
- }, datasetIntegrityUpdate(updateModel, merged, FieldValue.delete()), {
498
- updatedAt: FieldValue.serverTimestamp()
499
- }), // `mergeFields`, not `merge: true`: a merge would fold the new
500
- // `filterValues` map into the stored one key by key, and a value
501
- // cleared here would go on answering its old equality. Each named
502
- // field is replaced whole; `merged` already holds every value.
503
- {
504
- mergeFields: [
505
- 'values',
506
- 'referencedIds',
507
- 'filterKeys',
508
- 'filterValues',
509
- 'updatedAt'
510
- ]
511
- });
512
- } else {
513
- // The APPEND leg of update-or-append, and the only one of the two
514
- // that adds a row — the merge above rewrites a record that already
515
- // counts against the band. A new row is held to the whole model,
516
- // required fields included, like any other append.
517
- if (Object.keys(incoming.errors).length) {
518
- return failed(`record failed validation for dataset "${updateLabel}": ${describeDatasetRecordErrors(incoming.errors)}`);
519
- }
520
- const refusal = await datasetAppendRefusal(env, datasetDoc.ref);
521
- if (refusal) return failed(refusal);
522
- await datasetDoc.ref.collection('records').add(_extends({
523
- values: incoming.values
524
- }, datasetIntegrityFields(updateModel, incoming.values), {
525
- createdAt: FieldValue.serverTimestamp()
526
- }));
527
- }
528
- // Both legs changed a row, so both make the same pages stale — an edited
529
- // record reads no differently from a new one on a page that lists them.
530
- await announceDatasetStepWrite(env, datasetDoc.id);
531
327
  } else if (step.type === 'notifyAdmins') {
532
328
  var _step_title;
533
329
  await notifyHostManagers(hostId, _extends({
@@ -539,7 +335,7 @@ async function datasetAppendRefusal(env, datasetRef) {
539
335
  link: `/${hostId}`
540
336
  }));
541
337
  } else if (step.type === 'sendEmail') {
542
- var _payload_, _ref4, _hostPublicOrigin, _step_topicId, _step_subject, _step_body, _ref5, _run_orgId, _env_org, _env_org1, _screened_releasedReviewId;
338
+ var _payload_, _ref4, _hostPublicOrigin, _step_topicId, _step_subject, _step_body, _ref5, _run_orgId, _env_org, _env_org1, _payload_contactId, _payload_email, _screened_releasedReviewId;
543
339
  var _step_toField, _this;
544
340
  const to = String((_payload_ = payload[((_step_toField = step.toField) == null ? void 0 : _step_toField.trim()) || 'email']) != null ? _payload_ : '').trim();
545
341
  if (!isEmailConfigured()) return failed('email is not configured');
@@ -653,19 +449,26 @@ async function datasetAppendRefusal(env, datasetRef) {
653
449
  }
654
450
  const consentGroup = consentGroupForHost((_env_org1 = env.org) != null ? _env_org1 : null, hostId);
655
451
  /*
656
- * THE TIMELINE ENTRY (AGL-2615). A message addressed to the contact
657
- * the event is about is logged on that contact's timeline as an
658
- * email activity — the same row the console's own send logs — so an
659
- * automated welcome shows beside the calls a rep made, with its
660
- * delivery state. Prepared first because the row's id has to be on
661
- * the message for the webhook to find it; written only after the
662
- * provider accepted. Behind the suite gate like every other CRM
663
- * write an action makes.
664
- */ const emailActivity = env.crmAllowed ? await prepareCrmEmailActivity({
452
+ * THE TIMELINE ENTRY (AGL-2615). A message addressed to the person the
453
+ * event is about is filed on that person's timeline — the same entry a
454
+ * member's own send files — so an automated welcome shows beside the
455
+ * calls a rep made, with its delivery state. Whether the message earns
456
+ * an entry, and where, is the record system's to say: it is offered
457
+ * the message first, because the entry's tags have to ride the message
458
+ * for the delivery webhook to find it, and files it only after the
459
+ * provider accepted. No record system, or a message that earns no
460
+ * entry, sends untagged.
461
+ */ const recordEmail = env.orgId ? await preparePluginRecordEmail({
462
+ orgId: env.orgId,
665
463
  hostId,
666
- org: env.org,
667
- orgId: env.orgId
668
- }, to, payload) : null;
464
+ to,
465
+ link: _extends({}, String((_payload_contactId = payload['contactId']) != null ? _payload_contactId : '').trim() ? {
466
+ contactId: String(payload['contactId']).trim()
467
+ } : {}, String((_payload_email = payload['email']) != null ? _payload_email : '').trim() ? {
468
+ email: String(payload['email']).trim()
469
+ } : {}),
470
+ org: env.org
471
+ }) : null;
669
472
  // In the site's header and footer (AGL-3370): a workflow's email is the
670
473
  // site writing to its contact, in words the site owner typed.
671
474
  const framed = await renderSiteTextEmail(firebaseAdmin.app().firestore(), hostId, 'workflow-email', {
@@ -680,8 +483,10 @@ async function datasetAppendRefusal(env, datasetRef) {
680
483
  html: framed.html
681
484
  } : {}, {
682
485
  sendingIdentity: await hostSendingIdentity(hostId)
683
- }, emailActivity ? {
684
- tags: emailActivity.tags
486
+ }, recordEmail ? {
487
+ tags: [
488
+ ...recordEmail.tags
489
+ ]
685
490
  } : {}, {
686
491
  audience: 'tenant',
687
492
  context: enrollmentRef ? 'flow step' : 'event action',
@@ -730,17 +535,12 @@ async function datasetAppendRefusal(env, datasetRef) {
730
535
  // cost.
731
536
  if (result.sent) {
732
537
  await meterHostEmail(hostId);
733
- if (emailActivity) {
734
- await logCrmEmailActivity({
735
- hostId,
736
- org: env.org,
737
- orgId: env.orgId
738
- }, emailActivity, {
739
- subject: emailSubject,
740
- body: emailText,
741
- to
742
- }, run.id);
743
- }
538
+ await (recordEmail == null ? void 0 : recordEmail.file({
539
+ subject: emailSubject,
540
+ body: emailText,
541
+ to,
542
+ sourceRef: run.id
543
+ }));
744
544
  }
745
545
  if (sendError) return failed(sendError);
746
546
  } else if (step.type === 'enrollList') {
@@ -774,12 +574,14 @@ async function datasetAppendRefusal(env, datasetRef) {
774
574
  return failed('no contact email to assign');
775
575
  }
776
576
  // Scoped to this host (AGL-1039): a site must not reach a contact
777
- // it cannot see, even to tag it onto a campaign. Through the org's
778
- // address index (AGL-2633), so an address a merge folded into
779
- // another record still names the person who now holds it.
780
- const { ref: contactsRef } = await orgDataQueryForHost(hostId, 'contacts');
781
- const contact = await findContactByEmail(contactsRef, email, {
782
- hostId
577
+ // it cannot see, even to tag it onto a campaign. Asked of the plugin
578
+ // that keeps people (AGL-3080), whose lookup answers an address a
579
+ // merge folded into another record (AGL-2633) with the person who now
580
+ // holds it.
581
+ const contact = await findPluginPerson({
582
+ hostId,
583
+ email,
584
+ onlyVisibleToSite: true
783
585
  });
784
586
  if (!contact) return failed(`no contact for ${email}`);
785
587
  /*
@@ -827,42 +629,24 @@ async function datasetAppendRefusal(env, datasetRef) {
827
629
  return failed(`campaign "${campaignLabel}" is not placed on this site`);
828
630
  }
829
631
  /*
830
- * INSIDE THIS SITE'S FACET, not at the top of the document.
831
- *
832
- * A contact is one row shared by every site in the org, and which
833
- * campaigns a merchant has filed somebody under is that merchant's
834
- * business record on the same footing as their notes and their tags.
835
- * Written at the top it would be readable by every other site in an
836
- * agency's account.
837
- *
838
- * `update` with a dotted path, never `set({merge:true})`: a `set`
839
- * treats the string as a literal field NAME and would mint a
840
- * top-level key with dots in it. The document was just read, so the
841
- * update cannot fail for absence.
842
- */ const group = await consentGroupForSite(hostId);
843
- await contact.ref.update({
844
- [contactContainerFieldPath(group.groupId, 'campaign')]: FieldValue.arrayUnion(campaign.id),
845
- updatedAt: FieldValue.serverTimestamp()
846
- });
847
- } else if (isCrmActionStep(step)) {
848
- // The plan gate, the way `webhookPost` takes the `webhooks` one:
849
- // refused into the run history with the tier that carries it, so
850
- // a Free workspace whose flow names a CRM step reads why the step
851
- // did nothing rather than a log that says it ran.
852
- if (!env.crmAllowed) {
853
- return failed(`CRM steps require the ${planLabelGrantingFeature('crm')} plan`);
854
- }
855
- // The five CRM steps (AGL-2605) share a resolver and a scope, so
856
- // they share a module; see `crm-action-steps.ts`.
857
- const outcome = await runCrmActionStep({
632
+ * Filed by the plugin that keeps people, as THIS SITE holds them
633
+ * (AGL-3080): which campaigns a merchant has filed somebody under is
634
+ * that merchant's business record, on the same footing as their notes
635
+ * and their tags, and where it lives on the person is the owner's to
636
+ * know. A person the owner no longer finds for the site is not filed.
637
+ */ const filed = await filePluginPersonUnder({
858
638
  hostId,
859
- org: env.org,
860
- orgId: env.orgId
861
- }, run.id, step, payload);
862
- if (outcome.error) return failed(outcome.error);
863
- detail = outcome.detail;
864
- // A stage set by an automation IS a stage change; see `raiseEarnedEvent`.
865
- if (outcome.emit) await raiseEarnedEvent(env, outcome.emit);
639
+ orgId: campaignOrgId,
640
+ record: {
641
+ kind: contact.kind,
642
+ id: contact.id
643
+ },
644
+ containerKind: 'campaign',
645
+ ids: [
646
+ campaign.id
647
+ ]
648
+ });
649
+ if (!(filed == null ? void 0 : filed.filed)) return failed(`no contact for ${email}`);
866
650
  } else {
867
651
  /*
868
652
  * A STEP ANOTHER PLUGIN RUNS: one that writes that plugin's records,
@@ -1273,12 +1057,9 @@ async function datasetAppendRefusal(env, datasetRef) {
1273
1057
  * this site's own actions exactly as before.
1274
1058
  */ const placed = await findOrgAutomationsForEvent(hostId, event);
1275
1059
  if (!actions.length && !placed) return alerts;
1276
- const monthKey = new Date().toISOString().slice(0, 7);
1277
- const runCounterRef = hostRef.collection('counters').doc('actionRuns');
1278
1060
  // Webhook steps take the higher `webhooks` gate (AGL-149); plan gates
1279
1061
  // ride the owning org's doc (AGL-238).
1280
1062
  let webhooksAllowed = true;
1281
- let crmAllowed = true;
1282
1063
  /*
1283
1064
  * Whether each half fits under the month's allowance — all or nothing per
1284
1065
  * half, as the site's own actions always were.
@@ -1287,25 +1068,32 @@ async function datasetAppendRefusal(env, datasetRef) {
1287
1068
  * were always asked, so an organization placing automations on a site can
1288
1069
  * never be what stops that site's own actions running. The org half is
1289
1070
  * asked second, against what is left after the site's half, and both
1290
- * count on this site's one meter: an org automation's run is this site's
1071
+ * count on this site's meter: an org automation's run is this site's
1291
1072
  * run, and the usage card, the usage alerts and the COGS rollup read it
1292
1073
  * there.
1293
1074
  */ let runSite = false;
1294
1075
  let runOrg = false;
1295
1076
  // Plan-less orgs resolve as free (AGL-247) — gates always run. Held for
1296
- // the rest of the run so the dataset caps below cost no second read.
1077
+ // the rest of the run so a step's own plan check costs no second read.
1297
1078
  const owner = await getOrgForHost(hostId);
1079
+ // The band is the WORKSPACE's (AGL-3472): every site's runs count
1080
+ // against it, read off the org's counter — see `run-meter.ts`.
1081
+ const meter = {
1082
+ firestore,
1083
+ hostRef,
1084
+ orgId: owner == null ? void 0 : owner.orgId,
1085
+ counter: 'actionRuns',
1086
+ month: runMonthKey()
1087
+ };
1298
1088
  // A suspended site runs nothing (AGL-3356): see `eventRunSuspension`.
1299
1089
  if (await eventRunSuspension(hostRef, owner == null ? void 0 : owner.org)) return alerts;
1300
1090
  {
1301
- var _counterSnapshot_get, _ref2;
1091
+ var _ref2;
1302
1092
  const org = owner == null ? void 0 : owner.org;
1303
1093
  if (!checkEntitlement(org, 'actions')) return alerts;
1304
1094
  webhooksAllowed = checkEntitlement(org, 'webhooks');
1305
- crmAllowed = checkEntitlement(org, 'crm');
1306
1095
  const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
1307
- const counterSnapshot = await runCounterRef.get();
1308
- const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
1096
+ const used = await runsUsedThisMonth(meter);
1309
1097
  // Asked as "would it go over", the question this gate has always
1310
1098
  // asked — so a counter that reads as no number refuses nothing, as
1311
1099
  // before, rather than refusing everything.
@@ -1324,7 +1112,6 @@ async function datasetAppendRefusal(env, datasetRef) {
1324
1112
  // Admitted by the `actions` gate above.
1325
1113
  actionsAllowed: true,
1326
1114
  webhooksAllowed,
1327
- crmAllowed,
1328
1115
  depth,
1329
1116
  org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
1330
1117
  orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null,
@@ -1391,13 +1178,9 @@ async function datasetAppendRefusal(env, datasetRef) {
1391
1178
  }
1392
1179
  });
1393
1180
  }
1394
- if (executed > 0) {
1395
- await runCounterRef.set({
1396
- [monthKey]: FieldValue.increment(executed)
1397
- }, {
1398
- merge: true
1399
- }).catch(()=>undefined);
1400
- }
1181
+ await recordRuns(_extends({}, meter, {
1182
+ count: executed
1183
+ }));
1401
1184
  } catch (error) {
1402
1185
  console.error('runEventActions failed', hostId, event, error);
1403
1186
  }
@@ -1450,21 +1233,23 @@ async function datasetAppendRefusal(env, datasetRef) {
1450
1233
  }, payload))) {
1451
1234
  return skipped('conditions');
1452
1235
  }
1453
- const monthKey = new Date().toISOString().slice(0, 7);
1454
- const runCounterRef = hostRef.collection('counters').doc('actionRuns');
1455
1236
  let webhooksAllowed = true;
1456
- let crmAllowed = true;
1457
1237
  // Held for the rest of the run, as in `runEventActions` above.
1458
1238
  const owner = await getOrgForHost(hostId);
1239
+ // The workspace's band, as in `runEventActions` above (AGL-3472).
1240
+ const meter = {
1241
+ firestore,
1242
+ hostRef,
1243
+ orgId: owner == null ? void 0 : owner.orgId,
1244
+ counter: 'actionRuns',
1245
+ month: runMonthKey()
1246
+ };
1459
1247
  {
1460
- var _counterSnapshot_get;
1461
1248
  const org = owner == null ? void 0 : owner.org;
1462
1249
  if (!checkEntitlement(org, 'actions')) return skipped('plan');
1463
1250
  webhooksAllowed = checkEntitlement(org, 'webhooks');
1464
- crmAllowed = checkEntitlement(org, 'crm');
1465
1251
  const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
1466
- const counterSnapshot = await runCounterRef.get();
1467
- const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
1252
+ const used = await runsUsedThisMonth(meter);
1468
1253
  if (used + 1 > limit) return skipped('allowance', limit);
1469
1254
  }
1470
1255
  const env = {
@@ -1474,18 +1259,15 @@ async function datasetAppendRefusal(env, datasetRef) {
1474
1259
  // Admitted by the `actions` gate above.
1475
1260
  actionsAllowed: true,
1476
1261
  webhooksAllowed,
1477
- crmAllowed,
1478
1262
  depth: 0,
1479
1263
  org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
1480
1264
  orgId: (_ref2 = owner == null ? void 0 : owner.orgId) != null ? _ref2 : null,
1481
1265
  loadWorkflowContext: makeWorkflowContextLoader(hostRef)
1482
1266
  };
1483
1267
  await executeAction(env, doc.id, action, event, payload);
1484
- await runCounterRef.set({
1485
- [monthKey]: FieldValue.increment(1)
1486
- }, {
1487
- merge: true
1488
- }).catch(()=>undefined);
1268
+ await recordRuns(_extends({}, meter, {
1269
+ count: 1
1270
+ }));
1489
1271
  return {
1490
1272
  ran: true,
1491
1273
  skipped: null,
@@ -1584,7 +1366,6 @@ async function datasetAppendRefusal(env, datasetRef) {
1584
1366
  // Admitted by the `actions` gate above.
1585
1367
  actionsAllowed: true,
1586
1368
  webhooksAllowed: checkEntitlement(owner == null ? void 0 : owner.org, 'webhooks'),
1587
- crmAllowed: checkEntitlement(owner == null ? void 0 : owner.org, 'crm'),
1588
1369
  depth: 0,
1589
1370
  org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
1590
1371
  orgId: (_ref2 = owner == null ? void 0 : owner.orgId) != null ? _ref2 : null,
@@ -1619,12 +1400,14 @@ async function datasetAppendRefusal(env, datasetRef) {
1619
1400
  * would abandon somebody half-way through a sequence, which is a capacity
1620
1401
  * limit enforced against a person rather than against the decision that
1621
1402
  * added them. The gate belongs at enrollment, and that is where it is.
1622
- */ const monthKey = new Date(nowMs).toISOString().slice(0, 7);
1623
- await hostRef.collection('counters').doc('actionRuns').set({
1624
- [monthKey]: FieldValue.increment(1)
1625
- }, {
1626
- merge: true
1627
- }).catch(()=>undefined);
1403
+ */ await recordRuns({
1404
+ firestore,
1405
+ hostRef,
1406
+ orgId: owner == null ? void 0 : owner.orgId,
1407
+ counter: 'actionRuns',
1408
+ month: runMonthKey(nowMs),
1409
+ count: 1
1410
+ });
1628
1411
  return ending;
1629
1412
  }
1630
1413
  /** How long a deferred enrollment waits before the next attempt. */ export const FLOW_CLAIM_RETRY_MS = 15 * 60000;