@aglyn/plugins-marketing 1.0.0-beta.222 → 1.0.0-beta.224
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 +15 -15
- package/src/lib/components/campaign-detail-card.js +17 -3
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-edit-drawer.d.ts +6 -0
- package/src/lib/components/campaign-edit-drawer.js +13 -2
- package/src/lib/components/campaign-edit-drawer.js.map +1 -1
- package/src/lib/components/campaign-queries.d.ts +5 -0
- package/src/lib/components/campaign-queries.js +7 -0
- package/src/lib/components/campaign-queries.js.map +1 -1
- package/src/lib/components/campaign-reach-sections.d.ts +28 -0
- package/src/lib/components/campaign-reach-sections.js +95 -4
- package/src/lib/components/campaign-reach-sections.js.map +1 -1
- package/src/lib/components/conversion-attribution.component.d.ts +18 -0
- package/src/lib/components/conversion-attribution.component.js +177 -39
- package/src/lib/components/conversion-attribution.component.js.map +1 -1
- package/src/lib/components/record-attribution-widget.d.ts +5 -0
- package/src/lib/components/record-attribution-widget.js +6 -3
- package/src/lib/components/record-attribution-widget.js.map +1 -1
- package/src/lib/components/site-runtime.d.ts +5 -0
- package/src/lib/components/site-runtime.js +101 -2
- package/src/lib/components/site-runtime.js.map +1 -1
- package/src/lib/declarations.server.js +17 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/campaign-container.d.ts +28 -0
- package/src/lib/model/campaign-container.js +34 -0
- package/src/lib/model/campaign-container.js.map +1 -1
- package/src/lib/model/campaign-conversions.d.ts +47 -3
- package/src/lib/model/campaign-conversions.js +49 -4
- package/src/lib/model/campaign-conversions.js.map +1 -1
- package/src/lib/model/campaign-visits.d.ts +74 -0
- package/src/lib/model/campaign-visits.js +65 -0
- package/src/lib/model/campaign-visits.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +34 -3
- package/src/lib/server/campaign-conversion-attribution.js +126 -24
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/campaign-touch-targets.d.ts +61 -0
- package/src/lib/server/campaign-touch-targets.js +162 -0
- package/src/lib/server/campaign-touch-targets.js.map +1 -0
- package/src/lib/server/campaign-visit-beacon.d.ts +44 -0
- package/src/lib/server/campaign-visit-beacon.js +125 -0
- package/src/lib/server/campaign-visit-beacon.js.map +1 -0
- package/src/lib/server/conversion-creditor.js +109 -2
- package/src/lib/server/conversion-creditor.js.map +1 -1
- package/src/lib/server/email-events.js +24 -25
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/site-page-enricher.js +18 -2
- package/src/lib/server/site-page-enricher.js.map +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { EMAIL_CAMPAIGNS_COLLECTION, campaignPlacedOnHost, normalizeCampaignUtmLabel } from "../model/campaign-container.js";
|
|
17
|
+
import { readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
|
|
18
|
+
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
19
|
+
import { CAMPAIGN_KIND } from "../model/campaign-kind.js";
|
|
20
|
+
/**
|
|
21
|
+
* WHAT A VISITOR'S TOUCH NAMES, CHECKED AGAINST WHAT IS STORED (AGL-3461).
|
|
22
|
+
*
|
|
23
|
+
* Two touches name a campaign DOCUMENT without a click on its mail: a page
|
|
24
|
+
* the visitor viewed that is filed under a campaign, and a `utm_campaign`
|
|
25
|
+
* label a campaign declares as its own. Both arrive from the visitor's
|
|
26
|
+
* browser, so neither is trusted as sent:
|
|
27
|
+
*
|
|
28
|
+
* - a page touch credits a campaign only while the campaign exists and the
|
|
29
|
+
* page is still filed under it — one screen read and one campaign read;
|
|
30
|
+
* - a label credits a campaign only when exactly one live campaign placed
|
|
31
|
+
* on the site declares it — one `array-contains` query on the org's
|
|
32
|
+
* campaigns, served by the automatic single-field index. A label two
|
|
33
|
+
* campaigns claim credits neither, rather than whichever the index
|
|
34
|
+
* happens to return first.
|
|
35
|
+
*
|
|
36
|
+
* The conversion join (`campaign-conversion-attribution.ts`) asks at an
|
|
37
|
+
* identify moment and the first-visit beacon (`campaign-visit-beacon.ts`) on
|
|
38
|
+
* a pageview. Each answer is memoized for a minute per Firestore instance —
|
|
39
|
+
* the beacon is per pageview and a landing page's visitors all ask the same
|
|
40
|
+
* question — so a campaign deleted or a page refiled is believed within the
|
|
41
|
+
* minute, which is the staleness the site collector's own caches accept.
|
|
42
|
+
*
|
|
43
|
+
* Never throws: a lookup that fails answers "names nothing", and the touch is
|
|
44
|
+
* then treated as the label or the page it is, credited to no document.
|
|
45
|
+
*/ /** How long one answer is believed, per Firestore instance. */ const MEMO_MS = 60000;
|
|
46
|
+
/** Size cap on each memo; cleared wholesale like every visitor-keyed map. */ const MEMO_MAX = 2000;
|
|
47
|
+
const memos = new WeakMap();
|
|
48
|
+
function memoFor(firestore) {
|
|
49
|
+
let memo = memos.get(firestore);
|
|
50
|
+
if (!memo) {
|
|
51
|
+
memo = new Map();
|
|
52
|
+
memos.set(firestore, memo);
|
|
53
|
+
}
|
|
54
|
+
return memo;
|
|
55
|
+
}
|
|
56
|
+
async function remembered(firestore, key, read) {
|
|
57
|
+
const memo = memoFor(firestore);
|
|
58
|
+
const hit = memo.get(key);
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (hit && now - hit.at < MEMO_MS) return hit.value;
|
|
61
|
+
const value = await read();
|
|
62
|
+
if (memo.size > MEMO_MAX) memo.clear();
|
|
63
|
+
memo.set(key, {
|
|
64
|
+
value,
|
|
65
|
+
at: now
|
|
66
|
+
});
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The campaign at `orgs/{orgId}/emailCampaigns/{campaignId}`, or `null` when
|
|
71
|
+
* it does not exist or its owner retired it.
|
|
72
|
+
*/ export async function readLiveCampaign(options, firestore) {
|
|
73
|
+
const { orgId, campaignId } = options;
|
|
74
|
+
if (!isDocumentId(orgId) || !isDocumentId(campaignId)) return null;
|
|
75
|
+
try {
|
|
76
|
+
return await remembered(firestore, `campaign:${orgId}:${campaignId}`, async ()=>{
|
|
77
|
+
var _snapshot_data, _data_name;
|
|
78
|
+
const snapshot = await firestore.collection('orgs').doc(orgId).collection(EMAIL_CAMPAIGNS_COLLECTION).doc(campaignId).get();
|
|
79
|
+
if (!snapshot.exists) return null;
|
|
80
|
+
const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
81
|
+
if (data.deletedAt) return null;
|
|
82
|
+
return {
|
|
83
|
+
id: campaignId,
|
|
84
|
+
name: String((_data_name = data.name) != null ? _data_name : '').trim() || campaignId
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('[campaign-touch] campaign read failed', error);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The campaigns a site's screen is filed under right now, or `null` when the
|
|
94
|
+
* screen does not exist.
|
|
95
|
+
*/ export async function readScreenCampaignIds(options, firestore) {
|
|
96
|
+
const { hostId, screenId } = options;
|
|
97
|
+
if (!isDocumentId(hostId) || !isDocumentId(screenId)) return null;
|
|
98
|
+
try {
|
|
99
|
+
return await remembered(firestore, `screen:${hostId}:${screenId}`, async ()=>{
|
|
100
|
+
var _snapshot_data;
|
|
101
|
+
const snapshot = await firestore.collection('hosts').doc(hostId).collection('screens').doc(screenId).get();
|
|
102
|
+
if (!snapshot.exists) return null;
|
|
103
|
+
return readContainerIds((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}, CAMPAIGN_KIND);
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('[campaign-touch] screen read failed', error);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Of the campaigns a page touch carries, the first that the page is STILL
|
|
112
|
+
* filed under and that still exists — or `null`.
|
|
113
|
+
*
|
|
114
|
+
* The touch carries the screen's campaigns in the screen's own order, so a
|
|
115
|
+
* page filed under two campaigns credits the one it names first. One
|
|
116
|
+
* outcome, one campaign: crediting both would count one visitor's form twice
|
|
117
|
+
* across the org's campaigns.
|
|
118
|
+
*/ export async function filedCampaignForPage(options, firestore) {
|
|
119
|
+
const filed = await readScreenCampaignIds(options, firestore);
|
|
120
|
+
if (!(filed == null ? void 0 : filed.length)) return null;
|
|
121
|
+
for (const campaignId of options.campaignIds){
|
|
122
|
+
if (!filed.includes(campaignId)) continue;
|
|
123
|
+
const campaign = await readLiveCampaign({
|
|
124
|
+
orgId: options.orgId,
|
|
125
|
+
campaignId
|
|
126
|
+
}, firestore);
|
|
127
|
+
if (campaign) return campaign;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The one live campaign placed on this site that declares `label` among its
|
|
133
|
+
* `utmCampaigns`, or `null` — for no such campaign, and for two.
|
|
134
|
+
*/ export async function campaignForUtmLabel(options, firestore) {
|
|
135
|
+
const { orgId, hostId } = options;
|
|
136
|
+
const label = normalizeCampaignUtmLabel(options.label);
|
|
137
|
+
if (!label || !isDocumentId(orgId)) return null;
|
|
138
|
+
try {
|
|
139
|
+
return await remembered(firestore, `label:${orgId}:${hostId}:${label}`, async ()=>{
|
|
140
|
+
var _snapshot_docs, _match_data_name;
|
|
141
|
+
const snapshot = await firestore.collection('orgs').doc(orgId).collection(EMAIL_CAMPAIGNS_COLLECTION).where('utmCampaigns', 'array-contains', label).limit(5).get();
|
|
142
|
+
const matches = ((_snapshot_docs = snapshot.docs) != null ? _snapshot_docs : []).map((doc)=>{
|
|
143
|
+
var _doc_data;
|
|
144
|
+
return {
|
|
145
|
+
id: String(doc.id),
|
|
146
|
+
data: (_doc_data = doc.data()) != null ? _doc_data : {}
|
|
147
|
+
};
|
|
148
|
+
}).filter(({ data })=>!data.deletedAt).filter(({ data })=>campaignPlacedOnHost(data, hostId));
|
|
149
|
+
if (matches.length !== 1) return null;
|
|
150
|
+
const [match] = matches;
|
|
151
|
+
return {
|
|
152
|
+
id: match.id,
|
|
153
|
+
name: String((_match_data_name = match.data.name) != null ? _match_data_name : '').trim() || match.id
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('[campaign-touch] label lookup failed', error);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
//# sourceMappingURL=campaign-touch-targets.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-touch-targets.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 EMAIL_CAMPAIGNS_COLLECTION,\n campaignPlacedOnHost,\n normalizeCampaignUtmLabel,\n type EmailCampaign,\n} from '../model/campaign-container'\nimport { readContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { CAMPAIGN_KIND } from '../model/campaign-kind'\n\n/**\n * WHAT A VISITOR'S TOUCH NAMES, CHECKED AGAINST WHAT IS STORED (AGL-3461).\n *\n * Two touches name a campaign DOCUMENT without a click on its mail: a page\n * the visitor viewed that is filed under a campaign, and a `utm_campaign`\n * label a campaign declares as its own. Both arrive from the visitor's\n * browser, so neither is trusted as sent:\n *\n * - a page touch credits a campaign only while the campaign exists and the\n * page is still filed under it — one screen read and one campaign read;\n * - a label credits a campaign only when exactly one live campaign placed\n * on the site declares it — one `array-contains` query on the org's\n * campaigns, served by the automatic single-field index. A label two\n * campaigns claim credits neither, rather than whichever the index\n * happens to return first.\n *\n * The conversion join (`campaign-conversion-attribution.ts`) asks at an\n * identify moment and the first-visit beacon (`campaign-visit-beacon.ts`) on\n * a pageview. Each answer is memoized for a minute per Firestore instance —\n * the beacon is per pageview and a landing page's visitors all ask the same\n * question — so a campaign deleted or a page refiled is believed within the\n * minute, which is the staleness the site collector's own caches accept.\n *\n * Never throws: a lookup that fails answers \"names nothing\", and the touch is\n * then treated as the label or the page it is, credited to no document.\n */\n\n/** How long one answer is believed, per Firestore instance. */\nconst MEMO_MS = 60_000\n\n/** Size cap on each memo; cleared wholesale like every visitor-keyed map. */\nconst MEMO_MAX = 2_000\n\ntype Memo = Map<string, { value: unknown; at: number }>\n\nconst memos = new WeakMap<object, Memo>()\n\nfunction memoFor(firestore: object): Memo {\n let memo = memos.get(firestore)\n if (!memo) {\n memo = new Map()\n memos.set(firestore, memo)\n }\n return memo\n}\n\nasync function remembered<T>(\n firestore: object,\n key: string,\n read: () => Promise<T>,\n): Promise<T> {\n const memo = memoFor(firestore)\n const hit = memo.get(key)\n const now = Date.now()\n if (hit && now - hit.at < MEMO_MS) return hit.value as T\n const value = await read()\n if (memo.size > MEMO_MAX) memo.clear()\n memo.set(key, { value, at: now })\n return value\n}\n\n/** A campaign container, as much of it as a touch's credit needs. */\nexport interface LiveCampaign {\n id: string\n name: string\n}\n\n/**\n * The campaign at `orgs/{orgId}/emailCampaigns/{campaignId}`, or `null` when\n * it does not exist or its owner retired it.\n */\nexport async function readLiveCampaign(\n options: { orgId: string; campaignId: string },\n firestore: any,\n): Promise<LiveCampaign | null> {\n const { orgId, campaignId } = options\n if (!isDocumentId(orgId) || !isDocumentId(campaignId)) return null\n try {\n return await remembered(firestore, `campaign:${orgId}:${campaignId}`, async () => {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_CAMPAIGNS_COLLECTION)\n .doc(campaignId)\n .get()\n if (!snapshot.exists) return null\n const data = (snapshot.data() ?? {}) as Partial<EmailCampaign>\n if (data.deletedAt) return null\n return { id: campaignId, name: String(data.name ?? '').trim() || campaignId }\n })\n } catch (error) {\n console.error('[campaign-touch] campaign read failed', error)\n return null\n }\n}\n\n/**\n * The campaigns a site's screen is filed under right now, or `null` when the\n * screen does not exist.\n */\nexport async function readScreenCampaignIds(\n options: { hostId: string; screenId: string },\n firestore: any,\n): Promise<string[] | null> {\n const { hostId, screenId } = options\n if (!isDocumentId(hostId) || !isDocumentId(screenId)) return null\n try {\n return await remembered(firestore, `screen:${hostId}:${screenId}`, async () => {\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('screens')\n .doc(screenId)\n .get()\n if (!snapshot.exists) return null\n return readContainerIds(\n (snapshot.data() ?? {}) as Record<string, unknown>,\n CAMPAIGN_KIND,\n )\n })\n } catch (error) {\n console.error('[campaign-touch] screen read failed', error)\n return null\n }\n}\n\n/**\n * Of the campaigns a page touch carries, the first that the page is STILL\n * filed under and that still exists — or `null`.\n *\n * The touch carries the screen's campaigns in the screen's own order, so a\n * page filed under two campaigns credits the one it names first. One\n * outcome, one campaign: crediting both would count one visitor's form twice\n * across the org's campaigns.\n */\nexport async function filedCampaignForPage(\n options: {\n orgId: string\n hostId: string\n screenId: string\n campaignIds: readonly string[]\n },\n firestore: any,\n): Promise<LiveCampaign | null> {\n const filed = await readScreenCampaignIds(options, firestore)\n if (!filed?.length) return null\n for (const campaignId of options.campaignIds) {\n if (!filed.includes(campaignId)) continue\n const campaign = await readLiveCampaign(\n { orgId: options.orgId, campaignId },\n firestore,\n )\n if (campaign) return campaign\n }\n return null\n}\n\n/**\n * The one live campaign placed on this site that declares `label` among its\n * `utmCampaigns`, or `null` — for no such campaign, and for two.\n */\nexport async function campaignForUtmLabel(\n options: { orgId: string; hostId: string; label: string | null | undefined },\n firestore: any,\n): Promise<LiveCampaign | null> {\n const { orgId, hostId } = options\n const label = normalizeCampaignUtmLabel(options.label)\n if (!label || !isDocumentId(orgId)) return null\n try {\n return await remembered(firestore, `label:${orgId}:${hostId}:${label}`, async () => {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_CAMPAIGNS_COLLECTION)\n .where('utmCampaigns', 'array-contains', label)\n .limit(5)\n .get()\n const matches = (snapshot.docs ?? [])\n .map((doc: any) => ({ id: String(doc.id), data: (doc.data() ?? {}) as Partial<EmailCampaign> }))\n .filter(({ data }: { data: Partial<EmailCampaign> }) => !data.deletedAt)\n .filter(({ data }: { data: Partial<EmailCampaign> }) => campaignPlacedOnHost(data, hostId))\n if (matches.length !== 1) return null\n const [match] = matches\n return { id: match.id, name: String(match.data.name ?? '').trim() || match.id }\n })\n } catch (error) {\n console.error('[campaign-touch] label lookup failed', error)\n return null\n }\n}\n"],"names":["EMAIL_CAMPAIGNS_COLLECTION","campaignPlacedOnHost","normalizeCampaignUtmLabel","readContainerIds","isDocumentId","CAMPAIGN_KIND","MEMO_MS","MEMO_MAX","memos","WeakMap","memoFor","firestore","memo","get","Map","set","remembered","key","read","hit","now","Date","at","value","size","clear","readLiveCampaign","options","orgId","campaignId","snapshot","data","collection","doc","exists","deletedAt","id","name","String","trim","error","console","readScreenCampaignIds","hostId","screenId","filedCampaignForPage","filed","length","campaignIds","includes","campaign","campaignForUtmLabel","label","match","where","limit","matches","docs","map","filter"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginSiteBeaconRequest } from '@aglyn/aglyn/plugin-manager/plugin-site-beacons';
|
|
18
|
+
/**
|
|
19
|
+
* The body field that marks a beacon as a campaign visit. The site runtime
|
|
20
|
+
* sends it; the collector hands the beacon here after its host, lockdown and
|
|
21
|
+
* rate gates.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CAMPAIGN_VISIT_BEACON_FIELD = "campaignVisit";
|
|
24
|
+
/**
|
|
25
|
+
* Counts one campaign visit beacon (AGL-3461) into
|
|
26
|
+
* `orgs/{orgId}/campaignVisitReports/{campaignId}`.
|
|
27
|
+
*
|
|
28
|
+
* The body says what the visitor's browser saw — the screen and the
|
|
29
|
+
* campaigns its page was filed under, the `utm_campaign` label on the
|
|
30
|
+
* address, and which of those the device claimed as a FIRST visit — and none
|
|
31
|
+
* of it is believed as sent:
|
|
32
|
+
*
|
|
33
|
+
* - a page's campaign is counted only while the screen is still filed under
|
|
34
|
+
* it and the campaign still exists;
|
|
35
|
+
* - a label is counted only for the one live campaign on the site that
|
|
36
|
+
* declares it;
|
|
37
|
+
* - a campaign named by both the page and the label in one beacon is one
|
|
38
|
+
* visit, and counts once.
|
|
39
|
+
*
|
|
40
|
+
* `views` moves for the page's campaigns only — a label is an arrival, not a
|
|
41
|
+
* view of the campaign's pages. Never throws past the collector, which logs
|
|
42
|
+
* and answers 204 either way.
|
|
43
|
+
*/
|
|
44
|
+
export declare function countCampaignVisitBeacon(request: PluginSiteBeaconRequest, firestore?: any): Promise<number>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
18
|
+
import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
19
|
+
import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
20
|
+
import { CAMPAIGN_VISIT_REPORTS_COLLECTION } from "../model/campaign-visits.js";
|
|
21
|
+
import { campaignForUtmLabel, readLiveCampaign, readScreenCampaignIds } from "./campaign-touch-targets.js";
|
|
22
|
+
/**
|
|
23
|
+
* The body field that marks a beacon as a campaign visit. The site runtime
|
|
24
|
+
* sends it; the collector hands the beacon here after its host, lockdown and
|
|
25
|
+
* rate gates.
|
|
26
|
+
*/ export const CAMPAIGN_VISIT_BEACON_FIELD = 'campaignVisit';
|
|
27
|
+
/** The most campaigns one beacon may name, matching the page touch. */ const MAX_CAMPAIGNS = 5;
|
|
28
|
+
const DOCUMENT_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
|
+
function idsOf(raw) {
|
|
30
|
+
if (!Array.isArray(raw)) return [];
|
|
31
|
+
const ids = [];
|
|
32
|
+
for (const entry of raw){
|
|
33
|
+
const id = typeof entry === 'string' ? entry.trim() : '';
|
|
34
|
+
if (!DOCUMENT_ID.test(id) || ids.includes(id)) continue;
|
|
35
|
+
ids.push(id);
|
|
36
|
+
if (ids.length >= MAX_CAMPAIGNS) break;
|
|
37
|
+
}
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Counts one campaign visit beacon (AGL-3461) into
|
|
42
|
+
* `orgs/{orgId}/campaignVisitReports/{campaignId}`.
|
|
43
|
+
*
|
|
44
|
+
* The body says what the visitor's browser saw — the screen and the
|
|
45
|
+
* campaigns its page was filed under, the `utm_campaign` label on the
|
|
46
|
+
* address, and which of those the device claimed as a FIRST visit — and none
|
|
47
|
+
* of it is believed as sent:
|
|
48
|
+
*
|
|
49
|
+
* - a page's campaign is counted only while the screen is still filed under
|
|
50
|
+
* it and the campaign still exists;
|
|
51
|
+
* - a label is counted only for the one live campaign on the site that
|
|
52
|
+
* declares it;
|
|
53
|
+
* - a campaign named by both the page and the label in one beacon is one
|
|
54
|
+
* visit, and counts once.
|
|
55
|
+
*
|
|
56
|
+
* `views` moves for the page's campaigns only — a label is an arrival, not a
|
|
57
|
+
* view of the campaign's pages. Never throws past the collector, which logs
|
|
58
|
+
* and answers 204 either way.
|
|
59
|
+
*/ export async function countCampaignVisitBeacon(request, firestore = firebaseAdmin.app().firestore()) {
|
|
60
|
+
const { hostId, day, body } = request;
|
|
61
|
+
const orgId = await resolveOrgIdForHost(hostId);
|
|
62
|
+
if (!orgId) return 0;
|
|
63
|
+
const month = String(day != null ? day : '').slice(0, 7);
|
|
64
|
+
/** Per campaign: whether this pageview viewed its page, and whether it was a first visit. */ const counted = new Map();
|
|
65
|
+
const note = (id, view, first)=>{
|
|
66
|
+
var _counted_get;
|
|
67
|
+
const held = (_counted_get = counted.get(id)) != null ? _counted_get : {
|
|
68
|
+
view: false,
|
|
69
|
+
first: false
|
|
70
|
+
};
|
|
71
|
+
counted.set(id, {
|
|
72
|
+
view: held.view || view,
|
|
73
|
+
first: held.first || first
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const screenId = typeof body['screenId'] === 'string' ? body['screenId'].trim() : '';
|
|
77
|
+
const carried = idsOf(body['campaignIds']);
|
|
78
|
+
const firsts = new Set(idsOf(body['first']));
|
|
79
|
+
if (DOCUMENT_ID.test(screenId) && carried.length) {
|
|
80
|
+
const filed = await readScreenCampaignIds({
|
|
81
|
+
hostId,
|
|
82
|
+
screenId
|
|
83
|
+
}, firestore);
|
|
84
|
+
for (const id of carried){
|
|
85
|
+
if (!(filed == null ? void 0 : filed.includes(id))) continue;
|
|
86
|
+
if (!await readLiveCampaign({
|
|
87
|
+
orgId,
|
|
88
|
+
campaignId: id
|
|
89
|
+
}, firestore)) continue;
|
|
90
|
+
note(id, true, firsts.has(id));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const label = typeof body['utmCampaign'] === 'string' ? body['utmCampaign'] : '';
|
|
94
|
+
if (label && body['firstLabel'] === true) {
|
|
95
|
+
const campaign = await campaignForUtmLabel({
|
|
96
|
+
orgId,
|
|
97
|
+
hostId,
|
|
98
|
+
label
|
|
99
|
+
}, firestore);
|
|
100
|
+
if (campaign) note(campaign.id, false, true);
|
|
101
|
+
}
|
|
102
|
+
const reports = firestore.collection('orgs').doc(orgId).collection(CAMPAIGN_VISIT_REPORTS_COLLECTION);
|
|
103
|
+
await Promise.all([
|
|
104
|
+
...counted.entries()
|
|
105
|
+
].map(([campaignId, { view, first }])=>{
|
|
106
|
+
const fields = _extends({}, view ? {
|
|
107
|
+
views: FieldValue.increment(1)
|
|
108
|
+
} : {}, first ? {
|
|
109
|
+
firstVisits: FieldValue.increment(1)
|
|
110
|
+
} : {});
|
|
111
|
+
if (!Object.keys(fields).length) return Promise.resolve();
|
|
112
|
+
return reports.doc(campaignId).set(_extends({}, fields, /^\d{4}-\d{2}$/.test(month) ? {
|
|
113
|
+
byMonth: {
|
|
114
|
+
[month]: fields
|
|
115
|
+
}
|
|
116
|
+
} : {}, {
|
|
117
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
118
|
+
}), {
|
|
119
|
+
merge: true
|
|
120
|
+
});
|
|
121
|
+
}));
|
|
122
|
+
return counted.size;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//# sourceMappingURL=campaign-visit-beacon.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-visit-beacon.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 type { PluginSiteBeaconRequest } from '@aglyn/aglyn/plugin-manager/plugin-site-beacons'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport { CAMPAIGN_VISIT_REPORTS_COLLECTION } from '../model/campaign-visits'\nimport {\n campaignForUtmLabel,\n readLiveCampaign,\n readScreenCampaignIds,\n} from './campaign-touch-targets'\n\n/**\n * The body field that marks a beacon as a campaign visit. The site runtime\n * sends it; the collector hands the beacon here after its host, lockdown and\n * rate gates.\n */\nexport const CAMPAIGN_VISIT_BEACON_FIELD = 'campaignVisit'\n\n/** The most campaigns one beacon may name, matching the page touch. */\nconst MAX_CAMPAIGNS = 5\n\nconst DOCUMENT_ID = /^[A-Za-z0-9_-]{1,64}$/\n\nfunction idsOf(raw: unknown): string[] {\n if (!Array.isArray(raw)) return []\n const ids: string[] = []\n for (const entry of raw) {\n const id = typeof entry === 'string' ? entry.trim() : ''\n if (!DOCUMENT_ID.test(id) || ids.includes(id)) continue\n ids.push(id)\n if (ids.length >= MAX_CAMPAIGNS) break\n }\n return ids\n}\n\n/**\n * Counts one campaign visit beacon (AGL-3461) into\n * `orgs/{orgId}/campaignVisitReports/{campaignId}`.\n *\n * The body says what the visitor's browser saw — the screen and the\n * campaigns its page was filed under, the `utm_campaign` label on the\n * address, and which of those the device claimed as a FIRST visit — and none\n * of it is believed as sent:\n *\n * - a page's campaign is counted only while the screen is still filed under\n * it and the campaign still exists;\n * - a label is counted only for the one live campaign on the site that\n * declares it;\n * - a campaign named by both the page and the label in one beacon is one\n * visit, and counts once.\n *\n * `views` moves for the page's campaigns only — a label is an arrival, not a\n * view of the campaign's pages. Never throws past the collector, which logs\n * and answers 204 either way.\n */\nexport async function countCampaignVisitBeacon(\n request: PluginSiteBeaconRequest,\n firestore: any = firebaseAdmin.app().firestore(),\n): Promise<number> {\n const { hostId, day, body } = request\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return 0\n const month = String(day ?? '').slice(0, 7)\n\n /** Per campaign: whether this pageview viewed its page, and whether it was a first visit. */\n const counted = new Map<string, { view: boolean; first: boolean }>()\n const note = (id: string, view: boolean, first: boolean) => {\n const held = counted.get(id) ?? { view: false, first: false }\n counted.set(id, { view: held.view || view, first: held.first || first })\n }\n\n const screenId = typeof body['screenId'] === 'string' ? body['screenId'].trim() : ''\n const carried = idsOf(body['campaignIds'])\n const firsts = new Set(idsOf(body['first']))\n if (DOCUMENT_ID.test(screenId) && carried.length) {\n const filed = await readScreenCampaignIds({ hostId, screenId }, firestore)\n for (const id of carried) {\n if (!filed?.includes(id)) continue\n if (!(await readLiveCampaign({ orgId, campaignId: id }, firestore))) continue\n note(id, true, firsts.has(id))\n }\n }\n\n const label = typeof body['utmCampaign'] === 'string' ? body['utmCampaign'] : ''\n if (label && body['firstLabel'] === true) {\n const campaign = await campaignForUtmLabel({ orgId, hostId, label }, firestore)\n if (campaign) note(campaign.id, false, true)\n }\n\n const reports = firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CAMPAIGN_VISIT_REPORTS_COLLECTION)\n await Promise.all(\n [...counted.entries()].map(([campaignId, { view, first }]) => {\n const fields: Record<string, unknown> = {\n ...(view ? { views: FieldValue.increment(1) } : {}),\n ...(first ? { firstVisits: FieldValue.increment(1) } : {}),\n }\n if (!Object.keys(fields).length) return Promise.resolve()\n return reports.doc(campaignId).set(\n {\n ...fields,\n ...(/^\\d{4}-\\d{2}$/.test(month) ? { byMonth: { [month]: fields } } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }),\n )\n return counted.size\n}\n"],"names":["FieldValue","firebaseAdmin","resolveOrgIdForHost","CAMPAIGN_VISIT_REPORTS_COLLECTION","campaignForUtmLabel","readLiveCampaign","readScreenCampaignIds","CAMPAIGN_VISIT_BEACON_FIELD","MAX_CAMPAIGNS","DOCUMENT_ID","idsOf","raw","Array","isArray","ids","entry","id","trim","test","includes","push","length","countCampaignVisitBeacon","request","firestore","app","hostId","day","body","orgId","month","String","slice","counted","Map","note","view","first","held","get","set","screenId","carried","firsts","Set","filed","campaignId","has","label","campaign","reports","collection","doc","Promise","all","entries","map","fields","views","increment","firstVisits","Object","keys","resolve","byMonth","updatedAt","serverTimestamp","merge","size"],"mappings":"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"}
|
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
18
|
+
import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
19
|
+
import { CAMPAIGN_SEND_CONTAINER_FIELD } from "../model/campaign-container.js";
|
|
17
20
|
import { CAMPAIGN_CONVERSION_KINDS } from "../model/campaign-conversions.js";
|
|
18
|
-
import { attributeCampaignConversion, creditCampaignSequenceOutcome, eraseCampaignAttributionsForPersonKey, resolveCampaignTouch } from "./campaign-conversion-attribution.js";
|
|
21
|
+
import { attributeCampaignConversion, creditCampaignSequenceOutcome, eraseCampaignAttributionsForPersonKey, resolveCampaignSendRef, resolveCampaignTouch } from "./campaign-conversion-attribution.js";
|
|
19
22
|
import { eraseEmailCampaignTouches, recordEmailCampaignTouch } from "./email-campaign-touch.js";
|
|
20
23
|
import { attributeOrderToEmail, reverseEmailAttributedRevenue } from "./email-revenue-attribution.js";
|
|
24
|
+
import { readLiveCampaign } from "./campaign-touch-targets.js";
|
|
21
25
|
/**
|
|
22
26
|
* The Marketing plugin's conversion creditor: the platform's
|
|
23
27
|
* `plugin-conversion-credit` contract, answered by the campaign joins this
|
|
@@ -43,7 +47,8 @@ import { attributeOrderToEmail, reverseEmailAttributedRevenue } from "./email-re
|
|
|
43
47
|
*/ const CHANNELS = [
|
|
44
48
|
'email',
|
|
45
49
|
'web',
|
|
46
|
-
'sequence'
|
|
50
|
+
'sequence',
|
|
51
|
+
'page'
|
|
47
52
|
];
|
|
48
53
|
/** A touch as this plugin resolved it, or `null` for anything else. */ function asResolvedTouch(value) {
|
|
49
54
|
if (!value || typeof value !== 'object') return null;
|
|
@@ -69,6 +74,61 @@ import { attributeOrderToEmail, reverseEmailAttributedRevenue } from "./email-re
|
|
|
69
74
|
});
|
|
70
75
|
}
|
|
71
76
|
const isConversionKind = (kind)=>CAMPAIGN_CONVERSION_KINDS.includes(kind);
|
|
77
|
+
/** How many of a record's containers an alert names. */ const DESCRIBE_CONTAINERS_MAX = 5;
|
|
78
|
+
/** How a touch reached the visitor, in the phrase an alert prints. */ function howTouched(touch) {
|
|
79
|
+
switch(touch.channel){
|
|
80
|
+
case 'page':
|
|
81
|
+
return touch.path ? `viewed ${touch.path}, a page filed under it` : 'viewed a page filed under it';
|
|
82
|
+
case 'email':
|
|
83
|
+
return 'clicked one of its emails';
|
|
84
|
+
case 'sequence':
|
|
85
|
+
return 'clicked a sequence email in it';
|
|
86
|
+
default:
|
|
87
|
+
return touch.campaign ? `followed a link labeled ${touch.campaign}` : 'followed a labeled link';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The campaign a touch names, by name: the container it carries, or — for a
|
|
92
|
+
* click on a campaign's mail, which carries the SEND — the container that
|
|
93
|
+
* send is in, else the send's subject.
|
|
94
|
+
*/ async function creditedCampaignName(hostId, orgId, touch, db) {
|
|
95
|
+
var _touch_campaignId, _data, _send_CAMPAIGN_SEND_CONTAINER_FIELD, _send_subject;
|
|
96
|
+
const campaignId = String((_touch_campaignId = touch.campaignId) != null ? _touch_campaignId : '');
|
|
97
|
+
if (!campaignId) return null;
|
|
98
|
+
if (touch.channel !== 'email') {
|
|
99
|
+
if (!orgId) return null;
|
|
100
|
+
const campaign = await readLiveCampaign({
|
|
101
|
+
orgId,
|
|
102
|
+
campaignId
|
|
103
|
+
}, db);
|
|
104
|
+
return campaign ? {
|
|
105
|
+
label: campaign.name,
|
|
106
|
+
containerId: campaign.id
|
|
107
|
+
} : null;
|
|
108
|
+
}
|
|
109
|
+
const sendRef = await resolveCampaignSendRef({
|
|
110
|
+
hostId,
|
|
111
|
+
sendId: campaignId,
|
|
112
|
+
orgId,
|
|
113
|
+
firestore: db
|
|
114
|
+
});
|
|
115
|
+
const send = sendRef ? (_data = (await sendRef.get()).data()) != null ? _data : {} : {};
|
|
116
|
+
const containerId = String((_send_CAMPAIGN_SEND_CONTAINER_FIELD = send[CAMPAIGN_SEND_CONTAINER_FIELD]) != null ? _send_CAMPAIGN_SEND_CONTAINER_FIELD : '');
|
|
117
|
+
if (containerId && orgId) {
|
|
118
|
+
const campaign = await readLiveCampaign({
|
|
119
|
+
orgId,
|
|
120
|
+
campaignId: containerId
|
|
121
|
+
}, db);
|
|
122
|
+
if (campaign) return {
|
|
123
|
+
label: campaign.name,
|
|
124
|
+
containerId: campaign.id
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const subject = String((_send_subject = send['subject']) != null ? _send_subject : '').trim();
|
|
128
|
+
return subject ? {
|
|
129
|
+
label: subject
|
|
130
|
+
} : null;
|
|
131
|
+
}
|
|
72
132
|
export const marketingConversionCreditor = {
|
|
73
133
|
async resolveTouch (request) {
|
|
74
134
|
const touch = await resolveCampaignTouch(request);
|
|
@@ -125,6 +185,53 @@ export const marketingConversionCreditor = {
|
|
|
125
185
|
const touches = await eraseEmailCampaignTouches(key);
|
|
126
186
|
const credits = await eraseCampaignAttributionsForPersonKey(key);
|
|
127
187
|
return credits + (touches ? 1 : 0);
|
|
188
|
+
},
|
|
189
|
+
async describeConversion (request) {
|
|
190
|
+
try {
|
|
191
|
+
var _request_containerIds;
|
|
192
|
+
const touch = asResolvedTouch(request.touch);
|
|
193
|
+
const ids = [
|
|
194
|
+
...new Set(((_request_containerIds = request.containerIds) != null ? _request_containerIds : []).map((id)=>String(id != null ? id : '').trim()))
|
|
195
|
+
].filter(Boolean).slice(0, DESCRIBE_CONTAINERS_MAX);
|
|
196
|
+
if (!touch && !ids.length) return null;
|
|
197
|
+
const orgId = await resolveOrgIdForHost(request.hostId).catch(()=>null);
|
|
198
|
+
const db = firebaseAdmin.app().firestore();
|
|
199
|
+
// Read side by side, kept in the order asked; a campaign since deleted
|
|
200
|
+
// is left out rather than named by its id.
|
|
201
|
+
const filedUnder = orgId ? (await Promise.all(ids.map((campaignId)=>readLiveCampaign({
|
|
202
|
+
orgId,
|
|
203
|
+
campaignId
|
|
204
|
+
}, db)))).filter((campaign)=>Boolean(campaign)).map((campaign)=>({
|
|
205
|
+
id: campaign.id,
|
|
206
|
+
label: campaign.name
|
|
207
|
+
})) : [];
|
|
208
|
+
let credited;
|
|
209
|
+
if (touch) {
|
|
210
|
+
var _ref;
|
|
211
|
+
const named = await creditedCampaignName(request.hostId, orgId, touch, db);
|
|
212
|
+
const label = (_ref = named == null ? void 0 : named.label) != null ? _ref : touch.channel === 'web' ? [
|
|
213
|
+
touch.source,
|
|
214
|
+
touch.medium,
|
|
215
|
+
touch.campaign
|
|
216
|
+
].filter(Boolean).join(' / ') : '';
|
|
217
|
+
if (label) {
|
|
218
|
+
credited = _extends({
|
|
219
|
+
label,
|
|
220
|
+
how: howTouched(touch)
|
|
221
|
+
}, (named == null ? void 0 : named.containerId) ? {
|
|
222
|
+
containerId: named.containerId
|
|
223
|
+
} : {});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return _extends({}, credited ? {
|
|
227
|
+
credited
|
|
228
|
+
} : {}, {
|
|
229
|
+
filedUnder
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error('[conversion-credit] describe failed', error);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
128
235
|
}
|
|
129
236
|
};
|
|
130
237
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/conversion-creditor.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 type {\n PluginConversionClick,\n PluginConversionCreditor,\n PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { CAMPAIGN_CONVERSION_KINDS, type CampaignConversionKind } from '../model/campaign-conversions'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n eraseCampaignAttributionsForPersonKey,\n resolveCampaignTouch,\n type CampaignSequenceOutcome,\n type CampaignTouchChannel,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { eraseEmailCampaignTouches, recordEmailCampaignTouch } from './email-campaign-touch'\nimport { attributeOrderToEmail, reverseEmailAttributedRevenue } from './email-revenue-attribution'\n\n/**\n * The Marketing plugin's conversion creditor: the platform's\n * `plugin-conversion-credit` contract, answered by the campaign joins this\n * plugin keeps.\n *\n * - a door's identify moment (a form, a lead, a contact, a booking) by the\n * last-touch join (`campaign-conversion-attribution.ts`);\n * - an order's money and its refund by the revenue join\n * (`email-revenue-attribution.ts`);\n * - a click on mail another plugin sent by the touch map\n * (`email-campaign-touch.ts`), which a sequence's mail stamps with its\n * sequence and enrollment;\n * - what a sequence produced, counted under the campaigns it is filed in;\n * - and the person's erasure, which takes the touches and every credit\n * drawn from them.\n *\n * A touch leaves here as the join resolved it and comes back from the door\n * unread, so a value is checked for the shape the join writes before it is\n * credited: a door that handed something else hands nobody's credit.\n *\n * Loaded with the first credit, never at boot; every writer it reaches\n * already never throws.\n */\n\nconst CHANNELS: readonly CampaignTouchChannel[] = ['email', 'web', 'sequence']\n\n/** A touch as this plugin resolved it, or `null` for anything else. */\nfunction asResolvedTouch(value: PluginConversionTouch | null | undefined): ResolvedCampaignTouch | null {\n if (!value || typeof value !== 'object') return null\n const channel = value['channel']\n const touchedAtMs = value['touchedAtMs']\n if (!CHANNELS.includes(channel as CampaignTouchChannel)) return null\n if (typeof touchedAtMs !== 'number' || !Number.isFinite(touchedAtMs)) return null\n return value as unknown as ResolvedCampaignTouch\n}\n\n/** The touch a click on this or another plugin's mail is credited as. */\nfunction touchOfClick(click: PluginConversionClick): ResolvedCampaignTouch {\n const sequenceId = click.via?.['sequenceId']\n const enrollmentId = click.via?.['enrollmentId']\n const viaSequence = Boolean(sequenceId && enrollmentId)\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: click.creditTo,\n ...(viaSequence ? { sequenceId, enrollmentId } : {}),\n touchedAtMs: click.atMs,\n }\n}\n\nconst isConversionKind = (kind: string): kind is CampaignConversionKind =>\n (CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(kind)\n\nexport const marketingConversionCreditor: PluginConversionCreditor = {\n async resolveTouch(request) {\n const touch = await resolveCampaignTouch(request)\n return touch ? ({ ...touch } as PluginConversionTouch) : null\n },\n\n async creditConversion(request) {\n if (!isConversionKind(request.kind)) return false\n const touch = request.click ? touchOfClick(request.click) : asResolvedTouch(request.touch)\n if (!touch) return false\n const record = await attributeCampaignConversion({\n hostId: request.hostId,\n kind: request.kind,\n refId: request.refId,\n touch,\n ...(request.convertedAtMs !== undefined ? { convertedAtMs: request.convertedAtMs } : {}),\n })\n return record !== null\n },\n\n async creditOrder(request) {\n return (await attributeOrderToEmail(request)) !== null\n },\n\n async reverseOrder(request) {\n return reverseEmailAttributedRevenue(request)\n },\n\n async recordClick(click) {\n const sequenceId = click.via?.['sequenceId']\n const enrollmentId = click.via?.['enrollmentId']\n return recordEmailCampaignTouch({\n email: typeof click.email === 'string' ? click.email : String(click.email ?? ''),\n hostId: click.hostId,\n campaignId: click.creditTo,\n atMs: click.atMs,\n ...(sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}),\n })\n },\n\n async creditOutcome(request) {\n return creditCampaignSequenceOutcome({\n hostId: request.hostId,\n ...(request.orgId ? { orgId: request.orgId } : {}),\n campaignIds: request.containerIds,\n outcome: request.outcome as CampaignSequenceOutcome,\n ...(request.atMs !== undefined ? { atMs: request.atMs } : {}),\n })\n },\n\n async erasePerson(key) {\n const touches = await eraseEmailCampaignTouches(key)\n const credits = await eraseCampaignAttributionsForPersonKey(key)\n return credits + (touches ? 1 : 0)\n },\n}\n"],"names":["CAMPAIGN_CONVERSION_KINDS","attributeCampaignConversion","creditCampaignSequenceOutcome","eraseCampaignAttributionsForPersonKey","resolveCampaignTouch","eraseEmailCampaignTouches","recordEmailCampaignTouch","attributeOrderToEmail","reverseEmailAttributedRevenue","CHANNELS","asResolvedTouch","value","channel","touchedAtMs","includes","Number","isFinite","touchOfClick","click","sequenceId","via","enrollmentId","viaSequence","Boolean","campaignId","creditTo","atMs","isConversionKind","kind","marketingConversionCreditor","resolveTouch","request","touch","creditConversion","record","hostId","refId","convertedAtMs","undefined","creditOrder","reverseOrder","recordClick","email","String","creditOutcome","orgId","campaignIds","containerIds","outcome","erasePerson","key","touches","credits"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/conversion-creditor.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 type {\n PluginConversionClick,\n PluginConversionCreditor,\n PluginConversionDescription,\n PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { CAMPAIGN_SEND_CONTAINER_FIELD } from '../model/campaign-container'\nimport { CAMPAIGN_CONVERSION_KINDS, type CampaignConversionKind } from '../model/campaign-conversions'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n eraseCampaignAttributionsForPersonKey,\n resolveCampaignSendRef,\n resolveCampaignTouch,\n type CampaignSequenceOutcome,\n type CampaignTouchChannel,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { eraseEmailCampaignTouches, recordEmailCampaignTouch } from './email-campaign-touch'\nimport { attributeOrderToEmail, reverseEmailAttributedRevenue } from './email-revenue-attribution'\nimport { readLiveCampaign } from './campaign-touch-targets'\n\n/**\n * The Marketing plugin's conversion creditor: the platform's\n * `plugin-conversion-credit` contract, answered by the campaign joins this\n * plugin keeps.\n *\n * - a door's identify moment (a form, a lead, a contact, a booking) by the\n * last-touch join (`campaign-conversion-attribution.ts`);\n * - an order's money and its refund by the revenue join\n * (`email-revenue-attribution.ts`);\n * - a click on mail another plugin sent by the touch map\n * (`email-campaign-touch.ts`), which a sequence's mail stamps with its\n * sequence and enrollment;\n * - what a sequence produced, counted under the campaigns it is filed in;\n * - and the person's erasure, which takes the touches and every credit\n * drawn from them.\n *\n * A touch leaves here as the join resolved it and comes back from the door\n * unread, so a value is checked for the shape the join writes before it is\n * credited: a door that handed something else hands nobody's credit.\n *\n * Loaded with the first credit, never at boot; every writer it reaches\n * already never throws.\n */\n\nconst CHANNELS: readonly CampaignTouchChannel[] = ['email', 'web', 'sequence', 'page']\n\n/** A touch as this plugin resolved it, or `null` for anything else. */\nfunction asResolvedTouch(value: PluginConversionTouch | null | undefined): ResolvedCampaignTouch | null {\n if (!value || typeof value !== 'object') return null\n const channel = value['channel']\n const touchedAtMs = value['touchedAtMs']\n if (!CHANNELS.includes(channel as CampaignTouchChannel)) return null\n if (typeof touchedAtMs !== 'number' || !Number.isFinite(touchedAtMs)) return null\n return value as unknown as ResolvedCampaignTouch\n}\n\n/** The touch a click on this or another plugin's mail is credited as. */\nfunction touchOfClick(click: PluginConversionClick): ResolvedCampaignTouch {\n const sequenceId = click.via?.['sequenceId']\n const enrollmentId = click.via?.['enrollmentId']\n const viaSequence = Boolean(sequenceId && enrollmentId)\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: click.creditTo,\n ...(viaSequence ? { sequenceId, enrollmentId } : {}),\n touchedAtMs: click.atMs,\n }\n}\n\nconst isConversionKind = (kind: string): kind is CampaignConversionKind =>\n (CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(kind)\n\n/** How many of a record's containers an alert names. */\nconst DESCRIBE_CONTAINERS_MAX = 5\n\n/** How a touch reached the visitor, in the phrase an alert prints. */\nfunction howTouched(touch: ResolvedCampaignTouch): string {\n switch (touch.channel) {\n case 'page':\n return touch.path ? `viewed ${touch.path}, a page filed under it` : 'viewed a page filed under it'\n case 'email':\n return 'clicked one of its emails'\n case 'sequence':\n return 'clicked a sequence email in it'\n default:\n return touch.campaign ? `followed a link labeled ${touch.campaign}` : 'followed a labeled link'\n }\n}\n\n/**\n * The campaign a touch names, by name: the container it carries, or — for a\n * click on a campaign's mail, which carries the SEND — the container that\n * send is in, else the send's subject.\n */\nasync function creditedCampaignName(\n hostId: string,\n orgId: string | null,\n touch: ResolvedCampaignTouch,\n db: Firestore,\n): Promise<{ label: string; containerId?: string } | null> {\n const campaignId = String(touch.campaignId ?? '')\n if (!campaignId) return null\n if (touch.channel !== 'email') {\n if (!orgId) return null\n const campaign = await readLiveCampaign({ orgId, campaignId }, db)\n return campaign ? { label: campaign.name, containerId: campaign.id } : null\n }\n const sendRef = await resolveCampaignSendRef({ hostId, sendId: campaignId, orgId, firestore: db })\n const send = sendRef ? ((await sendRef.get()).data() ?? {}) : {}\n const containerId = String(send[CAMPAIGN_SEND_CONTAINER_FIELD] ?? '')\n if (containerId && orgId) {\n const campaign = await readLiveCampaign({ orgId, campaignId: containerId }, db)\n if (campaign) return { label: campaign.name, containerId: campaign.id }\n }\n const subject = String(send['subject'] ?? '').trim()\n return subject ? { label: subject } : null\n}\n\nexport const marketingConversionCreditor: PluginConversionCreditor = {\n async resolveTouch(request) {\n const touch = await resolveCampaignTouch(request)\n return touch ? ({ ...touch } as PluginConversionTouch) : null\n },\n\n async creditConversion(request) {\n if (!isConversionKind(request.kind)) return false\n const touch = request.click ? touchOfClick(request.click) : asResolvedTouch(request.touch)\n if (!touch) return false\n const record = await attributeCampaignConversion({\n hostId: request.hostId,\n kind: request.kind,\n refId: request.refId,\n touch,\n ...(request.convertedAtMs !== undefined ? { convertedAtMs: request.convertedAtMs } : {}),\n })\n return record !== null\n },\n\n async creditOrder(request) {\n return (await attributeOrderToEmail(request)) !== null\n },\n\n async reverseOrder(request) {\n return reverseEmailAttributedRevenue(request)\n },\n\n async recordClick(click) {\n const sequenceId = click.via?.['sequenceId']\n const enrollmentId = click.via?.['enrollmentId']\n return recordEmailCampaignTouch({\n email: typeof click.email === 'string' ? click.email : String(click.email ?? ''),\n hostId: click.hostId,\n campaignId: click.creditTo,\n atMs: click.atMs,\n ...(sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}),\n })\n },\n\n async creditOutcome(request) {\n return creditCampaignSequenceOutcome({\n hostId: request.hostId,\n ...(request.orgId ? { orgId: request.orgId } : {}),\n campaignIds: request.containerIds,\n outcome: request.outcome as CampaignSequenceOutcome,\n ...(request.atMs !== undefined ? { atMs: request.atMs } : {}),\n })\n },\n\n async erasePerson(key) {\n const touches = await eraseEmailCampaignTouches(key)\n const credits = await eraseCampaignAttributionsForPersonKey(key)\n return credits + (touches ? 1 : 0)\n },\n\n async describeConversion(request) {\n try {\n const touch = asResolvedTouch(request.touch)\n const ids = [...new Set((request.containerIds ?? []).map((id) => String(id ?? '').trim()))]\n .filter(Boolean)\n .slice(0, DESCRIBE_CONTAINERS_MAX)\n if (!touch && !ids.length) return null\n const orgId = await resolveOrgIdForHost(request.hostId).catch(() => null)\n const db = firebaseAdmin.app().firestore()\n // Read side by side, kept in the order asked; a campaign since deleted\n // is left out rather than named by its id.\n const filedUnder: PluginConversionDescription['filedUnder'] = orgId\n ? (\n await Promise.all(\n ids.map((campaignId) => readLiveCampaign({ orgId, campaignId }, db)),\n )\n )\n .filter((campaign): campaign is NonNullable<typeof campaign> => Boolean(campaign))\n .map((campaign) => ({ id: campaign.id, label: campaign.name }))\n : []\n let credited: PluginConversionDescription['credited']\n if (touch) {\n const named = await creditedCampaignName(request.hostId, orgId, touch, db)\n const label =\n named?.label ??\n (touch.channel === 'web'\n ? [touch.source, touch.medium, touch.campaign].filter(Boolean).join(' / ')\n : '')\n if (label) {\n credited = {\n label,\n how: howTouched(touch),\n ...(named?.containerId ? { containerId: named.containerId } : {}),\n }\n }\n }\n return { ...(credited ? { credited } : {}), filedUnder }\n } catch (error) {\n console.error('[conversion-credit] describe failed', error)\n return null\n }\n },\n}\n"],"names":["resolveOrgIdForHost","firebaseAdmin","CAMPAIGN_SEND_CONTAINER_FIELD","CAMPAIGN_CONVERSION_KINDS","attributeCampaignConversion","creditCampaignSequenceOutcome","eraseCampaignAttributionsForPersonKey","resolveCampaignSendRef","resolveCampaignTouch","eraseEmailCampaignTouches","recordEmailCampaignTouch","attributeOrderToEmail","reverseEmailAttributedRevenue","readLiveCampaign","CHANNELS","asResolvedTouch","value","channel","touchedAtMs","includes","Number","isFinite","touchOfClick","click","sequenceId","via","enrollmentId","viaSequence","Boolean","campaignId","creditTo","atMs","isConversionKind","kind","DESCRIBE_CONTAINERS_MAX","howTouched","touch","path","campaign","creditedCampaignName","hostId","orgId","db","send","String","label","name","containerId","id","sendRef","sendId","firestore","get","data","subject","trim","marketingConversionCreditor","resolveTouch","request","creditConversion","record","refId","convertedAtMs","undefined","creditOrder","reverseOrder","recordClick","email","creditOutcome","campaignIds","containerIds","outcome","erasePerson","key","touches","credits","describeConversion","ids","Set","map","filter","slice","length","catch","app","filedUnder","Promise","all","credited","named","source","medium","join","how","error","console"],"mappings":"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"}
|