@aglyn/plugins-crm 1.0.0-beta.173 → 1.0.0-beta.174

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +11 -6
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/lead-campaigns-card.js +1 -1
  5. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  6. package/src/lib/components/lead-detail-page.d.ts +1 -2
  7. package/src/lib/components/lead-detail-page.js +12 -8
  8. package/src/lib/components/lead-detail-page.js.map +1 -1
  9. package/src/lib/components/leads-bulk-bar.js +28 -17
  10. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  11. package/src/lib/components/leads-section.js +21 -19
  12. package/src/lib/components/leads-section.js.map +1 -1
  13. package/src/lib/components/new-lead-drawer.js +13 -14
  14. package/src/lib/components/new-lead-drawer.js.map +1 -1
  15. package/src/lib/hooks/use-campaign-filing-log.d.ts +6 -1
  16. package/src/lib/hooks/use-campaign-filing-log.js +10 -4
  17. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  18. package/src/lib/hooks/use-crm-campaigns.d.ts +21 -0
  19. package/src/lib/hooks/use-crm-campaigns.js +44 -0
  20. package/src/lib/hooks/use-crm-campaigns.js.map +1 -0
  21. package/src/lib/server/campaign-filing-activity.d.ts +4 -3
  22. package/src/lib/server/campaign-filing-activity.js +9 -4
  23. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  24. package/src/lib/server/contact-email-history.js +22 -14
  25. package/src/lib/server/contact-email-history.js.map +1 -1
  26. package/src/lib/server/lead-campaign-carry.js +2 -2
  27. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  28. package/src/lib/server/lead-create.d.ts +14 -13
  29. package/src/lib/server/lead-create.js +16 -15
  30. package/src/lib/server/lead-create.js.map +1 -1
  31. package/src/lib/server/leads-import.js +22 -12
  32. package/src/lib/server/leads-import.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-email-history.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/contact-email-history` (AGL-2616): the campaign mail one\n * contact was sent, as the timeline draws it.\n *\n * ## Why a server route\n *\n * The per-recipient delivery log — `emailDeliveries/{key}/messages` — is\n * closed to every client by the rules, and rightly: it is keyed by an\n * address hash across every org on the platform, and a row carries the\n * recipient's address, the provider and the links they followed. What a\n * contact's timeline needs is one slice of it — this person, this group's\n * campaigns — projected to the fields a timeline shows. That projection is\n * this route, and the authorization in front of it is the contacts read\n * rule restated for the Admin SDK, which evaluates no rules of its own.\n *\n * ## What one open of a contact page costs\n *\n * Bounded by the PERSON, never by the campaign ledger: one keyed query down\n * the person's own message subcollection, newest first and capped at\n * {@link EMAIL_DELIVERY_READ_LIMIT}, then one `getAll` for the names of the\n * distinct emails those rows name. Nothing here walks a campaign's\n * recipients — that direction is the campaign report's, and is a\n * collection-group query this route never issues.\n *\n * ## Only this group's campaigns\n *\n * The log holds every message ever sent to the address, from every site on\n * the platform. The rows handed back are those a campaign on one of the\n * reading group's sites sent — the `hostId` tag `campaign-send` stamps,\n * filtered against the consent group's `hostIds` — so a sibling business\n * sharing the same contact row never sees the mail another business sent\n * the person, and transactional mail (a receipt, an invite, which carry no\n * site) is not on a CRM timeline at all. The filter runs over the newest\n * fifty messages, so a person mailed heavily by several orgs can have this\n * group's older campaigns fall past the window; the response says what the\n * window was.\n */\n\nimport {\n type AglynOrgMember,\n type ContactCampaignEmail,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, for the reason the webhook imports it from the leaf: a spec that\n// mocks the admin barrel wholesale must not silently replace the reader.\nimport {\n EMAIL_DELIVERY_READ_LIMIT,\n type EmailDeliveryRecord,\n readEmailDeliveryHistory,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CONTACT_EMAIL_HISTORY_ROUTE = 'crm/contact-email-history'\n\n/** What the route answers with. */\nexport interface ContactEmailHistoryResponse {\n ok: true\n /** Newest first, by the instant each was sent. */\n emails: ContactCampaignEmail[]\n /**\n * True when the log could not be read, so an empty list means \"unknown\"\n * rather than \"nothing was sent\" — the two lead a reader to opposite\n * conclusions about the relationship.\n */\n lookupFailed: boolean\n /** How many of the person's newest messages the campaign filter ran over. */\n limit: number\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Reader {\n ok: true\n uid: string\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n member: Partial<AglynOrgMember> | null\n}\n\nconst refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n error,\n})\n\n/**\n * Who is asking, and whether they may read this site's CRM at all — the\n * rules' `canReadScopedPeople` restated, minus the per-document half that\n * the handler asks against the contact once it has it.\n *\n * `data.manage` is the key, because it is the key the contacts read rule\n * gates on: an org viewer holding no grant may open no contact in the\n * console and may not read one here. Staff pass on the claim alone, as the\n * rules admit them.\n */\nasync function authorizeCrmReader(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Reader | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] contact-email-history could not verify the reader', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const staff = decoded.staff === true\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || suspended) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n }\n}\n\n/** A finite, positive instant, or nothing. */\nconst instant = (value: unknown): number | undefined => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/**\n * One delivery-log row as the timeline reads it.\n *\n * Exported for the spec, and pure: it is the projection that decides what\n * leaves the server — the address, the provider, the followed links and the\n * bounce detail stay behind. `sentAtMs` prefers the provider's own `sent`\n * instant and falls back to the first event seen, which the log guarantees\n * on every row, so an entry can always be placed.\n */\nexport function contactCampaignEmailFromDelivery(\n row: EmailDeliveryRecord,\n campaignName: string | null,\n): ContactCampaignEmail | null {\n if (!row.campaignId || !row.hostId) return null\n const stamps = row.timestamps ?? {}\n const sentAtMs = instant(stamps.sent) ?? instant(row.firstSeenAtMs)\n if (!sentAtMs) return null\n const delivered = instant(stamps.delivered)\n const opened = instant(stamps.opened)\n const clicked = instant(stamps.clicked)\n const bounced = instant(stamps.bounced)\n const complained = instant(stamps.complained)\n return {\n messageId: row.messageId,\n hostId: row.hostId,\n campaignId: row.campaignId,\n campaignName,\n subject: row.subject ?? null,\n sentAtMs,\n ...(delivered ? { deliveredAtMs: delivered } : {}),\n ...(opened ? { openedAtMs: opened } : {}),\n ...(clicked ? { clickedAtMs: clicked } : {}),\n ...(bounced ? { bouncedAtMs: bounced } : {}),\n ...(complained ? { complainedAtMs: complained } : {}),\n openCount: Number(row.openCount ?? 0),\n clickCount: Number(row.clickCount ?? 0),\n }\n}\n\n/**\n * The names of the emails these rows name, in one `getAll`.\n *\n * Keyed reads rather than a query: the ids are known, a `getAll` is one round\n * trip however many there are, and it needs no index. An email the team has\n * since deleted reads as `null`, which the timeline draws as the subject the\n * person received with no report to link to.\n */\nasync function readCampaignNames(\n rows: readonly EmailDeliveryRecord[],\n): Promise<Map<string, string | null>> {\n const names = new Map<string, string | null>()\n const firestore = firebaseAdmin.app().firestore()\n const refs: FirebaseFirestore.DocumentReference[] = []\n for (const row of rows) {\n const key = `${row.hostId}/${row.campaignId}`\n if (names.has(key)) continue\n names.set(key, null)\n refs.push(\n firestore\n .collection('hosts')\n .doc(String(row.hostId))\n .collection('campaigns')\n .doc(String(row.campaignId)),\n )\n }\n if (!refs.length) return names\n const snapshots = await firestore.getAll(...refs)\n for (const snapshot of snapshots) {\n if (!snapshot.exists) continue\n const hostId = snapshot.ref.parent.parent?.id ?? ''\n const name =\n String(snapshot.get('displayName') ?? '').trim() ||\n String(snapshot.get('subject') ?? '').trim()\n names.set(`${hostId}/${snapshot.id}`, name || null)\n }\n return names\n}\n\nexport const contactEmailHistoryHandler: 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 hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n const reader = await authorizeCrmReader(req, hostId)\n if (reader.ok === false) {\n res.status(reader.status).json({ error: reader.error })\n return\n }\n // The plan after the reader, before any contact or log is read (AGL-2851).\n const suite = crmSuiteRefusal(reader.org, \"A contact's campaign email history\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n try {\n /*\n * The contact by id, then the per-document half of the read rule: the\n * Admin SDK evaluates no rules, so a scoped member who can guess an id\n * must be refused here exactly as their listener would refuse the row.\n * \"Not visible\" and \"not there\" are one answer on purpose — a 403 would\n * confirm the id exists.\n */\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n const visibleTo = snapshot.exists\n ? (snapshot.get('visibleTo') as string[] | undefined)\n : undefined\n if (!snapshot.exists || !(reader.staff || memberCanSee(reader.member, visibleTo))) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const email = String(snapshot.get('email') ?? '')\n const group = await consentGroupForSite(hostId, reader.org)\n const history = await readEmailDeliveryHistory(email, {\n limit: EMAIL_DELIVERY_READ_LIMIT,\n })\n const rows = history.rows.filter(\n (row) =>\n Boolean(row.campaignId) &&\n Boolean(row.hostId) &&\n group.hostIds.includes(String(row.hostId)),\n )\n const names = await readCampaignNames(rows)\n const emails = rows\n .map((row) =>\n contactCampaignEmailFromDelivery(\n row,\n names.get(`${row.hostId}/${row.campaignId}`) ?? null,\n ),\n )\n .filter((entry): entry is ContactCampaignEmail => entry !== null)\n .sort((a, b) => b.sentAtMs - a.sentAtMs)\n const body: ContactEmailHistoryResponse = {\n ok: true,\n emails,\n lookupFailed: history.lookupFailed,\n limit: EMAIL_DELIVERY_READ_LIMIT,\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] contact-email-history failed', hostId, contactId, error)\n res.status(500).json({ error: 'The campaign history could not be read' })\n }\n}\n"],"names":["memberCanSee","consentGroupForSite","firebaseAdmin","getOrgForHost","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","EMAIL_DELIVERY_READ_LIMIT","readEmailDeliveryHistory","isRefusedIdToken","crmSuiteRefusal","CONTACT_EMAIL_HISTORY_ROUTE","refuse","status","error","ok","authorizeCrmReader","req","hostId","authorization","String","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","console","staff","resolved","catch","orgId","org","membership","uid","member","suspended","orgSuspended","instant","value","parsed","Number","isFinite","contactCampaignEmailFromDelivery","row","campaignName","campaignId","stamps","timestamps","sentAtMs","sent","firstSeenAtMs","delivered","opened","clicked","bounced","complained","messageId","subject","deliveredAtMs","openedAtMs","clickedAtMs","bouncedAtMs","complainedAtMs","openCount","clickCount","readCampaignNames","rows","names","Map","firestore","refs","key","has","set","push","collection","doc","snapshots","getAll","snapshot","exists","ref","parent","id","name","get","trim","contactEmailHistoryHandler","res","method","setHeader","json","body","contactId","reader","suite","contactsRef","visibleTo","email","group","history","limit","filter","Boolean","hostIds","includes","emails","map","entry","sort","a","b","lookupFailed"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-email-history.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/contact-email-history` (AGL-2616): the campaign mail one\n * contact was sent, as the timeline draws it.\n *\n * ## Why a server route\n *\n * The per-recipient delivery log — `emailDeliveries/{key}/messages` — is\n * closed to every client by the rules, and rightly: it is keyed by an\n * address hash across every org on the platform, and a row carries the\n * recipient's address, the provider and the links they followed. What a\n * contact's timeline needs is one slice of it — this person, this group's\n * campaigns — projected to the fields a timeline shows. That projection is\n * this route, and the authorization in front of it is the contacts read\n * rule restated for the Admin SDK, which evaluates no rules of its own.\n *\n * ## What one open of a contact page costs\n *\n * Bounded by the PERSON, never by the campaign ledger: one keyed query down\n * the person's own message subcollection, newest first and capped at\n * {@link EMAIL_DELIVERY_READ_LIMIT}, then one `getAll` for the names of the\n * distinct emails those rows name. Nothing here walks a campaign's\n * recipients — that direction is the campaign report's, and is a\n * collection-group query this route never issues.\n *\n * ## Only this group's campaigns\n *\n * The log holds every message ever sent to the address, from every site on\n * the platform. The rows handed back are those a campaign on one of the\n * reading group's sites sent — the `hostId` tag `campaign-send` stamps,\n * filtered against the consent group's `hostIds` — so a sibling business\n * sharing the same contact row never sees the mail another business sent\n * the person, and transactional mail (a receipt, an invite, which carry no\n * site) is not on a CRM timeline at all. The filter runs over the newest\n * fifty messages, so a person mailed heavily by several orgs can have this\n * group's older campaigns fall past the window; the response says what the\n * window was.\n */\n\nimport {\n type AglynOrgMember,\n type ContactCampaignEmail,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaf, for the reason the webhook imports it from the leaf: a spec that\n// mocks the admin barrel wholesale must not silently replace the reader.\nimport {\n EMAIL_DELIVERY_READ_LIMIT,\n type EmailDeliveryRecord,\n readEmailDeliveryHistory,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CONTACT_EMAIL_HISTORY_ROUTE = 'crm/contact-email-history'\n\n/** What the route answers with. */\nexport interface ContactEmailHistoryResponse {\n ok: true\n /** Newest first, by the instant each was sent. */\n emails: ContactCampaignEmail[]\n /**\n * True when the log could not be read, so an empty list means \"unknown\"\n * rather than \"nothing was sent\" — the two lead a reader to opposite\n * conclusions about the relationship.\n */\n lookupFailed: boolean\n /** How many of the person's newest messages the campaign filter ran over. */\n limit: number\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Reader {\n ok: true\n uid: string\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n member: Partial<AglynOrgMember> | null\n}\n\nconst refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n error,\n})\n\n/**\n * Who is asking, and whether they may read this site's CRM at all — the\n * rules' `canReadScopedPeople` restated, minus the per-document half that\n * the handler asks against the contact once it has it.\n *\n * `data.manage` is the key, because it is the key the contacts read rule\n * gates on: an org viewer holding no grant may open no contact in the\n * console and may not read one here. Staff pass on the claim alone, as the\n * rules admit them.\n */\nasync function authorizeCrmReader(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Reader | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] contact-email-history could not verify the reader', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const staff = decoded.staff === true\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || suspended) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(403, 'Your organization role does not allow reading the CRM.')\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n }\n}\n\n/** A finite, positive instant, or nothing. */\nconst instant = (value: unknown): number | undefined => {\n const parsed = Number(value)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/**\n * One delivery-log row as the timeline reads it.\n *\n * Exported for the spec, and pure: it is the projection that decides what\n * leaves the server — the address, the provider, the followed links and the\n * bounce detail stay behind. `sentAtMs` prefers the provider's own `sent`\n * instant and falls back to the first event seen, which the log guarantees\n * on every row, so an entry can always be placed.\n */\nexport function contactCampaignEmailFromDelivery(\n row: EmailDeliveryRecord,\n campaignName: string | null,\n): ContactCampaignEmail | null {\n if (!row.campaignId || !row.hostId) return null\n const stamps = row.timestamps ?? {}\n const sentAtMs = instant(stamps.sent) ?? instant(row.firstSeenAtMs)\n if (!sentAtMs) return null\n const delivered = instant(stamps.delivered)\n const opened = instant(stamps.opened)\n const clicked = instant(stamps.clicked)\n const bounced = instant(stamps.bounced)\n const complained = instant(stamps.complained)\n return {\n messageId: row.messageId,\n hostId: row.hostId,\n campaignId: row.campaignId,\n campaignName,\n subject: row.subject ?? null,\n sentAtMs,\n ...(delivered ? { deliveredAtMs: delivered } : {}),\n ...(opened ? { openedAtMs: opened } : {}),\n ...(clicked ? { clickedAtMs: clicked } : {}),\n ...(bounced ? { bouncedAtMs: bounced } : {}),\n ...(complained ? { complainedAtMs: complained } : {}),\n openCount: Number(row.openCount ?? 0),\n clickCount: Number(row.clickCount ?? 0),\n }\n}\n\n/**\n * The names of the emails these rows name, in one `getAll`.\n *\n * Keyed reads rather than a query: the ids are known, a `getAll` is one round\n * trip however many there are, and it needs no index. An email the team has\n * since deleted reads as `null`, which the timeline draws as the subject the\n * person received with no report to link to.\n *\n * A send is the organization's (`orgs/{orgId}/campaigns/{sendId}`); the\n * rows reaching here were already narrowed to the reading group's sites,\n * all of which belong to that org.\n */\nasync function readCampaignNames(\n orgId: string,\n rows: readonly EmailDeliveryRecord[],\n): Promise<Map<string, string | null>> {\n const names = new Map<string, string | null>()\n const firestore = firebaseAdmin.app().firestore()\n const pending: { key: string; hostId: string; sendId: string }[] = []\n for (const row of rows) {\n const key = `${row.hostId}/${row.campaignId}`\n if (names.has(key)) continue\n names.set(key, null)\n pending.push({ key, hostId: String(row.hostId), sendId: String(row.campaignId) })\n }\n if (!pending.length) return names\n const nameOf = (snapshot: FirebaseFirestore.DocumentSnapshot) =>\n String(snapshot.get('displayName') ?? '').trim() ||\n String(snapshot.get('subject') ?? '').trim() ||\n null\n const sends = firestore.collection('orgs').doc(orgId).collection('campaigns')\n const found = await firestore.getAll(...pending.map((entry) => sends.doc(entry.sendId)))\n found.forEach((snapshot, index) => {\n if (snapshot.exists) names.set(pending[index].key, nameOf(snapshot))\n })\n return names\n}\n\nexport const contactEmailHistoryHandler: 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 hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n const reader = await authorizeCrmReader(req, hostId)\n if (reader.ok === false) {\n res.status(reader.status).json({ error: reader.error })\n return\n }\n // The plan after the reader, before any contact or log is read (AGL-2851).\n const suite = crmSuiteRefusal(reader.org, \"A contact's campaign email history\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n try {\n /*\n * The contact by id, then the per-document half of the read rule: the\n * Admin SDK evaluates no rules, so a scoped member who can guess an id\n * must be refused here exactly as their listener would refuse the row.\n * \"Not visible\" and \"not there\" are one answer on purpose — a 403 would\n * confirm the id exists.\n */\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n const visibleTo = snapshot.exists\n ? (snapshot.get('visibleTo') as string[] | undefined)\n : undefined\n if (!snapshot.exists || !(reader.staff || memberCanSee(reader.member, visibleTo))) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const email = String(snapshot.get('email') ?? '')\n const group = await consentGroupForSite(hostId, reader.org)\n const history = await readEmailDeliveryHistory(email, {\n limit: EMAIL_DELIVERY_READ_LIMIT,\n })\n const rows = history.rows.filter(\n (row) =>\n Boolean(row.campaignId) &&\n Boolean(row.hostId) &&\n group.hostIds.includes(String(row.hostId)),\n )\n const names = await readCampaignNames(reader.orgId, rows)\n const emails = rows\n .map((row) =>\n contactCampaignEmailFromDelivery(\n row,\n names.get(`${row.hostId}/${row.campaignId}`) ?? null,\n ),\n )\n .filter((entry): entry is ContactCampaignEmail => entry !== null)\n .sort((a, b) => b.sentAtMs - a.sentAtMs)\n const body: ContactEmailHistoryResponse = {\n ok: true,\n emails,\n lookupFailed: history.lookupFailed,\n limit: EMAIL_DELIVERY_READ_LIMIT,\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] contact-email-history failed', hostId, contactId, error)\n res.status(500).json({ error: 'The campaign history could not be read' })\n }\n}\n"],"names":["memberCanSee","consentGroupForSite","firebaseAdmin","getOrgForHost","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","EMAIL_DELIVERY_READ_LIMIT","readEmailDeliveryHistory","isRefusedIdToken","crmSuiteRefusal","CONTACT_EMAIL_HISTORY_ROUTE","refuse","status","error","ok","authorizeCrmReader","req","hostId","authorization","String","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","console","staff","resolved","catch","orgId","org","membership","uid","member","suspended","orgSuspended","instant","value","parsed","Number","isFinite","contactCampaignEmailFromDelivery","row","campaignName","campaignId","stamps","timestamps","sentAtMs","sent","firstSeenAtMs","delivered","opened","clicked","bounced","complained","messageId","subject","deliveredAtMs","openedAtMs","clickedAtMs","bouncedAtMs","complainedAtMs","openCount","clickCount","readCampaignNames","rows","names","Map","firestore","pending","key","has","set","push","sendId","nameOf","snapshot","get","trim","sends","collection","doc","found","getAll","map","entry","forEach","index","exists","contactEmailHistoryHandler","res","method","setHeader","json","body","contactId","reader","suite","contactsRef","visibleTo","email","group","history","limit","filter","Boolean","hostIds","includes","emails","sort","a","b","lookupFailed"],"mappings":"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"}
