@aglyn/aglyn 1.0.0-beta.200 → 1.0.0-beta.202
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/package.json +11 -11
- package/src/lib/app-utils/activity-presenter.d.ts +29 -3
- package/src/lib/app-utils/activity-presenter.js +32 -4
- package/src/lib/app-utils/activity-presenter.js.map +1 -1
- package/src/lib/app-utils/api-plugins.d.ts +20 -0
- package/src/lib/app-utils/api-plugins.js +9 -0
- package/src/lib/app-utils/api-plugins.js.map +1 -1
- package/src/lib/app-utils/artifact-list-keys.js +10 -0
- package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
- package/src/lib/app-utils/card-payment-velocity.d.ts +57 -0
- package/src/lib/app-utils/card-payment-velocity.js +90 -0
- package/src/lib/app-utils/card-payment-velocity.js.map +1 -0
- package/src/lib/app-utils/crm-digest.d.ts +6 -0
- package/src/lib/app-utils/crm-digest.js +1 -0
- package/src/lib/app-utils/crm-digest.js.map +1 -1
- package/src/lib/app-utils/crm-task-reminders.d.ts +6 -0
- package/src/lib/app-utils/crm-task-reminders.js +1 -0
- package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +19 -0
- package/src/lib/app-utils/crm.js +27 -1
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
- package/src/lib/app-utils/docs-help.generated.js +12 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +85 -64
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/email-state.d.ts +7 -3
- package/src/lib/app-utils/email-state.js +22 -6
- package/src/lib/app-utils/email-state.js.map +1 -1
- package/src/lib/app-utils/forms.d.ts +101 -9
- package/src/lib/app-utils/forms.js +137 -7
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +3 -3
- package/src/lib/app-utils/health-report.js +9 -9
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/host-naming.d.ts +12 -7
- package/src/lib/app-utils/host-naming.js +18 -3
- package/src/lib/app-utils/host-naming.js.map +1 -1
- package/src/lib/app-utils/lockdown.d.ts +19 -0
- package/src/lib/app-utils/lockdown.js +21 -0
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/marketplace-listing-visibility.js +5 -5
- package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
- package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
- package/src/lib/app-utils/media-embedded-fields.js +77 -0
- package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
- package/src/lib/app-utils/media-metadata.d.ts +17 -1
- package/src/lib/app-utils/media-metadata.js +22 -2
- package/src/lib/app-utils/media-metadata.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +35 -0
- package/src/lib/app-utils/notifications.js +57 -1
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/payment-risk.d.ts +75 -0
- package/src/lib/app-utils/payment-risk.js +139 -0
- package/src/lib/app-utils/payment-risk.js.map +1 -0
- package/src/lib/app-utils/platform-brand.d.ts +8 -2
- package/src/lib/app-utils/platform-brand.js +9 -3
- package/src/lib/app-utils/platform-brand.js.map +1 -1
- package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
- package/src/lib/app-utils/plugin-api-request-host.js +90 -0
- package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
- package/src/lib/app-utils/plugin-bundle-checks.d.ts +4 -2
- package/src/lib/app-utils/plugin-bundle-checks.js +111 -5
- package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
- package/src/lib/app-utils/site-return-url.d.ts +55 -0
- package/src/lib/app-utils/site-return-url.js +129 -0
- package/src/lib/app-utils/site-return-url.js.map +1 -0
- package/src/lib/app-utils/webhook-delivery.js +4 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +18 -0
- package/src/lib/plugin-manager/billing-webhook-hooks.js +9 -1
- package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
- package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
- package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
- package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
- package/src/lib/plugin-manager/plugin-jobs.js +8 -4
- package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
- package/src/lib/plugin-manager/plugin-recurring-charges.d.ts +39 -0
- package/src/lib/plugin-manager/plugin-recurring-charges.js +49 -0
- package/src/lib/plugin-manager/plugin-recurring-charges.js.map +1 -0
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
/** Every state, weakest first. */
|
|
18
|
-
export declare const EMAIL_STATE_STATUSES: readonly ["ok", "bounced", "blocked", "unsubscribed", "complained", "do_not_contact"];
|
|
18
|
+
export declare const EMAIL_STATE_STATUSES: readonly ["ok", "undeliverable", "bounced", "blocked", "unsubscribed", "complained", "do_not_contact"];
|
|
19
19
|
export type EmailStateStatus = (typeof EMAIL_STATE_STATUSES)[number];
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Which sender's verdict it is. `check` is the deliverability check
|
|
22
|
+
* (AGL-3328), which reads the domain's DNS when the record is captured and
|
|
23
|
+
* before a send, and says what it found before anything bounces.
|
|
24
|
+
*/
|
|
25
|
+
export type EmailStateSource = 'sequence' | 'campaign' | 'member' | 'check';
|
|
22
26
|
/** The field on the lead and the contact. */
|
|
23
27
|
export declare const EMAIL_STATE_FIELD = "emailState";
|
|
24
28
|
export interface EmailState {
|
|
@@ -37,15 +37,28 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
37
37
|
* overwrites a refusal on its own: the ranking below decides.
|
|
38
38
|
*==========================================*/ /** Every state, weakest first. */ export const EMAIL_STATE_STATUSES = [
|
|
39
39
|
/** The address may be emailed: released, or confirmed again. */ 'ok',
|
|
40
|
+
/**
|
|
41
|
+
* Mail to it WOULD bounce: its domain has no mail server, or publishes a
|
|
42
|
+
* null MX (AGL-3328). A prediction from DNS rather than a bounce, so the
|
|
43
|
+
* weakest refusal: any real verdict replaces it, and the check clears it
|
|
44
|
+
* when the domain starts taking mail.
|
|
45
|
+
*/ 'undeliverable',
|
|
40
46
|
/** Mail to it bounced for good: the mailbox does not exist. */ 'bounced',
|
|
41
47
|
/** The recipient's mail gateway refused the sender on policy or reputation. */ 'blocked',
|
|
42
48
|
/** The unsubscribe link or header was used. */ 'unsubscribed',
|
|
43
49
|
/** A reply called the email spam, or the recipient reported it. */ 'complained',
|
|
44
50
|
/** On the organization's do-not-contact list — a member's or the runtime's entry. */ 'do_not_contact'
|
|
45
51
|
];
|
|
52
|
+
const EMAIL_STATE_SOURCES = [
|
|
53
|
+
'sequence',
|
|
54
|
+
'campaign',
|
|
55
|
+
'member',
|
|
56
|
+
'check'
|
|
57
|
+
];
|
|
46
58
|
/** The field on the lead and the contact. */ export const EMAIL_STATE_FIELD = 'emailState';
|
|
47
59
|
/** How a state reads on the chip — typed so a state cannot ship unlabeled. */ export const EMAIL_STATE_LABELS = {
|
|
48
60
|
ok: 'Email OK',
|
|
61
|
+
undeliverable: 'Would bounce',
|
|
49
62
|
bounced: 'Bounced',
|
|
50
63
|
blocked: 'Blocked by their mail gateway',
|
|
51
64
|
unsubscribed: 'Unsubscribed',
|
|
@@ -60,11 +73,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
60
73
|
* over nothing — a release is written on purpose, by a path that forces it.
|
|
61
74
|
*/ const EMAIL_STATE_RANK = {
|
|
62
75
|
ok: 0,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
undeliverable: 1,
|
|
77
|
+
bounced: 2,
|
|
78
|
+
blocked: 3,
|
|
79
|
+
unsubscribed: 4,
|
|
80
|
+
complained: 5,
|
|
81
|
+
do_not_contact: 6
|
|
68
82
|
};
|
|
69
83
|
export function isEmailStateStatus(value) {
|
|
70
84
|
return typeof value === 'string' && EMAIL_STATE_STATUSES.includes(value);
|
|
@@ -80,7 +94,7 @@ export function isEmailStateStatus(value) {
|
|
|
80
94
|
return _extends({
|
|
81
95
|
status: state['status'],
|
|
82
96
|
atMs: Number.isFinite(atMs) && atMs > 0 ? atMs : 0,
|
|
83
|
-
source: source
|
|
97
|
+
source: EMAIL_STATE_SOURCES.includes(source) ? source : 'campaign',
|
|
84
98
|
detail: typeof state['detail'] === 'string' && state['detail'] ? state['detail'] : null
|
|
85
99
|
}, enrollmentId ? {
|
|
86
100
|
enrollmentId
|
|
@@ -118,6 +132,8 @@ export function isEmailStateStatus(value) {
|
|
|
118
132
|
const since = when ? ` on ${when}` : '';
|
|
119
133
|
const said = state.detail ? ` ${state.detail}` : '';
|
|
120
134
|
switch(state.status){
|
|
135
|
+
case 'undeliverable':
|
|
136
|
+
return `Email to this address would bounce: its domain has no mail server${since ? ` (checked${since})` : ''}.${said}`;
|
|
121
137
|
case 'bounced':
|
|
122
138
|
return `Email to this address bounced${since}: the mailbox does not exist.${said}`;
|
|
123
139
|
case 'blocked':
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A RECORD'S EMAIL STATE (AGL-3245).\n *\n * The lists the senders consult — the platform suppression list, a site's\n * suppressions, an organization's do-not-contact list — are keyed by a\n * hash nobody can read. The record a person opens is the lead or the\n * contact, and until now it said nothing: an address that hard-bounced at\n * 10:30 still read as \"Sent\" at 12:30, and a member reading it would have\n * emailed the person again by hand.\n *\n * So the record carries `emailState`: what the last verdict on its address\n * was, when, from which sender, and in the sender's own words. It is\n * WRITTEN BY THE PLATFORM ONLY — the outreach runtime, the campaign\n * webhook, a member's do-not-contact mark — never by a form, an import or\n * the record's own editor, and the rules hold clients off the field. It\n * is a mirror of the lists, not a list: a send still asks the lists, and\n * the record says what they hold in words a person can act on.\n *\n * Absent means nothing is known. `ok` is a verdict too — a release, a\n * confirmed re-subscription — and it is the one state that never\n * overwrites a refusal on its own: the ranking below decides.\n *==========================================*/\n\n/** Every state, weakest first. */\nexport const EMAIL_STATE_STATUSES = [\n /** The address may be emailed: released, or confirmed again. */\n 'ok',\n /** Mail to it bounced for good: the mailbox does not exist. */\n 'bounced',\n /** The recipient's mail gateway refused the sender on policy or reputation. */\n 'blocked',\n /** The unsubscribe link or header was used. */\n 'unsubscribed',\n /** A reply called the email spam, or the recipient reported it. */\n 'complained',\n /** On the organization's do-not-contact list — a member's or the runtime's entry. */\n 'do_not_contact',\n] as const\n\nexport type EmailStateStatus = (typeof EMAIL_STATE_STATUSES)[number]\n\n/** Which sender's verdict it is. */\nexport type EmailStateSource = 'sequence' | 'campaign' | 'member'\n\n/** The field on the lead and the contact. */\nexport const EMAIL_STATE_FIELD = 'emailState'\n\nexport interface EmailState {\n status: EmailStateStatus\n /** When the verdict was given, epoch ms. */\n atMs: number\n source: EmailStateSource\n /** The sender's own words: the bounce's diagnostic, a member's note. */\n detail: string | null\n /** The sequence enrollment it came from, when one did. */\n enrollmentId?: string\n}\n\n/** How a state reads on the chip — typed so a state cannot ship unlabeled. */\nexport const EMAIL_STATE_LABELS: Record<EmailStateStatus, string> = {\n ok: 'Email OK',\n bounced: 'Bounced',\n blocked: 'Blocked by their mail gateway',\n unsubscribed: 'Unsubscribed',\n complained: 'Marked as spam',\n do_not_contact: 'Do not contact',\n}\n\n/**\n * The rank a later verdict must reach to replace an earlier one. A member's\n * do-not-contact mark stands over every automatic verdict; a complaint\n * over an unsubscribe, which is the person's own act over a server's; a\n * block over a bounce, since the block names the whole domain; and `ok`\n * over nothing — a release is written on purpose, by a path that forces it.\n */\nconst EMAIL_STATE_RANK: Record<EmailStateStatus, number> = {\n ok: 0,\n bounced: 1,\n blocked: 2,\n unsubscribed: 3,\n complained: 4,\n do_not_contact: 5,\n}\n\nexport function isEmailStateStatus(value: unknown): value is EmailStateStatus {\n return typeof value === 'string' && (EMAIL_STATE_STATUSES as readonly string[]).includes(value)\n}\n\n/** The state a record holds, held to its shape, or `null` when it holds none. */\nexport function readEmailState(record: Record<string, unknown> | null | undefined): EmailState | null {\n const raw = record?.[EMAIL_STATE_FIELD]\n if (!raw || typeof raw !== 'object') return null\n const state = raw as Record<string, unknown>\n if (!isEmailStateStatus(state['status'])) return null\n const atMs = Number(state['atMs'])\n const source = state['source']\n const enrollmentId = typeof state['enrollmentId'] === 'string' && state['enrollmentId'] ? state['enrollmentId'] : undefined\n return {\n status: state['status'],\n atMs: Number.isFinite(atMs) && atMs > 0 ? atMs : 0,\n source: source === 'sequence' || source === 'campaign' || source === 'member' ? source : 'campaign',\n detail: typeof state['detail'] === 'string' && state['detail'] ? state['detail'] : null,\n ...(enrollmentId ? { enrollmentId } : {}),\n }\n}\n\n/** Whether the state means the address must not be emailed by hand. */\nexport function emailStateForbidsEmail(state: EmailState | null | undefined): boolean {\n return Boolean(state) && state?.status !== 'ok'\n}\n\n/**\n * The state a record holds after one more verdict: the incoming one when it\n * ranks at least as high as the current — a fresh bounce replaces an old\n * one, a block replaces a bounce, a member's mark replaces anything — else\n * the current. `force` writes the incoming state whatever stands, which is\n * how a release lands.\n */\nexport function nextEmailState(\n current: EmailState | null | undefined,\n incoming: EmailState,\n options: { force?: boolean } = {},\n): EmailState {\n if (!current || options.force) return incoming\n return EMAIL_STATE_RANK[incoming.status] >= EMAIL_STATE_RANK[current.status] ? incoming : current\n}\n\n/** When the verdict was given, as the chip's tooltip and the refusal say it. */\nexport function emailStateWhen(state: Pick<EmailState, 'atMs'>): string {\n if (!state.atMs) return ''\n try {\n return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(state.atMs)\n } catch {\n return new Date(state.atMs).toDateString()\n }\n}\n\n/**\n * Why the record must not be emailed, in one sentence a disabled button or\n * a refused enrollment can show — or `null` when it may be.\n */\nexport function emailStateRefusal(state: EmailState | null | undefined): string | null {\n if (!state || !emailStateForbidsEmail(state)) return null\n const when = emailStateWhen(state)\n const since = when ? ` on ${when}` : ''\n const said = state.detail ? ` ${state.detail}` : ''\n switch (state.status) {\n case 'bounced':\n return `Email to this address bounced${since}: the mailbox does not exist.${said}`\n case 'blocked':\n return `Their mail gateway blocked email to this address${since}.${said}`\n case 'unsubscribed':\n return `They unsubscribed${since}.${said}`\n case 'complained':\n return `They marked an email as spam${since}.${said}`\n case 'do_not_contact':\n return `On the do-not-contact list${since}.${said}`\n default:\n return null\n }\n}\n"],"names":["EMAIL_STATE_STATUSES","EMAIL_STATE_FIELD","EMAIL_STATE_LABELS","ok","bounced","blocked","unsubscribed","complained","do_not_contact","EMAIL_STATE_RANK","isEmailStateStatus","value","includes","readEmailState","record","raw","state","atMs","Number","source","enrollmentId","undefined","status","isFinite","detail","emailStateForbidsEmail","Boolean","nextEmailState","current","incoming","options","force","emailStateWhen","Intl","DateTimeFormat","dateStyle","format","Date","toDateString","emailStateRefusal","when","since","said"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A RECORD'S EMAIL STATE (AGL-3245).\n *\n * The lists the senders consult — the platform suppression list, a site's\n * suppressions, an organization's do-not-contact list — are keyed by a\n * hash nobody can read. The record a person opens is the lead or the\n * contact, and until now it said nothing: an address that hard-bounced at\n * 10:30 still read as \"Sent\" at 12:30, and a member reading it would have\n * emailed the person again by hand.\n *\n * So the record carries `emailState`: what the last verdict on its address\n * was, when, from which sender, and in the sender's own words. It is\n * WRITTEN BY THE PLATFORM ONLY — the outreach runtime, the campaign\n * webhook, a member's do-not-contact mark — never by a form, an import or\n * the record's own editor, and the rules hold clients off the field. It\n * is a mirror of the lists, not a list: a send still asks the lists, and\n * the record says what they hold in words a person can act on.\n *\n * Absent means nothing is known. `ok` is a verdict too — a release, a\n * confirmed re-subscription — and it is the one state that never\n * overwrites a refusal on its own: the ranking below decides.\n *==========================================*/\n\n/** Every state, weakest first. */\nexport const EMAIL_STATE_STATUSES = [\n /** The address may be emailed: released, or confirmed again. */\n 'ok',\n /**\n * Mail to it WOULD bounce: its domain has no mail server, or publishes a\n * null MX (AGL-3328). A prediction from DNS rather than a bounce, so the\n * weakest refusal: any real verdict replaces it, and the check clears it\n * when the domain starts taking mail.\n */\n 'undeliverable',\n /** Mail to it bounced for good: the mailbox does not exist. */\n 'bounced',\n /** The recipient's mail gateway refused the sender on policy or reputation. */\n 'blocked',\n /** The unsubscribe link or header was used. */\n 'unsubscribed',\n /** A reply called the email spam, or the recipient reported it. */\n 'complained',\n /** On the organization's do-not-contact list — a member's or the runtime's entry. */\n 'do_not_contact',\n] as const\n\nexport type EmailStateStatus = (typeof EMAIL_STATE_STATUSES)[number]\n\n/**\n * Which sender's verdict it is. `check` is the deliverability check\n * (AGL-3328), which reads the domain's DNS when the record is captured and\n * before a send, and says what it found before anything bounces.\n */\nexport type EmailStateSource = 'sequence' | 'campaign' | 'member' | 'check'\n\nconst EMAIL_STATE_SOURCES: readonly EmailStateSource[] = ['sequence', 'campaign', 'member', 'check']\n\n/** The field on the lead and the contact. */\nexport const EMAIL_STATE_FIELD = 'emailState'\n\nexport interface EmailState {\n status: EmailStateStatus\n /** When the verdict was given, epoch ms. */\n atMs: number\n source: EmailStateSource\n /** The sender's own words: the bounce's diagnostic, a member's note. */\n detail: string | null\n /** The sequence enrollment it came from, when one did. */\n enrollmentId?: string\n}\n\n/** How a state reads on the chip — typed so a state cannot ship unlabeled. */\nexport const EMAIL_STATE_LABELS: Record<EmailStateStatus, string> = {\n ok: 'Email OK',\n undeliverable: 'Would bounce',\n bounced: 'Bounced',\n blocked: 'Blocked by their mail gateway',\n unsubscribed: 'Unsubscribed',\n complained: 'Marked as spam',\n do_not_contact: 'Do not contact',\n}\n\n/**\n * The rank a later verdict must reach to replace an earlier one. A member's\n * do-not-contact mark stands over every automatic verdict; a complaint\n * over an unsubscribe, which is the person's own act over a server's; a\n * block over a bounce, since the block names the whole domain; and `ok`\n * over nothing — a release is written on purpose, by a path that forces it.\n */\nconst EMAIL_STATE_RANK: Record<EmailStateStatus, number> = {\n ok: 0,\n undeliverable: 1,\n bounced: 2,\n blocked: 3,\n unsubscribed: 4,\n complained: 5,\n do_not_contact: 6,\n}\n\nexport function isEmailStateStatus(value: unknown): value is EmailStateStatus {\n return typeof value === 'string' && (EMAIL_STATE_STATUSES as readonly string[]).includes(value)\n}\n\n/** The state a record holds, held to its shape, or `null` when it holds none. */\nexport function readEmailState(record: Record<string, unknown> | null | undefined): EmailState | null {\n const raw = record?.[EMAIL_STATE_FIELD]\n if (!raw || typeof raw !== 'object') return null\n const state = raw as Record<string, unknown>\n if (!isEmailStateStatus(state['status'])) return null\n const atMs = Number(state['atMs'])\n const source = state['source']\n const enrollmentId = typeof state['enrollmentId'] === 'string' && state['enrollmentId'] ? state['enrollmentId'] : undefined\n return {\n status: state['status'],\n atMs: Number.isFinite(atMs) && atMs > 0 ? atMs : 0,\n source: (EMAIL_STATE_SOURCES as readonly unknown[]).includes(source) ? (source as EmailStateSource) : 'campaign',\n detail: typeof state['detail'] === 'string' && state['detail'] ? state['detail'] : null,\n ...(enrollmentId ? { enrollmentId } : {}),\n }\n}\n\n/** Whether the state means the address must not be emailed by hand. */\nexport function emailStateForbidsEmail(state: EmailState | null | undefined): boolean {\n return Boolean(state) && state?.status !== 'ok'\n}\n\n/**\n * The state a record holds after one more verdict: the incoming one when it\n * ranks at least as high as the current — a fresh bounce replaces an old\n * one, a block replaces a bounce, a member's mark replaces anything — else\n * the current. `force` writes the incoming state whatever stands, which is\n * how a release lands.\n */\nexport function nextEmailState(\n current: EmailState | null | undefined,\n incoming: EmailState,\n options: { force?: boolean } = {},\n): EmailState {\n if (!current || options.force) return incoming\n return EMAIL_STATE_RANK[incoming.status] >= EMAIL_STATE_RANK[current.status] ? incoming : current\n}\n\n/** When the verdict was given, as the chip's tooltip and the refusal say it. */\nexport function emailStateWhen(state: Pick<EmailState, 'atMs'>): string {\n if (!state.atMs) return ''\n try {\n return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(state.atMs)\n } catch {\n return new Date(state.atMs).toDateString()\n }\n}\n\n/**\n * Why the record must not be emailed, in one sentence a disabled button or\n * a refused enrollment can show — or `null` when it may be.\n */\nexport function emailStateRefusal(state: EmailState | null | undefined): string | null {\n if (!state || !emailStateForbidsEmail(state)) return null\n const when = emailStateWhen(state)\n const since = when ? ` on ${when}` : ''\n const said = state.detail ? ` ${state.detail}` : ''\n switch (state.status) {\n case 'undeliverable':\n return `Email to this address would bounce: its domain has no mail server${since ? ` (checked${since})` : ''}.${said}`\n case 'bounced':\n return `Email to this address bounced${since}: the mailbox does not exist.${said}`\n case 'blocked':\n return `Their mail gateway blocked email to this address${since}.${said}`\n case 'unsubscribed':\n return `They unsubscribed${since}.${said}`\n case 'complained':\n return `They marked an email as spam${since}.${said}`\n case 'do_not_contact':\n return `On the do-not-contact list${since}.${said}`\n default:\n return null\n }\n}\n"],"names":["EMAIL_STATE_STATUSES","EMAIL_STATE_SOURCES","EMAIL_STATE_FIELD","EMAIL_STATE_LABELS","ok","undeliverable","bounced","blocked","unsubscribed","complained","do_not_contact","EMAIL_STATE_RANK","isEmailStateStatus","value","includes","readEmailState","record","raw","state","atMs","Number","source","enrollmentId","undefined","status","isFinite","detail","emailStateForbidsEmail","Boolean","nextEmailState","current","incoming","options","force","emailStateWhen","Intl","DateTimeFormat","dateStyle","format","Date","toDateString","emailStateRefusal","when","since","said"],"mappings":"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"}
|
|
@@ -179,6 +179,16 @@ export interface FormDocument<N = AglynNodeSchema> {
|
|
|
179
179
|
* `false` on every create — see {@link newFormListFields}.
|
|
180
180
|
*/
|
|
181
181
|
retired?: boolean;
|
|
182
|
+
/** The campaigns the form is filed under — see {@link formCampaignFields}. */
|
|
183
|
+
campaignIds?: string[];
|
|
184
|
+
/**
|
|
185
|
+
* Whether the form is in any campaign, as a boolean the Forms list can
|
|
186
|
+
* QUERY (AGL-3330): "in no campaign" is an empty array, and Firestore can
|
|
187
|
+
* neither find a document by an absent field nor ask an array for its
|
|
188
|
+
* length. Written beside `campaignIds` by every writer of it, through
|
|
189
|
+
* {@link formCampaignFields}.
|
|
190
|
+
*/
|
|
191
|
+
inCampaign?: boolean;
|
|
182
192
|
/** The Forms list's search fields — see {@link formListFields}. */
|
|
183
193
|
nameLower?: string;
|
|
184
194
|
nameTokens?: string[];
|
|
@@ -255,18 +265,45 @@ export declare function formListFields(form: {
|
|
|
255
265
|
displayName?: unknown;
|
|
256
266
|
slug?: unknown;
|
|
257
267
|
}): FormListSearchFields;
|
|
268
|
+
/**
|
|
269
|
+
* The campaign fields a form is written with, from the campaigns it is filed
|
|
270
|
+
* under (AGL-3330): the normalized ids, and `inCampaign`, the boolean the
|
|
271
|
+
* Forms list's "In a campaign" filter asks by equality.
|
|
272
|
+
*
|
|
273
|
+
* ⚠️ The ONE way to write a form's `campaignIds`. A writer that sets the ids
|
|
274
|
+
* without `inCampaign` leaves the form answering the filter by what it used
|
|
275
|
+
* to be filed under; `apps/console/specs/form-writers-use-the-helpers.spec.ts`
|
|
276
|
+
* refuses one. The campaign deletion pass removes an id with `arrayRemove`
|
|
277
|
+
* and restamps `inCampaign` from the form as it then stands.
|
|
278
|
+
*/
|
|
279
|
+
export declare function formCampaignFields(selected: unknown): {
|
|
280
|
+
campaignIds: string[];
|
|
281
|
+
inCampaign: boolean;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* The `stats.leads` a form holds when nothing has been counted for it.
|
|
285
|
+
*
|
|
286
|
+
* A form that routes leads holds `0` until its first lead, so "Leads = 0"
|
|
287
|
+
* finds the lead surfaces that have produced nothing yet. A form that does
|
|
288
|
+
* not route leads holds `null`: it has no leads measurement, which is what
|
|
289
|
+
* "Leads is empty" asks. The recount and every writer of the switch agree
|
|
290
|
+
* through this and {@link formCountersFromSource}.
|
|
291
|
+
*/
|
|
292
|
+
export declare function formLeadsStatWhenUncounted(routesLeads: boolean): 0 | null;
|
|
258
293
|
/**
|
|
259
294
|
* What a NEW form is written with so the Forms list can query every filter
|
|
260
|
-
* it offers (AGL-3330): its search
|
|
295
|
+
* it offers (AGL-3330): its search keys, and an explicit value for each
|
|
261
296
|
* field a query would otherwise have to find by its absence.
|
|
262
297
|
*
|
|
263
298
|
* - `retired: false`, the queryable mirror of an unset `archivedAt`.
|
|
264
299
|
* - `routing.lead` as a boolean, `false` unless the form routes to leads, so
|
|
265
300
|
* "Lead routing is off" is an equality rather than a missing field.
|
|
266
|
-
* - `
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
301
|
+
* - `inCampaign`, from the campaigns the form is created in.
|
|
302
|
+
* - `stats` with a NULL for each counter, except `leads` on a form that
|
|
303
|
+
* routes leads, which starts at `0` (see {@link formLeadsStatWhenUncounted}).
|
|
304
|
+
* Null, never zero, where nothing is measured: a zero would claim a
|
|
305
|
+
* measurement. `/api/forms/submit` increments these, and an increment on
|
|
306
|
+
* null starts from nothing, so it needs no change.
|
|
270
307
|
*
|
|
271
308
|
* `routing` keeps whatever else it carries (a dataset binding).
|
|
272
309
|
*/
|
|
@@ -275,15 +312,66 @@ export declare function newFormListFields(form: {
|
|
|
275
312
|
displayName?: unknown;
|
|
276
313
|
slug?: unknown;
|
|
277
314
|
routing?: FormRouting | null;
|
|
315
|
+
campaignIds?: unknown;
|
|
278
316
|
}): FormListSearchFields & {
|
|
279
317
|
retired: false;
|
|
280
318
|
routing: FormRouting;
|
|
319
|
+
inCampaign: boolean;
|
|
281
320
|
stats: {
|
|
282
321
|
submissions: null;
|
|
283
|
-
leads: null;
|
|
322
|
+
leads: 0 | null;
|
|
284
323
|
lastSubmissionAtMs: null;
|
|
285
324
|
};
|
|
286
325
|
};
|
|
326
|
+
/** The source a lead filed by a form carries in `sources`. */
|
|
327
|
+
export declare const FORM_LEAD_SOURCE_PREFIX = "form:";
|
|
328
|
+
/** `form:{formId}` — the lead source a form's capture writes. */
|
|
329
|
+
export declare function formLeadSource(formId: string): string;
|
|
330
|
+
/** The form ids a lead's `sources` name, in order, deduplicated. */
|
|
331
|
+
export declare function formIdsOfLeadSources(sources: unknown): string[];
|
|
332
|
+
/** The three counters the Forms list filters by. */
|
|
333
|
+
export interface FormCounterStats {
|
|
334
|
+
submissions: number | null;
|
|
335
|
+
leads: number | null;
|
|
336
|
+
lastSubmissionAtMs: number | null;
|
|
337
|
+
}
|
|
338
|
+
/** The counter fields, in reading order. */
|
|
339
|
+
export declare const FORM_COUNTER_FIELDS: readonly (keyof FormCounterStats)[];
|
|
340
|
+
/**
|
|
341
|
+
* The counters a form should hold, from the rows counted.
|
|
342
|
+
*
|
|
343
|
+
* Zero submissions is `null`, the list's "none yet", matching a new form.
|
|
344
|
+
* Leads follow {@link formLeadsStatWhenUncounted} when there are none — and
|
|
345
|
+
* a form that stopped routing leads keeps the leads it did file, because
|
|
346
|
+
* those people are still on the list.
|
|
347
|
+
*/
|
|
348
|
+
export declare function formCountersFromSource(source: {
|
|
349
|
+
submissions: number;
|
|
350
|
+
leads: number;
|
|
351
|
+
newestSubmissionAtMs: number | null;
|
|
352
|
+
routesLeads: boolean;
|
|
353
|
+
}): FormCounterStats;
|
|
354
|
+
/**
|
|
355
|
+
* How far the stored `lastSubmissionAtMs` may sit from the newest
|
|
356
|
+
* submission's `createdAt` and still agree.
|
|
357
|
+
*
|
|
358
|
+
* The submit path stamps the counter from its own clock after it has written
|
|
359
|
+
* the submission, whose `createdAt` is the server's commit time, and a lead
|
|
360
|
+
* capture runs between the two — so a counter that is right reads a little
|
|
361
|
+
* after the row. Ten minutes is far past that gap and far short of two
|
|
362
|
+
* submissions a merchant would tell apart.
|
|
363
|
+
*/
|
|
364
|
+
export declare const FORM_LAST_SUBMISSION_TOLERANCE_MS: number;
|
|
365
|
+
/**
|
|
366
|
+
* The counters on which `stored` disagrees with `recounted`, in reading
|
|
367
|
+
* order; empty when the form holds what its rows say.
|
|
368
|
+
*
|
|
369
|
+
* An ABSENT counter disagrees with a `null` one: "is empty" asks
|
|
370
|
+
* `== null`, which a missing field does not answer.
|
|
371
|
+
*/
|
|
372
|
+
export declare function formCounterDrift(stored: Record<string, unknown> | null | undefined, recounted: FormCounterStats): (keyof FormCounterStats)[];
|
|
373
|
+
/** The dotted-path update that puts `recounted` on a form. */
|
|
374
|
+
export declare function formCounterPatch(recounted: FormCounterStats): Record<string, number | null>;
|
|
287
375
|
/**
|
|
288
376
|
* One entry in a form's `versions` subcollection.
|
|
289
377
|
*
|
|
@@ -306,7 +394,10 @@ export interface FormVersion<N = AglynNodeSchema> {
|
|
|
306
394
|
* bound and is the one the customer is billed on; a console surface that
|
|
307
395
|
* counted it on render would be the expensive-read shape this product has
|
|
308
396
|
* created repeatedly. `hosts/{hostId}/overlays/{overlayId}.stats` is the same
|
|
309
|
-
* pattern with the same reasoning.
|
|
397
|
+
* pattern with the same reasoning. What an increment cannot see — a deleted
|
|
398
|
+
* submission, an erased lead, a failed write — is put back by the RECOUNT
|
|
399
|
+
* ({@link formCountersFromSource}), which runs after a removal and over every
|
|
400
|
+
* form from `tools/scripts/recount-form-stats.mjs`; never on render.
|
|
310
401
|
*/
|
|
311
402
|
export interface FormStats {
|
|
312
403
|
submissions?: number | null;
|
|
@@ -432,9 +523,10 @@ export declare function formPeriodKey(atMs: number | null | undefined): string |
|
|
|
432
523
|
*
|
|
433
524
|
* Every counter is `number | null` and the distinction is load-bearing. A
|
|
434
525
|
* counter is `null` when nothing was recorded for it at all: `leads` is
|
|
435
|
-
*
|
|
526
|
+
* counted only for a form whose `routing.lead` is set, so a form that does
|
|
436
527
|
* not route leads has no leads measurement rather than a measured zero, and a
|
|
437
|
-
* `0` in that slot states a result nobody took.
|
|
528
|
+
* `0` in that slot states a result nobody took. A form that routes leads and
|
|
529
|
+
* has filed none holds `0`, which is a measurement.
|
|
438
530
|
*/
|
|
439
531
|
export interface FormStatsTotals {
|
|
440
532
|
views: number | null;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* Everything that needs Firestore or `node:crypto` lives at the call sites.
|
|
30
30
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
31
31
|
import { FORMS_PLUGIN_ID } from "../plugin-manager/enabled-plugins.js";
|
|
32
|
+
import { campaignMembershipValue } from "./campaign-membership.js";
|
|
32
33
|
import { submissionMonthKey } from "./form-abuse-ceiling.js";
|
|
33
34
|
import { displayNameSearchFields, nameSearchTokens } from "./name-search.js";
|
|
34
35
|
/**
|
|
@@ -156,35 +157,164 @@ export const FORM_FIELD_COMPONENT_ID = 'formField';
|
|
|
156
157
|
]
|
|
157
158
|
});
|
|
158
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* The campaign fields a form is written with, from the campaigns it is filed
|
|
162
|
+
* under (AGL-3330): the normalized ids, and `inCampaign`, the boolean the
|
|
163
|
+
* Forms list's "In a campaign" filter asks by equality.
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ The ONE way to write a form's `campaignIds`. A writer that sets the ids
|
|
166
|
+
* without `inCampaign` leaves the form answering the filter by what it used
|
|
167
|
+
* to be filed under; `apps/console/specs/form-writers-use-the-helpers.spec.ts`
|
|
168
|
+
* refuses one. The campaign deletion pass removes an id with `arrayRemove`
|
|
169
|
+
* and restamps `inCampaign` from the form as it then stands.
|
|
170
|
+
*/ export function formCampaignFields(selected) {
|
|
171
|
+
const campaignIds = campaignMembershipValue(Array.isArray(selected) ? selected : []);
|
|
172
|
+
return {
|
|
173
|
+
campaignIds,
|
|
174
|
+
inCampaign: campaignIds.length > 0
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The `stats.leads` a form holds when nothing has been counted for it.
|
|
179
|
+
*
|
|
180
|
+
* A form that routes leads holds `0` until its first lead, so "Leads = 0"
|
|
181
|
+
* finds the lead surfaces that have produced nothing yet. A form that does
|
|
182
|
+
* not route leads holds `null`: it has no leads measurement, which is what
|
|
183
|
+
* "Leads is empty" asks. The recount and every writer of the switch agree
|
|
184
|
+
* through this and {@link formCountersFromSource}.
|
|
185
|
+
*/ export function formLeadsStatWhenUncounted(routesLeads) {
|
|
186
|
+
return routesLeads ? 0 : null;
|
|
187
|
+
}
|
|
159
188
|
/**
|
|
160
189
|
* What a NEW form is written with so the Forms list can query every filter
|
|
161
|
-
* it offers (AGL-3330): its search
|
|
190
|
+
* it offers (AGL-3330): its search keys, and an explicit value for each
|
|
162
191
|
* field a query would otherwise have to find by its absence.
|
|
163
192
|
*
|
|
164
193
|
* - `retired: false`, the queryable mirror of an unset `archivedAt`.
|
|
165
194
|
* - `routing.lead` as a boolean, `false` unless the form routes to leads, so
|
|
166
195
|
* "Lead routing is off" is an equality rather than a missing field.
|
|
167
|
-
* - `
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
196
|
+
* - `inCampaign`, from the campaigns the form is created in.
|
|
197
|
+
* - `stats` with a NULL for each counter, except `leads` on a form that
|
|
198
|
+
* routes leads, which starts at `0` (see {@link formLeadsStatWhenUncounted}).
|
|
199
|
+
* Null, never zero, where nothing is measured: a zero would claim a
|
|
200
|
+
* measurement. `/api/forms/submit` increments these, and an increment on
|
|
201
|
+
* null starts from nothing, so it needs no change.
|
|
171
202
|
*
|
|
172
203
|
* `routing` keeps whatever else it carries (a dataset binding).
|
|
173
204
|
*/ export function newFormListFields(form) {
|
|
174
205
|
var _form_routing;
|
|
175
206
|
var _form_routing1;
|
|
207
|
+
const routesLeads = ((_form_routing1 = form.routing) == null ? void 0 : _form_routing1.lead) === true;
|
|
176
208
|
return _extends({}, formListFields(form), {
|
|
177
209
|
retired: false,
|
|
178
210
|
routing: _extends({}, (_form_routing = form.routing) != null ? _form_routing : {}, {
|
|
179
|
-
lead:
|
|
211
|
+
lead: routesLeads
|
|
180
212
|
}),
|
|
213
|
+
inCampaign: formCampaignFields(form.campaignIds).inCampaign,
|
|
181
214
|
stats: {
|
|
182
215
|
submissions: null,
|
|
183
|
-
leads:
|
|
216
|
+
leads: formLeadsStatWhenUncounted(routesLeads),
|
|
184
217
|
lastSubmissionAtMs: null
|
|
185
218
|
}
|
|
186
219
|
});
|
|
187
220
|
}
|
|
221
|
+
/*==========================================
|
|
222
|
+
* THE COUNTERS, RECOUNTED FROM WHAT THEY COUNT (AGL-3330).
|
|
223
|
+
*
|
|
224
|
+
* `stats.submissions`, `stats.leads` and `stats.lastSubmissionAtMs` are kept
|
|
225
|
+
* by increments on the submit path, which is cheap and never re-reads the
|
|
226
|
+
* collection it counts. An increment cannot see a delete, a failed write or
|
|
227
|
+
* a history written before it, so the stored figure drifts; the recount is
|
|
228
|
+
* what puts it back, from the rows themselves:
|
|
229
|
+
*
|
|
230
|
+
* - submissions: the site's `formSubmissions` whose `formId` is the form;
|
|
231
|
+
* - leads: the organization's leads whose `sources` name the form
|
|
232
|
+
* (`form:{formId}`, {@link formLeadSource}) — PEOPLE this form brought in
|
|
233
|
+
* that the workspace still holds, which is what the submit path counts
|
|
234
|
+
* (a returning visitor, or a person already on the list through this
|
|
235
|
+
* form, files no second lead);
|
|
236
|
+
* - last submission: the newest of those submissions' `createdAt`.
|
|
237
|
+
*
|
|
238
|
+
* A server helper (`recountFormStats`) runs it after anything that removes a
|
|
239
|
+
* row, and `tools/scripts/recount-form-stats.mjs` runs it over every form.
|
|
240
|
+
* Both decide with the pure functions below; the script's twin answers
|
|
241
|
+
* `tools/scripts/lib/site-form-stats-recount.fixtures.json`, as this does.
|
|
242
|
+
*=========================================*/ /** The source a lead filed by a form carries in `sources`. */ export const FORM_LEAD_SOURCE_PREFIX = 'form:';
|
|
243
|
+
/** `form:{formId}` — the lead source a form's capture writes. */ export function formLeadSource(formId) {
|
|
244
|
+
return `${FORM_LEAD_SOURCE_PREFIX}${formId}`;
|
|
245
|
+
}
|
|
246
|
+
/** The form ids a lead's `sources` name, in order, deduplicated. */ export function formIdsOfLeadSources(sources) {
|
|
247
|
+
if (!Array.isArray(sources)) return [];
|
|
248
|
+
const ids = [];
|
|
249
|
+
for (const source of sources){
|
|
250
|
+
if (typeof source !== 'string' || !source.startsWith(FORM_LEAD_SOURCE_PREFIX)) continue;
|
|
251
|
+
const id = source.slice(FORM_LEAD_SOURCE_PREFIX.length);
|
|
252
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
253
|
+
}
|
|
254
|
+
return ids;
|
|
255
|
+
}
|
|
256
|
+
/** The counter fields, in reading order. */ export const FORM_COUNTER_FIELDS = [
|
|
257
|
+
'submissions',
|
|
258
|
+
'leads',
|
|
259
|
+
'lastSubmissionAtMs'
|
|
260
|
+
];
|
|
261
|
+
/**
|
|
262
|
+
* The counters a form should hold, from the rows counted.
|
|
263
|
+
*
|
|
264
|
+
* Zero submissions is `null`, the list's "none yet", matching a new form.
|
|
265
|
+
* Leads follow {@link formLeadsStatWhenUncounted} when there are none — and
|
|
266
|
+
* a form that stopped routing leads keeps the leads it did file, because
|
|
267
|
+
* those people are still on the list.
|
|
268
|
+
*/ export function formCountersFromSource(source) {
|
|
269
|
+
const submissions = source.submissions > 0 ? source.submissions : null;
|
|
270
|
+
return {
|
|
271
|
+
submissions,
|
|
272
|
+
leads: source.leads > 0 ? source.leads : formLeadsStatWhenUncounted(source.routesLeads),
|
|
273
|
+
lastSubmissionAtMs: submissions !== null && typeof source.newestSubmissionAtMs === 'number' ? source.newestSubmissionAtMs : null
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* How far the stored `lastSubmissionAtMs` may sit from the newest
|
|
278
|
+
* submission's `createdAt` and still agree.
|
|
279
|
+
*
|
|
280
|
+
* The submit path stamps the counter from its own clock after it has written
|
|
281
|
+
* the submission, whose `createdAt` is the server's commit time, and a lead
|
|
282
|
+
* capture runs between the two — so a counter that is right reads a little
|
|
283
|
+
* after the row. Ten minutes is far past that gap and far short of two
|
|
284
|
+
* submissions a merchant would tell apart.
|
|
285
|
+
*/ export const FORM_LAST_SUBMISSION_TOLERANCE_MS = 10 * 60000;
|
|
286
|
+
/** A stored counter as the recount compares it: a finite number, or `null`. */ function storedCounter(value) {
|
|
287
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The counters on which `stored` disagrees with `recounted`, in reading
|
|
291
|
+
* order; empty when the form holds what its rows say.
|
|
292
|
+
*
|
|
293
|
+
* An ABSENT counter disagrees with a `null` one: "is empty" asks
|
|
294
|
+
* `== null`, which a missing field does not answer.
|
|
295
|
+
*/ export function formCounterDrift(stored, recounted) {
|
|
296
|
+
const drift = [];
|
|
297
|
+
for (const field of FORM_COUNTER_FIELDS){
|
|
298
|
+
const raw = stored == null ? void 0 : stored[field];
|
|
299
|
+
const value = storedCounter(raw);
|
|
300
|
+
const want = recounted[field];
|
|
301
|
+
if (raw === undefined || value === null && raw !== null) {
|
|
302
|
+
drift.push(field);
|
|
303
|
+
} else if (field === 'lastSubmissionAtMs' && value !== null && want !== null) {
|
|
304
|
+
if (Math.abs(value - want) > FORM_LAST_SUBMISSION_TOLERANCE_MS) drift.push(field);
|
|
305
|
+
} else if (value !== want) {
|
|
306
|
+
drift.push(field);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return drift;
|
|
310
|
+
}
|
|
311
|
+
/** The dotted-path update that puts `recounted` on a form. */ export function formCounterPatch(recounted) {
|
|
312
|
+
return {
|
|
313
|
+
'stats.submissions': recounted.submissions,
|
|
314
|
+
'stats.leads': recounted.leads,
|
|
315
|
+
'stats.lastSubmissionAtMs': recounted.lastSubmissionAtMs
|
|
316
|
+
};
|
|
317
|
+
}
|
|
188
318
|
export const FORM_STAT_KINDS = [
|
|
189
319
|
'views',
|
|
190
320
|
'starts',
|