@aglyn/plugins-workflows 1.0.0-beta.221 → 1.0.0-beta.223
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.
- package/README.md +22 -14
- package/package.json +12 -12
- package/src/lib/components/automation-step-fields.component.d.ts +16 -16
- package/src/lib/components/automation-step-fields.component.js +146 -12
- package/src/lib/components/automation-step-fields.component.js.map +1 -1
- package/src/lib/components/automation-trigger-conditions.component.d.ts +25 -3
- package/src/lib/components/automation-trigger-conditions.component.js +89 -10
- package/src/lib/components/automation-trigger-conditions.component.js.map +1 -1
- package/src/lib/components/host-actions-card.component.js +126 -84
- package/src/lib/components/host-actions-card.component.js.map +1 -1
- package/src/lib/components/host-webhooks-card.component.js +2 -1
- package/src/lib/components/host-webhooks-card.component.js.map +1 -1
- package/src/lib/components/host-workflows-card.component.js +31 -10
- package/src/lib/components/host-workflows-card.component.js.map +1 -1
- package/src/lib/components/org-automation-editor.component.d.ts +2 -1
- package/src/lib/components/org-automation-editor.component.js +20 -11
- package/src/lib/components/org-automation-editor.component.js.map +1 -1
- package/src/lib/components/org-automations-card.component.js +2 -1
- package/src/lib/components/org-automations-card.component.js.map +1 -1
- package/src/lib/components/run-quota-line.component.d.ts +17 -4
- package/src/lib/components/run-quota-line.component.js +16 -7
- package/src/lib/components/run-quota-line.component.js.map +1 -1
- package/src/lib/components/site-org-automations-panel.component.js +2 -1
- package/src/lib/components/site-org-automations-panel.component.js.map +1 -1
- package/src/lib/components/workflows-console-page.js +18 -7
- package/src/lib/components/workflows-console-page.js.map +1 -1
- package/src/lib/declarations.d.ts +28 -0
- package/src/lib/declarations.js +37 -0
- package/src/lib/declarations.js.map +1 -0
- package/src/lib/declarations.server.js +17 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/engine/email-merge.d.ts +69 -0
- package/src/lib/engine/email-merge.js +98 -0
- package/src/lib/engine/email-merge.js.map +1 -0
- package/src/lib/engine/run-event-actions.d.ts +35 -10
- package/src/lib/engine/run-event-actions.js +256 -369
- package/src/lib/engine/run-event-actions.js.map +1 -1
- package/src/lib/engine/run-event-automations.d.ts +1 -1
- package/src/lib/engine/run-event-automations.js.map +1 -1
- package/src/lib/engine/run-event-workflows.d.ts +2 -1
- package/src/lib/engine/run-event-workflows.js +71 -30
- package/src/lib/engine/run-event-workflows.js.map +1 -1
- package/src/lib/engine/run-meter.d.ts +98 -0
- package/src/lib/engine/run-meter.js +121 -0
- package/src/lib/engine/run-meter.js.map +1 -0
- package/src/lib/engine/run-org-automations.d.ts +3 -2
- package/src/lib/engine/run-org-automations.js +17 -12
- package/src/lib/engine/run-org-automations.js.map +1 -1
- package/src/lib/engine/triggered-docs.d.ts +39 -0
- package/src/lib/engine/triggered-docs.js +47 -0
- package/src/lib/engine/triggered-docs.js.map +1 -0
- package/src/lib/engine/workflow-steps.d.ts +1 -1
- package/src/lib/engine/workflow-steps.js +3 -2
- package/src/lib/engine/workflow-steps.js.map +1 -1
- package/src/lib/model/host-actions.d.ts +284 -0
- package/src/lib/model/host-actions.js +316 -0
- package/src/lib/model/host-actions.js.map +1 -0
- package/src/lib/model/org-automations.d.ts +8 -5
- package/src/lib/model/org-automations.js +14 -8
- package/src/lib/model/org-automations.js.map +1 -1
- package/src/lib/model/step-outcomes.d.ts +2 -2
- package/src/lib/model/step-outcomes.js +1 -1
- package/src/lib/model/step-outcomes.js.map +1 -1
- package/src/lib/plugin.js +10 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/action-test-run-route.d.ts +3 -2
- package/src/lib/server/action-test-run-route.js +3 -2
- package/src/lib/server/action-test-run-route.js.map +1 -1
- package/src/lib/server/org-automations-routes.d.ts +42 -1
- package/src/lib/server/org-automations-routes.js +120 -72
- package/src/lib/server/org-automations-routes.js.map +1 -1
- package/src/lib/server-automation-drafts.d.ts +2 -2
- package/src/lib/server-automation-drafts.js +11 -7
- package/src/lib/server-automation-drafts.js.map +1 -1
- package/src/lib/server.js +19 -13
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-crm-steps.d.ts +45 -0
- package/src/lib/testing/stand-in-crm-steps.js +97 -0
- package/src/lib/testing/stand-in-crm-steps.js.map +1 -0
- package/src/lib/testing/stand-in-dataset-list.js +11 -3
- package/src/lib/testing/stand-in-dataset-list.js.map +1 -1
- package/src/lib/testing/stand-in-dataset-steps.d.ts +43 -0
- package/src/lib/testing/stand-in-dataset-steps.js +65 -0
- package/src/lib/testing/stand-in-dataset-steps.js.map +1 -0
- package/src/lib/testing/stand-in-person-records.d.ts +35 -0
- package/src/lib/testing/stand-in-person-records.js +54 -0
- package/src/lib/testing/stand-in-person-records.js.map +1 -0
- package/src/lib/testing/stand-in-recipes.d.ts +36 -0
- package/src/lib/testing/stand-in-recipes.js +206 -0
- package/src/lib/testing/stand-in-recipes.js.map +1 -0
- package/src/lib/transfer/org-automations-package.d.ts +54 -0
- package/src/lib/transfer/org-automations-package.js +167 -0
- package/src/lib/transfer/org-automations-package.js.map +1 -0
- package/src/lib/transfer/org-automations-package.server.d.ts +38 -0
- package/src/lib/transfer/org-automations-package.server.js +296 -0
- package/src/lib/transfer/org-automations-package.server.js.map +1 -0
- package/src/lib/usage-axes.js +5 -1
- package/src/lib/usage-axes.js.map +1 -1
- package/src/lib/engine/crm-action-steps.d.ts +0 -142
- package/src/lib/engine/crm-action-steps.js +0 -452
- package/src/lib/engine/crm-action-steps.js.map +0 -1
|
@@ -14,15 +14,16 @@ 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,
|
|
17
|
+
*/ import { ACTION_MAX_STEPS, ACTIONS_MAX_PER_HOST, checkEntitlement, consentGroupForHost, planLabelGrantingFeature, evaluateExpression, evaluateStepGuard, evaluateTriggerConditions, flowSubscriptionTopicId, hostPublicOrigin, isClientActionStep, normalizeTriggerConditions, resolveOrgEntitlements } from "@aglyn/aglyn/server";
|
|
18
18
|
import { isDeferrableSendResult, isEmailConfigured, sendEmail, sendFailureReason } from "@aglyn/shared-util-email";
|
|
19
|
-
import {
|
|
20
|
-
import { announceDatasetRecordChange } from "@aglyn/tenant-data-admin/server/dataset-live-pages";
|
|
19
|
+
import { enrollListMember, filterSendableForHost, firebaseAdmin, flowEmailRefusal, getOrgForHost, hostDisplayName, 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
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
22
|
+
import { triggerFilterProblem } from "@aglyn/aglyn/app-utils/site-interactions";
|
|
23
|
+
// The person behind an address, and their filing under a campaign, through
|
|
24
|
+
// the plugin that keeps people (AGL-3080): the engine opens none of its
|
|
25
|
+
// collections.
|
|
26
|
+
import { filePluginPersonUnder, findPluginPerson } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
|
|
26
27
|
import { findOrgContainersByName, readOrgContainers } from "@aglyn/tenant-data-admin/server/org-containers";
|
|
27
28
|
// The leaf again, for the phishing screen's hold (AGL-3356): the control
|
|
28
29
|
// that stops a send must be the real one under a spec that mocks the barrel.
|
|
@@ -35,9 +36,10 @@ import { ACTION_MAX_EVENT_DEPTH, runWorkflow, WORKFLOW_MAX_STEPS } from "../mode
|
|
|
35
36
|
import { describeStepOutcome } from "../model/step-outcomes.js";
|
|
36
37
|
import { WEBHOOK_URL_PATTERN } from "../model/webhooks.js";
|
|
37
38
|
import { eventRunSuspension } from "./site-suspension.js";
|
|
39
|
+
import { triggeredDocsForEvent } from "./triggered-docs.js";
|
|
40
|
+
import { resolveStepEmailMerge, stepEmailMergeContext } from "./email-merge.js";
|
|
41
|
+
import { recordRuns, runMonthKey, runsUsedThisMonth } from "./run-meter.js";
|
|
38
42
|
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
43
|
import { resumeWorkflowEnrollment, runEventWorkflows } from "./run-event-workflows.js";
|
|
42
44
|
import { ORG_AUTOMATION_RUN_TARGET, orgAutomationStepRefusal } from "../model/org-automations.js";
|
|
43
45
|
import { findOrgAutomationsForEvent, resumeOrgAutomationEnrollment } from "./run-org-automations.js";
|
|
@@ -45,7 +47,20 @@ import { isWorkflowActionStep, workflowActionStepRefusal, workflowHasActionSteps
|
|
|
45
47
|
import { runTriggeredByFields } from "./run-trigger-actor.js";
|
|
46
48
|
// By path, not the barrel: only a server run asks.
|
|
47
49
|
import { pluginServerStepExecutor } from "@aglyn/aglyn/plugin-manager/plugin-server-steps";
|
|
48
|
-
|
|
50
|
+
import { preparePluginRecordEmail } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
|
|
51
|
+
import { FLOW_TIMED_OUT_FIELD, isFlowSuspendingStep, sendEmailIsTransactionalReply } from "../model/host-actions.js";
|
|
52
|
+
/**
|
|
53
|
+
* Every live, switched-on action a site holds for an event runs, in
|
|
54
|
+
* document-id order (AGL-3458) — up to the most live actions a site may hold
|
|
55
|
+
* at all, so no matching action is ever left out. See `triggered-docs.ts`.
|
|
56
|
+
*/ const MAX_TRIGGERED_ACTIONS = ACTIONS_MAX_PER_HOST;
|
|
57
|
+
/** The live, switched-on actions a site holds for `event`, in document-id order. */ export function liveActionsForEvent(hostRef, event) {
|
|
58
|
+
return triggeredDocsForEvent(hostRef.collection('actions'), event, {
|
|
59
|
+
keep: (doc)=>!doc.get('deletedAt') && doc.get('enabled') !== false,
|
|
60
|
+
max: MAX_TRIGGERED_ACTIONS,
|
|
61
|
+
maxReads: MAX_TRIGGERED_ACTIONS * 4
|
|
62
|
+
});
|
|
63
|
+
}
|
|
49
64
|
/**
|
|
50
65
|
* The run environment for one automation run: the org's plan gates resolved
|
|
51
66
|
* once, from the org document the caller already read.
|
|
@@ -59,7 +74,6 @@ import { pluginServerStepExecutor } from "@aglyn/aglyn/plugin-manager/plugin-ser
|
|
|
59
74
|
alerts: (_input_alerts = input.alerts) != null ? _input_alerts : [],
|
|
60
75
|
actionsAllowed: checkEntitlement(org, 'actions'),
|
|
61
76
|
webhooksAllowed: checkEntitlement(org, 'webhooks'),
|
|
62
|
-
crmAllowed: checkEntitlement(org, 'crm'),
|
|
63
77
|
depth: input.depth,
|
|
64
78
|
org,
|
|
65
79
|
orgId: (_ref1 = (_input_owner1 = input.owner) == null ? void 0 : _input_owner1.orgId) != null ? _ref1 : null,
|
|
@@ -113,76 +127,6 @@ export function makeWorkflowContextLoader(hostRef) {
|
|
|
113
127
|
workflows
|
|
114
128
|
};
|
|
115
129
|
}
|
|
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
130
|
/**
|
|
187
131
|
* Folds one step's verdict into its run's tally, in the run-history phrasing
|
|
188
132
|
* every automation shares; true when the run stops at this step.
|
|
@@ -234,7 +178,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
234
178
|
*
|
|
235
179
|
* An action's step list runs through it one step at a time, and so does
|
|
236
180
|
* every Actions step inside a workflow — so the two engines cannot disagree
|
|
237
|
-
* about what `sendEmail` or `
|
|
181
|
+
* about what `sendEmail` or `webhookPost` does, what a step is gated on, or
|
|
238
182
|
* how its outcome reads in the run history. There is no second copy of any
|
|
239
183
|
* branch below.
|
|
240
184
|
*
|
|
@@ -246,8 +190,8 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
246
190
|
const { hostId, hostRef, alerts, depth } = env;
|
|
247
191
|
const { event, payload, enrollmentRef } = context;
|
|
248
192
|
/**
|
|
249
|
-
* The one fact worth carrying into the summary — the
|
|
250
|
-
*
|
|
193
|
+
* The one fact worth carrying into the summary — the webhook's status, or
|
|
194
|
+
* whatever a plugin's step answers. Set by the branch that knows it.
|
|
251
195
|
*/ let detail;
|
|
252
196
|
const failed = (error)=>({
|
|
253
197
|
kind: 'failed',
|
|
@@ -394,140 +338,6 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
394
338
|
// until AGL-2171. A 200 and a 204 are both `ok`, and knowing which is
|
|
395
339
|
// the whole reason anyone opens a run history after a webhook.
|
|
396
340
|
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
341
|
} else if (step.type === 'notifyAdmins') {
|
|
532
342
|
var _step_title;
|
|
533
343
|
await notifyHostManagers(hostId, _extends({
|
|
@@ -539,7 +349,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
539
349
|
link: `/${hostId}`
|
|
540
350
|
}));
|
|
541
351
|
} else if (step.type === 'sendEmail') {
|
|
542
|
-
var _payload_, _ref4, _hostPublicOrigin,
|
|
352
|
+
var _payload_, _ref4, _hostPublicOrigin, _env_org, _step_subject, _step_body, _payload_email, _ref5, _payload_name, _ref6, _run_orgId, _env_org1, _payload_contactId, _payload_email1, _screened_releasedReviewId;
|
|
543
353
|
var _step_toField, _this;
|
|
544
354
|
const to = String((_payload_ = payload[((_step_toField = step.toField) == null ? void 0 : _step_toField.trim()) || 'email']) != null ? _payload_ : '').trim();
|
|
545
355
|
if (!isEmailConfigured()) return failed('email is not configured');
|
|
@@ -553,13 +363,14 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
553
363
|
const hostData = (_ref4 = (_this = await hostRef.get().catch(()=>null)) == null ? void 0 : _this.data()) != null ? _ref4 : null;
|
|
554
364
|
const siteBase = (_hostPublicOrigin = hostPublicOrigin(hostData)) != null ? _hostPublicOrigin : '';
|
|
555
365
|
/*
|
|
556
|
-
* MARKETING
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
366
|
+
* MARKETING, unless it is a transactional reply (below). The subject
|
|
367
|
+
* and body are merchant-authored and the recipient comes out of the
|
|
368
|
+
* event payload — which, for the collect route, is a write triggered
|
|
369
|
+
* by an anonymous visitor. So a step that is not a reply is a site
|
|
370
|
+
* mailing an address on the merchant's say-so, and it owes what every
|
|
371
|
+
* other such message owes: the unsubscribe header pair and a visible
|
|
372
|
+
* link, both suppression lists, and a share of the ceiling on how much
|
|
373
|
+
* one person receives from this site.
|
|
563
374
|
*
|
|
564
375
|
* Priority stays transactional. An action run is not resumable — the
|
|
565
376
|
* event has already happened and there is no beat that comes back for
|
|
@@ -597,27 +408,74 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
597
408
|
* enrollment moves on rather than retrying.
|
|
598
409
|
*/ const scope = enrollmentRef ? 'scheduled' : 'immediate';
|
|
599
410
|
/*
|
|
411
|
+
* A TRANSACTIONAL REPLY (AGL-3458) — the answer to what the recipient
|
|
412
|
+
* just did: the run started on their own form submission, booking or
|
|
413
|
+
* sign-up, this step runs at once, names no topic, and goes to the
|
|
414
|
+
* address the event carries. Such a message is not a mailing, so it
|
|
415
|
+
* leaves with no unsubscribe header and no unsubscribe link, and asks
|
|
416
|
+
* no marketing-consent question — the reply the Inbox sends to a
|
|
417
|
+
* submission is the same kind of message, and CAN-SPAM exempts both.
|
|
418
|
+
* What it keeps is the suppression check below: a hard bounce, a spam
|
|
419
|
+
* complaint or a person who asked this site to stop is not mailed by
|
|
420
|
+
* anything. The author can switch a qualifying step back to a mailing
|
|
421
|
+
* (`transactional: false`); a step that does not qualify is a mailing
|
|
422
|
+
* whatever it says.
|
|
423
|
+
*/ const transactional = sendEmailIsTransactionalReply(step, {
|
|
424
|
+
event,
|
|
425
|
+
afterWait: Boolean(enrollmentRef)
|
|
426
|
+
});
|
|
427
|
+
/*
|
|
600
428
|
* The stream this message belongs to, resolved ONCE and read twice:
|
|
601
429
|
* the gate below filters on it, and it rides the `marketing` context
|
|
602
430
|
* so the opt-out link the seam mints names it. Without that the
|
|
603
431
|
* preference page opens on a list of every stream the site has, and
|
|
604
432
|
* the recipient has to find the one they were trying to leave.
|
|
605
433
|
*/ const topicId = flowSubscriptionTopicId(step.topicId, scope);
|
|
606
|
-
const
|
|
434
|
+
const consentGroup = consentGroupForHost((_env_org = env.org) != null ? _env_org : null, hostId);
|
|
435
|
+
if (transactional) {
|
|
436
|
+
// Both suppression lists — the platform's bounces and complaints, and
|
|
437
|
+
// this site's (and its consent group's) opt-outs — fail CLOSED.
|
|
438
|
+
const sendable = await filterSendableForHost(hostId, [
|
|
439
|
+
to
|
|
440
|
+
], undefined, consentGroup);
|
|
441
|
+
if (!sendable.length) return failed('the recipient is unsubscribed or suppressed');
|
|
442
|
+
} else {
|
|
443
|
+
var _step_topicId;
|
|
444
|
+
const gate = await flowEmailRefusal({
|
|
445
|
+
hostId,
|
|
446
|
+
email: to,
|
|
447
|
+
topicId: (_step_topicId = step.topicId) != null ? _step_topicId : null,
|
|
448
|
+
org: env.org,
|
|
449
|
+
scope
|
|
450
|
+
});
|
|
451
|
+
if (gate) {
|
|
452
|
+
return failed(gate !== 'consent-withheld' ? 'the recipient has left this email topic' : // only on a stated refusal, so reporting it as a missing
|
|
453
|
+
// record would send a merchant looking for a consent field to
|
|
454
|
+
// fill in that would change nothing.
|
|
455
|
+
enrollmentRef ? 'the recipient has no marketing consent record on this site' : 'the recipient has declined marketing from this site');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/*
|
|
459
|
+
* THE WORDS AS AUTHORED, and as sent (AGL-3458). The merge tags — the
|
|
460
|
+
* campaign's `{{firstName|there}}` and the CRM's `{{contact.firstName}}`
|
|
461
|
+
* — are filled from the contact or the lead the run is about, else from
|
|
462
|
+
* the name and address the event carried. The phishing screen below
|
|
463
|
+
* reads the AUTHORED words: it judges what the business wrote, once per
|
|
464
|
+
* automation, not one copy per recipient.
|
|
465
|
+
*/ const authoredSubject = String((_step_subject = step.subject) != null ? _step_subject : '').slice(0, 200);
|
|
466
|
+
const authoredText = String((_step_body = step.body) != null ? _step_body : '').slice(0, 5000);
|
|
467
|
+
const mergeContext = await stepEmailMergeContext({
|
|
607
468
|
hostId,
|
|
608
|
-
email: to,
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
scope
|
|
469
|
+
email: String((_payload_email = payload['email']) != null ? _payload_email : '').trim() || to,
|
|
470
|
+
contactGroupId: consentGroup.groupId,
|
|
471
|
+
siteName: hostDisplayName(hostData != null ? hostData : undefined, hostId)
|
|
612
472
|
});
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const emailSubject = String((_step_subject = step.subject) != null ? _step_subject : '').slice(0, 200);
|
|
620
|
-
const emailText = String((_step_body = step.body) != null ? _step_body : '').slice(0, 5000);
|
|
473
|
+
const mergePerson = {
|
|
474
|
+
email: to,
|
|
475
|
+
name: String((_ref5 = (_payload_name = payload['name']) != null ? _payload_name : payload['fullName']) != null ? _ref5 : '').trim()
|
|
476
|
+
};
|
|
477
|
+
const emailSubject = resolveStepEmailMerge(authoredSubject, mergeContext, mergePerson).text.replace(/[\r\n]+/g, ' ').slice(0, 200);
|
|
478
|
+
const emailText = resolveStepEmailMerge(authoredText, mergeContext, mergePerson).text.slice(0, 5000);
|
|
621
479
|
/*
|
|
622
480
|
* THE PHISHING SCREEN, for a workspace in its first fortnight
|
|
623
481
|
* (AGL-3356). The incident's second message was exactly this step: a
|
|
@@ -632,14 +490,14 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
632
490
|
* once released. See `outbound-send-review.ts`.
|
|
633
491
|
*/ const screened = await screenOutboundSend({
|
|
634
492
|
kind: run.kind,
|
|
635
|
-
path: run.kind === 'orgAutomation' ? `orgs/${(
|
|
493
|
+
path: run.kind === 'orgAutomation' ? `orgs/${(_ref6 = (_run_orgId = run.orgId) != null ? _run_orgId : env.orgId) != null ? _ref6 : ''}/automations/${run.id}` : `hosts/${hostId}/${run.kind === 'workflow' ? 'workflows' : 'actions'}/${run.id}`,
|
|
636
494
|
hostId,
|
|
637
495
|
orgId: env.orgId,
|
|
638
|
-
org: (
|
|
496
|
+
org: (_env_org1 = env.org) != null ? _env_org1 : null,
|
|
639
497
|
host: hostData,
|
|
640
|
-
subject:
|
|
498
|
+
subject: authoredSubject,
|
|
641
499
|
bodies: [
|
|
642
|
-
|
|
500
|
+
authoredText
|
|
643
501
|
]
|
|
644
502
|
});
|
|
645
503
|
if (screened.outcome === 'rejected') {
|
|
@@ -651,21 +509,27 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
651
509
|
};
|
|
652
510
|
return failed('this email is held for staff review because it may impersonate ' + `another business (${screened.reference})`);
|
|
653
511
|
}
|
|
654
|
-
const consentGroup = consentGroupForHost((_env_org1 = env.org) != null ? _env_org1 : null, hostId);
|
|
655
512
|
/*
|
|
656
|
-
* THE TIMELINE ENTRY (AGL-2615). A message addressed to the
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* the message
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
|
|
513
|
+
* THE TIMELINE ENTRY (AGL-2615). A message addressed to the person the
|
|
514
|
+
* event is about is filed on that person's timeline — the same entry a
|
|
515
|
+
* member's own send files — so an automated welcome shows beside the
|
|
516
|
+
* calls a rep made, with its delivery state. Whether the message earns
|
|
517
|
+
* an entry, and where, is the record system's to say: it is offered
|
|
518
|
+
* the message first, because the entry's tags have to ride the message
|
|
519
|
+
* for the delivery webhook to find it, and files it only after the
|
|
520
|
+
* provider accepted. No record system, or a message that earns no
|
|
521
|
+
* entry, sends untagged.
|
|
522
|
+
*/ const recordEmail = env.orgId ? await preparePluginRecordEmail({
|
|
523
|
+
orgId: env.orgId,
|
|
665
524
|
hostId,
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
525
|
+
to,
|
|
526
|
+
link: _extends({}, String((_payload_contactId = payload['contactId']) != null ? _payload_contactId : '').trim() ? {
|
|
527
|
+
contactId: String(payload['contactId']).trim()
|
|
528
|
+
} : {}, String((_payload_email1 = payload['email']) != null ? _payload_email1 : '').trim() ? {
|
|
529
|
+
email: String(payload['email']).trim()
|
|
530
|
+
} : {}),
|
|
531
|
+
org: env.org
|
|
532
|
+
}) : null;
|
|
669
533
|
// In the site's header and footer (AGL-3370): a workflow's email is the
|
|
670
534
|
// site writing to its contact, in words the site owner typed.
|
|
671
535
|
const framed = await renderSiteTextEmail(firebaseAdmin.app().firestore(), hostId, 'workflow-email', {
|
|
@@ -680,8 +544,10 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
680
544
|
html: framed.html
|
|
681
545
|
} : {}, {
|
|
682
546
|
sendingIdentity: await hostSendingIdentity(hostId)
|
|
683
|
-
},
|
|
684
|
-
tags:
|
|
547
|
+
}, recordEmail ? {
|
|
548
|
+
tags: [
|
|
549
|
+
...recordEmail.tags
|
|
550
|
+
]
|
|
685
551
|
} : {}, {
|
|
686
552
|
audience: 'tenant',
|
|
687
553
|
context: enrollmentRef ? 'flow step' : 'event action',
|
|
@@ -690,13 +556,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
690
556
|
releasedReviewId: screened.outcome === 'send' ? (_screened_releasedReviewId = screened.releasedReviewId) != null ? _screened_releasedReviewId : null : null
|
|
691
557
|
}, enrollmentRef ? {
|
|
692
558
|
priority: 'bulk'
|
|
693
|
-
} : {}, {
|
|
694
|
-
// `topicId` is `''` for a step that belongs to no stream, which
|
|
695
|
-
// every reader of it treats as absent — see `flowSubscriptionTopicId`.
|
|
696
|
-
// The consent group comes off the org the run already holds, so the
|
|
697
|
-
// gate honors an unsubscribe from any site of a declared group —
|
|
698
|
-
// the sender this site mails as — and the org's confirmation switch,
|
|
699
|
-
// at no extra read.
|
|
559
|
+
} : {}, transactional ? {} : {
|
|
700
560
|
marketing: {
|
|
701
561
|
hostId,
|
|
702
562
|
siteBase,
|
|
@@ -730,29 +590,24 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
730
590
|
// cost.
|
|
731
591
|
if (result.sent) {
|
|
732
592
|
await meterHostEmail(hostId);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
subject: emailSubject,
|
|
740
|
-
body: emailText,
|
|
741
|
-
to
|
|
742
|
-
}, run.id);
|
|
743
|
-
}
|
|
593
|
+
await (recordEmail == null ? void 0 : recordEmail.file({
|
|
594
|
+
subject: emailSubject,
|
|
595
|
+
body: emailText,
|
|
596
|
+
to,
|
|
597
|
+
sourceRef: run.id
|
|
598
|
+
}));
|
|
744
599
|
}
|
|
745
600
|
if (sendError) return failed(sendError);
|
|
746
601
|
} else if (step.type === 'enrollList') {
|
|
747
|
-
var
|
|
602
|
+
var _payload_email2, _ref7;
|
|
748
603
|
var _step_listId, _step_listName;
|
|
749
604
|
const orgId = await resolveOrgIdForHost(hostId);
|
|
750
|
-
const email = String((
|
|
605
|
+
const email = String((_payload_email2 = payload.email) != null ? _payload_email2 : '').trim().toLowerCase();
|
|
751
606
|
if (!orgId || !email || !email.includes('@')) {
|
|
752
607
|
return failed('no email to enroll');
|
|
753
608
|
}
|
|
754
609
|
const listsRef = firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('lists');
|
|
755
|
-
const listDoc = ((_step_listId = step.listId) == null ? void 0 : _step_listId.trim()) ? await listsRef.doc(step.listId.trim()).get() : (await listsRef.where('name', '==', (
|
|
610
|
+
const listDoc = ((_step_listId = step.listId) == null ? void 0 : _step_listId.trim()) ? await listsRef.doc(step.listId.trim()).get() : (await listsRef.where('name', '==', (_ref7 = (_step_listName = step.listName) == null ? void 0 : _step_listName.trim()) != null ? _ref7 : '').limit(1).get()).docs[0];
|
|
756
611
|
if (!(listDoc == null ? void 0 : listDoc.exists)) {
|
|
757
612
|
return failed(`unknown list "${step.listName || step.listId}"`);
|
|
758
613
|
}
|
|
@@ -767,19 +622,21 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
767
622
|
source: `${run.kind}:${run.id}`
|
|
768
623
|
});
|
|
769
624
|
} else if (step.type === 'assignCampaign') {
|
|
770
|
-
var
|
|
625
|
+
var _payload_email3, _env_orgId, _ref8, _ref9;
|
|
771
626
|
var _step_campaignId, _step_campaignName;
|
|
772
|
-
const email = String((
|
|
627
|
+
const email = String((_payload_email3 = payload.email) != null ? _payload_email3 : '').trim().toLowerCase();
|
|
773
628
|
if (!email || !email.includes('@')) {
|
|
774
629
|
return failed('no contact email to assign');
|
|
775
630
|
}
|
|
776
631
|
// Scoped to this host (AGL-1039): a site must not reach a contact
|
|
777
|
-
// it cannot see, even to tag it onto a campaign.
|
|
778
|
-
//
|
|
779
|
-
// another record
|
|
780
|
-
|
|
781
|
-
const contact = await
|
|
782
|
-
hostId
|
|
632
|
+
// it cannot see, even to tag it onto a campaign. Asked of the plugin
|
|
633
|
+
// that keeps people (AGL-3080), whose lookup answers an address a
|
|
634
|
+
// merge folded into another record (AGL-2633) with the person who now
|
|
635
|
+
// holds it.
|
|
636
|
+
const contact = await findPluginPerson({
|
|
637
|
+
hostId,
|
|
638
|
+
email,
|
|
639
|
+
onlyVisibleToSite: true
|
|
783
640
|
});
|
|
784
641
|
if (!contact) return failed(`no contact for ${email}`);
|
|
785
642
|
/*
|
|
@@ -813,10 +670,10 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
813
670
|
// declaration says they are stored.
|
|
814
671
|
const containerStore = firebaseAdmin.app().firestore();
|
|
815
672
|
const usable = (container)=>Boolean((container == null ? void 0 : container.live) && visibleToHost(container.visibleTo, hostId));
|
|
816
|
-
const namedId = (
|
|
673
|
+
const namedId = (_ref8 = (_step_campaignId = step.campaignId) == null ? void 0 : _step_campaignId.trim()) != null ? _ref8 : '';
|
|
817
674
|
const campaign = namedId ? (await readOrgContainers(containerStore, 'campaign', campaignOrgId, [
|
|
818
675
|
namedId
|
|
819
|
-
]))[0] : (await findOrgContainersByName(containerStore, 'campaign', campaignOrgId, (
|
|
676
|
+
]))[0] : (await findOrgContainersByName(containerStore, 'campaign', campaignOrgId, (_ref9 = (_step_campaignName = step.campaignName) == null ? void 0 : _step_campaignName.trim()) != null ? _ref9 : '', 10)).find(usable);
|
|
820
677
|
if (!(campaign == null ? void 0 : campaign.exists)) {
|
|
821
678
|
return failed(`unknown campaign "${campaignLabel}"`);
|
|
822
679
|
}
|
|
@@ -827,42 +684,24 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
827
684
|
return failed(`campaign "${campaignLabel}" is not placed on this site`);
|
|
828
685
|
}
|
|
829
686
|
/*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
|
|
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({
|
|
687
|
+
* Filed by the plugin that keeps people, as THIS SITE holds them
|
|
688
|
+
* (AGL-3080): which campaigns a merchant has filed somebody under is
|
|
689
|
+
* that merchant's business record, on the same footing as their notes
|
|
690
|
+
* and their tags, and where it lives on the person is the owner's to
|
|
691
|
+
* know. A person the owner no longer finds for the site is not filed.
|
|
692
|
+
*/ const filed = await filePluginPersonUnder({
|
|
858
693
|
hostId,
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
694
|
+
orgId: campaignOrgId,
|
|
695
|
+
record: {
|
|
696
|
+
kind: contact.kind,
|
|
697
|
+
id: contact.id
|
|
698
|
+
},
|
|
699
|
+
containerKind: 'campaign',
|
|
700
|
+
ids: [
|
|
701
|
+
campaign.id
|
|
702
|
+
]
|
|
703
|
+
});
|
|
704
|
+
if (!(filed == null ? void 0 : filed.filed)) return failed(`no contact for ${email}`);
|
|
866
705
|
} else {
|
|
867
706
|
/*
|
|
868
707
|
* A STEP ANOTHER PLUGIN RUNS: one that writes that plugin's records,
|
|
@@ -1195,9 +1034,45 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1195
1034
|
* tunes by watching the site, not by reading a log. The events people
|
|
1196
1035
|
* actually debug are the server-emitted ones (a form submission, a
|
|
1197
1036
|
* booking, a sign-up), and those are low-volume by construction.
|
|
1198
|
-
*/ const SKIP_LOG_EXCLUDED_EVENTS = new Set([
|
|
1037
|
+
*/ export const SKIP_LOG_EXCLUDED_EVENTS = new Set([
|
|
1199
1038
|
'pageView'
|
|
1200
1039
|
]);
|
|
1040
|
+
/**
|
|
1041
|
+
* Whether an action's unmet conditions only say it is ANOTHER FORM'S
|
|
1042
|
+
* (AGL-3458) — the case that is noise rather than an answer.
|
|
1043
|
+
*
|
|
1044
|
+
* A site with one auto-reply per form keyed on `formId equals …` would write
|
|
1045
|
+
* a `Skipped` row for every other form's reply on every submission: eight
|
|
1046
|
+
* rows a fill on a nine-form site, burying the one row that explains a real
|
|
1047
|
+
* miss. A form picked by id cannot be mistyped, so its mismatch against a
|
|
1048
|
+
* submission that names a DIFFERENT form says only "not this form", and it
|
|
1049
|
+
* is not recorded. Every other unmet condition still is: a hand-typed
|
|
1050
|
+
* `formName`, a field condition, a submission that names no form at all.
|
|
1051
|
+
*
|
|
1052
|
+
* With `and`, one such clause is the whole answer; with `or`, only when every
|
|
1053
|
+
* clause is about which form it was — `formId` or `formName` equals — since
|
|
1054
|
+
* any other clause could have been the one that was meant to match.
|
|
1055
|
+
*/ export function conditionsNameAnotherForm(trigger, payload) {
|
|
1056
|
+
var _payload_formId;
|
|
1057
|
+
const submitted = String((_payload_formId = payload['formId']) != null ? _payload_formId : '').trim();
|
|
1058
|
+
if (!submitted) return false;
|
|
1059
|
+
const clauses = normalizeTriggerConditions(trigger);
|
|
1060
|
+
const another = (clause)=>{
|
|
1061
|
+
var _clause_field, _clause_value, _clause_value1;
|
|
1062
|
+
return ((_clause_field = clause.field) == null ? void 0 : _clause_field.trim()) === 'formId' && clause.op === 'equals' && Boolean((_clause_value = clause.value) == null ? void 0 : _clause_value.trim()) && ((_clause_value1 = clause.value) == null ? void 0 : _clause_value1.trim()) !== submitted;
|
|
1063
|
+
};
|
|
1064
|
+
if ((trigger == null ? void 0 : trigger.combinator) === 'or') {
|
|
1065
|
+
return clauses.some(another) && clauses.every((clause)=>{
|
|
1066
|
+
var _ref;
|
|
1067
|
+
var _clause_field;
|
|
1068
|
+
return clause.op === 'equals' && [
|
|
1069
|
+
'formId',
|
|
1070
|
+
'formName'
|
|
1071
|
+
].includes((_ref = (_clause_field = clause.field) == null ? void 0 : _clause_field.trim()) != null ? _ref : '');
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
return clauses.some(another);
|
|
1075
|
+
}
|
|
1201
1076
|
/**
|
|
1202
1077
|
* Writes the `Skipped` row (AGL-2171): which condition stopped the run,
|
|
1203
1078
|
* so the answer to "why didn't it fire?" is in the same place as every
|
|
@@ -1205,7 +1080,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1205
1080
|
*
|
|
1206
1081
|
* Never counts against `actionRunsPerMonth` — nothing executed, and
|
|
1207
1082
|
* charging for a condition that said no would be its own bug.
|
|
1208
|
-
*/ async function recordSkippedRun(hostRef, actionId, action, event, kind = 'action') {
|
|
1083
|
+
*/ async function recordSkippedRun(hostRef, actionId, action, event, kind = 'action', /** What stopped it, when it was not an unmet condition. */ because) {
|
|
1209
1084
|
var _action_name;
|
|
1210
1085
|
if (SKIP_LOG_EXCLUDED_EVENTS.has(event)) return;
|
|
1211
1086
|
// `normalizeTriggerConditions`, not `trigger.conditions` — a pre-AGL-565
|
|
@@ -1215,7 +1090,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1215
1090
|
var _ref;
|
|
1216
1091
|
return String((_ref = condition == null ? void 0 : condition.field) != null ? _ref : '').trim();
|
|
1217
1092
|
}).filter(Boolean);
|
|
1218
|
-
const reason = named.length ? `Condition on ${named.slice(0, 3).join(', ')} not met` : 'Trigger condition not met';
|
|
1093
|
+
const reason = because != null ? because : named.length ? `Condition on ${named.slice(0, 3).join(', ')} not met` : 'Trigger condition not met';
|
|
1219
1094
|
await hostRef.collection('activity').add(_extends({
|
|
1220
1095
|
actorId: null,
|
|
1221
1096
|
actorEmail: null
|
|
@@ -1262,8 +1137,7 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1262
1137
|
var _ref, _ref1;
|
|
1263
1138
|
const firestore = firebaseAdmin.app().firestore();
|
|
1264
1139
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
1265
|
-
const
|
|
1266
|
-
const actions = triggered.docs.filter((doc)=>!doc.get('deletedAt') && doc.get('enabled') !== false);
|
|
1140
|
+
const actions = await liveActionsForEvent(hostRef, event);
|
|
1267
1141
|
/*
|
|
1268
1142
|
* THE ORGANIZATION'S AUTOMATIONS placed on this site and not paused here.
|
|
1269
1143
|
*
|
|
@@ -1273,12 +1147,9 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1273
1147
|
* this site's own actions exactly as before.
|
|
1274
1148
|
*/ const placed = await findOrgAutomationsForEvent(hostId, event);
|
|
1275
1149
|
if (!actions.length && !placed) return alerts;
|
|
1276
|
-
const monthKey = new Date().toISOString().slice(0, 7);
|
|
1277
|
-
const runCounterRef = hostRef.collection('counters').doc('actionRuns');
|
|
1278
1150
|
// Webhook steps take the higher `webhooks` gate (AGL-149); plan gates
|
|
1279
1151
|
// ride the owning org's doc (AGL-238).
|
|
1280
1152
|
let webhooksAllowed = true;
|
|
1281
|
-
let crmAllowed = true;
|
|
1282
1153
|
/*
|
|
1283
1154
|
* Whether each half fits under the month's allowance — all or nothing per
|
|
1284
1155
|
* half, as the site's own actions always were.
|
|
@@ -1287,25 +1158,32 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1287
1158
|
* were always asked, so an organization placing automations on a site can
|
|
1288
1159
|
* never be what stops that site's own actions running. The org half is
|
|
1289
1160
|
* asked second, against what is left after the site's half, and both
|
|
1290
|
-
* count on this site's
|
|
1161
|
+
* count on this site's meter: an org automation's run is this site's
|
|
1291
1162
|
* run, and the usage card, the usage alerts and the COGS rollup read it
|
|
1292
1163
|
* there.
|
|
1293
1164
|
*/ let runSite = false;
|
|
1294
1165
|
let runOrg = false;
|
|
1295
1166
|
// Plan-less orgs resolve as free (AGL-247) — gates always run. Held for
|
|
1296
|
-
// the rest of the run so
|
|
1167
|
+
// the rest of the run so a step's own plan check costs no second read.
|
|
1297
1168
|
const owner = await getOrgForHost(hostId);
|
|
1169
|
+
// The band is the WORKSPACE's (AGL-3472): every site's runs count
|
|
1170
|
+
// against it, read off the org's counter — see `run-meter.ts`.
|
|
1171
|
+
const meter = {
|
|
1172
|
+
firestore,
|
|
1173
|
+
hostRef,
|
|
1174
|
+
orgId: owner == null ? void 0 : owner.orgId,
|
|
1175
|
+
counter: 'actionRuns',
|
|
1176
|
+
month: runMonthKey()
|
|
1177
|
+
};
|
|
1298
1178
|
// A suspended site runs nothing (AGL-3356): see `eventRunSuspension`.
|
|
1299
1179
|
if (await eventRunSuspension(hostRef, owner == null ? void 0 : owner.org)) return alerts;
|
|
1300
1180
|
{
|
|
1301
|
-
var
|
|
1181
|
+
var _ref2;
|
|
1302
1182
|
const org = owner == null ? void 0 : owner.org;
|
|
1303
1183
|
if (!checkEntitlement(org, 'actions')) return alerts;
|
|
1304
1184
|
webhooksAllowed = checkEntitlement(org, 'webhooks');
|
|
1305
|
-
crmAllowed = checkEntitlement(org, 'crm');
|
|
1306
1185
|
const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
|
|
1307
|
-
const
|
|
1308
|
-
const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
|
|
1186
|
+
const used = await runsUsedThisMonth(meter);
|
|
1309
1187
|
// Asked as "would it go over", the question this gate has always
|
|
1310
1188
|
// asked — so a counter that reads as no number refuses nothing, as
|
|
1311
1189
|
// before, rather than refusing everything.
|
|
@@ -1324,7 +1202,6 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1324
1202
|
// Admitted by the `actions` gate above.
|
|
1325
1203
|
actionsAllowed: true,
|
|
1326
1204
|
webhooksAllowed,
|
|
1327
|
-
crmAllowed,
|
|
1328
1205
|
depth,
|
|
1329
1206
|
org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
|
|
1330
1207
|
orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null,
|
|
@@ -1336,12 +1213,17 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1336
1213
|
const action = doc.data();
|
|
1337
1214
|
const filter = (_action_trigger = action.trigger) == null ? void 0 : (_action_trigger_filter = _action_trigger.filter) == null ? void 0 : _action_trigger_filter.trim();
|
|
1338
1215
|
if (filter) {
|
|
1216
|
+
const unrunnable = triggerFilterProblem(filter);
|
|
1217
|
+
if (unrunnable) {
|
|
1218
|
+
await recordSkippedRun(hostRef, doc.id, action, event, 'action', unrunnable);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1339
1221
|
try {
|
|
1340
1222
|
if (!evaluateExpression(filter, _extends({
|
|
1341
1223
|
event
|
|
1342
1224
|
}, payload))) continue;
|
|
1343
1225
|
} catch (unused) {
|
|
1344
|
-
continue; // A
|
|
1226
|
+
continue; // A field the filter names is not on this event.
|
|
1345
1227
|
}
|
|
1346
1228
|
}
|
|
1347
1229
|
// Structured payload conditions (AGL-557; AND/OR chaining AGL-565):
|
|
@@ -1355,7 +1237,9 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1355
1237
|
// common support question about automations — had no answer
|
|
1356
1238
|
// anywhere in the product, while `/product/workflows` advertises
|
|
1357
1239
|
// an amber `Skipped` row that answers it.
|
|
1358
|
-
|
|
1240
|
+
if (!conditionsNameAnotherForm(action.trigger, payload)) {
|
|
1241
|
+
await recordSkippedRun(hostRef, doc.id, action, event);
|
|
1242
|
+
}
|
|
1359
1243
|
continue;
|
|
1360
1244
|
}
|
|
1361
1245
|
executed += 1;
|
|
@@ -1370,18 +1254,25 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1370
1254
|
const automation = doc.data();
|
|
1371
1255
|
const filter = (_automation_trigger = automation.trigger) == null ? void 0 : (_automation_trigger_filter = _automation_trigger.filter) == null ? void 0 : _automation_trigger_filter.trim();
|
|
1372
1256
|
if (filter) {
|
|
1257
|
+
const unrunnable = triggerFilterProblem(filter);
|
|
1258
|
+
if (unrunnable) {
|
|
1259
|
+
await recordSkippedRun(hostRef, doc.id, automation, event, 'orgAutomation', unrunnable);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1373
1262
|
try {
|
|
1374
1263
|
if (!evaluateExpression(filter, _extends({
|
|
1375
1264
|
event
|
|
1376
1265
|
}, payload))) continue;
|
|
1377
1266
|
} catch (unused) {
|
|
1378
|
-
continue; // A
|
|
1267
|
+
continue; // A field the filter names is not on this event.
|
|
1379
1268
|
}
|
|
1380
1269
|
}
|
|
1381
1270
|
if (!evaluateTriggerConditions(automation.trigger, _extends({
|
|
1382
1271
|
event
|
|
1383
1272
|
}, payload))) {
|
|
1384
|
-
|
|
1273
|
+
if (!conditionsNameAnotherForm(automation.trigger, payload)) {
|
|
1274
|
+
await recordSkippedRun(hostRef, doc.id, automation, event, 'orgAutomation');
|
|
1275
|
+
}
|
|
1385
1276
|
continue;
|
|
1386
1277
|
}
|
|
1387
1278
|
executed += 1;
|
|
@@ -1391,13 +1282,9 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1391
1282
|
}
|
|
1392
1283
|
});
|
|
1393
1284
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
}, {
|
|
1398
|
-
merge: true
|
|
1399
|
-
}).catch(()=>undefined);
|
|
1400
|
-
}
|
|
1285
|
+
await recordRuns(_extends({}, meter, {
|
|
1286
|
+
count: executed
|
|
1287
|
+
}));
|
|
1401
1288
|
} catch (error) {
|
|
1402
1289
|
console.error('runEventActions failed', hostId, event, error);
|
|
1403
1290
|
}
|
|
@@ -1450,21 +1337,23 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1450
1337
|
}, payload))) {
|
|
1451
1338
|
return skipped('conditions');
|
|
1452
1339
|
}
|
|
1453
|
-
const monthKey = new Date().toISOString().slice(0, 7);
|
|
1454
|
-
const runCounterRef = hostRef.collection('counters').doc('actionRuns');
|
|
1455
1340
|
let webhooksAllowed = true;
|
|
1456
|
-
let crmAllowed = true;
|
|
1457
1341
|
// Held for the rest of the run, as in `runEventActions` above.
|
|
1458
1342
|
const owner = await getOrgForHost(hostId);
|
|
1343
|
+
// The workspace's band, as in `runEventActions` above (AGL-3472).
|
|
1344
|
+
const meter = {
|
|
1345
|
+
firestore,
|
|
1346
|
+
hostRef,
|
|
1347
|
+
orgId: owner == null ? void 0 : owner.orgId,
|
|
1348
|
+
counter: 'actionRuns',
|
|
1349
|
+
month: runMonthKey()
|
|
1350
|
+
};
|
|
1459
1351
|
{
|
|
1460
|
-
var _counterSnapshot_get;
|
|
1461
1352
|
const org = owner == null ? void 0 : owner.org;
|
|
1462
1353
|
if (!checkEntitlement(org, 'actions')) return skipped('plan');
|
|
1463
1354
|
webhooksAllowed = checkEntitlement(org, 'webhooks');
|
|
1464
|
-
crmAllowed = checkEntitlement(org, 'crm');
|
|
1465
1355
|
const limit = resolveOrgEntitlements(org).actionRunsPerMonth;
|
|
1466
|
-
const
|
|
1467
|
-
const used = Number((_counterSnapshot_get = counterSnapshot.get(monthKey)) != null ? _counterSnapshot_get : 0);
|
|
1356
|
+
const used = await runsUsedThisMonth(meter);
|
|
1468
1357
|
if (used + 1 > limit) return skipped('allowance', limit);
|
|
1469
1358
|
}
|
|
1470
1359
|
const env = {
|
|
@@ -1474,18 +1363,15 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1474
1363
|
// Admitted by the `actions` gate above.
|
|
1475
1364
|
actionsAllowed: true,
|
|
1476
1365
|
webhooksAllowed,
|
|
1477
|
-
crmAllowed,
|
|
1478
1366
|
depth: 0,
|
|
1479
1367
|
org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
|
|
1480
1368
|
orgId: (_ref2 = owner == null ? void 0 : owner.orgId) != null ? _ref2 : null,
|
|
1481
1369
|
loadWorkflowContext: makeWorkflowContextLoader(hostRef)
|
|
1482
1370
|
};
|
|
1483
1371
|
await executeAction(env, doc.id, action, event, payload);
|
|
1484
|
-
await
|
|
1485
|
-
|
|
1486
|
-
}
|
|
1487
|
-
merge: true
|
|
1488
|
-
}).catch(()=>undefined);
|
|
1372
|
+
await recordRuns(_extends({}, meter, {
|
|
1373
|
+
count: 1
|
|
1374
|
+
}));
|
|
1489
1375
|
return {
|
|
1490
1376
|
ran: true,
|
|
1491
1377
|
skipped: null,
|
|
@@ -1584,7 +1470,6 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1584
1470
|
// Admitted by the `actions` gate above.
|
|
1585
1471
|
actionsAllowed: true,
|
|
1586
1472
|
webhooksAllowed: checkEntitlement(owner == null ? void 0 : owner.org, 'webhooks'),
|
|
1587
|
-
crmAllowed: checkEntitlement(owner == null ? void 0 : owner.org, 'crm'),
|
|
1588
1473
|
depth: 0,
|
|
1589
1474
|
org: (_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null,
|
|
1590
1475
|
orgId: (_ref2 = owner == null ? void 0 : owner.orgId) != null ? _ref2 : null,
|
|
@@ -1619,12 +1504,14 @@ async function datasetAppendRefusal(env, datasetRef) {
|
|
|
1619
1504
|
* would abandon somebody half-way through a sequence, which is a capacity
|
|
1620
1505
|
* limit enforced against a person rather than against the decision that
|
|
1621
1506
|
* added them. The gate belongs at enrollment, and that is where it is.
|
|
1622
|
-
*/
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1507
|
+
*/ await recordRuns({
|
|
1508
|
+
firestore,
|
|
1509
|
+
hostRef,
|
|
1510
|
+
orgId: owner == null ? void 0 : owner.orgId,
|
|
1511
|
+
counter: 'actionRuns',
|
|
1512
|
+
month: runMonthKey(nowMs),
|
|
1513
|
+
count: 1
|
|
1514
|
+
});
|
|
1628
1515
|
return ending;
|
|
1629
1516
|
}
|
|
1630
1517
|
/** How long a deferred enrollment waits before the next attempt. */ export const FLOW_CLAIM_RETRY_MS = 15 * 60000;
|