@aglyn/tenant-runtime 1.0.0-beta.143

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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +26 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +21 -0
  5. package/src/index.js +22 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/apply-publish-schedule.d.ts +34 -0
  8. package/src/lib/apply-publish-schedule.js +285 -0
  9. package/src/lib/apply-publish-schedule.js.map +1 -0
  10. package/src/lib/assign-contact-owner.d.ts +162 -0
  11. package/src/lib/assign-contact-owner.js +322 -0
  12. package/src/lib/assign-contact-owner.js.map +1 -0
  13. package/src/lib/associate-company-by-domain.d.ts +70 -0
  14. package/src/lib/associate-company-by-domain.js +150 -0
  15. package/src/lib/associate-company-by-domain.js.map +1 -0
  16. package/src/lib/author-page-nodes.d.ts +36 -0
  17. package/src/lib/author-page-nodes.js +296 -0
  18. package/src/lib/author-page-nodes.js.map +1 -0
  19. package/src/lib/built-in-page-layout.d.ts +48 -0
  20. package/src/lib/built-in-page-layout.js +107 -0
  21. package/src/lib/built-in-page-layout.js.map +1 -0
  22. package/src/lib/capture-host-contact.d.ts +95 -0
  23. package/src/lib/capture-host-contact.js +137 -0
  24. package/src/lib/capture-host-contact.js.map +1 -0
  25. package/src/lib/collection-fallback-nodes.d.ts +90 -0
  26. package/src/lib/collection-fallback-nodes.js +602 -0
  27. package/src/lib/collection-fallback-nodes.js.map +1 -0
  28. package/src/lib/compose-author-page.d.ts +109 -0
  29. package/src/lib/compose-author-page.js +225 -0
  30. package/src/lib/compose-author-page.js.map +1 -0
  31. package/src/lib/compose-collection-page.d.ts +94 -0
  32. package/src/lib/compose-collection-page.js +256 -0
  33. package/src/lib/compose-collection-page.js.map +1 -0
  34. package/src/lib/compose-screen-nodes.d.ts +156 -0
  35. package/src/lib/compose-screen-nodes.js +547 -0
  36. package/src/lib/compose-screen-nodes.js.map +1 -0
  37. package/src/lib/compose-search-page.d.ts +39 -0
  38. package/src/lib/compose-search-page.js +57 -0
  39. package/src/lib/compose-search-page.js.map +1 -0
  40. package/src/lib/convert-host-lead.d.ts +189 -0
  41. package/src/lib/convert-host-lead.js +443 -0
  42. package/src/lib/convert-host-lead.js.map +1 -0
  43. package/src/lib/defer-lazy-panels.d.ts +58 -0
  44. package/src/lib/defer-lazy-panels.js +171 -0
  45. package/src/lib/defer-lazy-panels.js.map +1 -0
  46. package/src/lib/element-animation-assets.d.ts +62 -0
  47. package/src/lib/element-animation-assets.js +290 -0
  48. package/src/lib/element-animation-assets.js.map +1 -0
  49. package/src/lib/emit-host-event.d.ts +29 -0
  50. package/src/lib/emit-host-event.js +30 -0
  51. package/src/lib/emit-host-event.js.map +1 -0
  52. package/src/lib/enrich-gated-page.d.ts +67 -0
  53. package/src/lib/enrich-gated-page.js +134 -0
  54. package/src/lib/enrich-gated-page.js.map +1 -0
  55. package/src/lib/entry-link-routes.d.ts +61 -0
  56. package/src/lib/entry-link-routes.js +169 -0
  57. package/src/lib/entry-link-routes.js.map +1 -0
  58. package/src/lib/get-author-content.d.ts +119 -0
  59. package/src/lib/get-author-content.js +288 -0
  60. package/src/lib/get-author-content.js.map +1 -0
  61. package/src/lib/get-collection-content.d.ts +290 -0
  62. package/src/lib/get-collection-content.js +565 -0
  63. package/src/lib/get-collection-content.js.map +1 -0
  64. package/src/lib/get-components.d.ts +24 -0
  65. package/src/lib/get-components.js +102 -0
  66. package/src/lib/get-components.js.map +1 -0
  67. package/src/lib/get-datasets.d.ts +45 -0
  68. package/src/lib/get-datasets.js +169 -0
  69. package/src/lib/get-datasets.js.map +1 -0
  70. package/src/lib/get-forms.d.ts +24 -0
  71. package/src/lib/get-forms.js +116 -0
  72. package/src/lib/get-forms.js.map +1 -0
  73. package/src/lib/get-layout-version.d.ts +26 -0
  74. package/src/lib/get-layout-version.js +99 -0
  75. package/src/lib/get-layout-version.js.map +1 -0
  76. package/src/lib/get-media-asset-facts.d.ts +74 -0
  77. package/src/lib/get-media-asset-facts.js +116 -0
  78. package/src/lib/get-media-asset-facts.js.map +1 -0
  79. package/src/lib/get-plugin-installs.d.ts +29 -0
  80. package/src/lib/get-plugin-installs.js +94 -0
  81. package/src/lib/get-plugin-installs.js.map +1 -0
  82. package/src/lib/get-screen-version.d.ts +27 -0
  83. package/src/lib/get-screen-version.js +66 -0
  84. package/src/lib/get-screen-version.js.map +1 -0
  85. package/src/lib/get-screen.d.ts +39 -0
  86. package/src/lib/get-screen.js +126 -0
  87. package/src/lib/get-screen.js.map +1 -0
  88. package/src/lib/get-variables.d.ts +41 -0
  89. package/src/lib/get-variables.js +128 -0
  90. package/src/lib/get-variables.js.map +1 -0
  91. package/src/lib/get-video-asset-facts.d.ts +22 -0
  92. package/src/lib/get-video-asset-facts.js +22 -0
  93. package/src/lib/get-video-asset-facts.js.map +1 -0
  94. package/src/lib/host-event-listeners.d.ts +83 -0
  95. package/src/lib/host-event-listeners.js +91 -0
  96. package/src/lib/host-event-listeners.js.map +1 -0
  97. package/src/lib/org-permissions.d.ts +63 -0
  98. package/src/lib/org-permissions.js +187 -0
  99. package/src/lib/org-permissions.js.map +1 -0
  100. package/src/lib/repeat-record-pages.d.ts +48 -0
  101. package/src/lib/repeat-record-pages.js +64 -0
  102. package/src/lib/repeat-record-pages.js.map +1 -0
  103. package/src/lib/required-site-plugins.d.ts +114 -0
  104. package/src/lib/required-site-plugins.js +121 -0
  105. package/src/lib/required-site-plugins.js.map +1 -0
  106. package/src/lib/resolve-dataset.d.ts +40 -0
  107. package/src/lib/resolve-dataset.js +64 -0
  108. package/src/lib/resolve-dataset.js.map +1 -0
  109. package/src/lib/search-results-nodes.d.ts +48 -0
  110. package/src/lib/search-results-nodes.js +302 -0
  111. package/src/lib/search-results-nodes.js.map +1 -0
  112. package/src/lib/social-image-facts.d.ts +107 -0
  113. package/src/lib/social-image-facts.js +133 -0
  114. package/src/lib/social-image-facts.js.map +1 -0
  115. package/src/lib/stamp-form-dataset-bindings.d.ts +32 -0
  116. package/src/lib/stamp-form-dataset-bindings.js +59 -0
  117. package/src/lib/stamp-form-dataset-bindings.js.map +1 -0
  118. package/src/lib/template-screens.d.ts +227 -0
  119. package/src/lib/template-screens.js +364 -0
  120. package/src/lib/template-screens.js.map +1 -0
