@aglyn/tenant-runtime 1.0.0-beta.218 → 1.0.0-beta.219

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.
@@ -1,518 +0,0 @@
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 { consentGroupDisclosureKey, consentGroupScope, CRM_COLLECTIONS, crmScopeTokens, DEFAULT_DEAL_STAGES, grantedUnderConsentGroup, nameSearchFields, contactFacetPath, normalizeContactEmail, ORG_SCOPE_TOKEN, readContactFacet, crmNewRecordListFields, readMarketingBasis, soloConsentGroup } from "@aglyn/aglyn/server";
77
- import { consentGroupForSite, crmRecordsQuotaForOrg, findContactByEmail, logHostActivity, orgDataCollectionForHost, restampCrmListFieldsAt, 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
- import { handOffLeadRecords } from "./hand-off-lead.js";
82
- /**
83
- * The stage a new deal opens in.
84
- *
85
- * The caller's choice when the pipeline has it, otherwise the first OPEN
86
- * stage by order — a deal created by converting a lead is by definition not
87
- * yet won or lost, so defaulting to a closed stage would record an outcome
88
- * nobody reached. A pipeline with no open stage at all falls back to its
89
- * first stage rather than refusing: the merchant edited their pipeline into
90
- * that shape, and a conversion that failed because of it would read as a bug
91
- * in the lead.
92
- */ export function stageForNewDeal(pipeline, requestedStageId) {
93
- var _pipeline_stages, _ref;
94
- const stages = [
95
- ...(_pipeline_stages = pipeline.stages) != null ? _pipeline_stages : []
96
- ].sort((a, b)=>a.order - b.order);
97
- if (!stages.length) return null;
98
- const requested = requestedStageId ? stages.find((stage)=>stage.id === requestedStageId) : undefined;
99
- return (_ref = requested != null ? requested : stages.find((stage)=>stage.kind === 'open')) != null ? _ref : stages[0];
100
- }
101
- /** A stage's kind as the deal's denormalized status. */ function dealStatusForStage(stage) {
102
- return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open';
103
- }
104
- /**
105
- * Convert one lead. Throws only on infrastructure failure; every refusal
106
- * the data can produce is a `ConvertHostLeadResult`.
107
- */ export async function convertHostLead(input) {
108
- var _input_companyId, _leadSnapshot_data, _input_ownerUid, _actor_email, _actor_apiKeyName, _contactSnapshot_data, _readContactFacet_ownerUid, _actor_email1, _lead_name;
109
- const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input;
110
- const requestedCompanyId = String((_input_companyId = input.companyId) != null ? _input_companyId : '').trim();
111
- // The lead's one home, on the org (AGL-3275). `orgId` is already resolved
112
- // by the caller, so this needs no second lookup to find it.
113
- const leadRef = firestore.collection('orgs').doc(orgId).collection('leads').doc(leadId);
114
- const leadSnapshot = await leadRef.get();
115
- if (!leadSnapshot.exists) return {
116
- ok: false,
117
- reason: 'unknown-lead'
118
- };
119
- const lead = (_leadSnapshot_data = leadSnapshot.data()) != null ? _leadSnapshot_data : {};
120
- if (lead.convertedContactId) {
121
- return _extends({
122
- ok: true,
123
- contactId: lead.convertedContactId
124
- }, lead.companyId ? {
125
- companyId: lead.companyId
126
- } : {}, lead.dealId ? {
127
- dealId: lead.dealId
128
- } : {}, {
129
- alreadyConverted: true
130
- });
131
- }
132
- const email = normalizeContactEmail(lead['email']);
133
- if (!email) return {
134
- ok: false,
135
- reason: 'no-email'
136
- };
137
- const group = await consentGroupForSite(hostId, org);
138
- const visibleTo = crmScopeTokens(org, group);
139
- /*
140
- * What the CALLER may read, for the company-by-domain reuse below: their
141
- * group's own tokens plus `org`, exactly the set the console's listeners
142
- * filter on. A company outside it is one they could not open, so linking
143
- * a contact to it would point at a record the page then 404s on.
144
- */ const readableTokens = new Set([
145
- ORG_SCOPE_TOKEN,
146
- ...consentGroupScope(group)
147
- ]);
148
- /*
149
- * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else
150
- * whoever was already working the lead. Handed to the capture as the
151
- * facet's owner, which is what tells the capture's own assignment pass
152
- * to stand down — a person's choice outranks a rule. When nobody chose,
153
- * the facet names no owner and the pass runs the org's rules and the
154
- * site's default for a contact it creates (AGL-2618).
155
- */ const pickedOwner = String((_input_ownerUid = input.ownerUid) != null ? _input_ownerUid : '').trim();
156
- const chosenOwner = pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '');
157
- const now = Date.now();
158
- const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined;
159
- /*==========================================
160
- * 1. THE CONTACT — through the one door that dedupes and meters.
161
- *
162
- * `source: 'manual'` because a person converted this lead by hand, and
163
- * the source filter should say so; the interaction names the lead so the
164
- * contact's timeline can be walked back to what was captured. The facet
165
- * carries the stage and the owner, which is what makes this a sales
166
- * record rather than another form capture.
167
- *
168
- * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce
169
- * hands a lead's fields to the contact: phone, title, lead source, address, the
170
- * company as text (until step 2 links a record, whose own name then
171
- * replaces it), tags, and the marketing basis the lead recorded — the
172
- * person ticked the box on the lead, and a conversion must not be the
173
- * moment they lose it. Only what the lead holds is handed over; a value
174
- * the contact already carries is the contact's, because the capture door
175
- * writes a profile field only where the facet has none.
176
- *=========================================*/ const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : [];
177
- const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId));
178
- /*
179
- * WHICH SITES THE CONTACT'S GRANT COVERS (AGL-3320): the lead's, never
180
- * more. The capture door records a fresh grant, so it is handed the group's
181
- * disclosure key only when the lead ALREADY holds a grant given under the
182
- * group as it stands, at every site it names — a lead captured by one site
183
- * before the group existed, or before a site joined, converts to a contact
184
- * that site alone may mail, exactly as the lead was.
185
- */ const leadDisclosure = grantedUnderConsentGroup(lead, group) ? consentGroupDisclosureKey(group) : null;
186
- const captured = await captureHostContact(_extends({
187
- hostId,
188
- email
189
- }, leadName ? {
190
- name: leadName
191
- } : {}, {
192
- source: 'manual',
193
- interaction: {
194
- summary: 'Converted from a lead',
195
- refId: leadId
196
- },
197
- // Who converted it, for the runs its contactCreated sets off (AGL-3376).
198
- actor: actor.kind === 'member' ? {
199
- kind: 'member',
200
- uid: actor.uid,
201
- email: (_actor_email = actor.email) != null ? _actor_email : null
202
- } : {
203
- kind: 'apiKey',
204
- apiKeyName: (_actor_apiKeyName = actor.apiKeyName) != null ? _actor_apiKeyName : null
205
- }
206
- }, leadConsent.basis === 'granted' ? {
207
- marketingConsent: true
208
- } : {}, leadConsent.basis === 'granted' && leadDisclosure ? {
209
- disclosedConsentGroup: leadDisclosure
210
- } : {}, leadTags.length ? {
211
- tags: leadTags
212
- } : {}, {
213
- facet: _extends({
214
- lifecycleStage: 'sales-qualified'
215
- }, chosenOwner ? {
216
- ownerUid: chosenOwner
217
- } : {}, typeof lead.phone === 'string' && lead.phone ? {
218
- phone: lead.phone
219
- } : {}, typeof lead.jobTitle === 'string' && lead.jobTitle ? {
220
- jobTitle: lead.jobTitle
221
- } : {}, typeof lead.leadSource === 'string' && lead.leadSource ? {
222
- leadSource: lead.leadSource
223
- } : {}, lead.address && typeof lead.address === 'object' ? {
224
- address: lead.address
225
- } : {}, typeof lead.company === 'string' && lead.company ? {
226
- companyName: lead.company
227
- } : {})
228
- }));
229
- /*
230
- * The one verdict of the door's that is named here. Its other refusals —
231
- * the band, an address it will not take, its own swallowed error — all
232
- * read the same from this side: no row to find below, answered as
233
- * `contact-not-created`. An erasure is different in kind: not a limit the
234
- * org can lift or a fault in the request but a decision the workspace made
235
- * about this person, and a caller told "the band may be full" would retry
236
- * against it forever.
237
- */ if (captured && 'refused' in captured && captured.refused === 'erased') {
238
- return {
239
- ok: false,
240
- reason: 'erased'
241
- };
242
- }
243
- const contactsRef = await orgDataCollectionForHost(hostId, 'contacts');
244
- /*
245
- * The same resolution the capture door just made (AGL-2625): the
246
- * address index first, so a lead whose address is an ALTERNATE on a
247
- * merged contact converts into the survivor the capture landed on
248
- * rather than reading as a contact the band dropped.
249
- */ const contactSnapshot = await findContactByEmail(contactsRef, email);
250
- if (!contactSnapshot) return {
251
- ok: false,
252
- reason: 'contact-not-created'
253
- };
254
- const contactRef = contactSnapshot.ref;
255
- const contactId = contactSnapshot.id;
256
- const contactData = (_contactSnapshot_data = contactSnapshot.data()) != null ? _contactSnapshot_data : {};
257
- const orgRef = firestore.collection('orgs').doc(orgId);
258
- /*
259
- * THE RECORDS BAND (AGL-2611), asked before each record this conversion
260
- * CREATES. The contact went through the capture door, which refuses at
261
- * the band on its own; a company and a deal are records of the same
262
- * band, and on a Free org at its hundred the honest answer is the one
263
- * the drawers give — refuse, write nothing more, and leave the lead
264
- * unconverted so a retry after the upgrade finds it where it was. The
265
- * contact step 1 captured stays: it is one person's record, and a second
266
- * conversion merges onto it rather than making another. Measured fresh
267
- * each time because the company created in step 2 is itself a record.
268
- */ const bandFull = async ()=>{
269
- const room = await crmRecordsQuotaForOrg(org, orgRef);
270
- return !room.allowed;
271
- };
272
- /*
273
- * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen
274
- * owner, or its pass assigned one to a contact it created; a contact
275
- * the org already held with no owner has had neither, so the same pass
276
- * is asked once more — it touches only a record with no owner and
277
- * answers `unchanged` for one that has — and a member actor is the last
278
- * resort. A colleague the converter picked is told; the lead's existing
279
- * owner and the caller are not, having chosen for themselves.
280
- */ let ownerUid = (_readContactFacet_ownerUid = readContactFacet(contactData, group.groupId).ownerUid) != null ? _readContactFacet_ownerUid : '';
281
- if (!ownerUid) {
282
- const assigned = await assignOwnerForCapture({
283
- hostId,
284
- contactId,
285
- email,
286
- source: 'manual',
287
- actorUid: actor.kind === 'member' ? actor.uid : null
288
- });
289
- if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid;
290
- }
291
- if (!ownerUid && actor.kind === 'member') {
292
- ownerUid = actor.uid;
293
- await contactRef.update({
294
- [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,
295
- updatedAt: FieldValue.serverTimestamp()
296
- });
297
- // The owner is what the Contacts list filters by (AGL-3321).
298
- await restampCrmListFieldsAt(contactRef, 'contacts');
299
- } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {
300
- await notifyRecordAssigned({
301
- hostId,
302
- orgId,
303
- ownerUid,
304
- actorUid: actor.kind === 'member' ? actor.uid : null,
305
- record: {
306
- kind: 'contact',
307
- id: contactId
308
- },
309
- who: leadName || email
310
- });
311
- }
312
- /*==========================================
313
- * 2. THE COMPANY — linked, found by domain, or created.
314
- *=========================================*/ let companyId;
315
- if (requestedCompanyId) {
316
- const companySnapshot = await orgRef.collection(CRM_COLLECTIONS.companies).doc(requestedCompanyId).get();
317
- if (!companySnapshot.exists) return {
318
- ok: false,
319
- reason: 'unknown-company'
320
- };
321
- companyId = requestedCompanyId;
322
- } else if (createCompany) {
323
- /*
324
- * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its
325
- * company as text, and a company the org already files under that
326
- * name is the account, whatever domain it carries. Then the domain,
327
- * the key two contacts at one business share — a second company
328
- * document for `acme.com` would split the account the key exists to
329
- * join. Only a company the caller can see counts as found — see
330
- * `readableTokens`.
331
- */ const visibleAmong = (snapshots)=>{
332
- var _snapshots_find;
333
- return (_snapshots_find = snapshots.find((snapshot)=>{
334
- const tokens = snapshot.get('visibleTo');
335
- return Array.isArray(tokens) && tokens.some((token)=>readableTokens.has(String(token)));
336
- })) == null ? void 0 : _snapshots_find.id;
337
- };
338
- const byName = await orgRef.collection(CRM_COLLECTIONS.companies).where('nameLower', '==', createCompany.name.toLowerCase()).limit(5).get();
339
- companyId = visibleAmong(byName.docs);
340
- if (!companyId && createCompany.domain) {
341
- const byDomain = await orgRef.collection(CRM_COLLECTIONS.companies).where('domain', '==', createCompany.domain).limit(5).get();
342
- companyId = visibleAmong(byDomain.docs);
343
- }
344
- if (!companyId) {
345
- if (await bandFull()) return {
346
- ok: false,
347
- reason: 'band-full'
348
- };
349
- const company = _extends({}, nameSearchFields(createCompany.name), createCompany.domain ? {
350
- domain: createCompany.domain
351
- } : {}, ownerUid ? {
352
- ownerUid
353
- } : {}, {
354
- visibleTo,
355
- hostId,
356
- createdByUid: actor.uid,
357
- createdAt: FieldValue.serverTimestamp(),
358
- updatedAt: FieldValue.serverTimestamp()
359
- });
360
- const created = await orgRef.collection(CRM_COLLECTIONS.companies).add(_extends({}, company, crmNewRecordListFields('companies', company)));
361
- companyId = created.id;
362
- }
363
- }
364
- if (companyId) {
365
- /*
366
- * The one link writer (AGL-2613): the holder's facet field the contact
367
- * record reads, the queryable top-level mirror the company's contacts
368
- * card filters on, and the company's contacts count — planned from
369
- * the row as the capture left it, so a person the org already held
370
- * under another company is MOVED rather than counted twice.
371
- */ await writeContactCompanyLink({
372
- firestore,
373
- contactRef,
374
- contact: contactData,
375
- companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),
376
- groupId: group.groupId,
377
- companyId
378
- });
379
- }
380
- /*==========================================
381
- * 3. THE DEAL — in the default pipeline, seeded if the org has none.
382
- *=========================================*/ let dealId;
383
- if (deal) {
384
- const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines);
385
- let pipelineId;
386
- let pipeline;
387
- const defaults = await pipelinesRef.where('isDefault', '==', true).limit(1).get();
388
- if (!defaults.empty) {
389
- pipelineId = defaults.docs[0].id;
390
- pipeline = defaults.docs[0].data();
391
- } else {
392
- /*
393
- * The deals section's own read shape, so the two agree on which
394
- * pipeline "the org's pipeline" is when none is flagged default: the
395
- * first by document id of a bounded window. Seeding a SECOND pipeline
396
- * here while one exists unflagged would leave the merchant with two
397
- * boards and their deals split between them.
398
- */ const any = await pipelinesRef.orderBy(FieldPath.documentId()).limit(20).get();
399
- if (!any.empty) {
400
- pipelineId = any.docs[0].id;
401
- pipeline = any.docs[0].data();
402
- } else {
403
- // Exactly what the Deals section seeds: one `Sales` pipeline
404
- // carrying a COPY of the default stages, flagged default.
405
- const seeded = {
406
- name: 'Sales',
407
- stages: [
408
- ...DEFAULT_DEAL_STAGES
409
- ],
410
- isDefault: true,
411
- visibleTo,
412
- hostId
413
- };
414
- const created = await pipelinesRef.add(_extends({}, seeded, {
415
- createdByUid: actor.uid,
416
- createdAt: FieldValue.serverTimestamp(),
417
- updatedAt: FieldValue.serverTimestamp()
418
- }));
419
- pipelineId = created.id;
420
- pipeline = seeded;
421
- }
422
- }
423
- const stage = stageForNewDeal(pipeline, deal.stageId);
424
- if (!stage) return {
425
- ok: false,
426
- reason: 'no-stages'
427
- };
428
- if (await bandFull()) return {
429
- ok: false,
430
- reason: 'band-full'
431
- };
432
- const dealRecord = _extends({
433
- title: deal.title,
434
- titleLower: deal.title.toLowerCase(),
435
- pipelineId,
436
- stageId: stage.id,
437
- status: dealStatusForStage(stage)
438
- }, deal.amountCents !== null ? {
439
- amountCents: deal.amountCents
440
- } : {}, {
441
- currency: deal.currency,
442
- stageChangedAtMs: now
443
- }, ownerUid ? {
444
- ownerUid
445
- } : {}, {
446
- contactId
447
- }, companyId ? {
448
- companyId
449
- } : {}, {
450
- visibleTo,
451
- hostId,
452
- createdByUid: actor.uid,
453
- createdAt: FieldValue.serverTimestamp(),
454
- updatedAt: FieldValue.serverTimestamp()
455
- });
456
- const created = await orgRef.collection(CRM_COLLECTIONS.deals).add(_extends({}, dealRecord, crmNewRecordListFields('deals', dealRecord)));
457
- dealId = created.id;
458
- }
459
- /*==========================================
460
- * 4. THE LEAD — stamped last, once everything it names exists.
461
- *=========================================*/ const stamp = _extends({
462
- status: 'qualified',
463
- convertedContactId: contactId,
464
- convertedAtMs: now
465
- }, ownerUid ? {
466
- ownerUid
467
- } : {}, companyId ? {
468
- companyId
469
- } : {}, dealId ? {
470
- dealId
471
- } : {});
472
- await leadRef.update(_extends({}, stamp, {
473
- updatedAt: FieldValue.serverTimestamp()
474
- }));
475
- /*
476
- * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the
477
- * contact, and every plugin with a listener — Sequences, for an
478
- * enrollment naming the lead — re-points its own records. After the
479
- * stamp, so a listener reading the lead finds the conversion on it; it
480
- * never throws.
481
- */ await handOffLeadRecords({
482
- firestore,
483
- orgId,
484
- hostId,
485
- leadId,
486
- contactId,
487
- email,
488
- by: actor.kind === 'member' ? 'member' : 'api'
489
- });
490
- /*
491
- * The audit line, from the function that did the work (AGL-2622): the
492
- * conversion is one act however many records it opened, so it is one
493
- * entry, on the lead, in the feed of the site that holds the lead. A
494
- * repeat call answered above with the ids it already had wrote nothing
495
- * and logs nothing.
496
- */ await logHostActivity(hostId, _extends({
497
- uid: actor.uid,
498
- email: (_actor_email1 = actor.email) != null ? _actor_email1 : null
499
- }, actor.apiKeyName ? {
500
- apiKeyName: actor.apiKeyName
501
- } : {}), 'Converted lead', {
502
- type: 'lead',
503
- id: leadId,
504
- name: String((_lead_name = lead['name']) != null ? _lead_name : '') || email
505
- });
506
- return _extends({
507
- ok: true,
508
- contactId
509
- }, companyId ? {
510
- companyId
511
- } : {}, dealId ? {
512
- dealId
513
- } : {}, {
514
- alreadyConverted: false
515
- });
516
- }
517
-
518
- //# sourceMappingURL=convert-host-lead.js.map
@@ -1 +0,0 @@
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 consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n grantedUnderConsentGroup,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n crmNewRecordListFields,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n restampCrmListFieldsAt,\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'\nimport { handOffLeadRecords } from './hand-off-lead'\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 // The lead's one home, on the org (AGL-3275). `orgId` is already resolved\n // by the caller, so this needs no second lookup to find it.\n const leadRef = firestore\n .collection('orgs')\n .doc(orgId)\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 * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce\n * hands a lead's fields to the contact: phone, title, lead source, address, the\n * company as text (until step 2 links a record, whose own name then\n * replaces it), tags, and the marketing basis the lead recorded — the\n * person ticked the box on the lead, and a conversion must not be the\n * moment they lose it. Only what the lead holds is handed over; a value\n * the contact already carries is the contact's, because the capture door\n * writes a profile field only where the facet has none.\n *=========================================*/\n const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : []\n const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId))\n /*\n * WHICH SITES THE CONTACT'S GRANT COVERS (AGL-3320): the lead's, never\n * more. The capture door records a fresh grant, so it is handed the group's\n * disclosure key only when the lead ALREADY holds a grant given under the\n * group as it stands, at every site it names — a lead captured by one site\n * before the group existed, or before a site joined, converts to a contact\n * that site alone may mail, exactly as the lead was.\n */\n const leadDisclosure = grantedUnderConsentGroup(lead, group)\n ? consentGroupDisclosureKey(group)\n : null\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 // Who converted it, for the runs its contactCreated sets off (AGL-3376).\n actor:\n actor.kind === 'member'\n ? { kind: 'member', uid: actor.uid, email: actor.email ?? null }\n : { kind: 'apiKey', apiKeyName: actor.apiKeyName ?? null },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadConsent.basis === 'granted' && leadDisclosure\n ? { disclosedConsentGroup: leadDisclosure }\n : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n // Salesforce's Lead Source travels to the contact (AGL-3298).\n ...(typeof lead.leadSource === 'string' && lead.leadSource\n ? { leadSource: lead.leadSource }\n : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\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 // The owner is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\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 /*\n * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its\n * company as text, and a company the org already files under that\n * name is the account, whatever domain it carries. Then the domain,\n * the key two contacts at one business share — a second company\n * document for `acme.com` would split the account the key exists to\n * join. Only a company the caller can see counts as found — see\n * `readableTokens`.\n */\n const visibleAmong = (\n snapshots: readonly FirebaseFirestore.QueryDocumentSnapshot[],\n ): string | undefined =>\n snapshots.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })?.id\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n companyId = visibleAmong(byName.docs)\n if (!companyId && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n companyId = visibleAmong(byDomain.docs)\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const company: Record<string, unknown> = {\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 const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...company,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', company),\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 dealRecord: Record<string, unknown> = {\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 const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n ...dealRecord,\n // What the Deals list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('deals', dealRecord),\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 * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the\n * contact, and every plugin with a listener — Sequences, for an\n * enrollment naming the lead — re-points its own records. After the\n * stamp, so a listener reading the lead finds the conversion on it; it\n * never throws.\n */\n await handOffLeadRecords({\n firestore,\n orgId,\n hostId,\n leadId,\n contactId,\n email,\n by: actor.kind === 'member' ? 'member' : 'api',\n })\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":["consentGroupDisclosureKey","consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","grantedUnderConsentGroup","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","crmNewRecordListFields","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","restampCrmListFieldsAt","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","handOffLeadRecords","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","actor","contactSnapshot","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","leadTags","Array","isArray","tags","map","leadConsent","leadDisclosure","captured","name","source","interaction","summary","refId","uid","apiKeyName","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","createdByUid","createdAt","created","add","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","dealRecord","title","titleLower","status","amountCents","currency","stageChangedAtMs","deals","stamp","convertedAtMs","by","type"],"mappings":"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"}
@@ -1,45 +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
- import * as Aglyn from '@aglyn/aglyn/server';
18
- /**
19
- * The datasets a page repeats over, with their records, for repeatable
20
- * expansion (AGL-103).
21
- *
22
- * `keys` are the page's repeat keys (`repeatDatasetKeys`), each a dataset id or
23
- * a display name — editors type the friendly name into the Repeat attribute.
24
- * The map answers under every key exactly as written, and under every loaded
25
- * dataset's id, which is how a reference hop (AGL-180) finds its target.
26
- * Records are editor-ordered and capped at the repeat bound.
27
- *
28
- * Only the datasets the page names are read. A site may see thousands of
29
- * datasets, and a read of "the site's datasets" can only ever be a bounded
30
- * page of them, so a repeat bound to one outside that page rendered its
31
- * template once with nothing to say why.
32
- *
33
- * Fail-open, per key: a key that cannot be read is left out, and its
34
- * repeatable renders its template untouched, without costing the others.
35
- *
36
- * Scoped to what THIS host may see (AGL-1039): a display name resolves inside
37
- * the host's scope, and an id outside it resolves to nothing. The Admin SDK
38
- * does not evaluate rules, so AGL-1041 does not cover this path; the filter
39
- * has to live here.
40
- */
41
- export declare function getDatasets(options: {
42
- hostId: string;
43
- keys: readonly string[];
44
- }): Promise<Record<string, Aglyn.RepeatableDataset>>;
45
- export default getDatasets;