@@ -19,7 +19,7 @@ import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizatio
19
19
  import { readLeadForHost } from "@aglyn/tenant-data-admin/server/host-visitor-records";
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
21
  import { CAMPAIGN_FILING_CARRY_BY_NAME } from "../model/campaign-filing-activity.js";
22
- import { fileCampaignFilingActivities, siteCampaignRefs } from "./campaign-filing-activity.js";
22
+ import { fileCampaignFilingActivities, orgCampaignRefs } from "./campaign-filing-activity.js";
23
23
  /**
24
24
  * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).
25
25
  *
@@ -65,7 +65,7 @@ import { fileCampaignFilingActivities, siteCampaignRefs } from "./campaign-filin
65
65
  action: 'filed',
66
66
  // Named where the containers answer; by id where a read fails, so a
67
67
  // name lookup can never unmake a carry that has landed.
68
- campaigns: await siteCampaignRefs(firestore, request.hostId, campaignIds).catch(()=>campaignIds.map((id)=>({
68
+ campaigns: await orgCampaignRefs(firestore, request.orgId, campaignIds).catch(()=>campaignIds.map((id)=>({
69
69
  id,
70
70
  name: id
71
71
  }))),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { readLeadForHost } from '@aglyn/tenant-data-admin/server/host-visitor-records'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, siteCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await readLeadForHost(request.hostId, request.leadId)\n const campaignIds = readCampaignIds(\n lead ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await siteCampaignRefs(firestore, request.hostId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","readLeadForHost","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","siteCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","hostId","leadId","campaignIds","data","length","campaigns","group","collection","doc","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,eAAe,QAAQ,uDAAsD;AACtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,gBAAgB,QAAQ,gCAA4B;AAE3F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMR,gBAAgBO,QAAQE,MAAM,EAAEF,QAAQG,MAAM;QACjE,MAAMC,cAAcd,gBAClBW,OAAQA,KAAKI,IAAI,KAAiC;QAEpD,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBQ,QAAQE,MAAM;QACtD,MAAMH,UACHU,UAAU,CAAC,QACXC,GAAG,CAACV,QAAQW,KAAK,EACjBF,UAAU,CAAC,YACXC,GAAG,CAACV,QAAQY,SAAS,EACrBC,MAAM,CAAC;YACN,CAACxB,yBAAyBmB,MAAMM,OAAO,EAAE,EAAEpB,WAAWqB,UAAU,IAC3DX;YAELY,WAAWtB,WAAWuB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMrB,6BAA6BG,WAAW;YAC5CY,OAAOX,QAAQW,KAAK;YACpBT,QAAQF,QAAQE,MAAM;YACtBgB,MAAM;gBAAEN,WAAWZ,QAAQY,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDZ,WAAW,MAAMV,iBAAiBE,WAAWC,QAAQE,MAAM,EAAEE,aAAagB,KAAK,CAAC,IAC9EhB,YAAYiB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQjC;YACRkC,WAAW,CAAC,MAAM,EAAE7B,QAAQG,MAAM,CAAC,CAAC,EAAEH,QAAQY,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEL,WAAWH,YAAYE,MAAM;QAAC;IACzC,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACA9B,QAAQE,MAAM,EACdF,QAAQG,MAAM,EACd2B;QAEF,OAAO;YAAEvB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASyB,+BACdhC,OAGC;IAED,OAAOF,4BAA4BP,cAAc0C,GAAG,GAAGlC,SAAS,IAAIC;AACtE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { readLeadForHost } from '@aglyn/tenant-data-admin/server/host-visitor-records'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, orgCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await readLeadForHost(request.hostId, request.leadId)\n const campaignIds = readCampaignIds(\n lead ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await orgCampaignRefs(firestore, request.orgId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","readLeadForHost","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","orgCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","hostId","leadId","campaignIds","data","length","campaigns","group","collection","doc","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,eAAe,QAAQ,uDAAsD;AACtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,eAAe,QAAQ,gCAA4B;AAE1F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMR,gBAAgBO,QAAQE,MAAM,EAAEF,QAAQG,MAAM;QACjE,MAAMC,cAAcd,gBAClBW,OAAQA,KAAKI,IAAI,KAAiC;QAEpD,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBQ,QAAQE,MAAM;QACtD,MAAMH,UACHU,UAAU,CAAC,QACXC,GAAG,CAACV,QAAQW,KAAK,EACjBF,UAAU,CAAC,YACXC,GAAG,CAACV,QAAQY,SAAS,EACrBC,MAAM,CAAC;YACN,CAACxB,yBAAyBmB,MAAMM,OAAO,EAAE,EAAEpB,WAAWqB,UAAU,IAC3DX;YAELY,WAAWtB,WAAWuB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMrB,6BAA6BG,WAAW;YAC5CY,OAAOX,QAAQW,KAAK;YACpBT,QAAQF,QAAQE,MAAM;YACtBgB,MAAM;gBAAEN,WAAWZ,QAAQY,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDZ,WAAW,MAAMV,gBAAgBE,WAAWC,QAAQW,KAAK,EAAEP,aAAagB,KAAK,CAAC,IAC5EhB,YAAYiB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQjC;YACRkC,WAAW,CAAC,MAAM,EAAE7B,QAAQG,MAAM,CAAC,CAAC,EAAEH,QAAQY,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEL,WAAWH,YAAYE,MAAM;QAAC;IACzC,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACA9B,QAAQE,MAAM,EACdF,QAAQG,MAAM,EACd2B;QAEF,OAAO;YAAEvB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASyB,+BACdhC,OAGC;IAED,OAAOF,4BAA4BP,cAAc0C,GAAG,GAAGlC,SAAS,IAAIC;AACtE"}
@@ -64,7 +64,7 @@ export interface LeadCreateRequest {
64
64
  status?: CrmLeadStatus;
65
65
  ownerUid?: string;
66
66
  notes?: string;
67
- /** The site's campaigns to file the lead under (AGL-3254), by container id. */
67
+ /** The org's campaigns to file the lead under (AGL-3254), by container id. */
68
68
  campaignIds?: string[];