@@ -0,0 +1,443 @@
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
+ */ /**
18
+ * A lead becomes a contact, and optionally a company and a deal (AGL-2608,
19
+ * AGL-2627).
20
+ *
21
+ * ## Why one function under two doors
22
+ *
23
+ * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`
24
+ * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two
25
+ * doors authenticate differently — an ID token against the site's role, an
26
+ * API key against its scopes — and answer in different envelopes, but what
27
+ * they DO has to be one thing: a conversion that opened a second deal for
28
+ * one person because two callers reached the same lead through two
29
+ * implementations is the bug this file exists to make impossible. The doors
30
+ * own their transport; this owns the writes.
31
+ *
32
+ * It lives here, in the tenancy runtime, rather than beside the plugin
33
+ * route it came from, because the console's REST layer may not import a
34
+ * feature plugin (the app→addons boundary), while both the plugin and the
35
+ * console may import the runtime — which already holds the capture door
36
+ * and the owner assignment this conversion goes through.
37
+ *
38
+ * ## Why a server function and not four client writes
39
+ *
40
+ * The lead is a host document the browser may edit, and a company or a deal
41
+ * is a client-creatable org document. What the browser cannot do is CREATE A
42
+ * CONTACT: the contacts collection is written only by `captureHostContact`
43
+ * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`
44
+ * fires), because that function is the dedupe — one human, one row, found by
45
+ * normalized address across every site in the org — and the audience-band
46
+ * gate. A client that wrote a contact document of its own would mint a
47
+ * second row for a person the org already holds, and would do it past the
48
+ * band.
49
+ *
50
+ * Once the contact has to come from the server, the rest follows it: the
51
+ * conversion is one act with four writes, and a caller that made three of
52
+ * them and lost the connection before the fourth leaves a lead marked
53
+ * qualified with no contact, or a deal pointing at a contact that was never
54
+ * stamped on the lead. Here the order is fixed — contact, then company, then
55
+ * deal, then the lead — and the lead is stamped LAST, so a lead that carries
56
+ * `convertedContactId` names a contact that exists.
57
+ *
58
+ * ## Idempotent on the lead
59
+ *
60
+ * A lead already carrying `convertedContactId` answers with the ids it has
61
+ * and creates nothing more. A double-click on the dialog's button, a retry
62
+ * after a slow response, or a Zapier zap replaying a step must not open a
63
+ * second deal for one person.
64
+ *
65
+ * ## The actor
66
+ *
67
+ * A signed-in member converting from the console owns what nobody else
68
+ * claimed: their uid is the owner of last resort, written directly rather
69
+ * than through the deliberate reassignment, because they may be staff
70
+ * converting on a workspace's behalf and a roster check would refuse the one
71
+ * person who is actually here. An API key is not a person and is on no
72
+ * roster, so for an `api` actor that last step is skipped and a contact
73
+ * nobody chose an owner for — and no rule assigned — stays unowned, which
74
+ * the console renders honestly as unassigned. Either way the caller has
75
+ * already been authorized by its door; nothing here asks again.
76
+ */ import { consentGroupScope, CRM_COLLECTIONS, crmScopeTokens, DEFAULT_DEAL_STAGES, nameSearchFields, contactFacetPath, normalizeContactEmail, ORG_SCOPE_TOKEN, readContactFacet } from "@aglyn/aglyn/server";
77
+ import { consentGroupForSite, crmRecordsQuotaForOrg, findContactByEmail, logHostActivity, orgDataCollectionForHost, writeContactCompanyLink } from "@aglyn/tenant-data-admin";
78
+ import { FieldPath, FieldValue } from "firebase-admin/firestore";
79
+ import { assignOwnerForCapture, notifyRecordAssigned } from "./assign-contact-owner.js";
80
+ import { captureHostContact } from "./capture-host-contact.js";
81
+ /**
82
+ * The stage a new deal opens in.
83
+ *
84
+ * The caller's choice when the pipeline has it, otherwise the first OPEN
85
+ * stage by order — a deal created by converting a lead is by definition not
86
+ * yet won or lost, so defaulting to a closed stage would record an outcome
87
+ * nobody reached. A pipeline with no open stage at all falls back to its
88
+ * first stage rather than refusing: the merchant edited their pipeline into
89
+ * that shape, and a conversion that failed because of it would read as a bug
90
+ * in the lead.
91
+ */ export function stageForNewDeal(pipeline, requestedStageId) {
92
+ var _pipeline_stages, _ref;
93
+ const stages = [
94
+ ...(_pipeline_stages = pipeline.stages) != null ? _pipeline_stages : []
95
+ ].sort((a, b)=>a.order - b.order);
96
+ if (!stages.length) return null;
97
+ const requested = requestedStageId ? stages.find((stage)=>stage.id === requestedStageId) : undefined;
98
+ return (_ref = requested != null ? requested : stages.find((stage)=>stage.kind === 'open')) != null ? _ref : stages[0];
99
+ }
100
+ /** A stage's kind as the deal's denormalized status. */ function dealStatusForStage(stage) {
101
+ return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open';
102
+ }
103
+ /**
104
+ * Convert one lead. Throws only on infrastructure failure; every refusal
105
+ * the data can produce is a `ConvertHostLeadResult`.
106
+ */ export async function convertHostLead(input) {
107
+ var _input_companyId, _leadSnapshot_data, _input_ownerUid, _contactSnapshot_data, _readContactFacet_ownerUid, _actor_email, _lead_name;
108
+ const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input;
109
+ const requestedCompanyId = String((_input_companyId = input.companyId) != null ? _input_companyId : '').trim();
110
+ const leadRef = firestore.collection('hosts').doc(hostId).collection('leads').doc(leadId);
111
+ const leadSnapshot = await leadRef.get();
112
+ if (!leadSnapshot.exists) return {
113
+ ok: false,
114
+ reason: 'unknown-lead'
115
+ };
116
+ const lead = (_leadSnapshot_data = leadSnapshot.data()) != null ? _leadSnapshot_data : {};
117
+ if (lead.convertedContactId) {
118
+ return _extends({
119
+ ok: true,
120
+ contactId: lead.convertedContactId
121
+ }, lead.companyId ? {
122
+ companyId: lead.companyId
123
+ } : {}, lead.dealId ? {
124
+ dealId: lead.dealId
125
+ } : {}, {
126
+ alreadyConverted: true
127
+ });
128
+ }
129
+ const email = normalizeContactEmail(lead['email']);
130
+ if (!email) return {
131
+ ok: false,
132
+ reason: 'no-email'
133
+ };
134
+ const group = await consentGroupForSite(hostId, org);
135
+ const visibleTo = crmScopeTokens(org, group);
136
+ /*
137
+ * What the CALLER may read, for the company-by-domain reuse below: their
138
+ * group's own tokens plus `org`, exactly the set the console's listeners
139
+ * filter on. A company outside it is one they could not open, so linking
140
+ * a contact to it would point at a record the page then 404s on.
141
+ */ const readableTokens = new Set([
142
+ ORG_SCOPE_TOKEN,
143
+ ...consentGroupScope(group)
144
+ ]);
145
+ /*
146
+ * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else
147
+ * whoever was already working the lead. Handed to the capture as the
148
+ * facet's owner, which is what tells the capture's own assignment pass
149
+ * to stand down — a person's choice outranks a rule. When nobody chose,
150
+ * the facet names no owner and the pass runs the org's rules and the
151
+ * site's default for a contact it creates (AGL-2618).
152
+ */ const pickedOwner = String((_input_ownerUid = input.ownerUid) != null ? _input_ownerUid : '').trim();
153
+ const chosenOwner = pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '');
154
+ const now = Date.now();
155
+ const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined;
156
+ /*==========================================
157
+ * 1. THE CONTACT — through the one door that dedupes and meters.
158
+ *
159
+ * `source: 'manual'` because a person converted this lead by hand, and
160
+ * the source filter should say so; the interaction names the lead so the
161
+ * contact's timeline can be walked back to what was captured. The facet
162
+ * carries the stage and the owner, which is what makes this a sales
163
+ * record rather than another form capture.
164
+ *=========================================*/ const captured = await captureHostContact(_extends({
165
+ hostId,
166
+ email
167
+ }, leadName ? {
168
+ name: leadName
169
+ } : {}, {
170
+ source: 'manual',
171
+ interaction: {
172
+ summary: 'Converted from a lead',
173
+ refId: leadId
174
+ },
175
+ facet: _extends({
176
+ lifecycleStage: 'sales-qualified'
177
+ }, chosenOwner ? {
178
+ ownerUid: chosenOwner
179
+ } : {})
180
+ }));
181
+ /*
182
+ * The one verdict of the door's that is named here. Its other refusals —
183
+ * the band, an address it will not take, its own swallowed error — all
184
+ * read the same from this side: no row to find below, answered as
185
+ * `contact-not-created`. An erasure is different in kind: not a limit the
186
+ * org can lift or a fault in the request but a decision the workspace made
187
+ * about this person, and a caller told "the band may be full" would retry
188
+ * against it forever.
189
+ */ if (captured && 'refused' in captured && captured.refused === 'erased') {
190
+ return {
191
+ ok: false,
192
+ reason: 'erased'
193
+ };
194
+ }
195
+ const contactsRef = await orgDataCollectionForHost(hostId, 'contacts');
196
+ /*
197
+ * The same resolution the capture door just made (AGL-2625): the
198
+ * address index first, so a lead whose address is an ALTERNATE on a
199
+ * merged contact converts into the survivor the capture landed on
200
+ * rather than reading as a contact the band dropped.
201
+ */ const contactSnapshot = await findContactByEmail(contactsRef, email);
202
+ if (!contactSnapshot) return {
203
+ ok: false,
204
+ reason: 'contact-not-created'
205
+ };
206
+ const contactRef = contactSnapshot.ref;
207
+ const contactId = contactSnapshot.id;
208
+ const contactData = (_contactSnapshot_data = contactSnapshot.data()) != null ? _contactSnapshot_data : {};
209
+ const orgRef = firestore.collection('orgs').doc(orgId);
210
+ /*
211
+ * THE RECORDS BAND (AGL-2611), asked before each record this conversion
212
+ * CREATES. The contact went through the capture door, which refuses at
213
+ * the band on its own; a company and a deal are records of the same
214
+ * band, and on a Free org at its hundred the honest answer is the one
215
+ * the drawers give — refuse, write nothing more, and leave the lead
216
+ * unconverted so a retry after the upgrade finds it where it was. The
217
+ * contact step 1 captured stays: it is one person's record, and a second
218
+ * conversion merges onto it rather than making another. Measured fresh
219
+ * each time because the company created in step 2 is itself a record.
220
+ */ const bandFull = async ()=>{
221
+ const room = await crmRecordsQuotaForOrg(org, orgRef);
222
+ return !room.allowed;
223
+ };
224
+ /*
225
+ * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen
226
+ * owner, or its pass assigned one to a contact it created; a contact
227
+ * the org already held with no owner has had neither, so the same pass
228
+ * is asked once more — it touches only a record with no owner and
229
+ * answers `unchanged` for one that has — and a member actor is the last
230
+ * resort. A colleague the converter picked is told; the lead's existing
231
+ * owner and the caller are not, having chosen for themselves.
232
+ */ let ownerUid = (_readContactFacet_ownerUid = readContactFacet(contactData, group.groupId).ownerUid) != null ? _readContactFacet_ownerUid : '';
233
+ if (!ownerUid) {
234
+ const assigned = await assignOwnerForCapture({
235
+ hostId,
236
+ contactId,
237
+ email,
238
+ source: 'manual',
239
+ actorUid: actor.kind === 'member' ? actor.uid : null
240
+ });
241
+ if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid;
242
+ }
243
+ if (!ownerUid && actor.kind === 'member') {
244
+ ownerUid = actor.uid;
245
+ await contactRef.update({
246
+ [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,
247
+ updatedAt: FieldValue.serverTimestamp()
248
+ });
249
+ } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {
250
+ await notifyRecordAssigned({
251
+ hostId,
252
+ orgId,
253
+ ownerUid,
254
+ actorUid: actor.kind === 'member' ? actor.uid : null,
255
+ record: {
256
+ kind: 'contact',
257
+ id: contactId
258
+ },
259
+ who: leadName || email
260
+ });
261
+ }
262
+ /*==========================================
263
+ * 2. THE COMPANY — linked, found by domain, or created.
264
+ *=========================================*/ let companyId;
265
+ if (requestedCompanyId) {
266
+ const companySnapshot = await orgRef.collection(CRM_COLLECTIONS.companies).doc(requestedCompanyId).get();
267
+ if (!companySnapshot.exists) return {
268
+ ok: false,
269
+ reason: 'unknown-company'
270
+ };
271
+ companyId = requestedCompanyId;
272
+ } else if (createCompany) {
273
+ if (createCompany.domain) {
274
+ /*
275
+ * FIND BEFORE CREATE. The domain is the key two contacts at one
276
+ * business share, and a second company document for `acme.com`
277
+ * would split the account the key exists to join. Only a company the
278
+ * caller can see counts as found — see `readableTokens`.
279
+ */ const byDomain = await orgRef.collection(CRM_COLLECTIONS.companies).where('domain', '==', createCompany.domain).limit(5).get();
280
+ const visible = byDomain.docs.find((snapshot)=>{
281
+ const tokens = snapshot.get('visibleTo');
282
+ return Array.isArray(tokens) && tokens.some((token)=>readableTokens.has(String(token)));
283
+ });
284
+ if (visible) companyId = visible.id;
285
+ }
286
+ if (!companyId) {
287
+ if (await bandFull()) return {
288
+ ok: false,
289
+ reason: 'band-full'
290
+ };
291
+ const created = await orgRef.collection(CRM_COLLECTIONS.companies).add(_extends({}, nameSearchFields(createCompany.name), createCompany.domain ? {
292
+ domain: createCompany.domain
293
+ } : {}, ownerUid ? {
294
+ ownerUid
295
+ } : {}, {
296
+ visibleTo,
297
+ hostId,
298
+ createdByUid: actor.uid,
299
+ createdAt: FieldValue.serverTimestamp(),
300
+ updatedAt: FieldValue.serverTimestamp()
301
+ }));
302
+ companyId = created.id;
303
+ }
304
+ }
305
+ if (companyId) {
306
+ /*
307
+ * The one link writer (AGL-2613): the holder's facet field the contact
308
+ * record reads, the queryable top-level mirror the company's contacts
309
+ * card filters on, and the company's contacts count — planned from
310
+ * the row as the capture left it, so a person the org already held
311
+ * under another company is MOVED rather than counted twice.
312
+ */ await writeContactCompanyLink({
313
+ firestore,
314
+ contactRef,
315
+ contact: contactData,
316
+ companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),
317
+ groupId: group.groupId,
318
+ companyId
319
+ });
320
+ }
321
+ /*==========================================
322
+ * 3. THE DEAL — in the default pipeline, seeded if the org has none.
323
+ *=========================================*/ let dealId;
324
+ if (deal) {
325
+ const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines);
326
+ let pipelineId;
327
+ let pipeline;
328
+ const defaults = await pipelinesRef.where('isDefault', '==', true).limit(1).get();
329
+ if (!defaults.empty) {
330
+ pipelineId = defaults.docs[0].id;
331
+ pipeline = defaults.docs[0].data();
332
+ } else {
333
+ /*
334
+ * The deals section's own read shape, so the two agree on which
335
+ * pipeline "the org's pipeline" is when none is flagged default: the
336
+ * first by document id of a bounded window. Seeding a SECOND pipeline
337
+ * here while one exists unflagged would leave the merchant with two
338
+ * boards and their deals split between them.
339
+ */ const any = await pipelinesRef.orderBy(FieldPath.documentId()).limit(20).get();
340
+ if (!any.empty) {
341
+ pipelineId = any.docs[0].id;
342
+ pipeline = any.docs[0].data();
343
+ } else {
344
+ // Exactly what the Deals section seeds: one `Sales` pipeline
345
+ // carrying a COPY of the default stages, flagged default.
346
+ const seeded = {
347
+ name: 'Sales',
348
+ stages: [
349
+ ...DEFAULT_DEAL_STAGES
350
+ ],
351
+ isDefault: true,
352
+ visibleTo,
353
+ hostId
354
+ };
355
+ const created = await pipelinesRef.add(_extends({}, seeded, {
356
+ createdByUid: actor.uid,
357
+ createdAt: FieldValue.serverTimestamp(),
358
+ updatedAt: FieldValue.serverTimestamp()
359
+ }));
360
+ pipelineId = created.id;
361
+ pipeline = seeded;
362
+ }
363
+ }
364
+ const stage = stageForNewDeal(pipeline, deal.stageId);
365
+ if (!stage) return {
366
+ ok: false,
367
+ reason: 'no-stages'
368
+ };
369
+ if (await bandFull()) return {
370
+ ok: false,
371
+ reason: 'band-full'
372
+ };
373
+ const created = await orgRef.collection(CRM_COLLECTIONS.deals).add(_extends({
374
+ title: deal.title,
375
+ titleLower: deal.title.toLowerCase(),
376
+ pipelineId,
377
+ stageId: stage.id,
378
+ status: dealStatusForStage(stage)
379
+ }, deal.amountCents !== null ? {
380
+ amountCents: deal.amountCents
381
+ } : {}, {
382
+ currency: deal.currency,
383
+ stageChangedAtMs: now
384
+ }, ownerUid ? {
385
+ ownerUid
386
+ } : {}, {
387
+ contactId
388
+ }, companyId ? {
389
+ companyId
390
+ } : {}, {
391
+ visibleTo,
392
+ hostId,
393
+ createdByUid: actor.uid,
394
+ createdAt: FieldValue.serverTimestamp(),
395
+ updatedAt: FieldValue.serverTimestamp()
396
+ }));
397
+ dealId = created.id;
398
+ }
399
+ /*==========================================
400
+ * 4. THE LEAD — stamped last, once everything it names exists.
401
+ *=========================================*/ const stamp = _extends({
402
+ status: 'qualified',
403
+ convertedContactId: contactId,
404
+ convertedAtMs: now
405
+ }, ownerUid ? {
406
+ ownerUid
407
+ } : {}, companyId ? {
408
+ companyId
409
+ } : {}, dealId ? {
410
+ dealId
411
+ } : {});
412
+ await leadRef.update(_extends({}, stamp, {
413
+ updatedAt: FieldValue.serverTimestamp()
414
+ }));
415
+ /*
416
+ * The audit line, from the function that did the work (AGL-2622): the
417
+ * conversion is one act however many records it opened, so it is one
418
+ * entry, on the lead, in the feed of the site that holds the lead. A
419
+ * repeat call answered above with the ids it already had wrote nothing
420
+ * and logs nothing.
421
+ */ await logHostActivity(hostId, _extends({
422
+ uid: actor.uid,
423
+ email: (_actor_email = actor.email) != null ? _actor_email : null
424
+ }, actor.apiKeyName ? {
425
+ apiKeyName: actor.apiKeyName
426
+ } : {}), 'Converted lead', {
427
+ type: 'lead',
428
+ id: leadId,
429
+ name: String((_lead_name = lead['name']) != null ? _lead_name : '') || email
430
+ });
431
+ return _extends({
432
+ ok: true,
433
+ contactId
434
+ }, companyId ? {
435
+ companyId
436
+ } : {}, dealId ? {
437
+ dealId
438
+ } : {}, {
439
+ alreadyConverted: false
440
+ });
441
+ }
442
+
443
+ //# sourceMappingURL=convert-host-lead.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/convert-host-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A lead becomes a contact, and optionally a company and a deal (AGL-2608,\n * AGL-2627).\n *\n * ## Why one function under two doors\n *\n * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`\n * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two\n * doors authenticate differently — an ID token against the site's role, an\n * API key against its scopes — and answer in different envelopes, but what\n * they DO has to be one thing: a conversion that opened a second deal for\n * one person because two callers reached the same lead through two\n * implementations is the bug this file exists to make impossible. The doors\n * own their transport; this owns the writes.\n *\n * It lives here, in the tenancy runtime, rather than beside the plugin\n * route it came from, because the console's REST layer may not import a\n * feature plugin (the app→addons boundary), while both the plugin and the\n * console may import the runtime — which already holds the capture door\n * and the owner assignment this conversion goes through.\n *\n * ## Why a server function and not four client writes\n *\n * The lead is a host document the browser may edit, and a company or a deal\n * is a client-creatable org document. What the browser cannot do is CREATE A\n * CONTACT: the contacts collection is written only by `captureHostContact`\n * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`\n * fires), because that function is the dedupe — one human, one row, found by\n * normalized address across every site in the org — and the audience-band\n * gate. A client that wrote a contact document of its own would mint a\n * second row for a person the org already holds, and would do it past the\n * band.\n *\n * Once the contact has to come from the server, the rest follows it: the\n * conversion is one act with four writes, and a caller that made three of\n * them and lost the connection before the fourth leaves a lead marked\n * qualified with no contact, or a deal pointing at a contact that was never\n * stamped on the lead. Here the order is fixed — contact, then company, then\n * deal, then the lead — and the lead is stamped LAST, so a lead that carries\n * `convertedContactId` names a contact that exists.\n *\n * ## Idempotent on the lead\n *\n * A lead already carrying `convertedContactId` answers with the ids it has\n * and creates nothing more. A double-click on the dialog's button, a retry\n * after a slow response, or a Zapier zap replaying a step must not open a\n * second deal for one person.\n *\n * ## The actor\n *\n * A signed-in member converting from the console owns what nobody else\n * claimed: their uid is the owner of last resort, written directly rather\n * than through the deliberate reassignment, because they may be staff\n * converting on a workspace's behalf and a roster check would refuse the one\n * person who is actually here. An API key is not a person and is on no\n * roster, so for an `api` actor that last step is skipped and a contact\n * nobody chose an owner for — and no rule assigned — stays unowned, which\n * the console renders honestly as unassigned. Either way the caller has\n * already been authorized by its door; nothing here asks again.\n */\n\nimport {\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { assignOwnerForCapture, notifyRecordAssigned } from './assign-contact-owner'\nimport { captureHostContact } from './capture-host-contact'\n\n/** Who is converting, as far as the writes need to know. */\nexport interface LeadConvertActor {\n /**\n * What `createdByUid` records on every record the conversion opens, and\n * who the audit line names. A member's uid, or the literal `'api'` for a\n * key — the same attribution the REST creates stamp.\n */\n uid: string\n email?: string | null\n /**\n * `member`: a signed-in person, who becomes the owner when nobody else\n * does. `api`: a key, which cannot own a record — see the header.\n */\n kind: 'member' | 'api'\n /**\n * For an `api` actor, the key's name — what the audit line attributes the\n * conversion to, since a key has no address to be named by.\n */\n apiKeyName?: string | null\n}\n\n/** What a conversion is asked to do. Validated by the door before it gets here. */\nexport interface ConvertHostLeadInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orgId: string\n /** The org document, as the door resolved it. */\n org: Record<string, unknown>\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n actor: LeadConvertActor\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those a member actor — somebody\n * converted this person, and a record with no owner is one nobody follows\n * up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. Already normalized. */\n createCompany?: { name: string; domain: string | null } | null\n /** Open a deal in the org's default pipeline. Already normalized. */\n deal?: {\n title: string\n amountCents: number | null\n /** Lowercase ISO 4217. */\n currency: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n } | null\n}\n\n/**\n * Why a conversion did not happen. Each is a state of the data, not a fault\n * of the request — the door has already refused a malformed body — and each\n * leaves the lead unconverted so a retry after the remedy finds it where it\n * was.\n */\nexport type ConvertHostLeadRefusal =\n /** No document at `hosts/{hostId}/leads/{leadId}`. */\n | 'unknown-lead'\n /** The lead's address cannot become a contact. */\n | 'no-email'\n /**\n * The capture door produced no contact — it swallows its own failures and\n * drops a creation past a Free org's audience band. Nothing was written.\n */\n | 'contact-not-created'\n /**\n * The site holds an erasure for the lead's address (AGL-2623): the person\n * asked to be forgotten, and the capture door refuses to rebuild a record\n * for them — by a conversion as by a form. Nothing was written, and the\n * lead stays as it was; the erasure removes it when it runs.\n */\n | 'erased'\n /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */\n | 'band-full'\n /** `companyId` names no company in this organization. The contact stands. */\n | 'unknown-company'\n /** The default pipeline has no stages to open a deal in. The contact stands. */\n | 'no-stages'\n\nexport type ConvertHostLeadResult =\n | {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n }\n | { ok: false; reason: ConvertHostLeadRefusal }\n\n/** The stamp the conversion leaves on the lead — see `CrmLeadFields`. */\ntype LeadConversionStamp = Required<\n Pick<CrmLeadFields, 'status' | 'convertedContactId' | 'convertedAtMs'>\n> &\n Pick<CrmLeadFields, 'dealId' | 'companyId' | 'ownerUid'>\n\n/**\n * The stage a new deal opens in.\n *\n * The caller's choice when the pipeline has it, otherwise the first OPEN\n * stage by order — a deal created by converting a lead is by definition not\n * yet won or lost, so defaulting to a closed stage would record an outcome\n * nobody reached. A pipeline with no open stage at all falls back to its\n * first stage rather than refusing: the merchant edited their pipeline into\n * that shape, and a conversion that failed because of it would read as a bug\n * in the lead.\n */\nexport function stageForNewDeal(\n pipeline: Pick<CrmPipeline, 'stages'>,\n requestedStageId: string | undefined,\n): CrmDealStage | null {\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n if (!stages.length) return null\n const requested = requestedStageId\n ? stages.find((stage) => stage.id === requestedStageId)\n : undefined\n return requested ?? stages.find((stage) => stage.kind === 'open') ?? stages[0]\n}\n\n/** A stage's kind as the deal's denormalized status. */\nfunction dealStatusForStage(stage: CrmDealStage): CrmDealStatus {\n return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n}\n\n/**\n * Convert one lead. Throws only on infrastructure failure; every refusal\n * the data can produce is a `ConvertHostLeadResult`.\n */\nexport async function convertHostLead(\n input: ConvertHostLeadInput,\n): Promise<ConvertHostLeadResult> {\n const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input\n const requestedCompanyId = String(input.companyId ?? '').trim()\n const leadRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(leadId)\n const leadSnapshot = await leadRef.get()\n if (!leadSnapshot.exists) return { ok: false, reason: 'unknown-lead' }\n const lead = (leadSnapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId) {\n return {\n ok: true,\n contactId: lead.convertedContactId,\n ...(lead.companyId ? { companyId: lead.companyId } : {}),\n ...(lead.dealId ? { dealId: lead.dealId } : {}),\n alreadyConverted: true,\n }\n }\n const email = normalizeContactEmail(lead['email'])\n if (!email) return { ok: false, reason: 'no-email' }\n\n const group = await consentGroupForSite(hostId, org)\n const visibleTo = crmScopeTokens(org, group)\n /*\n * What the CALLER may read, for the company-by-domain reuse below: their\n * group's own tokens plus `org`, exactly the set the console's listeners\n * filter on. A company outside it is one they could not open, so linking\n * a contact to it would point at a record the page then 404s on.\n */\n const readableTokens = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...consentGroupScope(group),\n ])\n /*\n * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else\n * whoever was already working the lead. Handed to the capture as the\n * facet's owner, which is what tells the capture's own assignment pass\n * to stand down — a person's choice outranks a rule. When nobody chose,\n * the facet names no owner and the pass runs the org's rules and the\n * site's default for a contact it creates (AGL-2618).\n */\n const pickedOwner = String(input.ownerUid ?? '').trim()\n const chosenOwner =\n pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '')\n const now = Date.now()\n const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined\n\n /*==========================================\n * 1. THE CONTACT — through the one door that dedupes and meters.\n *\n * `source: 'manual'` because a person converted this lead by hand, and\n * the source filter should say so; the interaction names the lead so the\n * contact's timeline can be walked back to what was captured. The facet\n * carries the stage and the owner, which is what makes this a sales\n * record rather than another form capture.\n *=========================================*/\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {\n await notifyRecordAssigned({\n hostId,\n orgId,\n ownerUid,\n actorUid: actor.kind === 'member' ? actor.uid : null,\n record: { kind: 'contact', id: contactId },\n who: leadName || email,\n })\n }\n\n /*==========================================\n * 2. THE COMPANY — linked, found by domain, or created.\n *=========================================*/\n let companyId: string | undefined\n if (requestedCompanyId) {\n const companySnapshot = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(requestedCompanyId)\n .get()\n if (!companySnapshot.exists) return { ok: false, reason: 'unknown-company' }\n companyId = requestedCompanyId\n } else if (createCompany) {\n if (createCompany.domain) {\n /*\n * FIND BEFORE CREATE. The domain is the key two contacts at one\n * business share, and a second company document for `acme.com`\n * would split the account the key exists to join. Only a company the\n * caller can see counts as found — see `readableTokens`.\n */\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n const visible = byDomain.docs.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })\n if (visible) companyId = visible.id\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...nameSearchFields(createCompany.name),\n ...(createCompany.domain ? { domain: createCompany.domain } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n companyId = created.id\n }\n }\n if (companyId) {\n /*\n * The one link writer (AGL-2613): the holder's facet field the contact\n * record reads, the queryable top-level mirror the company's contacts\n * card filters on, and the company's contacts count — planned from\n * the row as the capture left it, so a person the org already held\n * under another company is MOVED rather than counted twice.\n */\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: contactData,\n companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),\n groupId: group.groupId,\n companyId,\n })\n }\n\n /*==========================================\n * 3. THE DEAL — in the default pipeline, seeded if the org has none.\n *=========================================*/\n let dealId: string | undefined\n if (deal) {\n const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines)\n let pipelineId: string\n let pipeline: Pick<CrmPipeline, 'stages'>\n const defaults = await pipelinesRef\n .where('isDefault', '==', true)\n .limit(1)\n .get()\n if (!defaults.empty) {\n pipelineId = defaults.docs[0].id\n pipeline = defaults.docs[0].data() as CrmPipeline\n } else {\n /*\n * The deals section's own read shape, so the two agree on which\n * pipeline \"the org's pipeline\" is when none is flagged default: the\n * first by document id of a bounded window. Seeding a SECOND pipeline\n * here while one exists unflagged would leave the merchant with two\n * boards and their deals split between them.\n */\n const any = await pipelinesRef\n .orderBy(FieldPath.documentId())\n .limit(20)\n .get()\n if (!any.empty) {\n pipelineId = any.docs[0].id\n pipeline = any.docs[0].data() as CrmPipeline\n } else {\n // Exactly what the Deals section seeds: one `Sales` pipeline\n // carrying a COPY of the default stages, flagged default.\n const seeded: Omit<CrmPipeline, 'createdAt' | 'updatedAt'> = {\n name: 'Sales',\n stages: [...DEFAULT_DEAL_STAGES],\n isDefault: true,\n visibleTo,\n hostId,\n }\n const created = await pipelinesRef.add({\n ...seeded,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n pipelineId = created.id\n pipeline = seeded\n }\n }\n const stage = stageForNewDeal(pipeline, deal.stageId)\n if (!stage) return { ok: false, reason: 'no-stages' }\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n title: deal.title,\n titleLower: deal.title.toLowerCase(),\n pipelineId,\n stageId: stage.id,\n status: dealStatusForStage(stage),\n ...(deal.amountCents !== null ? { amountCents: deal.amountCents } : {}),\n currency: deal.currency,\n stageChangedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n contactId,\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n dealId = created.id\n }\n\n /*==========================================\n * 4. THE LEAD — stamped last, once everything it names exists.\n *=========================================*/\n const stamp: LeadConversionStamp = {\n status: 'qualified',\n convertedContactId: contactId,\n convertedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n }\n await leadRef.update({ ...stamp, updatedAt: FieldValue.serverTimestamp() })\n\n /*\n * The audit line, from the function that did the work (AGL-2622): the\n * conversion is one act however many records it opened, so it is one\n * entry, on the lead, in the feed of the site that holds the lead. A\n * repeat call answered above with the ids it already had wrote nothing\n * and logs nothing.\n */\n await logHostActivity(\n hostId,\n {\n uid: actor.uid,\n email: actor.email ?? null,\n ...(actor.apiKeyName ? { apiKeyName: actor.apiKeyName } : {}),\n },\n 'Converted lead',\n { type: 'lead', id: leadId, name: String(lead['name'] ?? '') || email },\n )\n\n return {\n ok: true,\n contactId,\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n alreadyConverted: false,\n }\n}\n"],"names":["consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","contactSnapshot","actor","lead","firestore","hostId","orgId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","trim","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","Set","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","captured","name","source","interaction","summary","refId","facet","lifecycleStage","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","domain","byDomain","where","limit","visible","docs","snapshot","tokens","Array","isArray","some","token","has","created","add","createdByUid","createdAt","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","deals","title","titleLower","toLowerCase","status","amountCents","currency","stageChangedAtMs","stamp","convertedAtMs","apiKeyName","type"],"mappings":"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"}
@@ -0,0 +1,58 @@
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
+ * Withholds the node definitions inside lazy tab panels that will not mount on
19
+ * first render (AGL-1285).
20
+ *
21
+ * Lazy mounting (AGL-1283) already stops a Tabs container rendering anything
22
+ * but the selected panel. The document did not follow: every panel's nodes were
23
+ * still serialized into the page. On `/pricing` that is 1,489 of 2,523 nodes —
24
+ * eight copies of a 50-row table — and the RSC payload is 63% of the page's
25
+ * gzipped weight, so the render knowing they are unneeded was worth nothing.
26
+ *
27
+ * This prunes them server-side. The panel node stays (its tab, its aria wiring
28
+ * and its `label` are all still needed); only its subtree is withheld, and the
29
+ * panel is marked so the client can fetch the rest when the reader first opens
30
+ * one.
31
+ *
32
+ * Lazy is the DEFAULT (AGL-1283 option 3), so this prunes every multi-panel
33
+ * Tabs unless the author set `ssrPanels` — the same predicate the client
34
+ * mount uses (`tabs.tsx`), and the two MUST agree: a panel this function
35
+ * withholds that the client would render arrives empty, and a panel it ships
36
+ * that the client defers is payload for nothing. The legacy `lazyPanels`
37
+ * opt-in is subsumed by the default and no longer consulted.
38
+ *
39
+ * WHICH panel survives follows the author's `opensOn` (AGL-3164) by the same
40
+ * rule the element applies, for the same reason: the tab that opens first is
41
+ * the one whose panel has to be in the page source, or the page paints an
42
+ * empty panel and fills it after a round trip.
43
+ *
44
+ * SEO note: withheld content is not in the HTML. Where a panel holds content
45
+ * that appears nowhere else on the page and must reach crawlers, `ssrPanels`
46
+ * is the escape hatch — it keeps the panels in the payload AND in the markup.
47
+ */
48
+ /** Marks a panel whose children were withheld. */
49
+ export declare const DEFERRED_PANEL_PROP = "aglynDeferred";
50
+ export interface DeferLazyPanelsResult {
51
+ /** A NEW node map; the input is never mutated. */
52
+ nodes: Record<string, any>;
53
+ /** Panel node ids whose children were withheld. */
54
+ deferredPanelIds: string[];
55
+ /** Node definitions removed. */
56
+ removed: number;
57
+ }
58
+ export declare function deferLazyPanelNodes(nodes: Record<string, any> | null | undefined): DeferLazyPanelsResult;