@aglyn/aglyn 1.0.0-beta.219 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/actions.d.ts +1 -59
  3. package/src/lib/app-utils/actions.js +7 -72
  4. package/src/lib/app-utils/actions.js.map +1 -1
  5. package/src/lib/app-utils/consent-groups.d.ts +1 -1
  6. package/src/lib/app-utils/consent-groups.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +0 -36
  8. package/src/lib/app-utils/console-routes.js +2 -37
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contacts.d.ts +1 -1
  11. package/src/lib/app-utils/contacts.js.map +1 -1
  12. package/src/lib/app-utils/container-membership.d.ts +93 -0
  13. package/src/lib/app-utils/container-membership.js +165 -0
  14. package/src/lib/app-utils/container-membership.js.map +1 -0
  15. package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
  16. package/src/lib/app-utils/crm-task-reminders.js +32 -11
  17. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  18. package/src/lib/app-utils/crm.d.ts +24 -4
  19. package/src/lib/app-utils/crm.js +23 -0
  20. package/src/lib/app-utils/crm.js.map +1 -1
  21. package/src/lib/app-utils/csv-upload.d.ts +1 -1
  22. package/src/lib/app-utils/csv-upload.js +1 -1
  23. package/src/lib/app-utils/csv-upload.js.map +1 -1
  24. package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
  25. package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
  26. package/src/lib/app-utils/csv.js.map +1 -0
  27. package/src/lib/app-utils/datasets.d.ts +0 -19
  28. package/src/lib/app-utils/datasets.js +0 -13
  29. package/src/lib/app-utils/datasets.js.map +1 -1
  30. package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
  31. package/src/lib/app-utils/docs-help.generated.js +2 -0
  32. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  33. package/src/lib/app-utils/docs-index.generated.js +63 -35
  34. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  35. package/src/lib/app-utils/dynamic-list-rule.js +3 -3
  36. package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
  37. package/src/lib/app-utils/email-text.d.ts +80 -0
  38. package/src/lib/app-utils/email-text.js +189 -0
  39. package/src/lib/app-utils/email-text.js.map +1 -0
  40. package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
  41. package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
  42. package/src/lib/app-utils/enrollment-basis.js.map +1 -0
  43. package/src/lib/app-utils/expand-repeatables.js +4 -4
  44. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  45. package/src/lib/app-utils/forms.js +2 -2
  46. package/src/lib/app-utils/forms.js.map +1 -1
  47. package/src/lib/app-utils/health-report.d.ts +2 -3
  48. package/src/lib/app-utils/health-report.js +8 -6
  49. package/src/lib/app-utils/health-report.js.map +1 -1
  50. package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
  51. package/src/lib/app-utils/inbound-mail-domain.js +31 -0
  52. package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
  53. package/src/lib/app-utils/lockdown.d.ts +11 -0
  54. package/src/lib/app-utils/lockdown.js +33 -3
  55. package/src/lib/app-utils/lockdown.js.map +1 -1
  56. package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
  57. package/src/lib/app-utils/member-email-aliases.js +29 -0
  58. package/src/lib/app-utils/member-email-aliases.js.map +1 -1
  59. package/src/lib/app-utils/notifications.d.ts +69 -0
  60. package/src/lib/app-utils/notifications.js +119 -0
  61. package/src/lib/app-utils/notifications.js.map +1 -1
  62. package/src/lib/app-utils/operator-alerts.d.ts +15 -1
  63. package/src/lib/app-utils/operator-alerts.js +47 -10
  64. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  65. package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
  66. package/src/lib/app-utils/plan-entitlements.js +200 -224
  67. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  68. package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
  69. package/src/lib/app-utils/platform-marketing-consent.js +6 -5
  70. package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
  71. package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
  72. package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
  73. package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
  74. package/src/lib/app-utils/repeat-query.d.ts +56 -0
  75. package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
  76. package/src/lib/app-utils/repeat-query.js.map +1 -0
  77. package/src/lib/app-utils/server.d.ts +7 -9
  78. package/src/lib/app-utils/server.js +18 -26
  79. package/src/lib/app-utils/server.js.map +1 -1
  80. package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
  81. package/src/lib/app-utils/starter-template-nodes.js +129 -0
  82. package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
  83. package/src/lib/app-utils/starter-templates.d.ts +11 -34
  84. package/src/lib/app-utils/starter-templates.js +19 -230
  85. package/src/lib/app-utils/starter-templates.js.map +1 -1
  86. package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
  87. package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
  88. package/src/lib/app-utils/subscription-topics.js.map +1 -0
  89. package/src/lib/app-utils/usage-budget.d.ts +73 -188
  90. package/src/lib/app-utils/usage-budget.js +58 -194
  91. package/src/lib/app-utils/usage-budget.js.map +1 -1
  92. package/src/lib/app-utils/utm-attribution.d.ts +1 -1
  93. package/src/lib/app-utils/utm-attribution.js +1 -1
  94. package/src/lib/app-utils/utm-attribution.js.map +1 -1
  95. package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
  96. package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
  97. package/src/lib/app-utils/utm-touch.js.map +1 -0
  98. package/src/lib/app-utils/variables.d.ts +9 -9
  99. package/src/lib/app-utils/variables.js.map +1 -1
  100. package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
  101. package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
  102. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  103. package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
  104. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  105. package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
  106. package/src/lib/plugin-manager/feature-plugins.js +22 -35
  107. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  108. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +12 -1
  109. package/src/lib/plugin-manager/first-party-plugins.generated.js +58 -2
  110. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  111. package/src/lib/plugin-manager/index.d.ts +0 -1
  112. package/src/lib/plugin-manager/index.js +0 -1
  113. package/src/lib/plugin-manager/index.js.map +1 -1
  114. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
  115. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  116. package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
  117. package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
  118. package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
  119. package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
  120. package/src/lib/plugin-manager/plugin-containers.js +27 -0
  121. package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
  122. package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
  123. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  124. package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
  125. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  126. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
  127. package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
  128. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  129. package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
  130. package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
  131. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
  132. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
  133. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  134. package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
  135. package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
  136. package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
  137. package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
  138. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
  139. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
  140. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
  141. package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
  142. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  143. package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
  144. package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
  145. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
  146. package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
  147. package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
  148. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
  149. package/src/lib/app-utils/assist-credits.d.ts +0 -482
  150. package/src/lib/app-utils/assist-credits.js +0 -510
  151. package/src/lib/app-utils/assist-credits.js.map +0 -1
  152. package/src/lib/app-utils/campaign-membership.d.ts +0 -121
  153. package/src/lib/app-utils/campaign-membership.js +0 -189
  154. package/src/lib/app-utils/campaign-membership.js.map +0 -1
  155. package/src/lib/app-utils/campaign-touch.js.map +0 -1
  156. package/src/lib/app-utils/console-record-links.d.ts +0 -84
  157. package/src/lib/app-utils/console-record-links.js +0 -76
  158. package/src/lib/app-utils/console-record-links.js.map +0 -1
  159. package/src/lib/app-utils/crm-booking.d.ts +0 -75
  160. package/src/lib/app-utils/crm-booking.js +0 -133
  161. package/src/lib/app-utils/crm-booking.js.map +0 -1
  162. package/src/lib/app-utils/crm-inbound.d.ts +0 -245
  163. package/src/lib/app-utils/crm-inbound.js +0 -407
  164. package/src/lib/app-utils/crm-inbound.js.map +0 -1
  165. package/src/lib/app-utils/crm-reports.d.ts +0 -479
  166. package/src/lib/app-utils/crm-reports.js +0 -680
  167. package/src/lib/app-utils/crm-reports.js.map +0 -1
  168. package/src/lib/app-utils/dataset-csv.js.map +0 -1
  169. package/src/lib/app-utils/dataset-query.d.ts +0 -57
  170. package/src/lib/app-utils/dataset-query.js.map +0 -1
  171. package/src/lib/app-utils/email-topics.js.map +0 -1
  172. package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
  173. package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
  174. package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
  175. package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
