@aglyn/tenant-data-admin 1.0.0-beta.160 → 1.0.0-beta.163
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 +6 -6
- package/src/lib/server/campaign-conversion-attribution.d.ts +48 -3
- package/src/lib/server/campaign-conversion-attribution.js +95 -2
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +6 -0
- package/src/lib/server/crm-email-activity.js +8 -3
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +14 -1
- package/src/lib/server/email-delivery-log.js +31 -8
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +5 -0
- package/src/lib/server/email-suppression.js +20 -0
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/org-member-notice.d.ts +52 -0
- package/src/lib/server/org-member-notice.js +90 -0
- package/src/lib/server/org-member-notice.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.163",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.163",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.163",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.163",
|
|
31
|
+
"@aglyn/shared-util-http": "1.0.0-beta.163",
|
|
32
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.163",
|
|
33
33
|
"@msgpack/msgpack": "^3.1.3",
|
|
34
34
|
"@swc/helpers": "0.5.23",
|
|
35
35
|
"sharp": "^0.35.3",
|
|
@@ -98,13 +98,31 @@ export declare const CAMPAIGN_CONVERSIONS_REPORT_DOC = "conversions";
|
|
|
98
98
|
* by currency and never totals across them.
|
|
99
99
|
*/
|
|
100
100
|
export type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking';
|
|
101
|
-
/**
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Which channel the credited touch arrived through.
|
|
103
|
+
*
|
|
104
|
+
* `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one
|
|
105
|
+
* email a rep's sequence sent. It is read off the same touch map as the
|
|
106
|
+
* email channel — the Outreach click route stamps it there with the
|
|
107
|
+
* sequence and the enrollment beside the campaign the sequence is in — so
|
|
108
|
+
* a booking made from a sequence link is credited by the door that credits
|
|
109
|
+
* every other booking, and nothing in this join has to know what a
|
|
110
|
+
* sequence is.
|
|
111
|
+
*/
|
|
112
|
+
export type CampaignTouchChannel = 'email' | 'web' | 'sequence';
|
|
103
113
|
/** The touch a conversion is credited to, once both channels have been asked. */
|
|
104
114
|
export interface ResolvedCampaignTouch {
|
|
105
115
|
channel: CampaignTouchChannel;
|
|
106
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* The campaign document, when the touch was a click on our own mail — or
|
|
118
|
+
* the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a
|
|
119
|
+
* sequence touch.
|
|
120
|
+
*/
|
|
107
121
|
campaignId?: string;
|
|
122
|
+
/** The sequence the touch came through, for a `sequence` touch. */
|
|
123
|
+
sequenceId?: string;
|
|
124
|
+
/** The enrollment the touch came through, for a `sequence` touch. */
|
|
125
|
+
enrollmentId?: string;
|
|
108
126
|
/** `utm_source`, when the touch was a link on the web. */
|
|
109
127
|
source?: string;
|
|
110
128
|
/** `utm_medium`, when the touch was a link on the web. */
|
|
@@ -203,3 +221,30 @@ export declare function attributeCampaignConversion(options: {
|
|
|
203
221
|
/** When the visitor became identifiable. Defaults to now. */
|
|
204
222
|
convertedAtMs?: number;
|
|
205
223
|
}, firestore?: any): Promise<CampaignConversionRecord | null>;
|
|
224
|
+
/**
|
|
225
|
+
* The per-host collection of sequence rollups. Restated from the reader
|
|
226
|
+
* (`campaign-report.ts` in the campaigns UI library, which this package may
|
|
227
|
+
* not import) and asserted equal by the reader's spec.
|
|
228
|
+
*/
|
|
229
|
+
export declare const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = "campaignSequenceReports";
|
|
230
|
+
/** The outcomes the runtime credits, in funnel order. */
|
|
231
|
+
export declare const CAMPAIGN_SEQUENCE_OUTCOMES: readonly ["enrolled", "sent", "replied", "meetings", "converted"];
|
|
232
|
+
export type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number];
|
|
233
|
+
/**
|
|
234
|
+
* Credits one outcome, for one enrollment, to every campaign named.
|
|
235
|
+
*
|
|
236
|
+
* Never throws, and never refuses the batch for one bad id: the outcome
|
|
237
|
+
* has already happened, and a campaign that is gone gains an orphaned
|
|
238
|
+
* report rather than blocking the credit to one that is not — the
|
|
239
|
+
* conversions rollup's own argument for a merge-set that creates.
|
|
240
|
+
*
|
|
241
|
+
* @returns how many campaigns were credited.
|
|
242
|
+
*/
|
|
243
|
+
export declare function creditCampaignSequenceOutcome(options: {
|
|
244
|
+
hostId: string;
|
|
245
|
+
/** The campaigns the enrollment carries; unusable ids are skipped. */
|
|
246
|
+
campaignIds: readonly string[];
|
|
247
|
+
outcome: CampaignSequenceOutcome;
|
|
248
|
+
/** When the outcome happened. Defaults to now. */
|
|
249
|
+
atMs?: number;
|
|
250
|
+
}, firestore?: any): Promise<number>;
|
|
@@ -146,9 +146,14 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
|
146
146
|
* visitor's device supplied.
|
|
147
147
|
*/ const emailWins = emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs);
|
|
148
148
|
if (emailWins) {
|
|
149
|
+
const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId;
|
|
149
150
|
return _extends({
|
|
150
|
-
channel: 'email',
|
|
151
|
-
campaignId: emailInWindow.campaignId
|
|
151
|
+
channel: viaSequence ? 'sequence' : 'email',
|
|
152
|
+
campaignId: emailInWindow.campaignId
|
|
153
|
+
}, viaSequence ? {
|
|
154
|
+
sequenceId: emailInWindow.sequenceId,
|
|
155
|
+
enrollmentId: emailInWindow.enrollmentId
|
|
156
|
+
} : {}, {
|
|
152
157
|
touchedAtMs: emailInWindow.clickedAtMs
|
|
153
158
|
}, key ? {
|
|
154
159
|
personKey: key
|
|
@@ -220,6 +225,10 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
|
220
225
|
channel: touch.channel
|
|
221
226
|
}, touch.campaignId ? {
|
|
222
227
|
campaignId: touch.campaignId
|
|
228
|
+
} : {}, touch.sequenceId ? {
|
|
229
|
+
sequenceId: touch.sequenceId
|
|
230
|
+
} : {}, touch.enrollmentId ? {
|
|
231
|
+
enrollmentId: touch.enrollmentId
|
|
223
232
|
} : {}, touch.source ? {
|
|
224
233
|
source: touch.source
|
|
225
234
|
} : {}, touch.medium ? {
|
|
@@ -269,11 +278,95 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
|
269
278
|
merge: true
|
|
270
279
|
});
|
|
271
280
|
}
|
|
281
|
+
/*
|
|
282
|
+
* A SEQUENCE touch rolls up under the campaign's sequences report, and
|
|
283
|
+
* only a booking does: "meetings booked from a sequence link" is the one
|
|
284
|
+
* identify moment a sequence's own outcomes do not already count. The
|
|
285
|
+
* form, lead and contact kinds stand as records — visible on the record
|
|
286
|
+
* they credit and on the site's conversions list — and never in
|
|
287
|
+
* `byKind`, whose caveat promises the reader campaign emails only.
|
|
288
|
+
*/ if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {
|
|
289
|
+
await creditCampaignSequenceOutcome({
|
|
290
|
+
hostId,
|
|
291
|
+
campaignIds: [
|
|
292
|
+
touch.campaignId
|
|
293
|
+
],
|
|
294
|
+
outcome: 'meetings',
|
|
295
|
+
atMs: convertedAtMs
|
|
296
|
+
}, db);
|
|
297
|
+
}
|
|
272
298
|
return record;
|
|
273
299
|
} catch (error) {
|
|
274
300
|
console.error('attributeCampaignConversion failed', error);
|
|
275
301
|
return null;
|
|
276
302
|
}
|
|
277
303
|
}
|
|
304
|
+
/*==========================================
|
|
305
|
+
* WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).
|
|
306
|
+
*
|
|
307
|
+
* A sequence joins a campaign the way a form does, and the Outreach runtime
|
|
308
|
+
* credits what each enrollment produced — enrolled, first email sent, a
|
|
309
|
+
* reply, a meeting booked from a sequence link, an enrolled lead converted
|
|
310
|
+
* — to every campaign the sequence was in when the person was enrolled. The
|
|
311
|
+
* counts live in `hosts/{hostId}/campaignSequenceReports/{campaignId}`:
|
|
312
|
+
* one document per campaign, server-only like every other report here, and
|
|
313
|
+
* never inside the campaign container, which the history list reads.
|
|
314
|
+
*
|
|
315
|
+
* Idempotency is the CALLER's: a plugin credits an outcome once per
|
|
316
|
+
* enrollment from a state change that happens once — the enrollment is
|
|
317
|
+
* created once, its first email is the first, its status moves to replied
|
|
318
|
+
* once, its lead converts once — so this writer is a plain increment and
|
|
319
|
+
* keeps no per-enrollment record of its own.
|
|
320
|
+
*=========================================*/ /**
|
|
321
|
+
* The per-host collection of sequence rollups. Restated from the reader
|
|
322
|
+
* (`campaign-report.ts` in the campaigns UI library, which this package may
|
|
323
|
+
* not import) and asserted equal by the reader's spec.
|
|
324
|
+
*/ export const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports';
|
|
325
|
+
/** The outcomes the runtime credits, in funnel order. */ export const CAMPAIGN_SEQUENCE_OUTCOMES = [
|
|
326
|
+
'enrolled',
|
|
327
|
+
'sent',
|
|
328
|
+
'replied',
|
|
329
|
+
'meetings',
|
|
330
|
+
'converted'
|
|
331
|
+
];
|
|
332
|
+
/**
|
|
333
|
+
* Credits one outcome, for one enrollment, to every campaign named.
|
|
334
|
+
*
|
|
335
|
+
* Never throws, and never refuses the batch for one bad id: the outcome
|
|
336
|
+
* has already happened, and a campaign that is gone gains an orphaned
|
|
337
|
+
* report rather than blocking the credit to one that is not — the
|
|
338
|
+
* conversions rollup's own argument for a merge-set that creates.
|
|
339
|
+
*
|
|
340
|
+
* @returns how many campaigns were credited.
|
|
341
|
+
*/ export async function creditCampaignSequenceOutcome(options, firestore) {
|
|
342
|
+
try {
|
|
343
|
+
var _options_hostId, _options_atMs;
|
|
344
|
+
const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '');
|
|
345
|
+
if (!isDocumentId(hostId)) return 0;
|
|
346
|
+
if (!CAMPAIGN_SEQUENCE_OUTCOMES.includes(options.outcome)) return 0;
|
|
347
|
+
const atMs = Number((_options_atMs = options.atMs) != null ? _options_atMs : Date.now());
|
|
348
|
+
const ids = [
|
|
349
|
+
...new Set(options.campaignIds.map((id)=>String(id != null ? id : '').trim()))
|
|
350
|
+
].filter((id)=>isDocumentId(id));
|
|
351
|
+
if (!ids.length) return 0;
|
|
352
|
+
const db = firestore != null ? firestore : defaultFirestore();
|
|
353
|
+
const reports = db.collection('hosts').doc(hostId).collection(CAMPAIGN_SEQUENCE_REPORTS_COLLECTION);
|
|
354
|
+
await Promise.all(ids.map((campaignId)=>reports.doc(campaignId).set(_extends({
|
|
355
|
+
byOutcome: {
|
|
356
|
+
[options.outcome]: FieldValue.increment(1)
|
|
357
|
+
}
|
|
358
|
+
}, Number.isFinite(atMs) && atMs > 0 ? {
|
|
359
|
+
updatedAtMs: atMs
|
|
360
|
+
} : {}, {
|
|
361
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
362
|
+
}), {
|
|
363
|
+
merge: true
|
|
364
|
+
})));
|
|
365
|
+
return ids.length;
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.error('creditCampaignSequenceOutcome failed', error);
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
278
371
|
|
|
279
372
|
//# sourceMappingURL=campaign-conversion-attribution.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n parseCampaignTouch,\n type CampaignTouch,\n} from '@aglyn/aglyn/app-utils/campaign-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/** Which channel the credited touch arrived through. */\nexport type CampaignTouchChannel = 'email' | 'web'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /** The campaign document, when the touch was a click on our own mail. */\n campaignId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: CampaignTouch | null = parseCampaignTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n return {\n channel: 'email',\n campaignId: emailInWindow.campaignId,\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n if (touch.channel === 'email' && touch.campaignId) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await hostRef\n .collection('campaigns')\n .doc(touch.campaignId)\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n"],"names":["FieldValue","parseCampaignTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","set","byKind","increment","updatedAt","merge"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n parseCampaignTouch,\n type CampaignTouch,\n} from '@aglyn/aglyn/app-utils/campaign-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: CampaignTouch | null = parseCampaignTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n if (touch.channel === 'email' && touch.campaignId) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await hostRef\n .collection('campaigns')\n .doc(touch.campaignId)\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `hosts/{hostId}/campaignSequenceReports/{campaignId}`:\n * one document per campaign, server-only like every other report here, and\n * never inside the campaign container, which the history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-host collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n hostId: string\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return 0\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const db = firestore ?? defaultFirestore()\n const reports = db.collection('hosts').doc(hostId).collection(CAMPAIGN_SEQUENCE_REPORTS_COLLECTION)\n await Promise.all(\n ids.map((campaignId) =>\n reports.doc(campaignId).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n"],"names":["FieldValue","parseCampaignTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","reports","Promise","all","byOutcome","updatedAtMs"],"mappings":"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"}
|
|
@@ -91,6 +91,12 @@ export declare function recordCrmEmailDelivery(firestore: FirebaseFirestore.Fire
|
|
|
91
91
|
state: CrmEmailDeliveryState;
|
|
92
92
|
/** When the provider says it happened, epoch ms. */
|
|
93
93
|
atMs: number;
|
|
94
|
+
/**
|
|
95
|
+
* What the receiving server said (AGL-3245), for a failure: the bounce's
|
|
96
|
+
* diagnostic, scrubbed of the address. Kept beside the state as
|
|
97
|
+
* `deliveryDetail`, so the timeline can say why the message did not land.
|
|
98
|
+
*/
|
|
99
|
+
detail?: string | null;
|
|
94
100
|
}): Promise<CrmEmailDeliveryOutcome>;
|
|
95
101
|
/**
|
|
96
102
|
* `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with
|
|
@@ -84,9 +84,11 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
84
84
|
* explain. **Never throws** — the webhook answers the provider 200 whatever
|
|
85
85
|
* happens here, so a failure is logged and reported rather than raised.
|
|
86
86
|
*/ export async function recordCrmEmailDelivery(firestore, input) {
|
|
87
|
+
var _input_detail;
|
|
87
88
|
const { orgId, activityId, state } = input;
|
|
88
89
|
if (!orgId || !activityId) return 'missing';
|
|
89
90
|
const ref = crmActivityRef(firestore, orgId, activityId);
|
|
91
|
+
const detail = String((_input_detail = input.detail) != null ? _input_detail : '').replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
90
92
|
try {
|
|
91
93
|
return await firestore.runTransaction(async (transaction)=>{
|
|
92
94
|
const snapshot = await transaction.get(ref);
|
|
@@ -94,11 +96,14 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
94
96
|
const current = snapshot.get('deliveryState');
|
|
95
97
|
const next = nextCrmEmailDeliveryState(current, state);
|
|
96
98
|
if (next === current) return 'unchanged';
|
|
97
|
-
transaction.update(ref, {
|
|
99
|
+
transaction.update(ref, _extends({
|
|
98
100
|
deliveryState: next,
|
|
99
|
-
deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now()
|
|
101
|
+
deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now()
|
|
102
|
+
}, detail ? {
|
|
103
|
+
deliveryDetail: detail
|
|
104
|
+
} : {}, {
|
|
100
105
|
updatedAt: FieldValue.serverTimestamp()
|
|
101
|
-
});
|
|
106
|
+
}));
|
|
102
107
|
return 'advanced';
|
|
103
108
|
});
|
|
104
109
|
} catch (error) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-email-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmEmailDeliveryState,\n isCrmEmailDeliveryState,\n nextCrmEmailDeliveryState,\n} from '@aglyn/aglyn/server'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The activity row a one-to-one email is logged as, on the server side\n * (AGL-2615): where it lives, how it is written, and how the delivery\n * webhook moves its state.\n *\n * Two writers log a sent email — the console's `crm/email-send` route and\n * the `sendEmail` automation step — and one reader advances it, the Resend\n * event webhook. The reader has to find the row from nothing but the tags\n * on the message, so the id is minted BEFORE the send and stamped on it,\n * and the row is written only after the provider accepted the message. A\n * row written first would be an \"email\" on the timeline for a message that\n * never left.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts`: the callers\n * hold one, and a module that reached for the Admin app itself would drag\n * it into every spec that only wants the arithmetic.\n */\n\n/** `orgs/{orgId}/crmActivities/{activityId}`. */\nexport function crmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n activityId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc(activityId)\n}\n\n/**\n * A fresh reference for the row a send is about to earn — `doc()` with no\n * id mints one locally and reads nothing, so the id can ride the message\n * as a tag before anything is written.\n */\nexport function newCrmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc()\n}\n\n/**\n * Writes the row a sent email is logged as, stamped with the server clock.\n *\n * `set` rather than `add`, because the id was minted ahead of the send and\n * is already on the message; a second id here would be the one the webhook\n * could not find.\n */\nexport async function writeCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<void> {\n await ref.set({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n}\n\n/**\n * Which delivery state one normalized delivery event moves a row to, or\n * `null` for an event that moves nothing.\n *\n * Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the\n * provider's wire strings: `normalizeResendDeliveryEvents` is the one place\n * that reads those, and this module must not become a second. The five\n * states the timeline shows share the log's names exactly, so the mapping\n * is membership; `delayed` and `failed` are the log's own and the row keeps\n * whatever it held.\n */\nexport function crmEmailDeliveryStateForEvent(\n type: string | null | undefined,\n): CrmEmailDeliveryState | null {\n return isCrmEmailDeliveryState(type) ? type : null\n}\n\nexport type CrmEmailDeliveryOutcome =\n /** The row moved to the incoming state. */\n | 'advanced'\n /** The row already held this state or a later one; nothing written. */\n | 'unchanged'\n /** No email activity by that id in that org; nothing written. */\n | 'missing'\n /** The read or the write threw; logged, and nothing is promised. */\n | 'failed'\n\n/**\n * One delivery event, onto the activity row it names (AGL-2615).\n *\n * A TRANSACTION, because the rule is \"keep the later state\" and two events\n * for one message routinely arrive together — `delivered` and `opened`\n * within the same second from a mail client that fetches images on\n * receipt. Two plain read-then-writes interleaved would let the earlier\n * event land last and print \"Delivered\" over an open that already\n * happened; inside a transaction the second read sees the first write and\n * `nextCrmEmailDeliveryState` answers \"unchanged\".\n *\n * A row that is not an email activity is left alone: the tag named it, but\n * a delivery state on a call log would be a chip the timeline has no way to\n * explain. **Never throws** — the webhook answers the provider 200 whatever\n * happens here, so a failure is logged and reported rather than raised.\n */\nexport async function recordCrmEmailDelivery(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n activityId: string\n state: CrmEmailDeliveryState\n /** When the provider says it happened, epoch ms. */\n atMs: number\n },\n): Promise<CrmEmailDeliveryOutcome> {\n const { orgId, activityId, state } = input\n if (!orgId || !activityId) return 'missing'\n const ref = crmActivityRef(firestore, orgId, activityId)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists || snapshot.get('kind') !== 'email') return 'missing'\n const current = snapshot.get('deliveryState')\n const next = nextCrmEmailDeliveryState(current, state)\n if (next === current) return 'unchanged'\n transaction.update(ref, {\n deliveryState: next,\n deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'advanced'\n })\n } catch (error) {\n console.error('[crm] email delivery state write failed', orgId, activityId, error)\n return 'failed'\n }\n}\n\n/**\n * `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with\n * the id derived from the message's key — its `Message-ID`, else the\n * provider's id — so a second delivery of one message addresses the row\n * the first delivery wrote. A hash rather than the key itself, because a\n * `Message-ID` carries `<`, `@` and `/`, which a document id may not.\n */\nexport function crmCapturedEmailActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n key: string,\n): FirebaseFirestore.DocumentReference {\n const digest = createHash('sha256').update(key).digest('hex').slice(0, 28)\n return crmActivityRef(firestore, orgId, `cap_${digest}`)\n}\n\n/** What a create answered: the row is new, or it was already there. */\nexport type CrmEmailActivityCreateOutcome = 'created' | 'duplicate'\n\nconst isAlreadyExists = (error: unknown): boolean => {\n const failure = error as { code?: unknown; message?: unknown } | null\n return (\n failure?.code === 6 ||\n failure?.code === 'already-exists' ||\n /ALREADY_EXISTS/i.test(String(failure?.message ?? ''))\n )\n}\n\n/**\n * Writes a captured message's row ONCE: `create()`, which refuses a\n * document that already exists, is what makes two deliveries of one\n * message one row even when they arrive together — the second create is\n * the one Firestore refuses, and this answers `duplicate` for it. Any\n * other failure is the caller's.\n */\nexport async function createCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<CrmEmailActivityCreateOutcome> {\n try {\n await ref.create({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'created'\n } catch (error) {\n if (isAlreadyExists(error)) return 'duplicate'\n throw error\n }\n}\n"],"names":["CRM_COLLECTIONS","isCrmEmailDeliveryState","nextCrmEmailDeliveryState","createHash","FieldValue","crmActivityRef","firestore","orgId","activityId","collection","doc","activities","newCrmActivityRef","writeCrmEmailActivity","ref","activity","set","createdAt","serverTimestamp","updatedAt","crmEmailDeliveryStateForEvent","type","recordCrmEmailDelivery","input","state","runTransaction","transaction","snapshot","get","exists","current","next","update","deliveryState","deliveryAtMs","Number","isFinite","atMs","Date","now","error","console","crmCapturedEmailActivityRef","key","digest","slice","isAlreadyExists","failure","code","test","String","message","createCrmEmailActivity","create"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-email-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmEmailDeliveryState,\n isCrmEmailDeliveryState,\n nextCrmEmailDeliveryState,\n} from '@aglyn/aglyn/server'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The activity row a one-to-one email is logged as, on the server side\n * (AGL-2615): where it lives, how it is written, and how the delivery\n * webhook moves its state.\n *\n * Two writers log a sent email — the console's `crm/email-send` route and\n * the `sendEmail` automation step — and one reader advances it, the Resend\n * event webhook. The reader has to find the row from nothing but the tags\n * on the message, so the id is minted BEFORE the send and stamped on it,\n * and the row is written only after the provider accepted the message. A\n * row written first would be an \"email\" on the timeline for a message that\n * never left.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts`: the callers\n * hold one, and a module that reached for the Admin app itself would drag\n * it into every spec that only wants the arithmetic.\n */\n\n/** `orgs/{orgId}/crmActivities/{activityId}`. */\nexport function crmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n activityId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc(activityId)\n}\n\n/**\n * A fresh reference for the row a send is about to earn — `doc()` with no\n * id mints one locally and reads nothing, so the id can ride the message\n * as a tag before anything is written.\n */\nexport function newCrmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc()\n}\n\n/**\n * Writes the row a sent email is logged as, stamped with the server clock.\n *\n * `set` rather than `add`, because the id was minted ahead of the send and\n * is already on the message; a second id here would be the one the webhook\n * could not find.\n */\nexport async function writeCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<void> {\n await ref.set({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n}\n\n/**\n * Which delivery state one normalized delivery event moves a row to, or\n * `null` for an event that moves nothing.\n *\n * Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the\n * provider's wire strings: `normalizeResendDeliveryEvents` is the one place\n * that reads those, and this module must not become a second. The five\n * states the timeline shows share the log's names exactly, so the mapping\n * is membership; `delayed` and `failed` are the log's own and the row keeps\n * whatever it held.\n */\nexport function crmEmailDeliveryStateForEvent(\n type: string | null | undefined,\n): CrmEmailDeliveryState | null {\n return isCrmEmailDeliveryState(type) ? type : null\n}\n\nexport type CrmEmailDeliveryOutcome =\n /** The row moved to the incoming state. */\n | 'advanced'\n /** The row already held this state or a later one; nothing written. */\n | 'unchanged'\n /** No email activity by that id in that org; nothing written. */\n | 'missing'\n /** The read or the write threw; logged, and nothing is promised. */\n | 'failed'\n\n/**\n * One delivery event, onto the activity row it names (AGL-2615).\n *\n * A TRANSACTION, because the rule is \"keep the later state\" and two events\n * for one message routinely arrive together — `delivered` and `opened`\n * within the same second from a mail client that fetches images on\n * receipt. Two plain read-then-writes interleaved would let the earlier\n * event land last and print \"Delivered\" over an open that already\n * happened; inside a transaction the second read sees the first write and\n * `nextCrmEmailDeliveryState` answers \"unchanged\".\n *\n * A row that is not an email activity is left alone: the tag named it, but\n * a delivery state on a call log would be a chip the timeline has no way to\n * explain. **Never throws** — the webhook answers the provider 200 whatever\n * happens here, so a failure is logged and reported rather than raised.\n */\nexport async function recordCrmEmailDelivery(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n activityId: string\n state: CrmEmailDeliveryState\n /** When the provider says it happened, epoch ms. */\n atMs: number\n /**\n * What the receiving server said (AGL-3245), for a failure: the bounce's\n * diagnostic, scrubbed of the address. Kept beside the state as\n * `deliveryDetail`, so the timeline can say why the message did not land.\n */\n detail?: string | null\n },\n): Promise<CrmEmailDeliveryOutcome> {\n const { orgId, activityId, state } = input\n if (!orgId || !activityId) return 'missing'\n const ref = crmActivityRef(firestore, orgId, activityId)\n const detail = String(input.detail ?? '').replace(/\\s+/g, ' ').trim().slice(0, 500)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists || snapshot.get('kind') !== 'email') return 'missing'\n const current = snapshot.get('deliveryState')\n const next = nextCrmEmailDeliveryState(current, state)\n if (next === current) return 'unchanged'\n transaction.update(ref, {\n deliveryState: next,\n deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now(),\n ...(detail ? { deliveryDetail: detail } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'advanced'\n })\n } catch (error) {\n console.error('[crm] email delivery state write failed', orgId, activityId, error)\n return 'failed'\n }\n}\n\n/**\n * `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with\n * the id derived from the message's key — its `Message-ID`, else the\n * provider's id — so a second delivery of one message addresses the row\n * the first delivery wrote. A hash rather than the key itself, because a\n * `Message-ID` carries `<`, `@` and `/`, which a document id may not.\n */\nexport function crmCapturedEmailActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n key: string,\n): FirebaseFirestore.DocumentReference {\n const digest = createHash('sha256').update(key).digest('hex').slice(0, 28)\n return crmActivityRef(firestore, orgId, `cap_${digest}`)\n}\n\n/** What a create answered: the row is new, or it was already there. */\nexport type CrmEmailActivityCreateOutcome = 'created' | 'duplicate'\n\nconst isAlreadyExists = (error: unknown): boolean => {\n const failure = error as { code?: unknown; message?: unknown } | null\n return (\n failure?.code === 6 ||\n failure?.code === 'already-exists' ||\n /ALREADY_EXISTS/i.test(String(failure?.message ?? ''))\n )\n}\n\n/**\n * Writes a captured message's row ONCE: `create()`, which refuses a\n * document that already exists, is what makes two deliveries of one\n * message one row even when they arrive together — the second create is\n * the one Firestore refuses, and this answers `duplicate` for it. Any\n * other failure is the caller's.\n */\nexport async function createCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<CrmEmailActivityCreateOutcome> {\n try {\n await ref.create({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'created'\n } catch (error) {\n if (isAlreadyExists(error)) return 'duplicate'\n throw error\n }\n}\n"],"names":["CRM_COLLECTIONS","isCrmEmailDeliveryState","nextCrmEmailDeliveryState","createHash","FieldValue","crmActivityRef","firestore","orgId","activityId","collection","doc","activities","newCrmActivityRef","writeCrmEmailActivity","ref","activity","set","createdAt","serverTimestamp","updatedAt","crmEmailDeliveryStateForEvent","type","recordCrmEmailDelivery","input","state","detail","String","replace","trim","slice","runTransaction","transaction","snapshot","get","exists","current","next","update","deliveryState","deliveryAtMs","Number","isFinite","atMs","Date","now","deliveryDetail","error","console","crmCapturedEmailActivityRef","key","digest","isAlreadyExists","failure","code","test","message","createCrmEmailActivity","create"],"mappings":"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"}
|
|
@@ -201,12 +201,22 @@ export declare const EMAIL_TOUCH_FIELD = "campaignTouches";
|
|
|
201
201
|
* and nothing else bounds how many sites may mail one address.
|
|
202
202
|
*/
|
|
203
203
|
export declare const EMAIL_TOUCH_MAX_HOSTS = 10;
|
|
204
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* The last campaign one person clicked on one site.
|
|
206
|
+
*
|
|
207
|
+
* A click on a SEQUENCE email (AGL-3254) is the same touch with two more
|
|
208
|
+
* facts: the sequence and the enrollment the email went out under. The
|
|
209
|
+
* campaign is then the container the sequence is in, and the identify
|
|
210
|
+
* moments this touch is credited to read as the sequence's rather than as
|
|
211
|
+
* a campaign send's.
|
|
212
|
+
*/
|
|
205
213
|
export interface EmailCampaignTouch {
|
|
206
214
|
hostId: string;
|
|
207
215
|
campaignId: string;
|
|
208
216
|
/** When the click happened, epoch ms — the provider's instant. */
|
|
209
217
|
clickedAtMs: number;
|
|
218
|
+
sequenceId?: string;
|
|
219
|
+
enrollmentId?: string;
|
|
210
220
|
}
|
|
211
221
|
/**
|
|
212
222
|
* Records that this person clicked this campaign's mail. Never throws.
|
|
@@ -224,6 +234,9 @@ export declare function recordEmailCampaignTouch(touch: {
|
|
|
224
234
|
hostId: string;
|
|
225
235
|
campaignId: string;
|
|
226
236
|
atMs: number;
|
|
237
|
+
/** Both or neither: a sequence click names the enrollment it came through. */
|
|
238
|
+
sequenceId?: string;
|
|
239
|
+
enrollmentId?: string;
|
|
227
240
|
}, firestore?: any): Promise<boolean>;
|
|
228
241
|
/**
|
|
229
242
|
* The last campaign this person clicked on this site, or `null`.
|
|
@@ -513,14 +513,19 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
|
|
|
513
513
|
if (!raw || typeof raw !== 'object') return {};
|
|
514
514
|
const found = {};
|
|
515
515
|
for (const [hostId, entry] of Object.entries(raw)){
|
|
516
|
-
var _ref, _ref1;
|
|
516
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
517
517
|
const campaignId = String((_ref = entry == null ? void 0 : entry.campaignId) != null ? _ref : '');
|
|
518
518
|
const atMs = Number((_ref1 = entry == null ? void 0 : entry.atMs) != null ? _ref1 : 0);
|
|
519
519
|
if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue;
|
|
520
|
-
|
|
520
|
+
const sequenceId = String((_ref2 = entry == null ? void 0 : entry.sequenceId) != null ? _ref2 : '');
|
|
521
|
+
const enrollmentId = String((_ref3 = entry == null ? void 0 : entry.enrollmentId) != null ? _ref3 : '');
|
|
522
|
+
found[hostId] = _extends({
|
|
521
523
|
campaignId,
|
|
522
524
|
atMs
|
|
523
|
-
}
|
|
525
|
+
}, sequenceId && enrollmentId ? {
|
|
526
|
+
sequenceId,
|
|
527
|
+
enrollmentId
|
|
528
|
+
} : {});
|
|
524
529
|
}
|
|
525
530
|
return found;
|
|
526
531
|
}
|
|
@@ -535,13 +540,19 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
|
|
|
535
540
|
*
|
|
536
541
|
* @returns whether the touch moved forward.
|
|
537
542
|
*/ export async function recordEmailCampaignTouch(touch, firestore) {
|
|
538
|
-
var _touch_hostId, _touch_campaignId;
|
|
543
|
+
var _touch_hostId, _touch_campaignId, _touch_sequenceId, _touch_enrollmentId;
|
|
539
544
|
const key = emailSuppressionKey(touch.email);
|
|
540
545
|
const hostId = String((_touch_hostId = touch.hostId) != null ? _touch_hostId : '');
|
|
541
546
|
const campaignId = String((_touch_campaignId = touch.campaignId) != null ? _touch_campaignId : '');
|
|
542
547
|
const atMs = Number(touch.atMs);
|
|
543
548
|
if (!key || !hostId || !campaignId) return false;
|
|
544
549
|
if (!Number.isFinite(atMs) || atMs <= 0) return false;
|
|
550
|
+
const sequenceId = String((_touch_sequenceId = touch.sequenceId) != null ? _touch_sequenceId : '');
|
|
551
|
+
const enrollmentId = String((_touch_enrollmentId = touch.enrollmentId) != null ? _touch_enrollmentId : '');
|
|
552
|
+
const viaSequence = sequenceId && enrollmentId ? {
|
|
553
|
+
sequenceId,
|
|
554
|
+
enrollmentId
|
|
555
|
+
} : {};
|
|
545
556
|
try {
|
|
546
557
|
const db = firestore != null ? firestore : defaultFirestore();
|
|
547
558
|
const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key);
|
|
@@ -555,11 +566,20 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
|
|
|
555
566
|
// Not newer than what is already there, so nothing is written. An
|
|
556
567
|
// out-of-order or replayed event is the ordinary case this skips.
|
|
557
568
|
if (held && held.atMs >= atMs) return;
|
|
569
|
+
/*
|
|
570
|
+
* A merge-set merges nested maps at depth, so a campaign click after a
|
|
571
|
+
* sequence click would keep the sequence's ids beside the new campaign
|
|
572
|
+
* unless they are deleted by name: the two are written as the value
|
|
573
|
+
* or as `FieldValue.delete()` whenever the held entry carried them.
|
|
574
|
+
*/ const dropSequence = (held == null ? void 0 : held.sequenceId) && !('sequenceId' in viaSequence) ? {
|
|
575
|
+
sequenceId: FieldValue.delete(),
|
|
576
|
+
enrollmentId: FieldValue.delete()
|
|
577
|
+
} : {};
|
|
558
578
|
const update = {
|
|
559
|
-
[hostId]: {
|
|
579
|
+
[hostId]: _extends({
|
|
560
580
|
campaignId,
|
|
561
581
|
atMs
|
|
562
|
-
}
|
|
582
|
+
}, viaSequence, dropSequence)
|
|
563
583
|
};
|
|
564
584
|
/*
|
|
565
585
|
* EVICTION, and only when this host is NEW to the map. Replacing an
|
|
@@ -606,11 +626,14 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
|
|
|
606
626
|
const snapshot = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get();
|
|
607
627
|
const held = touchesFrom((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {})[hostId];
|
|
608
628
|
if (!held) return null;
|
|
609
|
-
return {
|
|
629
|
+
return _extends({
|
|
610
630
|
hostId,
|
|
611
631
|
campaignId: held.campaignId,
|
|
612
632
|
clickedAtMs: held.atMs
|
|
613
|
-
}
|
|
633
|
+
}, held.sequenceId && held.enrollmentId ? {
|
|
634
|
+
sequenceId: held.sequenceId,
|
|
635
|
+
enrollmentId: held.enrollmentId
|
|
636
|
+
} : {});
|
|
614
637
|
} catch (error) {
|
|
615
638
|
console.error('[email-delivery-log] campaign touch read failed', error);
|
|
616
639
|
return null;
|