@aglyn/plugins-crm 1.0.0-beta.164 → 1.0.0-beta.165
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contact-associations-card.js +14 -4
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-field-drawer.js +19 -2
- package/src/lib/components/contact-field-drawer.js.map +1 -1
- package/src/lib/components/fields-section.d.ts +2 -1
- package/src/lib/components/fields-section.js +6 -3
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +59 -0
- package/src/lib/components/lead-campaigns-card.js +160 -0
- package/src/lib/components/lead-campaigns-card.js.map +1 -0
- package/src/lib/components/lead-detail-page.js +53 -3
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-history-card.js +5 -0
- package/src/lib/components/lead-history-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.d.ts +12 -0
- package/src/lib/components/lead-properties-card.js +63 -5
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.d.ts +6 -0
- package/src/lib/components/leads-bulk-bar.js +34 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +16 -2
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +14 -1
- package/src/lib/components/new-lead-drawer.js +41 -3
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
- package/src/lib/hooks/use-campaign-filing-log.js +91 -0
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
- package/src/lib/model/campaign-filing-activity.d.ts +82 -0
- package/src/lib/model/campaign-filing-activity.js +88 -0
- package/src/lib/model/campaign-filing-activity.js.map +1 -0
- package/src/lib/server/campaign-filing-activity.d.ts +79 -0
- package/src/lib/server/campaign-filing-activity.js +93 -0
- package/src/lib/server/campaign-filing-activity.js.map +1 -0
- package/src/lib/server/lead-campaign-carry.js +25 -0
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +15 -4
- package/src/lib/server/lead-create.js +68 -10
- package/src/lib/server/lead-create.js.map +1 -1
|
@@ -44,11 +44,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
44
44
|
* to a sibling site holds the key but not the host. Then the CRM suite gate,
|
|
45
45
|
* because a lead entered by hand is suite work: the capture doors that fill
|
|
46
46
|
* a Free workspace's leads do not come through here.
|
|
47
|
-
*/ import { checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey } from "@aglyn/aglyn/server";
|
|
47
|
+
*/ import { checkVisitorRecordCeiling, CONTACT_FIELDS_MAX_PER_ORG, CRM_COLLECTIONS, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey, readCrmCustomInput } from "@aglyn/aglyn/server";
|
|
48
48
|
import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity } from "@aglyn/tenant-data-admin";
|
|
49
49
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
50
|
-
import { normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
50
|
+
import { normalizeCampaignIds, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
51
51
|
import { FieldValue } from "firebase-admin/firestore";
|
|
52
|
+
import { fileCampaignFilingActivities } from "./campaign-filing-activity.js";
|
|
52
53
|
import { holdsDataManage } from "./org-caller.js";
|
|
53
54
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
54
55
|
/** The statuses a lead may be entered in — everything but the converted state. */ const CREATE_STATUSES = [
|
|
@@ -60,20 +61,33 @@ const NOTES_MAX = 4000;
|
|
|
60
61
|
/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */ export const LEAD_MANUAL_SOURCE = 'manual';
|
|
61
62
|
/** The sentence a campaign that is not the site's is refused with, under the field. */ export const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked isn't a campaign of this site any more. Pick it again.";
|
|
62
63
|
/**
|
|
63
|
-
* The
|
|
64
|
+
* The campaigns a request may file a lead under: the site's own live
|
|
64
65
|
* containers, and nothing else. A request can claim any id, and a lead
|
|
65
66
|
* filed under another site's campaign — or one the console soft-deleted —
|
|
66
|
-
* would sit on a page nobody at this site can open.
|
|
67
|
+
* would sit on a page nobody at this site can open. Answered with the
|
|
68
|
+
* names as they stand, for the timeline entries the filing writes
|
|
69
|
+
* (AGL-3274).
|
|
67
70
|
*
|
|
68
|
-
* @returns the clean ids, or `null` when one
|
|
69
|
-
*/ export async function
|
|
71
|
+
* @returns the clean ids with their names, or `null` when one is not the site's.
|
|
72
|
+
*/ export async function siteCampaigns(hostRef, raw) {
|
|
70
73
|
const ids = normalizeCampaignIds(raw);
|
|
71
74
|
if (!ids.length) return [];
|
|
72
75
|
if (ids.some((id)=>id.includes('/'))) return null;
|
|
73
76
|
const containers = hostRef.collection('emailCampaigns');
|
|
74
77
|
const found = await hostRef.firestore.getAll(...ids.map((id)=>containers.doc(id)));
|
|
75
78
|
const live = found.every((snapshot)=>snapshot.exists && !snapshot.get('deletedAt'));
|
|
76
|
-
return live ? ids
|
|
79
|
+
return live ? ids.map((id, index)=>{
|
|
80
|
+
var _ref;
|
|
81
|
+
var _found_index;
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
name: String((_ref = (_found_index = found[index]) == null ? void 0 : _found_index.get('name')) != null ? _ref : '').trim() || id
|
|
85
|
+
};
|
|
86
|
+
}) : null;
|
|
87
|
+
}
|
|
88
|
+
/** The ids alone — see {@link siteCampaigns}. */ export async function siteCampaignIds(hostRef, raw) {
|
|
89
|
+
const campaigns = await siteCampaigns(hostRef, raw);
|
|
90
|
+
return campaigns ? campaigns.map((campaign)=>campaign.id) : null;
|
|
77
91
|
}
|
|
78
92
|
/** The lead's own fields as one write, `null` in the patch meaning a clear. */ export function leadProfileWrite(patch) {
|
|
79
93
|
const write = {};
|
|
@@ -139,7 +153,7 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
139
153
|
const ownerUid = String((_body_ownerUid = body.ownerUid) != null ? _body_ownerUid : '').trim().slice(0, 128);
|
|
140
154
|
const notes = String((_body_notes = body.notes) != null ? _body_notes : '').trim().slice(0, NOTES_MAX);
|
|
141
155
|
try {
|
|
142
|
-
var _decoded_email;
|
|
156
|
+
var _ref, _decoded_name, _decoded_email;
|
|
143
157
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
144
158
|
const staff = decoded['staff'] === true;
|
|
145
159
|
const membership = await resolveOrgPermissions(decoded.uid, {
|
|
@@ -166,16 +180,39 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
166
180
|
const firestore = firebaseAdmin.app().firestore();
|
|
167
181
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
168
182
|
const leadsRef = hostRef.collection('leads');
|
|
183
|
+
/*
|
|
184
|
+
* The org's own lead fields (AGL-3272), judged before any write and
|
|
185
|
+
* only when the body carried a map — a body without `custom` pays for
|
|
186
|
+
* no definition read. The refusal is the definition reader's own
|
|
187
|
+
* sentence, so a client learns which key it spelled wrong rather than
|
|
188
|
+
* that the lead could not be saved.
|
|
189
|
+
*/ let custom;
|
|
190
|
+
if (body.custom !== undefined) {
|
|
191
|
+
const definitions = await firestore.collection('orgs').doc(resolved.orgId).collection(CRM_COLLECTIONS.contactFields).limit(CONTACT_FIELDS_MAX_PER_ORG).get();
|
|
192
|
+
const judged = readCrmCustomInput(body.custom, definitions.docs.map((entry)=>entry.data()), 'lead');
|
|
193
|
+
if ('errors' in judged) {
|
|
194
|
+
var _Object_entries_;
|
|
195
|
+
const [field, message] = (_Object_entries_ = Object.entries(judged.errors)[0]) != null ? _Object_entries_ : [];
|
|
196
|
+
res.status(400).json(_extends({
|
|
197
|
+
error: message != null ? message : 'A custom value could not be saved.'
|
|
198
|
+
}, field ? {
|
|
199
|
+
field
|
|
200
|
+
} : {}));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
custom = judged.values;
|
|
204
|
+
}
|
|
169
205
|
// The campaigns, judged before any write, so a refused pick leaves
|
|
170
206
|
// nothing to retry against (AGL-3254).
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
207
|
+
const campaigns = await siteCampaigns(hostRef, body.campaignIds);
|
|
208
|
+
if (campaigns === null) {
|
|
173
209
|
res.status(400).json({
|
|
174
210
|
error: LEAD_CAMPAIGN_REFUSAL,
|
|
175
211
|
field: 'campaignIds'
|
|
176
212
|
});
|
|
177
213
|
return;
|
|
178
214
|
}
|
|
215
|
+
const campaignIds = campaigns.map((campaign)=>campaign.id);
|
|
179
216
|
// Non-null by construction: the normalizer refused every address this
|
|
180
217
|
// derivation could not key.
|
|
181
218
|
const leadId = personKey(email);
|
|
@@ -220,6 +257,8 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
220
257
|
notes
|
|
221
258
|
} : {}, campaignIds.length ? {
|
|
222
259
|
campaignIds: FieldValue.arrayUnion(...campaignIds)
|
|
260
|
+
} : {}, custom && Object.keys(custom).length ? {
|
|
261
|
+
custom
|
|
223
262
|
} : {});
|
|
224
263
|
if (Object.keys(working).length) {
|
|
225
264
|
await leadsRef.doc(leadId).set(_extends({}, working, {
|
|
@@ -229,6 +268,25 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
229
268
|
});
|
|
230
269
|
}
|
|
231
270
|
/*
|
|
271
|
+
* The filing on the lead's Activity (AGL-3274): one "Filed under" per
|
|
272
|
+
* campaign the lead was not already in, by the member who added it.
|
|
273
|
+
* After the write and never before, and never failing it — the
|
|
274
|
+
* membership is on the document; the entry records the act.
|
|
275
|
+
*/ const already = readCampaignIds(before.exists ? before.data() : null);
|
|
276
|
+
await fileCampaignFilingActivities(firestore, {
|
|
277
|
+
orgId: resolved.orgId,
|
|
278
|
+
org: resolved.org,
|
|
279
|
+
hostId,
|
|
280
|
+
link: {
|
|
281
|
+
leadId
|
|
282
|
+
},
|
|
283
|
+
action: 'filed',
|
|
284
|
+
campaigns: campaigns.filter((campaign)=>!already.includes(campaign.id)),
|
|
285
|
+
atMs: Date.now(),
|
|
286
|
+
byUid: decoded.uid,
|
|
287
|
+
byName: String((_ref = (_decoded_name = decoded['name']) != null ? _decoded_name : decoded.email) != null ? _ref : '').trim() || null
|
|
288
|
+
});
|
|
289
|
+
/*
|
|
232
290
|
* The audit line, written by the route that verified the caller and
|
|
233
291
|
* performed the write — the one writer that cannot record an add that
|
|
234
292
|
* did not happen. An update of a lead the site already held says so.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.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 * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The site's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n}\n\n/** The sentence a campaign that is not the site's is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked isn't a campaign of this site any more. Pick it again.\"\n\n/**\n * The campaign ids a request may file a lead under: the site's own live\n * containers, and nothing else. A request can claim any id, and a lead\n * filed under another site's campaign — or one the console soft-deleted —\n * would sit on a page nobody at this site can open.\n *\n * @returns the clean ids, or `null` when one of them is not the site's.\n */\nexport async function siteCampaignIds(\n hostRef: FirebaseFirestore.DocumentReference,\n raw: unknown,\n): Promise<string[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (ids.some((id) => id.includes('/'))) return null\n const containers = hostRef.collection('emailCampaigns')\n const found = await hostRef.firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live ? ids : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = hostRef.collection('leads')\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaignIds = await siteCampaignIds(hostRef, body.campaignIds)\n if (campaignIds === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await leadsRef.doc(leadId).get()\n const created = !before.exists\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n const used = (await leadsRef.count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","resolveOrgPermissions","normalizeCampaignIds","FieldValue","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","siteCampaignIds","hostRef","raw","ids","length","some","id","includes","containers","collection","found","firestore","getAll","map","doc","live","every","snapshot","exists","get","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","JSON","parse","hostId","trim","email","errors","firstError","field","rawStatus","name","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","suite","org","leadsRef","campaignIds","leadId","before","created","used","count","data","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","type","answer","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.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 * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The site's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the site's is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked isn't a campaign of this site any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the site's own live\n * containers, and nothing else. A request can claim any id, and a lead\n * filed under another site's campaign — or one the console soft-deleted —\n * would sit on a page nobody at this site can open. Answered with the\n * names as they stand, for the timeline entries the filing writes\n * (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the site's.\n */\nexport async function siteCampaigns(\n hostRef: FirebaseFirestore.DocumentReference,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (ids.some((id) => id.includes('/'))) return null\n const containers = hostRef.collection('emailCampaigns')\n const found = await hostRef.firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link siteCampaigns}. */\nexport async function siteCampaignIds(\n hostRef: FirebaseFirestore.DocumentReference,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await siteCampaigns(hostRef, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = hostRef.collection('leads')\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await siteCampaigns(hostRef, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await leadsRef.doc(leadId).get()\n const created = !before.exists\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n const used = (await leadsRef.count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before.exists ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","siteCampaigns","hostRef","raw","ids","length","some","id","includes","containers","collection","found","firestore","getAll","map","doc","live","every","snapshot","exists","get","index","name","String","trim","siteCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","suite","org","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GAED,SACEA,yBAAyB,EACzBC,0BAA0B,EAE1BC,eAAe,EAIfC,kBAAkB,EAClBC,qBAAqB,EACrBC,uBAAuB,EACvBC,SAAS,EAETC,kBAAkB,QACb,sBAAqB;AAC5B,SACEC,WAAW,EACXC,aAAa,EACbC,aAAa,EACbC,eAAe,QACV,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SAASC,oBAAoB,EAAEC,eAAe,QAAQ,6CAA4C;AAClG,SAASC,UAAU,QAAQ,2BAA0B;AAErD,SAASC,4BAA4B,QAAQ,gCAA4B;AACzE,SAASC,eAAe,QAAQ,kBAAc;AAC9C,SAASC,eAAe,QAAQ,kBAAc;AAE9C,gFAAgF,GAChF,MAAMC,kBAA4C;IAAC;IAAO;CAAU;AAEpE,MAAMC,WAAW;AACjB,MAAMC,YAAY;AAElB,iGAAiG,GACjG,OAAO,MAAMC,qBAAqB,SAAQ;AA2B1C,qFAAqF,GACrF,OAAO,MAAMC,wBACX,qFAAoF;AAEtF;;;;;;;;;CASC,GACD,OAAO,eAAeC,cACpBC,OAA4C,EAC5CC,GAAY;IAEZ,MAAMC,MAAMd,qBAAqBa;IACjC,IAAI,CAACC,IAAIC,MAAM,EAAE,OAAO,EAAE;IAC1B,IAAID,IAAIE,IAAI,CAAC,CAACC,KAAOA,GAAGC,QAAQ,CAAC,OAAO,OAAO;IAC/C,MAAMC,aAAaP,QAAQQ,UAAU,CAAC;IACtC,MAAMC,QAAQ,MAAMT,QAAQU,SAAS,CAACC,MAAM,IAAIT,IAAIU,GAAG,CAAC,CAACP,KAAOE,WAAWM,GAAG,CAACR;IAC/E,MAAMS,OAAOL,MAAMM,KAAK,CAAC,CAACC,WAAaA,SAASC,MAAM,IAAI,CAACD,SAASE,GAAG,CAAC;IACxE,OAAOJ,OACHZ,IAAIU,GAAG,CAAC,CAACP,IAAIc;;YAA8BV;eAAnB;YAAEJ;YAAIe,MAAMC,gBAAOZ,eAAAA,KAAK,CAACU,MAAM,qBAAZV,aAAcS,GAAG,CAAC,0BAAW,IAAII,IAAI,MAAMjB;QAAG;SACzF;AACN;AAEA,+CAA+C,GAC/C,OAAO,eAAekB,gBACpBvB,OAA4C,EAC5CC,GAAY;IAEZ,MAAMuB,YAAY,MAAMzB,cAAcC,SAASC;IAC/C,OAAOuB,YAAYA,UAAUZ,GAAG,CAAC,CAACa,WAAaA,SAASpB,EAAE,IAAI;AAChE;AASA,6EAA6E,GAC7E,OAAO,SAASqB,iBAAiBC,KAA0B;IACzD,MAAMC,QAAiC,CAAC;IACxC,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAACL,OAAQ;QAChD,IAAIG,UAAUG,WAAW;QACzBL,KAAK,CAACC,IAAI,GAAGC,UAAU,OAAOxC,WAAW4C,MAAM,KAAKJ;IACtD;IACA,OAAOF;AACT;AAEA,OAAO,MAAMO,oBAAsC,OAAOC,KAAKC;QAMhCD,4BAS4BA,WACnCE,cAuBGA,cAKLA,YAIIA,gBAGHA;IAlDrB,IAAIF,IAAIG,MAAM,KAAK,QAAQ;QACzBF,IAAIG,SAAS,CAAC,SAAS;QACvBH,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMC,gBAAgBvB,QAAOe,6BAAAA,IAAIS,OAAO,CAACD,aAAa,YAAzBR,6BAA6B;IAC1D,MAAMU,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAU7C,MAAM,IACpC8B;IACJ,IAAI,CAACa,SAAS;QACZT,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkB;QAChD;IACF;IACA,MAAML,OACJ,OAAOF,IAAIE,IAAI,KAAK,WAAWW,KAAKC,KAAK,CAACd,IAAIE,IAAI,KAAKF,YAAAA,IAAIE,IAAI,YAARF,YAAY,CAAC;IACtE,MAAMe,SAAS9B,QAAOiB,eAAAA,KAAKa,MAAM,YAAXb,eAAe,IAClChB,IAAI,GACJ0B,KAAK,CAAC,GAAG;IACZ,IAAI,CAACG,QAAQ;QACXd,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiB;QAC/C;IACF;IACA,MAAMS,QAAQzE,sBAAsB2D,KAAKc,KAAK;IAC9C,IAAI,CAACA,OAAO;QACVf,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;QAC7D;IACF;IACA;;;;GAIC,GACD,MAAM,EAAEhB,KAAK,EAAE0B,MAAM,EAAE,GAAGzE,wBAAwB0D;IAClD,MAAM,CAACgB,WAAW,GAAGvB,OAAOC,OAAO,CAACqB;IACpC,IAAIC,YAAY;QACdjB,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAOW,UAAU,CAAC,EAAE;YAAEC,OAAOD,UAAU,CAAC,EAAE;QAAC;QAClE;IACF;IACA,MAAME,YAAYnC,QAAOiB,eAAAA,KAAKG,MAAM,YAAXH,eAAe,IAAIhB,IAAI;IAChD,IAAIkC,aAAa,CAAC9D,gBAAgBY,QAAQ,CAACkD,YAA6B;QACtEnB,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAgC;QAC9D;IACF;IACA,MAAMvB,OAAOC,QAAOiB,aAAAA,KAAKlB,IAAI,YAATkB,aAAa,IAC9BhB,IAAI,GACJmC,OAAO,CAAC,QAAQ,KAChBT,KAAK,CAAC,GAAGrD;IACZ,MAAM+D,WAAWrC,QAAOiB,iBAAAA,KAAKoB,QAAQ,YAAbpB,iBAAiB,IACtChB,IAAI,GACJ0B,KAAK,CAAC,GAAG;IACZ,MAAMW,QAAQtC,QAAOiB,cAAAA,KAAKqB,KAAK,YAAVrB,cAAc,IAChChB,IAAI,GACJ0B,KAAK,CAAC,GAAGpD;IAEZ,IAAI;YA6IegE,MAAAA,eASYA;QArJ7B,MAAMA,UAAU,MAAM5E,cAAc6E,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACjB;QAC/D,MAAMkB,QAAQJ,OAAO,CAAC,QAAQ,KAAK;QACnC,MAAMK,aAAa,MAAM9E,sBAAsByE,QAAQM,GAAG,EAAE;YAAEf;QAAO;QACrE,IACE,CAACa,SACD,CAAEC,CAAAA,WAAWE,QAAQ,IAAK,MAAM3E,gBAAgByE,WAAWG,KAAK,EAAER,QAAQM,GAAG,CAAC,GAC9E;YACA7B,IACGI,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO;YAA0D;YAC3E;QACF;QACA,MAAM0B,WAAW,MAAMpF,cAAckE;QACrC,IAAI,CAACkB,UAAU;YACbhC,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;YAC7C;QACF;QACA,MAAM2B,QAAQ7E,gBAAgB4E,SAASE,GAAG,EAAE;QAC5C,IAAID,OAAO;YACTjC,IAAII,MAAM,CAAC6B,MAAM7B,MAAM,EAAEC,IAAI,CAAC4B,MAAMhC,IAAI;YACxC;QACF;QAEA,MAAM5B,YAAY1B,cAAc6E,GAAG,GAAGnD,SAAS;QAC/C,MAAMV,UAAUU,UAAUF,UAAU,CAAC,SAASK,GAAG,CAACsC;QAClD,MAAMqB,WAAWxE,QAAQQ,UAAU,CAAC;QACpC;;;;;;KAMC,GACD,IAAIiE;QACJ,IAAInC,KAAKmC,MAAM,KAAKxC,WAAW;YAC7B,MAAMyC,cAAc,MAAMhE,UACvBF,UAAU,CAAC,QACXK,GAAG,CAACwD,SAASD,KAAK,EAClB5D,UAAU,CAAC/B,gBAAgBkG,aAAa,EACxCC,KAAK,CAACpG,4BACN0C,GAAG;YACN,MAAM2D,SAAS/F,mBACbwD,KAAKmC,MAAM,EACXC,YAAYI,IAAI,CAAClE,GAAG,CAAC,CAACmE,QAAUA,MAAMC,IAAI,KAC1C;YAEF,IAAI,YAAYH,QAAQ;oBACG9C;gBAAzB,MAAM,CAACwB,OAAO0B,QAAQ,IAAGlD,mBAAAA,OAAOC,OAAO,CAAC6C,OAAOxB,MAAM,CAAC,CAAC,EAAE,YAAhCtB,mBAAoC,EAAE;gBAC/DM,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;oBACnBC,KAAK,EAAEsC,kBAAAA,UAAW;mBACd1B,QAAQ;oBAAEA;gBAAM,IAAI,CAAC;gBAE3B;YACF;YACAkB,SAASI,OAAOK,MAAM;QACxB;QACA,mEAAmE;QACnE,uCAAuC;QACvC,MAAM1D,YAAY,MAAMzB,cAAcC,SAASsC,KAAK6C,WAAW;QAC/D,IAAI3D,cAAc,MAAM;YACtBa,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO7C;gBAAuByD,OAAO;YAAc;YAC1E;QACF;QACA,MAAM4B,cAAc3D,UAAUZ,GAAG,CAAC,CAACa,WAAaA,SAASpB,EAAE;QAC3D,sEAAsE;QACtE,4BAA4B;QAC5B,MAAM+E,SAASvG,UAAUuE;QACzB,MAAMiC,SAAS,MAAMb,SAAS3D,GAAG,CAACuE,QAAQlE,GAAG;QAC7C,MAAMoE,UAAU,CAACD,OAAOpE,MAAM;QAC9B;;;;KAIC,GACD,IAAIqE,SAAS;YACX,MAAMC,OAAO,AAAC,CAAA,MAAMf,SAASgB,KAAK,GAAGtE,GAAG,EAAC,EAAG8D,IAAI,GAAGQ,KAAK;YACxD,IAAIjH,0BAA0BgH,MAAM7G,oBAAoB+G,QAAQ,EAAE;gBAChEpD,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;oBACnBC,OACE,kEACA,kEACA;oBACF+C,QAAQ;gBACV;gBACA;YACF;QACF;QACA,MAAMC,SAAS,MAAM5G,YAAY;YAC/BiB;YACAmD;YACAyC,MAAM;gBACJxC;eACIhC,OAAO;gBAAEA;YAAK,IAAI,CAAC;gBACvByE,QAAQhG;;QAEZ;QACA,IAAI,CAAC8F,QAAQ;YACXtD,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA+B;YAC7D;QACF;QACA,MAAMmD,UAAmC,aACpCpE,iBAAiBC,QAChB6B,YAAY;YAAEf,QAAQe;QAAU,IAAI,CAAC,GACrCE,WAAW;YAAEA;QAAS,IAAI,CAAC,GAC3BC,QAAQ;YAAEA;QAAM,IAAI,CAAC,GAIrBwB,YAAYhF,MAAM,GAAG;YAAEgF,aAAa7F,WAAWyG,UAAU,IAAIZ;QAAa,IAAI,CAAC,GAS/EV,UAAU1C,OAAOiE,IAAI,CAACvB,QAAQtE,MAAM,GAAG;YAAEsE;QAAO,IAAI,CAAC;QAE3D,IAAI1C,OAAOiE,IAAI,CAACF,SAAS3F,MAAM,EAAE;YAC/B,MAAMqE,SACH3D,GAAG,CAACuE,QACJa,GAAG,CAAC,aAAKH;gBAASI,WAAW5G,WAAW6G,eAAe;gBAAM;gBAAEC,OAAO;YAAK;QAChF;QACA;;;;;KAKC,GACD,MAAMC,UAAUhH,gBAAgBgG,OAAOpE,MAAM,GAAIoE,OAAOL,IAAI,KAAiC;QAC7F,MAAMzF,6BAA6BmB,WAAW;YAC5C0D,OAAOC,SAASD,KAAK;YACrBG,KAAKF,SAASE,GAAG;YACjBpB;YACAmD,MAAM;gBAAElB;YAAO;YACfmB,QAAQ;YACR/E,WAAWA,UAAUgF,MAAM,CAAC,CAAC/E,WAAa,CAAC4E,QAAQ/F,QAAQ,CAACmB,SAASpB,EAAE;YACvEoG,MAAMC,KAAKC,GAAG;YACdC,OAAOhD,QAAQM,GAAG;YAClB2C,QAAQxF,QAAOuC,QAAAA,gBAAAA,OAAO,CAAC,OAAO,YAAfA,gBAAmBA,QAAQR,KAAK,YAAhCQ,OAAoC,IAAItC,IAAI,MAAM;QACnE;QACA;;;;KAIC,GACD,MAAMpC,gBACJiE,QACA;YAAEe,KAAKN,QAAQM,GAAG;YAAEd,KAAK,GAAEQ,iBAAAA,QAAQR,KAAK,YAAbQ,iBAAiB;QAAK,GACjD0B,UAAU,eAAe,gBACzB;YAAEwB,MAAM;YAAQzG,IAAI+E;YAAQhE,MAAMA,QAAQgC;QAAM;QAElD,MAAM2D,SAA6B;YAAE3B;YAAQE;QAAQ;QACrDjD,IAAII,MAAM,CAAC6C,UAAU,MAAM,KAAK5C,IAAI,CAACqE;IACvC,EAAE,OAAOpE,OAAO;QACdqE,QAAQrE,KAAK,CAAC,uBAAuBA;QACrCN,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IAC/D;AACF,EAAC;AAED,eAAeR,kBAAiB"}
|