@@ -1,121 +0,0 @@
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
- /**
18
- * The field naming the campaigns a resource belongs to.
19
- *
20
- * Not `emailCampaignId`, which is the SEND's field: on a send document the
21
- * word `campaignId` already means the send's own id — it is what `cid`
22
- * carries into every unsubscribe footer — so that collection needed a
23
- * different word. A form, a screen and a contact have no second meaning for
24
- * it, and the plural says the resource may be in more than one.
25
- */
26
- export declare const CAMPAIGN_MEMBERSHIP_FIELD = "campaignIds";
27
- /**
28
- * How many campaigns one resource may name.
29
- *
30
- * A ceiling on the FIELD, not on the product: an array Firestore has to index
31
- * on every write is not the place to discover that a script has been adding
32
- * an id a day for a year. Twenty is far past what a person assigns by hand
33
- * and far short of anything that costs a write.
34
- */
35
- export declare const CAMPAIGN_MEMBERSHIP_CAP = 20;
36
- /**
37
- * A stored membership array as a clean list of ids.
38
- *
39
- * Deduped, trimmed, non-strings dropped, capped. Every reader goes through
40
- * this because the field is written by three consoles and one automation
41
- * step, and a surface that trusted the raw value would render `undefined` as
42
- * a chip the first time one of them wrote a blank.
43
- */
44
- export declare function normalizeCampaignIds(raw: unknown): string[];
45
- /**
46
- * The campaigns a HOST resource — a form, a screen — belongs to.
47
- *
48
- * Host resources carry the field at the top of the document: they belong to
49
- * one site, and whoever may read the resource may read which of the org's
50
- * campaigns it is filed under, so there is no second holder to keep the edge
51
- * away from.
52
- */
53
- export declare function readCampaignIds(resource: Record<string, unknown> | null | undefined): string[];
54
- /**
55
- * The dotted path to a contact's membership, inside ONE holder's facet.
56
- *
57
- * A contact document is shared by every site in the org — one human who
58
- * touched two sites is one row — and almost nothing on it is legitimately
59
- * shared. Which campaigns a merchant has filed a person under is that
60
- * merchant's business record on exactly the footing their notes and tags are,
61
- * so it lives at `facets.{groupId}.campaignIds` and not at the top of the
62
- * document, where every other site in an agency's account could read it.
63
- */
64
- export declare function contactCampaignFieldPath(groupId: string): string;
65
- /** The campaigns ONE holder has filed this contact under. */
66
- export declare function readContactCampaignIds(contact: Record<string, unknown> | null | undefined, groupId: string): string[];
67
- /**
68
- * The membership a save should write, given what is on screen.
69
- *
70
- * Pure, and shared by the three consoles that offer the picker, because the
71
- * decision each of them makes is identical and is not obvious: an EMPTY
72
- * selection has to be stored as an empty array rather than as a removed
73
- * field.
74
- *
75
- * Removing the field would read the same to every surface here — the readers
76
- * above answer `[]` either way — and would be wrong for the one reader that
77
- * is not a surface. `campaign-manage.ts` finds a campaign's members with
78
- * `array-contains`, which matches on the automatic single-field index; that
79
- * index has an entry only while the array does, so the two shapes are the
80
- * same to a reader and different to a writer only in cost. Storing `[]` keeps
81
- * one shape for "belongs to no campaign" across every writer, which is the
82
- * property the send collection's own detach comment argues for one field
83
- * over.
84
- *
85
- * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.
86
- */
87
- export declare function campaignMembershipValue(selected: readonly string[]): string[];
88
- /**
89
- * Whether a document's stored membership already says what a save would.
90
- *
91
- * Order-insensitive: a picker hands back its options' order and the stored
92
- * array is in the order it was written, so comparing them literally would
93
- * report a change on every open and leave Save permanently enabled.
94
- */
95
- export declare function campaignMembershipUnchanged(stored: readonly string[], selected: readonly string[]): boolean;
96
- /**
97
- * The host subcollections whose documents may name a campaign.
98
- *
99
- * The list a campaign's deletion walks, and the list this feature's coverage
100
- * spec reads — so a collection that grows the picker without growing the
101
- * detach fails the build rather than shipping a campaign whose removal leaves
102
- * that collection pointing at nothing.
103
- *
104
- * `leads` is here for the site rows the lead migration (AGL-3276) has not
105
- * reached. A live lead is an org row (`orgs/{orgId}/leads/{personKey}`) that
106
- * carries the field at the top of its document like a form does, and the
107
- * deletion walks that collection by name beside this list. A lead joins a
108
- * campaign by hand — the New lead drawer, the CSV import, the list's bulk
109
- * bar — or by being enrolled in a sequence that is in one, and it hands the
110
- * membership to the contact it converts into.
111
- *
112
- * Contacts are deliberately NOT here. They live on the org
113
- * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a
114
- * per-holder facet — so they are detached by their own pass, against a field
115
- * path that names the group. Nor are a plugin's own members — a sequence and
116
- * its enrollments live under the org, in collections the core does not name
117
- * — so a plugin registers a detacher for those
118
- * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.
119
- */
120
- export declare const CAMPAIGN_MEMBER_HOST_COLLECTIONS: readonly ["forms", "screens", "leads"];
121
- export type CampaignMemberHostCollection = (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number];
@@ -1,189 +0,0 @@
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
- * WHAT ELSE IS IN A CAMPAIGN: the edge between a campaign and the forms,
18
- * screens and people a push is coordinated across.
19
- *
20
- * ## The edge is a field on the RESOURCE, not a list on the campaign
21
- *
22
- * A campaign container lives at `orgs/{orgId}/emailCampaigns/{campaignId}`
23
- * and names nothing outside itself. A send joins one by carrying
24
- * `emailCampaignId`; every other member joins the same way, through
25
- * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.
26
- *
27
- * Three properties decide it, and all three point the same way:
28
- *
29
- * - **Reading is free where it is asked.** A form's own page reads the form
30
- * document to draw anything at all, so the campaign comes with it. A
31
- * membership collection under the campaign would make "which campaign is
32
- * this in" a query issued on mount by every resource page, for a fact one
33
- * field already carries.
34
- * - **The reduction is already gated this way.** `campaign-manage.ts` clears
35
- * `emailCampaignId` from every send before it removes a container, so no
36
- * send is left naming one that is gone. Extending that pass to the
37
- * membership field is the SAME mechanism; a membership collection would be
38
- * a second, different deletion story beside it.
39
- * - **Deleting a MEMBER can leave nothing behind.** The campaign holds no
40
- * list, so a deleted form takes its own edge with it. This matters most on
41
- * a contact: an erasure removes the document, and a membership row stored
42
- * anywhere else would survive it holding the id of a person who asked to
43
- * be forgotten.
44
- *
45
- * ## Plural, because a landing page outlives one campaign
46
- *
47
- * The field is an ARRAY. A signup form built for the spring push is the same
48
- * form the summer push places, and a single-valued field would make the
49
- * second assignment silently erase the first — a campaign losing a member
50
- * with nothing on screen to say so. The automation step that has always
51
- * written this edge (`assignCampaign`) already used `arrayUnion`, so the
52
- * product has treated it as many-to-many from the start.
53
- *
54
- * ## Ids, never names
55
- *
56
- * A campaign's name is editable, and every reader here resolves the name from
57
- * the campaign document at read time. Renaming a campaign therefore moves no
58
- * membership: the stored value is the container's document id, which nothing
59
- * rewrites.
60
- */ import { contactFacetPath, readContactFacet } from "./contacts.js";
61
- /**
62
- * The field naming the campaigns a resource belongs to.
63
- *
64
- * Not `emailCampaignId`, which is the SEND's field: on a send document the
65
- * word `campaignId` already means the send's own id — it is what `cid`
66
- * carries into every unsubscribe footer — so that collection needed a
67
- * different word. A form, a screen and a contact have no second meaning for
68
- * it, and the plural says the resource may be in more than one.
69
- */ export const CAMPAIGN_MEMBERSHIP_FIELD = 'campaignIds';
70
- /**
71
- * How many campaigns one resource may name.
72
- *
73
- * A ceiling on the FIELD, not on the product: an array Firestore has to index
74
- * on every write is not the place to discover that a script has been adding
75
- * an id a day for a year. Twenty is far past what a person assigns by hand
76
- * and far short of anything that costs a write.
77
- */ export const CAMPAIGN_MEMBERSHIP_CAP = 20;
78
- /**
79
- * A stored membership array as a clean list of ids.
80
- *
81
- * Deduped, trimmed, non-strings dropped, capped. Every reader goes through
82
- * this because the field is written by three consoles and one automation
83
- * step, and a surface that trusted the raw value would render `undefined` as
84
- * a chip the first time one of them wrote a blank.
85
- */ export function normalizeCampaignIds(raw) {
86
- if (!Array.isArray(raw)) return [];
87
- const seen = [];
88
- for (const entry of raw){
89
- if (typeof entry !== 'string') continue;
90
- const id = entry.trim();
91
- if (!id || seen.includes(id)) continue;
92
- seen.push(id);
93
- if (seen.length >= CAMPAIGN_MEMBERSHIP_CAP) break;
94
- }
95
- return seen;
96
- }
97
- /**
98
- * The campaigns a HOST resource — a form, a screen — belongs to.
99
- *
100
- * Host resources carry the field at the top of the document: they belong to
101
- * one site, and whoever may read the resource may read which of the org's
102
- * campaigns it is filed under, so there is no second holder to keep the edge
103
- * away from.
104
- */ export function readCampaignIds(resource) {
105
- return normalizeCampaignIds((resource != null ? resource : {})[CAMPAIGN_MEMBERSHIP_FIELD]);
106
- }
107
- /**
108
- * The dotted path to a contact's membership, inside ONE holder's facet.
109
- *
110
- * A contact document is shared by every site in the org — one human who
111
- * touched two sites is one row — and almost nothing on it is legitimately
112
- * shared. Which campaigns a merchant has filed a person under is that
113
- * merchant's business record on exactly the footing their notes and tags are,
114
- * so it lives at `facets.{groupId}.campaignIds` and not at the top of the
115
- * document, where every other site in an agency's account could read it.
116
- */ export function contactCampaignFieldPath(groupId) {
117
- return contactFacetPath(groupId, CAMPAIGN_MEMBERSHIP_FIELD);
118
- }
119
- /** The campaigns ONE holder has filed this contact under. */ export function readContactCampaignIds(contact, groupId) {
120
- return normalizeCampaignIds(readContactFacet(contact, groupId).campaignIds);
121
- }
122
- /**
123
- * The membership a save should write, given what is on screen.
124
- *
125
- * Pure, and shared by the three consoles that offer the picker, because the
126
- * decision each of them makes is identical and is not obvious: an EMPTY
127
- * selection has to be stored as an empty array rather than as a removed
128
- * field.
129
- *
130
- * Removing the field would read the same to every surface here — the readers
131
- * above answer `[]` either way — and would be wrong for the one reader that
132
- * is not a surface. `campaign-manage.ts` finds a campaign's members with
133
- * `array-contains`, which matches on the automatic single-field index; that
134
- * index has an entry only while the array does, so the two shapes are the
135
- * same to a reader and different to a writer only in cost. Storing `[]` keeps
136
- * one shape for "belongs to no campaign" across every writer, which is the
137
- * property the send collection's own detach comment argues for one field
138
- * over.
139
- *
140
- * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.
141
- */ export function campaignMembershipValue(selected) {
142
- return normalizeCampaignIds(selected);
143
- }
144
- /**
145
- * Whether a document's stored membership already says what a save would.
146
- *
147
- * Order-insensitive: a picker hands back its options' order and the stored
148
- * array is in the order it was written, so comparing them literally would
149
- * report a change on every open and leave Save permanently enabled.
150
- */ export function campaignMembershipUnchanged(stored, selected) {
151
- if (stored.length !== selected.length) return false;
152
- const sortedStored = [
153
- ...stored
154
- ].sort();
155
- const sortedSelected = [
156
- ...selected
157
- ].sort();
158
- return sortedStored.every((id, index)=>id === sortedSelected[index]);
159
- }
160
- /**
161
- * The host subcollections whose documents may name a campaign.
162
- *
163
- * The list a campaign's deletion walks, and the list this feature's coverage
164
- * spec reads — so a collection that grows the picker without growing the
165
- * detach fails the build rather than shipping a campaign whose removal leaves
166
- * that collection pointing at nothing.
167
- *
168
- * `leads` is here for the site rows the lead migration (AGL-3276) has not
169
- * reached. A live lead is an org row (`orgs/{orgId}/leads/{personKey}`) that
170
- * carries the field at the top of its document like a form does, and the
171
- * deletion walks that collection by name beside this list. A lead joins a
172
- * campaign by hand — the New lead drawer, the CSV import, the list's bulk
173
- * bar — or by being enrolled in a sequence that is in one, and it hands the
174
- * membership to the contact it converts into.
175
- *
176
- * Contacts are deliberately NOT here. They live on the org
177
- * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a
178
- * per-holder facet — so they are detached by their own pass, against a field
179
- * path that names the group. Nor are a plugin's own members — a sequence and
180
- * its enrollments live under the org, in collections the core does not name
181
- * — so a plugin registers a detacher for those
182
- * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.
183
- */ export const CAMPAIGN_MEMBER_HOST_COLLECTIONS = [
184
- 'forms',
185
- 'screens',
186
- 'leads'
187
- ];
188
-
189
- //# sourceMappingURL=campaign-membership.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/campaign-membership.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT ELSE IS IN A CAMPAIGN: the edge between a campaign and the forms,\n * screens and people a push is coordinated across.\n *\n * ## The edge is a field on the RESOURCE, not a list on the campaign\n *\n * A campaign container lives at `orgs/{orgId}/emailCampaigns/{campaignId}`\n * and names nothing outside itself. A send joins one by carrying\n * `emailCampaignId`; every other member joins the same way, through\n * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.\n *\n * Three properties decide it, and all three point the same way:\n *\n * - **Reading is free where it is asked.** A form's own page reads the form\n * document to draw anything at all, so the campaign comes with it. A\n * membership collection under the campaign would make \"which campaign is\n * this in\" a query issued on mount by every resource page, for a fact one\n * field already carries.\n * - **The reduction is already gated this way.** `campaign-manage.ts` clears\n * `emailCampaignId` from every send before it removes a container, so no\n * send is left naming one that is gone. Extending that pass to the\n * membership field is the SAME mechanism; a membership collection would be\n * a second, different deletion story beside it.\n * - **Deleting a MEMBER can leave nothing behind.** The campaign holds no\n * list, so a deleted form takes its own edge with it. This matters most on\n * a contact: an erasure removes the document, and a membership row stored\n * anywhere else would survive it holding the id of a person who asked to\n * be forgotten.\n *\n * ## Plural, because a landing page outlives one campaign\n *\n * The field is an ARRAY. A signup form built for the spring push is the same\n * form the summer push places, and a single-valued field would make the\n * second assignment silently erase the first — a campaign losing a member\n * with nothing on screen to say so. The automation step that has always\n * written this edge (`assignCampaign`) already used `arrayUnion`, so the\n * product has treated it as many-to-many from the start.\n *\n * ## Ids, never names\n *\n * A campaign's name is editable, and every reader here resolves the name from\n * the campaign document at read time. Renaming a campaign therefore moves no\n * membership: the stored value is the container's document id, which nothing\n * rewrites.\n */\n\nimport { contactFacetPath, readContactFacet } from './contacts'\n\n/**\n * The field naming the campaigns a resource belongs to.\n *\n * Not `emailCampaignId`, which is the SEND's field: on a send document the\n * word `campaignId` already means the send's own id — it is what `cid`\n * carries into every unsubscribe footer — so that collection needed a\n * different word. A form, a screen and a contact have no second meaning for\n * it, and the plural says the resource may be in more than one.\n */\nexport const CAMPAIGN_MEMBERSHIP_FIELD = 'campaignIds'\n\n/**\n * How many campaigns one resource may name.\n *\n * A ceiling on the FIELD, not on the product: an array Firestore has to index\n * on every write is not the place to discover that a script has been adding\n * an id a day for a year. Twenty is far past what a person assigns by hand\n * and far short of anything that costs a write.\n */\nexport const CAMPAIGN_MEMBERSHIP_CAP = 20\n\n/**\n * A stored membership array as a clean list of ids.\n *\n * Deduped, trimmed, non-strings dropped, capped. Every reader goes through\n * this because the field is written by three consoles and one automation\n * step, and a surface that trusted the raw value would render `undefined` as\n * a chip the first time one of them wrote a blank.\n */\nexport function normalizeCampaignIds(raw: unknown): string[] {\n if (!Array.isArray(raw)) return []\n const seen: string[] = []\n for (const entry of raw) {\n if (typeof entry !== 'string') continue\n const id = entry.trim()\n if (!id || seen.includes(id)) continue\n seen.push(id)\n if (seen.length >= CAMPAIGN_MEMBERSHIP_CAP) break\n }\n return seen\n}\n\n/**\n * The campaigns a HOST resource — a form, a screen — belongs to.\n *\n * Host resources carry the field at the top of the document: they belong to\n * one site, and whoever may read the resource may read which of the org's\n * campaigns it is filed under, so there is no second holder to keep the edge\n * away from.\n */\nexport function readCampaignIds(\n resource: Record<string, unknown> | null | undefined,\n): string[] {\n return normalizeCampaignIds((resource ?? {})[CAMPAIGN_MEMBERSHIP_FIELD])\n}\n\n/**\n * The dotted path to a contact's membership, inside ONE holder's facet.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one row — and almost nothing on it is legitimately\n * shared. Which campaigns a merchant has filed a person under is that\n * merchant's business record on exactly the footing their notes and tags are,\n * so it lives at `facets.{groupId}.campaignIds` and not at the top of the\n * document, where every other site in an agency's account could read it.\n */\nexport function contactCampaignFieldPath(groupId: string): string {\n return contactFacetPath(groupId, CAMPAIGN_MEMBERSHIP_FIELD)\n}\n\n/** The campaigns ONE holder has filed this contact under. */\nexport function readContactCampaignIds(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string[] {\n return normalizeCampaignIds(readContactFacet(contact, groupId).campaignIds)\n}\n\n/**\n * The membership a save should write, given what is on screen.\n *\n * Pure, and shared by the three consoles that offer the picker, because the\n * decision each of them makes is identical and is not obvious: an EMPTY\n * selection has to be stored as an empty array rather than as a removed\n * field.\n *\n * Removing the field would read the same to every surface here — the readers\n * above answer `[]` either way — and would be wrong for the one reader that\n * is not a surface. `campaign-manage.ts` finds a campaign's members with\n * `array-contains`, which matches on the automatic single-field index; that\n * index has an entry only while the array does, so the two shapes are the\n * same to a reader and different to a writer only in cost. Storing `[]` keeps\n * one shape for \"belongs to no campaign\" across every writer, which is the\n * property the send collection's own detach comment argues for one field\n * over.\n *\n * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.\n */\nexport function campaignMembershipValue(selected: readonly string[]): string[] {\n return normalizeCampaignIds(selected)\n}\n\n/**\n * Whether a document's stored membership already says what a save would.\n *\n * Order-insensitive: a picker hands back its options' order and the stored\n * array is in the order it was written, so comparing them literally would\n * report a change on every open and leave Save permanently enabled.\n */\nexport function campaignMembershipUnchanged(\n stored: readonly string[],\n selected: readonly string[],\n): boolean {\n if (stored.length !== selected.length) return false\n const sortedStored = [...stored].sort()\n const sortedSelected = [...selected].sort()\n return sortedStored.every((id, index) => id === sortedSelected[index])\n}\n\n/**\n * The host subcollections whose documents may name a campaign.\n *\n * The list a campaign's deletion walks, and the list this feature's coverage\n * spec reads — so a collection that grows the picker without growing the\n * detach fails the build rather than shipping a campaign whose removal leaves\n * that collection pointing at nothing.\n *\n * `leads` is here for the site rows the lead migration (AGL-3276) has not\n * reached. A live lead is an org row (`orgs/{orgId}/leads/{personKey}`) that\n * carries the field at the top of its document like a form does, and the\n * deletion walks that collection by name beside this list. A lead joins a\n * campaign by hand — the New lead drawer, the CSV import, the list's bulk\n * bar — or by being enrolled in a sequence that is in one, and it hands the\n * membership to the contact it converts into.\n *\n * Contacts are deliberately NOT here. They live on the org\n * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a\n * per-holder facet — so they are detached by their own pass, against a field\n * path that names the group. Nor are a plugin's own members — a sequence and\n * its enrollments live under the org, in collections the core does not name\n * — so a plugin registers a detacher for those\n * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.\n */\nexport const CAMPAIGN_MEMBER_HOST_COLLECTIONS = ['forms', 'screens', 'leads'] as const\n\nexport type CampaignMemberHostCollection =\n (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number]\n"],"names":["contactFacetPath","readContactFacet","CAMPAIGN_MEMBERSHIP_FIELD","CAMPAIGN_MEMBERSHIP_CAP","normalizeCampaignIds","raw","Array","isArray","seen","entry","id","trim","includes","push","length","readCampaignIds","resource","contactCampaignFieldPath","groupId","readContactCampaignIds","contact","campaignIds","campaignMembershipValue","selected","campaignMembershipUnchanged","stored","sortedStored","sort","sortedSelected","every","index","CAMPAIGN_MEMBER_HOST_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SAASA,gBAAgB,EAAEC,gBAAgB,QAAQ,gBAAY;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMC,4BAA4B,cAAa;AAEtD;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,GAAE;AAEzC;;;;;;;CAOC,GACD,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,MAAMG,OAAiB,EAAE;IACzB,KAAK,MAAMC,SAASJ,IAAK;QACvB,IAAI,OAAOI,UAAU,UAAU;QAC/B,MAAMC,KAAKD,MAAME,IAAI;QACrB,IAAI,CAACD,MAAMF,KAAKI,QAAQ,CAACF,KAAK;QAC9BF,KAAKK,IAAI,CAACH;QACV,IAAIF,KAAKM,MAAM,IAAIX,yBAAyB;IAC9C;IACA,OAAOK;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASO,gBACdC,QAAoD;IAEpD,OAAOZ,qBAAqB,CAACY,mBAAAA,WAAY,CAAC,EAAE,CAACd,0BAA0B;AACzE;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASe,yBAAyBC,OAAe;IACtD,OAAOlB,iBAAiBkB,SAAShB;AACnC;AAEA,2DAA2D,GAC3D,OAAO,SAASiB,uBACdC,OAAmD,EACnDF,OAAe;IAEf,OAAOd,qBAAqBH,iBAAiBmB,SAASF,SAASG,WAAW;AAC5E;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,wBAAwBC,QAA2B;IACjE,OAAOnB,qBAAqBmB;AAC9B;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,MAAyB,EACzBF,QAA2B;IAE3B,IAAIE,OAAOX,MAAM,KAAKS,SAAST,MAAM,EAAE,OAAO;IAC9C,MAAMY,eAAe;WAAID;KAAO,CAACE,IAAI;IACrC,MAAMC,iBAAiB;WAAIL;KAAS,CAACI,IAAI;IACzC,OAAOD,aAAaG,KAAK,CAAC,CAACnB,IAAIoB,QAAUpB,OAAOkB,cAAc,CAACE,MAAM;AACvE;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,MAAMC,mCAAmC;IAAC;IAAS;IAAW;CAAQ,CAAS"}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/campaign-touch.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE VISITOR'S HALF OF CAMPAIGN ATTRIBUTION — the touch that has to survive\n * the gap between arriving from a campaign and becoming somebody.\n *\n * ## The gap this exists to cross\n *\n * A known recipient can be joined on their address: they clicked a campaign's\n * mail, the delivery webhook stamped the click on their person document, and\n * `email-revenue-attribution.ts` reads it back when their order settles. An\n * ANONYMOUS visitor has no such handle. They arrive from an ad, a partner\n * link or a social post, browse for a while, and only become identifiable at\n * the moment they submit a form, sign up, book or check out. Between the\n * arrival and that moment there is nothing on the server that connects them.\n *\n * The site's own collector already reads `utm_source`/`utm_medium`/\n * `utm_campaign` off the landing URL and increments a per-day label counter\n * with them. That answers \"how many views did this campaign send us\" and\n * stops there — the labels are never carried to the moment the visitor says\n * who they are, so every form, lead, contact and booking a campaign caused is\n * recorded as though nobody caused it.\n *\n * This carries them. The touch is held on the VISITOR, and every identify\n * moment attaches it.\n *\n * ## What holds it, and why it outlives the tab\n *\n * `localStorage`, expiring at {@link ATTRIBUTION_WINDOW_DAYS}.\n *\n * `utm-forwarding.ts` holds its own campaign in `sessionStorage` and\n * argues for it: a first touch that outlived the visit would start\n * attributing next week's organic return to this week's ad. That argument is\n * correct for the question it answers — which campaign produced THIS SIGNUP,\n * asked of one uninterrupted visit to the marketing site — and it is the\n * wrong shape here, because this question already has a stated answer to\n * \"how long may a touch be credited\": the attribution window, seven days,\n * stamped onto every record so the rule is readable off the data.\n *\n * A store that dies with the tab cannot express a seven-day window. It would\n * credit only the visitors who convert without ever closing the tab and\n * silently record every other conversion as organic — a measured zero, which\n * is the failure mode this whole area exists to end. So the touch lives as\n * long as the window says it may and not one millisecond longer: an expired\n * entry is DELETED on the read that finds it, rather than ignored, so a\n * device never holds a campaign it can no longer be credited with.\n *\n * ## Last touch, not first\n *\n * The revenue join credits the LAST click, and a product where a lead and an\n * order attribute by different rules is worse than one that is uniformly\n * approximate. So a new campaign arrival OVERWRITES the stored one. This is\n * the deliberate opposite of {@link rememberVisitUtm}, which keeps the\n * first — that one describes a single visit's origin and this one describes\n * which campaign most recently brought a person back.\n *\n * ## Consent\n *\n * Two tiers, exactly as the console hop splits them, and the split is the\n * same because the acts are the same:\n *\n * - **The live URL at the moment of conversion** touches no storage. The\n * parameters are already in the page the visitor asked for, nothing is\n * written to their device, and there is accordingly nothing to consent to.\n * A visitor who converts on the page the ad landed them on is attributed\n * with no grant of any kind.\n * - **The remembered touch** is written to the visitor's device for an\n * analytics purpose, which is `analytics_storage`, and it waits for the\n * same grant the analytics tag waits for. The gate is not re-derived here:\n * the caller already computed it for the tag and hands the same boolean\n * down, so there is one gate and it cannot drift from itself.\n *\n * `null` is UNRESOLVED and is not `false`. With `strictNullChecks` off\n * repo-wide that distinction evaporates unless it is carried explicitly, so\n * it is: until the visitor's state is actually settled, nothing is read and\n * nothing is written. Failing closed costs an attribution; failing open\n * writes to a device before the visitor answered.\n *\n * On a WITHDRAWAL the stored touch is removed rather than merely ignored.\n * Somebody who changes their mind should not leave the thing they withdrew\n * consent for sitting on their device.\n *\n * ⛔ **A host whose posture requires prior consent and whose visitor has not\n * given it produces no remembered touch at all.** Their conversion attributes\n * to nothing, exactly as direct traffic does. That is the degradation, stated\n * rather than worked around: there is no fallback identifier, no fingerprint\n * and no server-side cookie, because every one of those is the durable\n * identifier the analytics posture on this runtime deliberately does not set.\n *\n * ## What is never carried\n *\n * Only the three allowlisted `utm_` labels, scrubbed by\n * {@link parseUtmAttribution} — which refuses an email-shaped value\n * outright. No address, no person's identifier, no click id, nothing that\n * names the visitor. A campaign link is exactly where putting a recipient in\n * a query string would be tempting, and the parser is what makes it\n * impossible rather than merely discouraged: the stored string is re-parsed\n * through the same allowlist that wrote it, so a hand-edited `localStorage`\n * entry can claim no more than a hand-edited URL could.\n *\n * ⛔ **Nothing here depends on a parameter surviving the mail provider's\n * click wrapper.** A campaign link is rewritten by the provider and redirects\n * to the authored URL, so whatever the marketer put on it arrives — but the\n * email channel does not rely on that, because an email click is already\n * recorded server-side against the recipient's address hash. The labels below\n * are the WEB channel's touch, and a campaign that carries none is joined by\n * address instead.\n */\n\nimport {\n utmAttributionQuery,\n parseUtmAttribution,\n type UtmAttribution,\n} from './utm-attribution'\n\n/*==========================================\n * THE WINDOW, AND WHY THIS IS THE SECOND COPY OF A NUMBER.\n *\n * `email-revenue-window.ts` in `shared-util-email` declares the same seven\n * days, and its docblock is right that a window defined twice is a window\n * that drifts. Importing it here is not available: `shared-util-email` is\n * tagged `scope:shared` and reaches back into `@aglyn/aglyn`, so an edge in\n * this direction closes a project cycle and the module-boundary rule refuses\n * it. That is the same wall `email-media-src.ts` hit, and the answer is the\n * same one it took — a deliberate copy, pinned by a drift guard in an app\n * spec that can import both sides (`attribution-window-drift.spec.ts`).\n *\n * The copy is the WINDOW only. The model name is stamped onto records, which\n * happens server-side, so it stays in one place and never comes near this\n * file.\n *=========================================*/\n\n/** Days between a campaign touch and a conversion it may be credited with. */\nexport const ATTRIBUTION_WINDOW_DAYS = 7\n\n/** The same window in milliseconds. */\nexport const ATTRIBUTION_WINDOW_MS =\n ATTRIBUTION_WINDOW_DAYS * 24 * 60 * 60 * 1000\n\n/**\n * Whether a touch may be credited with a conversion at `convertedAtMs`.\n *\n * Both bounds matter and they fail differently, exactly as the revenue join\n * describes them: a touch AFTER the conversion is the receipt rather than the\n * cause, and a touch older than the window is one nobody can argue caused\n * anything. Inclusive at both ends.\n */\nfunction touchIsInWindow(touchedAtMs: number, convertedAtMs: number): boolean {\n if (!Number.isFinite(touchedAtMs) || !Number.isFinite(convertedAtMs)) {\n return false\n }\n if (touchedAtMs <= 0 || convertedAtMs <= 0) return false\n const age = convertedAtMs - touchedAtMs\n return age >= 0 && age <= ATTRIBUTION_WINDOW_MS\n}\n\n/** Where the touch is held. Namespaced like every other key this app sets. */\nexport const CAMPAIGN_TOUCH_STORAGE_KEY = 'aglyn:campaign-touch'\n\n/**\n * The parameter the touch's instant rides under inside the wire form.\n *\n * Deliberately not a `utm_` name: the wire form is parsed by the same\n * allowlist that reads a URL, and a fourth `utm_` key would either be dropped\n * by it or have to widen it. `t` is outside the allowlist and read\n * separately.\n */\nexport const CAMPAIGN_TOUCH_TIME_KEY = 't'\n\n/** A campaign the visitor arrived from, and when they arrived from it. */\nexport interface CampaignTouch extends UtmAttribution {\n /** When the visitor followed the campaign link, epoch ms. */\n atMs: number\n}\n\n/**\n * Whether the visitor has granted analytics storage.\n *\n * `null` means NOT YET RESOLVED and is not the same as `false` — see the\n * consent section above.\n */\nlet storageConsent: boolean | null = null\n\nfunction localStore(): Storage | null {\n if (typeof window === 'undefined') return null\n // Touching the property itself throws in some privacy modes, not just its\n // methods.\n try {\n return window.localStorage ?? null\n } catch {\n return null\n }\n}\n\n/**\n * The canonical stored form of a touch — the ONLY place it is written, so it\n * cannot drift from what {@link parseCampaignTouch} reads back.\n */\nexport function campaignTouchWire(touch: CampaignTouch | null | undefined): string {\n if (!touch) return ''\n const labels = utmAttributionQuery(touch)\n if (!labels) return ''\n const atMs = Math.round(Number(touch.atMs))\n if (!Number.isFinite(atMs) || atMs <= 0) return ''\n return `${labels}&${CAMPAIGN_TOUCH_TIME_KEY}=${atMs}`\n}\n\n/**\n * Read a stored or transmitted touch back, or `null`.\n *\n * The window is enforced HERE rather than at the call sites, so every reader\n * — the browser deciding what to send, the server deciding what to credit —\n * agrees about what an expired touch is: nothing at all. A touch dated in the\n * future is refused for the reason the revenue join refuses one: a click\n * after the conversion is the receipt, not the cause.\n */\nexport function parseCampaignTouch(\n wire: unknown,\n nowMs: number = Date.now(),\n): CampaignTouch | null {\n if (typeof wire !== 'string' || !wire) return null\n let params: URLSearchParams\n try {\n params = new URLSearchParams(wire)\n } catch {\n return null\n }\n const campaign = parseUtmAttribution(params)\n if (!campaign) return null\n const atMs = Number(params.get(CAMPAIGN_TOUCH_TIME_KEY))\n if (!touchIsInWindow(atMs, nowMs)) return null\n return { ...campaign, atMs }\n}\n\n/**\n * Tell this module what the visitor's analytics consent state is.\n *\n * Called by whoever already computed it for the tag, on every render, so a\n * grant remembers the arrival the moment it is given and a withdrawal drops\n * the stored touch immediately.\n */\nexport function setCampaignTouchConsent(allowed: boolean | null): void {\n storageConsent = allowed === true ? true : allowed === false ? false : null\n if (storageConsent === true) {\n rememberCampaignTouch()\n return\n }\n if (storageConsent === false) {\n const store = localStore()\n if (!store) return\n try {\n store.removeItem(CAMPAIGN_TOUCH_STORAGE_KEY)\n } catch {\n // Nothing else to try, and a failed cleanup must not break the page.\n }\n }\n}\n\n/**\n * Remember the campaign on the current URL as this visitor's latest touch.\n *\n * Overwrites, because the model is last touch. A URL naming no campaign\n * writes NOTHING and clears nothing: a visitor who arrives from an ad and\n * then browses ten organic pages has one touch, not one touch erased by the\n * second pageview.\n *\n * @returns what is now remembered, or `null`.\n */\nexport function rememberCampaignTouch(\n search?: string,\n nowMs: number = Date.now(),\n): CampaignTouch | null {\n if (storageConsent !== true) return null\n const store = localStore()\n if (!store) return null\n const source =\n typeof search === 'string'\n ? search\n : typeof window === 'undefined'\n ? ''\n : window.location.search\n const campaign = parseUtmAttribution(new URLSearchParams(source))\n if (!campaign) return null\n const touch: CampaignTouch = { ...campaign, atMs: nowMs }\n const wire = campaignTouchWire(touch)\n if (!wire) return null\n try {\n store.setItem(CAMPAIGN_TOUCH_STORAGE_KEY, wire)\n } catch {\n // A store that refuses the write costs the walk from the landing page to\n // the conversion, never a conversion on the landing page itself — the\n // live URL below still carries that visitor.\n return null\n }\n return touch\n}\n\n/**\n * The touch to credit a conversion happening right now, or `null`.\n *\n * ## The order is the model\n *\n * The live URL is consulted FIRST and wins whenever it names a campaign,\n * because a campaign on the address bar at the moment of conversion is by\n * definition the most recent touch there is. Only when the current page names\n * none does this fall back to what was remembered.\n *\n * ## An expired entry is deleted, not skipped\n *\n * A touch past the window can never be credited again, so leaving it on the\n * device would be keeping a record of where somebody came from for no purpose\n * anything reads. The read that finds it removes it.\n */\nexport function readCampaignTouch(\n nowMs: number = Date.now(),\n): CampaignTouch | null {\n const live =\n typeof window === 'undefined'\n ? null\n : parseUtmAttribution(new URLSearchParams(window.location.search))\n if (live) return { ...live, atMs: nowMs }\n\n if (storageConsent !== true) return null\n const store = localStore()\n if (!store) return null\n let raw: string | null\n try {\n raw = store.getItem(CAMPAIGN_TOUCH_STORAGE_KEY)\n } catch {\n return null\n }\n if (!raw) return null\n const touch = parseCampaignTouch(raw, nowMs)\n if (!touch) {\n try {\n store.removeItem(CAMPAIGN_TOUCH_STORAGE_KEY)\n } catch {\n // The entry stays until the next read finds it again; it is already\n // uncreditable, so nothing downstream is affected.\n }\n return null\n }\n return touch\n}\n\n/**\n * The fragment a conversion request spreads into its body, or `{}`.\n *\n * One helper rather than four hand-written spreads: a door that forgets it\n * reports its conversions as organic, which looks exactly like a campaign\n * that produced none. Empty when there is no touch — never a placeholder,\n * never `campaignTouch: undefined`, so that \"arrived from nowhere\" and \"this\n * door does not report\" stay distinguishable on the wire.\n */\nexport function campaignTouchField(\n nowMs: number = Date.now(),\n): { campaignTouch?: string } {\n const wire = campaignTouchWire(readCampaignTouch(nowMs))\n return wire ? { campaignTouch: wire } : {}\n}\n"],"names":["utmAttributionQuery","parseUtmAttribution","ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_MS","touchIsInWindow","touchedAtMs","convertedAtMs","Number","isFinite","age","CAMPAIGN_TOUCH_STORAGE_KEY","CAMPAIGN_TOUCH_TIME_KEY","storageConsent","localStore","window","localStorage","campaignTouchWire","touch","labels","atMs","Math","round","parseCampaignTouch","wire","nowMs","Date","now","params","URLSearchParams","campaign","get","setCampaignTouchConsent","allowed","rememberCampaignTouch","store","removeItem","search","source","location","setItem","readCampaignTouch","live","raw","getItem","campaignTouchField","campaignTouch"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGC,GAED,SACEA,mBAAmB,EACnBC,mBAAmB,QAEd,uBAAmB;AAE1B;;;;;;;;;;;;;;;2CAe2C,GAE3C,4EAA4E,GAC5E,OAAO,MAAMC,0BAA0B,EAAC;AAExC,qCAAqC,GACrC,OAAO,MAAMC,wBACXD,0BAA0B,KAAK,KAAK,KAAK,KAAI;AAE/C;;;;;;;CAOC,GACD,SAASE,gBAAgBC,WAAmB,EAAEC,aAAqB;IACjE,IAAI,CAACC,OAAOC,QAAQ,CAACH,gBAAgB,CAACE,OAAOC,QAAQ,CAACF,gBAAgB;QACpE,OAAO;IACT;IACA,IAAID,eAAe,KAAKC,iBAAiB,GAAG,OAAO;IACnD,MAAMG,MAAMH,gBAAgBD;IAC5B,OAAOI,OAAO,KAAKA,OAAON;AAC5B;AAEA,4EAA4E,GAC5E,OAAO,MAAMO,6BAA6B,uBAAsB;AAEhE;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,IAAG;AAQ1C;;;;;CAKC,GACD,IAAIC,iBAAiC;AAErC,SAASC;IACP,IAAI,OAAOC,WAAW,aAAa,OAAO;IAC1C,0EAA0E;IAC1E,WAAW;IACX,IAAI;YACKA;QAAP,QAAOA,uBAAAA,OAAOC,YAAY,YAAnBD,uBAAuB;IAChC,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,SAASE,kBAAkBC,KAAuC;IACvE,IAAI,CAACA,OAAO,OAAO;IACnB,MAAMC,SAASlB,oBAAoBiB;IACnC,IAAI,CAACC,QAAQ,OAAO;IACpB,MAAMC,OAAOC,KAAKC,KAAK,CAACd,OAAOU,MAAME,IAAI;IACzC,IAAI,CAACZ,OAAOC,QAAQ,CAACW,SAASA,QAAQ,GAAG,OAAO;IAChD,OAAO,GAAGD,OAAO,CAAC,EAAEP,wBAAwB,CAAC,EAAEQ,MAAM;AACvD;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASG,mBACdC,IAAa,EACbC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IAAI,OAAOH,SAAS,YAAY,CAACA,MAAM,OAAO;IAC9C,IAAII;IACJ,IAAI;QACFA,SAAS,IAAIC,gBAAgBL;IAC/B,EAAE,eAAM;QACN,OAAO;IACT;IACA,MAAMM,WAAW5B,oBAAoB0B;IACrC,IAAI,CAACE,UAAU,OAAO;IACtB,MAAMV,OAAOZ,OAAOoB,OAAOG,GAAG,CAACnB;IAC/B,IAAI,CAACP,gBAAgBe,MAAMK,QAAQ,OAAO;IAC1C,OAAO,aAAKK;QAAUV;;AACxB;AAEA;;;;;;CAMC,GACD,OAAO,SAASY,wBAAwBC,OAAuB;IAC7DpB,iBAAiBoB,YAAY,OAAO,OAAOA,YAAY,QAAQ,QAAQ;IACvE,IAAIpB,mBAAmB,MAAM;QAC3BqB;QACA;IACF;IACA,IAAIrB,mBAAmB,OAAO;QAC5B,MAAMsB,QAAQrB;QACd,IAAI,CAACqB,OAAO;QACZ,IAAI;YACFA,MAAMC,UAAU,CAACzB;QACnB,EAAE,eAAM;QACN,qEAAqE;QACvE;IACF;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASuB,sBACdG,MAAe,EACfZ,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IAAId,mBAAmB,MAAM,OAAO;IACpC,MAAMsB,QAAQrB;IACd,IAAI,CAACqB,OAAO,OAAO;IACnB,MAAMG,SACJ,OAAOD,WAAW,WACdA,SACA,OAAOtB,WAAW,cAChB,KACAA,OAAOwB,QAAQ,CAACF,MAAM;IAC9B,MAAMP,WAAW5B,oBAAoB,IAAI2B,gBAAgBS;IACzD,IAAI,CAACR,UAAU,OAAO;IACtB,MAAMZ,QAAuB,aAAKY;QAAUV,MAAMK;;IAClD,MAAMD,OAAOP,kBAAkBC;IAC/B,IAAI,CAACM,MAAM,OAAO;IAClB,IAAI;QACFW,MAAMK,OAAO,CAAC7B,4BAA4Ba;IAC5C,EAAE,eAAM;QACN,yEAAyE;QACzE,sEAAsE;QACtE,6CAA6C;QAC7C,OAAO;IACT;IACA,OAAON;AACT;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASuB,kBACdhB,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,MAAMe,OACJ,OAAO3B,WAAW,cACd,OACAb,oBAAoB,IAAI2B,gBAAgBd,OAAOwB,QAAQ,CAACF,MAAM;IACpE,IAAIK,MAAM,OAAO,aAAKA;QAAMtB,MAAMK;;IAElC,IAAIZ,mBAAmB,MAAM,OAAO;IACpC,MAAMsB,QAAQrB;IACd,IAAI,CAACqB,OAAO,OAAO;IACnB,IAAIQ;IACJ,IAAI;QACFA,MAAMR,MAAMS,OAAO,CAACjC;IACtB,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAI,CAACgC,KAAK,OAAO;IACjB,MAAMzB,QAAQK,mBAAmBoB,KAAKlB;IACtC,IAAI,CAACP,OAAO;QACV,IAAI;YACFiB,MAAMC,UAAU,CAACzB;QACnB,EAAE,eAAM;QACN,oEAAoE;QACpE,mDAAmD;QACrD;QACA,OAAO;IACT;IACA,OAAOO;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,SAAS2B,mBACdpB,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,MAAMH,OAAOP,kBAAkBwB,kBAAkBhB;IACjD,OAAOD,OAAO;QAAEsB,eAAetB;IAAK,IAAI,CAAC;AAC3C"}
@@ -1,84 +0,0 @@
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
- /**
18
- * Where the CRM's records live, for the console's top-bar search (AGL-2622).
19
- *
20
- * ## What is left here, and why
21
- *
22
- * Every other surface asks the record-route registry
23
- * (`plugin-manager/plugin-record-routes`) for a record's address, which the
24
- * plugin that owns the kind publishes — a contact, an order, a submission, a
25
- * booking. The top-bar search still builds the CRM's four record kinds itself
26
- * until its rows come from the plugins that keep them; this is that copy,
27
- * pinned against the CRM plugin's own `crmRoutes(basePath)` by its spec so
28
- * the two cannot drift.
29
- *
30
- * ## Why the arguments are the two route params and not a host document
31
- *
32
- * Every site console page is addressed by `/{orgSlug}/hosts/{host}/…`, and
33
- * both segments are already in the URL a surface is rendered on. Building
34
- * from them costs nothing; resolving them from a host id costs two document
35
- * reads (`hostIndex`, then the org) on every open of a card, to render a
36
- * link.
37
- */
38
- export interface SiteConsoleContext {
39
- orgSlug: string;
40
- host: string;
41
- }
42
- /** The CRM's nav slug — the `[pluginSlug]` segment the shell resolves. */
43
- export declare const CRM_CONSOLE_SLUG = "crm";
44
- /** The four record kinds a surface outside the CRM may name. */
45
- export type CrmRecordKind = 'contact' | 'lead' | 'company' | 'deal';
46
- /**
47
- * The hub section each kind's record lives under, which is also the list a
48
- * kind-only link lands on. Named here rather than in the plugin's section
49
- * registry so the console app can address a record without the plugin.
50
- */
51
- export declare const CRM_RECORD_SECTIONS: Record<CrmRecordKind, string>;
52
- /** `/{orgSlug}/hosts/{host}/crm` — the CRM hub on the site being read. */
53
- export declare function crmHubHref(context: SiteConsoleContext): string;
54
- /** One section of the hub — the Leads list, the Deals board. */
55
- export declare function crmSectionHref(context: SiteConsoleContext, section: string): string;
56
- /**
57
- * One record's own page. The id is URL-encoded because a Firestore id is
58
- * opaque — the console mints its own, but an import or an API caller may
59
- * not, and a slash in an id would otherwise read as a further segment.
60
- */
61
- export declare function crmRecordHref(context: SiteConsoleContext, kind: CrmRecordKind, id: string): string;
62
- /**
63
- * `/{orgSlug}/crm` — the ORGANIZATION-level CRM hub (AGL-2630), the same
64
- * sections mounted with no site.
65
- *
66
- * The org-level twin of {@link crmHubHref}, and separate from it because the
67
- * two take different arguments: a site address needs the subdomain, and a
68
- * surface standing at the org hub has none to give. A caller that faked one
69
- * would put a site's name on a page about all of them.
70
- */
71
- export declare function crmOrgHubHref(orgSlug: string): string;
72
- /** One section of the org-level hub — the cross-site Deals board. */
73
- export declare function crmOrgSectionHref(orgSlug: string, section: string): string;
74
- /**
75
- * One record's page at the org level.
76
- *
77
- * A LEAD IS NO LONGER EXCLUDED (AGL-3275). It was, by the type rather than a
78
- * runtime check, because a lead's id is a person key — the same on every site
79
- * that met the person — while `hosts/{hostId}/leads` was host-scoped by path,
80
- * so two sites held two documents carrying one id and an org-level address
81
- * had to name the site to tell them apart. One org collection makes the
82
- * person key unambiguous, so a lead addresses like every other record.
83
- */
84
- export declare function crmOrgRecordHref(orgSlug: string, kind: CrmRecordKind, id: string): string;
@@ -1,76 +0,0 @@
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 { buildRoute, Route } from "./console-routes.js";
17
- /** The CRM's nav slug — the `[pluginSlug]` segment the shell resolves. */ export const CRM_CONSOLE_SLUG = 'crm';
18
- /**
19
- * The hub section each kind's record lives under, which is also the list a
20
- * kind-only link lands on. Named here rather than in the plugin's section
21
- * registry so the console app can address a record without the plugin.
22
- */ export const CRM_RECORD_SECTIONS = {
23
- contact: 'contacts',
24
- lead: 'leads',
25
- company: 'companies',
26
- deal: 'deals'
27
- };
28
- /** `/{orgSlug}/hosts/{host}/crm` — the CRM hub on the site being read. */ export function crmHubHref(context) {
29
- return buildRoute(Route.HOST_PLUGIN, {
30
- orgSlug: context.orgSlug,
31
- host: context.host,
32
- pluginSlug: CRM_CONSOLE_SLUG
33
- });
34
- }
35
- /** One section of the hub — the Leads list, the Deals board. */ export function crmSectionHref(context, section) {
36
- return `${crmHubHref(context)}/${section}`;
37
- }
38
- /**
39
- * One record's own page. The id is URL-encoded because a Firestore id is
40
- * opaque — the console mints its own, but an import or an API caller may
41
- * not, and a slash in an id would otherwise read as a further segment.
42
- */ export function crmRecordHref(context, kind, id) {
43
- return `${crmSectionHref(context, CRM_RECORD_SECTIONS[kind])}/${encodeURIComponent(id)}`;
44
- }
45
- /**
46
- * `/{orgSlug}/crm` — the ORGANIZATION-level CRM hub (AGL-2630), the same
47
- * sections mounted with no site.
48
- *
49
- * The org-level twin of {@link crmHubHref}, and separate from it because the
50
- * two take different arguments: a site address needs the subdomain, and a
51
- * surface standing at the org hub has none to give. A caller that faked one
52
- * would put a site's name on a page about all of them.
53
- */ export function crmOrgHubHref(orgSlug) {
54
- return buildRoute(Route.ORG_CRM, {
55
- orgSlug
56
- });
57
- }
58
- /** One section of the org-level hub — the cross-site Deals board. */ export function crmOrgSectionHref(orgSlug, section) {
59
- return `${crmOrgHubHref(orgSlug)}/${section}`;
60
- }
61
- /**
62
- * One record's page at the org level.
63
- *
64
- * A LEAD IS NO LONGER EXCLUDED (AGL-3275). It was, by the type rather than a
65
- * runtime check, because a lead's id is a person key — the same on every site
66
- * that met the person — while `hosts/{hostId}/leads` was host-scoped by path,
67
- * so two sites held two documents carrying one id and an org-level address
68
- * had to name the site to tell them apart. One org collection makes the
69
- * person key unambiguous, so a lead addresses like every other record.
70
- */ export function crmOrgRecordHref(orgSlug, kind, id) {
71
- return `${crmOrgSectionHref(orgSlug, CRM_RECORD_SECTIONS[kind])}/${encodeURIComponent(id)}`;
72
- } // `crmOrgLeadHref` is gone with the host-path fallback (AGL-3277). It took a
73
- // site it had already stopped using; `crmOrgRecordHref(orgSlug, 'lead', id)`
74
- // is the whole of what it did.
75
-
76
- //# sourceMappingURL=console-record-links.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/console-record-links.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 { buildRoute, Route } from './console-routes'\n\n/**\n * Where the CRM's records live, for the console's top-bar search (AGL-2622).\n *\n * ## What is left here, and why\n *\n * Every other surface asks the record-route registry\n * (`plugin-manager/plugin-record-routes`) for a record's address, which the\n * plugin that owns the kind publishes — a contact, an order, a submission, a\n * booking. The top-bar search still builds the CRM's four record kinds itself\n * until its rows come from the plugins that keep them; this is that copy,\n * pinned against the CRM plugin's own `crmRoutes(basePath)` by its spec so\n * the two cannot drift.\n *\n * ## Why the arguments are the two route params and not a host document\n *\n * Every site console page is addressed by `/{orgSlug}/hosts/{host}/…`, and\n * both segments are already in the URL a surface is rendered on. Building\n * from them costs nothing; resolving them from a host id costs two document\n * reads (`hostIndex`, then the org) on every open of a card, to render a\n * link.\n */\nexport interface SiteConsoleContext {\n orgSlug: string\n host: string\n}\n\n/** The CRM's nav slug — the `[pluginSlug]` segment the shell resolves. */\nexport const CRM_CONSOLE_SLUG = 'crm'\n\n/** The four record kinds a surface outside the CRM may name. */\nexport type CrmRecordKind = 'contact' | 'lead' | 'company' | 'deal'\n\n/**\n * The hub section each kind's record lives under, which is also the list a\n * kind-only link lands on. Named here rather than in the plugin's section\n * registry so the console app can address a record without the plugin.\n */\nexport const CRM_RECORD_SECTIONS: Record<CrmRecordKind, string> = {\n contact: 'contacts',\n lead: 'leads',\n company: 'companies',\n deal: 'deals',\n}\n\n/** `/{orgSlug}/hosts/{host}/crm` — the CRM hub on the site being read. */\nexport function crmHubHref(context: SiteConsoleContext): string {\n return buildRoute(Route.HOST_PLUGIN, {\n orgSlug: context.orgSlug,\n host: context.host,\n pluginSlug: CRM_CONSOLE_SLUG,\n })\n}\n\n/** One section of the hub — the Leads list, the Deals board. */\nexport function crmSectionHref(\n context: SiteConsoleContext,\n section: string,\n): string {\n return `${crmHubHref(context)}/${section}`\n}\n\n/**\n * One record's own page. The id is URL-encoded because a Firestore id is\n * opaque — the console mints its own, but an import or an API caller may\n * not, and a slash in an id would otherwise read as a further segment.\n */\nexport function crmRecordHref(\n context: SiteConsoleContext,\n kind: CrmRecordKind,\n id: string,\n): string {\n return `${crmSectionHref(context, CRM_RECORD_SECTIONS[kind])}/${encodeURIComponent(id)}`\n}\n\n/**\n * `/{orgSlug}/crm` — the ORGANIZATION-level CRM hub (AGL-2630), the same\n * sections mounted with no site.\n *\n * The org-level twin of {@link crmHubHref}, and separate from it because the\n * two take different arguments: a site address needs the subdomain, and a\n * surface standing at the org hub has none to give. A caller that faked one\n * would put a site's name on a page about all of them.\n */\nexport function crmOrgHubHref(orgSlug: string): string {\n return buildRoute(Route.ORG_CRM, { orgSlug })\n}\n\n/** One section of the org-level hub — the cross-site Deals board. */\nexport function crmOrgSectionHref(orgSlug: string, section: string): string {\n return `${crmOrgHubHref(orgSlug)}/${section}`\n}\n\n/**\n * One record's page at the org level.\n *\n * A LEAD IS NO LONGER EXCLUDED (AGL-3275). It was, by the type rather than a\n * runtime check, because a lead's id is a person key — the same on every site\n * that met the person — while `hosts/{hostId}/leads` was host-scoped by path,\n * so two sites held two documents carrying one id and an org-level address\n * had to name the site to tell them apart. One org collection makes the\n * person key unambiguous, so a lead addresses like every other record.\n */\nexport function crmOrgRecordHref(\n orgSlug: string,\n kind: CrmRecordKind,\n id: string,\n): string {\n return `${crmOrgSectionHref(orgSlug, CRM_RECORD_SECTIONS[kind])}/${encodeURIComponent(id)}`\n}\n\n// `crmOrgLeadHref` is gone with the host-path fallback (AGL-3277). It took a\n// site it had already stopped using; `crmOrgRecordHref(orgSlug, 'lead', id)`\n// is the whole of what it did.\n"],"names":["buildRoute","Route","CRM_CONSOLE_SLUG","CRM_RECORD_SECTIONS","contact","lead","company","deal","crmHubHref","context","HOST_PLUGIN","orgSlug","host","pluginSlug","crmSectionHref","section","crmRecordHref","kind","id","encodeURIComponent","crmOrgHubHref","ORG_CRM","crmOrgSectionHref","crmOrgRecordHref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,sBAAkB;AA4BpD,wEAAwE,GACxE,OAAO,MAAMC,mBAAmB,MAAK;AAKrC;;;;CAIC,GACD,OAAO,MAAMC,sBAAqD;IAChEC,SAAS;IACTC,MAAM;IACNC,SAAS;IACTC,MAAM;AACR,EAAC;AAED,wEAAwE,GACxE,OAAO,SAASC,WAAWC,OAA2B;IACpD,OAAOT,WAAWC,MAAMS,WAAW,EAAE;QACnCC,SAASF,QAAQE,OAAO;QACxBC,MAAMH,QAAQG,IAAI;QAClBC,YAAYX;IACd;AACF;AAEA,8DAA8D,GAC9D,OAAO,SAASY,eACdL,OAA2B,EAC3BM,OAAe;IAEf,OAAO,GAAGP,WAAWC,SAAS,CAAC,EAAEM,SAAS;AAC5C;AAEA;;;;CAIC,GACD,OAAO,SAASC,cACdP,OAA2B,EAC3BQ,IAAmB,EACnBC,EAAU;IAEV,OAAO,GAAGJ,eAAeL,SAASN,mBAAmB,CAACc,KAAK,EAAE,CAAC,EAAEE,mBAAmBD,KAAK;AAC1F;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,cAAcT,OAAe;IAC3C,OAAOX,WAAWC,MAAMoB,OAAO,EAAE;QAAEV;IAAQ;AAC7C;AAEA,mEAAmE,GACnE,OAAO,SAASW,kBAAkBX,OAAe,EAAEI,OAAe;IAChE,OAAO,GAAGK,cAAcT,SAAS,CAAC,EAAEI,SAAS;AAC/C;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASQ,iBACdZ,OAAe,EACfM,IAAmB,EACnBC,EAAU;IAEV,OAAO,GAAGI,kBAAkBX,SAASR,mBAAmB,CAACc,KAAK,EAAE,CAAC,EAAEE,mBAAmBD,KAAK;AAC7F,EAEA,6EAA6E;CAC7E,6EAA6E;CAC7E,+BAA+B"}
@@ -1,75 +0,0 @@
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
- /**
18
- * The query key a booking link carries the record on: `?crm=contact:abc`.
19
- *
20
- * The reference rides the LINK rather than relying on the booker's address
21
- * because the two routinely differ — a rep emails somebody's work address
22
- * and they book with a personal one — and a booking that could only be
23
- * matched by address would then land on nobody. The widget copies the value
24
- * onto the booking request, the booking row keeps it as `crmRef`, and the
25
- * server files the meeting under the record it names.
26
- */
27
- export declare const CRM_BOOKING_REF_PARAM = "crm";
28
- /**
29
- * The query key that preselects a service in the booking widget:
30
- * `?service={serviceId}`. The widget lists every active service and this
31
- * opens it on the one the link was about.
32
- */
33
- export declare const BOOKING_SERVICE_PARAM = "service";
34
- /** The three records a booking link can be dropped from. */
35
- export declare const CRM_BOOKING_REF_KINDS: readonly ["contact", "lead", "deal"];
36
- export type CrmBookingRefKind = (typeof CRM_BOOKING_REF_KINDS)[number];
37
- export interface CrmBookingRef {
38
- kind: CrmBookingRefKind;
39
- id: string;
40
- }
41
- /** `contact:abc` — the wire form of a reference. */
42
- export declare function formatCrmBookingRef(ref: CrmBookingRef): string;
43
- /**
44
- * The reference a query value names, or `null` for anything that is not one.
45
- *
46
- * Strict on purpose: the value arrives on a public, unauthenticated booking
47
- * request, so an unknown kind or a malformed id is dropped rather than
48
- * stored — a booking still lands, it simply matches by address instead.
49
- */
50
- export declare function parseCrmBookingRef(raw: unknown): CrmBookingRef | null;
51
- /**
52
- * What the meeting entry on the timeline says — the service and the slot,
53
- * in the service's own timezone, with the zone named so a rep in another
54
- * one is not misled. The body, not the subject: `subject` is the field a
55
- * sent email carries, and the timeline draws it as one.
56
- */
57
- export declare function crmBookingMeetingBody(input: {
58
- serviceName: string;
59
- startsAtMs: number;
60
- timezone?: string | null;
61
- }): string;
62
- /** The title of the task a service files when it asks for a follow-up. */
63
- export declare function crmBookingFollowUpTitle(serviceName: string): string;
64
- /**
65
- * When the follow-up falls due: ONE BUSINESS DAY after the slot, at the
66
- * slot's own clock time.
67
- *
68
- * A meeting on a Friday is followed up on Monday, one on a Saturday or a
69
- * Sunday on Monday too; any other day is followed up the next. Whole days
70
- * are added to the instant rather than a calendar walked, so the arithmetic
71
- * cannot land on a clock time that does not exist across a DST change —
72
- * the task is due "about this time tomorrow", and an hour either way is not
73
- * a fact the task keeps.
74
- */
75
- export declare function crmBookingFollowUpDueMs(endsAtMs: number, timezone?: string | null): number;