69
69
  /**
70
70
  * The org's custom LEAD fields (AGL-3272), keyed by each definition's
@@ -73,21 +73,22 @@ export interface LeadCreateRequest {
73
73
  */
74
74
  custom?: Record<string, CrmCustomValue>;
75
75
  }
76
- /** The sentence a campaign that is not the site's is refused with, under the field. */
77
- export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked isn't a campaign of this site any more. Pick it again.";
76
+ /** The sentence a campaign that is not the org's live one is refused with, under the field. */
77
+ export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked doesn't exist any more. Pick it again.";
78
78
  /**
79
- * The campaigns a request may file a lead under: the site's own live
80
- * containers, and nothing else. A request can claim any id, and a lead
81
- * filed under another site's campaign — or one the console soft-deleted —
82
- * would sit on a page nobody at this site can open. Answered with the
83
- * names as they stand, for the timeline entries the filing writes
84
- * (AGL-3274).
79
+ * The campaigns a request may file a lead under: the organization's own
80
+ * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A
81
+ * request can claim any id, and a lead filed under another org's campaign
82
+ * — or one the console soft-deleted — would sit on a page nobody in this
83
+ * org can open. A lead is an org record, so any live campaign of the org
84
+ * qualifies, whichever sites it is placed on. Answered with the names as
85
+ * they stand, for the timeline entries the filing writes (AGL-3274).
85
86
  *
86
- * @returns the clean ids with their names, or `null` when one is not the site's.
87
+ * @returns the clean ids with their names, or `null` when one is not the org's.
87
88
  */
88
- export declare function siteCampaigns(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<CampaignFilingRef[] | null>;
89
- /** The ids alone — see {@link siteCampaigns}. */
90
- export declare function siteCampaignIds(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<string[] | null>;
89
+ export declare function orgCampaigns(firestore: FirebaseFirestore.Firestore, orgId: string, raw: unknown): Promise<CampaignFilingRef[] | null>;
90
+ /** The ids alone — see {@link orgCampaigns}. */
91
+ export declare function orgCampaignIds(firestore: FirebaseFirestore.Firestore, orgId: string, raw: unknown): Promise<string[] | null>;
91
92
  /** What the route answers on success. */
92
93
  export interface LeadCreateResponse {
93
94
  leadId: string;
@@ -59,22 +59,23 @@ import { crmSuiteRefusal } from "./suite-gate.js";
59
59
  const NAME_MAX = 120;
60
60
  const NOTES_MAX = 4000;
61
61
  /** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */ export const LEAD_MANUAL_SOURCE = 'manual';
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
+ /** The sentence a campaign that is not the org's live one is refused with, under the field. */ export const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked doesn't exist any more. Pick it again.";
63
63
  /**
64
- * The campaigns a request may file a lead under: the site's own live
65
- * containers, and nothing else. A request can claim any id, and a lead
66
- * filed under another site's campaign — or one the console soft-deleted —
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).
64
+ * The campaigns a request may file a lead under: the organization's own
65
+ * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A
66
+ * request can claim any id, and a lead filed under another org's campaign
67
+ * — or one the console soft-deleted — would sit on a page nobody in this
68
+ * org can open. A lead is an org record, so any live campaign of the org
69
+ * qualifies, whichever sites it is placed on. Answered with the names as
70
+ * they stand, for the timeline entries the filing writes (AGL-3274).
70
71
  *
71
- * @returns the clean ids with their names, or `null` when one is not the site's.
72
- */ export async function siteCampaigns(hostRef, raw) {
72
+ * @returns the clean ids with their names, or `null` when one is not the org's.
73
+ */ export async function orgCampaigns(firestore, orgId, raw) {
73
74
  const ids = normalizeCampaignIds(raw);
74
75
  if (!ids.length) return [];
75
- if (ids.some((id)=>id.includes('/'))) return null;
76
- const containers = hostRef.collection('emailCampaigns');
77
- const found = await hostRef.firestore.getAll(...ids.map((id)=>containers.doc(id)));
76
+ if (!orgId || ids.some((id)=>id.includes('/'))) return null;
77
+ const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns');
78
+ const found = await firestore.getAll(...ids.map((id)=>containers.doc(id)));
78
79
  const live = found.every((snapshot)=>snapshot.exists && !snapshot.get('deletedAt'));
79
80
  return live ? ids.map((id, index)=>{
80
81
  var _ref;
@@ -85,8 +86,8 @@ const NOTES_MAX = 4000;
85
86
  };
86
87
  }) : null;
87
88
  }
88
- /** The ids alone — see {@link siteCampaigns}. */ export async function siteCampaignIds(hostRef, raw) {
89
- const campaigns = await siteCampaigns(hostRef, raw);
89
+ /** The ids alone — see {@link orgCampaigns}. */ export async function orgCampaignIds(firestore, orgId, raw) {
90
+ const campaigns = await orgCampaigns(firestore, orgId, raw);
90
91
  return campaigns ? campaigns.map((campaign)=>campaign.id) : null;
91
92
  }
92
93
  /** The lead's own fields as one write, `null` in the patch meaning a clear. */ export function leadProfileWrite(patch) {
@@ -204,7 +205,7 @@ export const leadCreateHandler = async (req, res)=>{
204
205
  }
205
206
  // The campaigns, judged before any write, so a refused pick leaves
206
207
  // nothing to retry against (AGL-3254).
207
- const campaigns = await siteCampaigns(hostRef, body.campaignIds);
208
+ const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds);
208
209
  if (campaigns === null) {
209
210
  res.status(400).json({
210
211
  error: LEAD_CAMPAIGN_REFUSAL,
@@ -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 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 orgLeadsForHost,\n readLeadForHost,\n scopedToHost,\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 = await orgLeadsForHost(hostId)\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 readLeadForHost(hostId, leadId)\n const created = !before\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 // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).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 ? (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","orgLeadsForHost","readLeadForHost","scopedToHost","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":"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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 orgLeadsForHost,\n readLeadForHost,\n scopedToHost,\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 org'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 org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await 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 orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, 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 = await orgLeadsForHost(hostId)\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 orgCampaigns(firestore, resolved.orgId, 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 readLeadForHost(hostId, leadId)\n const created = !before\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 // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).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 ? (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","orgLeadsForHost","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","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","resolved","suite","org","hostRef","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":"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"}
@@ -76,6 +76,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
76
76
  */ import { checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, personKey } from "@aglyn/aglyn/server";
77
77
  import { LEAD_IMPORT_CHUNK_SIZE, LEAD_IMPORT_MAX_BODY_BYTES, normalizeLeadImportRow } from "../model/crm-lead-import.js";
78
78
  import { addHostLead, firebaseAdmin, orgLeadsForHost } from "@aglyn/tenant-data-admin";
79
+ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
79
80
  import { FieldValue } from "firebase-admin/firestore";
80
81
  import { ownerDirectory, readImportRows, resolveImportContext } from "./import-context.js";
81
82
  /**
@@ -89,21 +90,30 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
89
90
  * read. One person brought in by one file reads the same on both records.
90
91
  */ const LEAD_IMPORT_SOURCE = 'import';
91
92
  /**
92
- * How many of the site's campaigns one chunk reads to resolve the names a
93
- * file carries (AGL-3254). Well past what a site keeps — the picker offers
93
+ * How many of the org's campaigns one chunk reads to resolve the names a
94
+ * file carries (AGL-3254). Well past what an org keeps — the picker offers
94
95
  * fifty — and read once per chunk rather than once per row.
95
96
  */ const CAMPAIGN_DIRECTORY_CEILING = 200;
96
97
  /**
97
- * The site's live campaigns by NAME, lower-cased, for the `campaigns`
98
- * column. A name the site does not have is not guessed at: the row is
99
- * refused whole and named, because a lead filed under half its campaigns
100
- * is a lead nobody asked for. Read only when some row names one.
101
- */ async function campaignDirectory(hostRef) {
98
+ * The org's live campaigns by NAME, lower-cased, for the `campaigns`
99
+ * column. A lead is an org record, so every live container of the org
100
+ * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is
101
+ * placed on; where two share a name, the one placed on the importing site
102
+ * wins, because that is the one its picker shows. A name the org does not
103
+ * have is not guessed at: the row is refused whole and named, because a
104
+ * lead filed under half its campaigns is a lead nobody asked for. Read
105
+ * only when some row names one.
106
+ */ async function campaignDirectory(firestore, orgId, hostId) {
102
107
  const directory = new Map();
103
- const containers = await hostRef.collection('emailCampaigns').limit(CAMPAIGN_DIRECTORY_CEILING).get();
104
- for (const container of containers.docs){
108
+ if (!orgId) return directory;
109
+ const containers = await firestore.collection('orgs').doc(orgId).collection('emailCampaigns').limit(CAMPAIGN_DIRECTORY_CEILING).get();
110
+ const live = containers.docs.filter((container)=>!container.get('deletedAt'));
111
+ const placed = (container)=>visibleToHost(container.get('visibleTo'), hostId);
112
+ for (const container of [
113
+ ...live.filter(placed),
114
+ ...live.filter((entry)=>!placed(entry))
115
+ ]){
105
116
  var _container_get;
106
- if (container.get('deletedAt')) continue;
107
117
  const name = String((_container_get = container.get('name')) != null ? _container_get : '').trim().toLowerCase();
108
118
  if (name && !directory.has(name)) directory.set(name, container.id);
109
119
  }
@@ -203,7 +213,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
203
213
  const [owners, before, campaigns] = await Promise.all([
204
214
  ownerDirectory(context.orgId, normalized.map((entry)=>entry.row)),
205
215
  refs.length ? firestore.getAll(...refs) : Promise.resolve([]),
206
- namesCampaigns ? campaignDirectory(hostRef) : Promise.resolve(new Map())
216
+ namesCampaigns ? campaignDirectory(firestore, context.orgId, context.hostId) : Promise.resolve(new Map())
207
217
  ]);
208
218
  const held = new Set(before.filter((snapshot)=>snapshot.exists).map((snapshot)=>snapshot.id));
209
219
  const ownersUnresolved = new Set();
@@ -213,7 +223,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
213
223
  let merged = 0;
214
224
  for (const { index, row, key } of normalized){
215
225
  var _row_campaigns;
216
- // The campaigns the row names, every one of them the site's, or the
226
+ // The campaigns the row names, every one of them the org's, or the
217
227
  // row is refused whole and named (AGL-3254).
218
228
  const campaignIds = ((_row_campaigns = row.campaigns) != null ? _row_campaigns : []).map((name)=>{
219
229
  var _campaigns_get;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.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-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin, orgLeadsForHost } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the site's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what a site keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The site's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A name the site does not have is not guessed at: the row is\n * refused whole and named, because a lead filed under half its campaigns\n * is a lead nobody asked for. Read only when some row names one.\n */\nasync function campaignDirectory(\n hostRef: FirebaseFirestore.DocumentReference,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n const containers = await hostRef.collection('emailCampaigns').limit(CAMPAIGN_DIRECTORY_CEILING).get()\n for (const container of containers.docs) {\n if (container.get('deletedAt')) continue\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns ? campaignDirectory(hostRef) : Promise.resolve(new Map<string, string>()),\n ])\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the site's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","hostRef","directory","Map","containers","collection","limit","get","container","docs","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","entry","field","firestore","app","doc","hostId","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","Promise","all","orgId","getAll","resolve","held","filter","snapshot","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.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-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin, orgLeadsForHost } from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n ])\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","visibleToHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","app","hostRef","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","Promise","all","getAll","resolve","held","snapshot","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GAED,SACEA,yBAAyB,EAEzBC,kBAAkB,EAClBC,SAAS,QAEJ,sBAAqB;AAC5B,SACEC,sBAAsB,EACtBC,0BAA0B,EAK1BC,sBAAsB,QACjB,8BAA0B;AACjC,SAASC,WAAW,EAAEC,aAAa,EAAEC,eAAe,QAAQ,2BAA0B;AACtF,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,cAAc,EACdC,cAAc,EACdC,oBAAoB,QACf,sBAAkB;AAEzB;;;;;;;;;CASC,GACD,MAAMC,qBAAoC;AAE1C;;;;CAIC,GACD,MAAMC,6BAA6B;AAEnC;;;;;;;;;CASC,GACD,eAAeC,kBACbC,SAAsC,EACtCC,KAAa,EACbC,MAAc;IAEd,MAAMC,YAAY,IAAIC;IACtB,IAAI,CAACH,OAAO,OAAOE;IACnB,MAAME,aAAa,MAAML,UACtBM,UAAU,CAAC,QACXC,GAAG,CAACN,OACJK,UAAU,CAAC,kBACXE,KAAK,CAACV,4BACNW,GAAG;IACN,MAAMC,OAAOL,WAAWM,IAAI,CAACC,MAAM,CAAC,CAACC,YAAc,CAACA,UAAUJ,GAAG,CAAC;IAClE,MAAMK,SAAS,CAACD,YACdrB,cAAcqB,UAAUJ,GAAG,CAAC,cAAsCP;IACpE,KAAK,MAAMW,aAAa;WAAIH,KAAKE,MAAM,CAACE;WAAYJ,KAAKE,MAAM,CAAC,CAACG,QAAU,CAACD,OAAOC;KAAQ,CAAE;YACvEF;QAApB,MAAMG,OAAOC,QAAOJ,iBAAAA,UAAUJ,GAAG,CAAC,mBAAdI,iBAAyB,IAAIK,IAAI,GAAGC,WAAW;QACnE,IAAIH,QAAQ,CAACb,UAAUiB,GAAG,CAACJ,OAAOb,UAAUkB,GAAG,CAACL,MAAMH,UAAUS,EAAE;IACpE;IACA,OAAOnB;AACT;AAEA;;;;;CAKC,GACD,SAASoB,aACPC,GAAkB,EAClBC,QAA4B,EAC5BC,WAA8B;IAE9B,MAAMC,SAAkC,aAClCH,IAAII,MAAM,GAAG;QAAEA,QAAQJ,IAAII,MAAM;IAAC,IAAI,CAAC,GACvCH,WAAW;QAAEA;IAAS,IAAI,CAAC,GAC3BD,IAAIK,iBAAiB,GAAG;QAAEA,mBAAmBL,IAAIK,iBAAiB;IAAC,IAAI,CAAC,GACxEL,IAAIM,KAAK,GAAG;QAAEA,OAAON,IAAIM,KAAK;IAAC,IAAI,CAAC,GACrCN,IAAIO,OAAO,EAIVL,YAAYM,MAAM,GAAG;QAAEN,aAAajC,WAAWwC,UAAU,IAAIP;IAAa,IAAI,CAAC;IAErF,OAAOQ,OAAOC,IAAI,CAACR,QAAQK,MAAM,GAAGL,SAAS;AAC/C;AAEA;;;;;CAKC,GACD,OAAO,MAAMS,wBAA0C,OAAOC,KAAKC;IACjE,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvB,OAAOF,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,OAAOhD,eAAiC0C,KAAK;QACjDO,cAAczD;QACd0D,WAAW3D;IACb;IACA,IAAI,WAAWyD,MAAM,OAAOL,IAAIV,MAAM,CAACe,KAAKf,MAAM,EAAEa,IAAI,CAAC;QAAEC,OAAOC,KAAKD,KAAK;IAAC;IAC7E,IAAI;QACF,MAAMI,UAAU,MAAMlD,qBAAqByC;QAC3C,IAAIS,QAAQC,EAAE,KAAK,OAAO,OAAOT,IAAIV,MAAM,CAACkB,QAAQlB,MAAM,EAAEa,IAAI,CAACK,QAAQE,IAAI;QAE7E,MAAMC,UAAkC,EAAE;QAC1C,MAAMC,UAAkC,CAAC;QACzC,MAAMC,aAAmE,EAAE;QAC3E;;;;;;KAMC,GACD,MAAMC,OAAO,IAAIC;QACjBV,KAAKW,IAAI,CAACC,OAAO,CAAC,CAACC,KAAKC;YACtB,MAAMC,UAAUtE,uBAAuBoE;YACvC,IAAIE,QAAQX,EAAE,KAAK,OAAO;gBACxBE,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQG,KAAK;oBAAEC,QAAQJ,QAAQI,MAAM;gBAAC;gBACnE;YACF;YACA,iEAAiE;YACjE,iCAAiC;YACjC,MAAMC,MAAM9E,UAAUyE,QAAQlC,GAAG,CAACoC,KAAK;YACvC,IAAIR,KAAKhC,GAAG,CAAC2C,MAAM;gBACjBd,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQlC,GAAG,CAACoC,KAAK;oBAAEE,QAAQ;gBAAY;gBACpE;YACF;YACAV,KAAKY,GAAG,CAACD;YACT,KAAK,MAAMhD,SAAS2C,QAAQlC,GAAG,CAAC0B,OAAO,CAAE;oBACfA;gBAAxBA,OAAO,CAACnC,MAAMkD,KAAK,CAAC,GAAG,EAACf,uBAAAA,OAAO,CAACnC,MAAMkD,KAAK,CAAC,YAApBf,uBAAwB,KAAK;YACvD;YACAC,WAAWQ,IAAI,CAAC;gBAAEF;gBAAOjC,KAAKkC,QAAQlC,GAAG;gBAAEuC;YAAI;QACjD;QAEA,MAAM/D,YAAYV,cAAc4E,GAAG,GAAGlE,SAAS;QAC/C,MAAMmE,UAAUnE,UAAUM,UAAU,CAAC,SAASC,GAAG,CAACuC,QAAQ5C,MAAM;QAChE,MAAMkE,WAAW,MAAM7E,gBAAgBuD,QAAQ5C,MAAM;QACrD,MAAMmE,OAAOlB,WAAWmB,GAAG,CAAC,CAACvD,QAAUqD,SAAS7D,GAAG,CAACQ,MAAMgD,GAAG;QAC7D,MAAMQ,iBAAiBpB,WAAWqB,IAAI,CAAC,CAACzD;gBAAUA;oBAAAA,uBAAAA,MAAMS,GAAG,CAACiD,SAAS,qBAAnB1D,qBAAqBiB,MAAM;;QAC7E,MAAM,CAAC0C,QAAQC,QAAQF,UAAU,GAAG,MAAMG,QAAQC,GAAG,CAAC;YACpDnF,eACEoD,QAAQ7C,KAAK,EACbkD,WAAWmB,GAAG,CAAC,CAACvD,QAAUA,MAAMS,GAAG;YAErC6C,KAAKrC,MAAM,GAAGhC,UAAU8E,MAAM,IAAIT,QAAQO,QAAQG,OAAO,CAAC,EAAE;YAC5DR,iBACIxE,kBAAkBC,WAAW8C,QAAQ7C,KAAK,EAAE6C,QAAQ5C,MAAM,IAC1D0E,QAAQG,OAAO,CAAC,IAAI3E;SACzB;QACD,MAAM4E,OAAO,IAAI3B,IACfsB,OAAO/D,MAAM,CAAC,CAACqE,WAAaA,SAASC,MAAM,EAAEZ,GAAG,CAAC,CAACW,WAAaA,SAAS3D,EAAE;QAE5E,MAAM6D,mBAAmB,IAAI9B;QAC7B,IAAI+B,UAAyB;QAC7B,IAAIC,cAAc;QAClB,IAAIC,UAAU;QACd,IAAIC,SAAS;QAEb,KAAK,MAAM,EAAE9B,KAAK,EAAEjC,GAAG,EAAEuC,GAAG,EAAE,IAAIZ,WAAY;gBAGvB3B;YAFrB,mEAAmE;YACnE,6CAA6C;YAC7C,MAAME,cAAc,EAACF,iBAAAA,IAAIiD,SAAS,YAAbjD,iBAAiB,EAAE,EAAE8C,GAAG,CAAC,CAACtD;oBAASyD;wBAAAA,iBAAAA,UAAUhE,GAAG,CAACO,KAAKG,WAAW,eAA9BsD,iBAAqC;;YAC7F,IAAI/C,YAAY8C,IAAI,CAAC,CAAClD,KAAO,CAACA,KAAK;gBACjC2B,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAmB;gBACnE;YACF;YACA,MAAM0B,QAAQ,CAACR,KAAK5D,GAAG,CAAC2C;YACxB;;;;;;;OAOC,GACD,IAAIyB,OAAO;gBACT,IAAIJ,YAAY,MAAM;oBACpBA,UAAU,AAAC,CAAA,MAAMhB,SAASqB,KAAK,GAAGhF,GAAG,EAAC,EAAGiF,IAAI,GAAGD,KAAK;gBACvD;gBACA,IACE1G,0BAA0BqG,UAAUC,aAAarG,oBAC9C2G,QAAQ,EACX;oBACA1C,QAAQU,IAAI,CAAC;wBAAEF;wBAAOG,OAAOpC,IAAIoC,KAAK;wBAAEE,QAAQ;oBAAe;oBAC/D;gBACF;YACF;YACA,IAAIrC;YACJ,IAAID,IAAIoE,UAAU,EAAE;gBAClBnE,WAAWiD,OAAOjE,GAAG,CAACe,IAAIoE,UAAU;gBACpC,IAAI,CAACnE,UAAU0D,iBAAiBnB,GAAG,CAACxC,IAAIoE,UAAU;YACpD;YACA,MAAMC,SAAS,MAAMxG,YAAY;gBAC/B8E;gBACAjE,QAAQ4C,QAAQ5C,MAAM;gBACtB4F,MAAM;oBACJlC,OAAOpC,IAAIoC,KAAK;mBACZpC,IAAIR,IAAI,GAAG;oBAAEA,MAAMQ,IAAIR,IAAI;gBAAC,IAAI,CAAC;oBACrC+E,QAAQlG;;YAEZ;YACA,IAAI,CAACgG,QAAQ;gBACX5C,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAe;gBAC/D;YACF;YACA,MAAMkC,UAAUzE,aAAaC,KAAKC,UAAU;mBAAI,IAAI4B,IAAI3B;aAAa;YACrE,IAAIsE,SAAS;gBACX,MAAM5B,SACH7D,GAAG,CAACwD,KACJ1C,GAAG,CACF,aAAK2E;oBAASC,WAAWxG,WAAWyG,eAAe;oBACnD;oBAAEC,OAAO;gBAAK;YAEpB;YACA,IAAIX,OAAO;gBACTF,WAAW;gBACXD,eAAe;YACjB,OAAO;gBACLE,UAAU;YACZ;QACF;QAEA,MAAMa,SAAgC;YACpCC,UAAU1D,KAAKW,IAAI,CAACtB,MAAM;YAC1BsD;YACAC;YACAtC,SAASA,QAAQqD,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAE9C,KAAK,GAAG+C,EAAE/C,KAAK;YACjDP;YACAiC,kBAAkB;mBAAIA;aAAiB;QACzC;QACA,OAAO7C,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC2D;IAC9B,EAAE,OAAO1D,OAAO;QACd+D,QAAQ/D,KAAK,CAAC,2BAA2BA;QACzC,OAAOJ,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACxE;AACF,EAAC"}