@aglyn/plugins-crm 1.0.0-beta.165 → 1.0.0-beta.167

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 (50) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +7 -1
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/crm-console-page.js +12 -7
  5. package/src/lib/components/crm-console-page.js.map +1 -1
  6. package/src/lib/components/crm-glance-card.js +4 -2
  7. package/src/lib/components/crm-glance-card.js.map +1 -1
  8. package/src/lib/components/lead-campaigns-card.js +7 -1
  9. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  10. package/src/lib/components/lead-detail-page.js +1 -1
  11. package/src/lib/components/lead-detail-page.js.map +1 -1
  12. package/src/lib/components/lead-properties-card.js +1 -1
  13. package/src/lib/components/lead-properties-card.js.map +1 -1
  14. package/src/lib/components/lead-unqualify-dialog.js +7 -1
  15. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  16. package/src/lib/components/leads-bulk-bar.d.ts +1 -2
  17. package/src/lib/components/leads-bulk-bar.js +8 -3
  18. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  19. package/src/lib/components/leads-section.d.ts +9 -9
  20. package/src/lib/components/leads-section.js +47 -28
  21. package/src/lib/components/leads-section.js.map +1 -1
  22. package/src/lib/components/reports/lead-counts.d.ts +16 -9
  23. package/src/lib/components/reports/lead-counts.js +29 -18
  24. package/src/lib/components/reports/lead-counts.js.map +1 -1
  25. package/src/lib/components/reports/lead-funnel-card.js +44 -40
  26. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  27. package/src/lib/hooks/use-org-leads.d.ts +29 -32
  28. package/src/lib/hooks/use-org-leads.js +58 -68
  29. package/src/lib/hooks/use-org-leads.js.map +1 -1
  30. package/src/lib/model/crm-routes.d.ts +9 -6
  31. package/src/lib/model/crm-routes.js +9 -6
  32. package/src/lib/model/crm-routes.js.map +1 -1
  33. package/src/lib/server/capture-contact.js +11 -5
  34. package/src/lib/server/capture-contact.js.map +1 -1
  35. package/src/lib/server/convert-open-lead.js +6 -2
  36. package/src/lib/server/convert-open-lead.js.map +1 -1
  37. package/src/lib/server/email-send.js +3 -3
  38. package/src/lib/server/email-send.js.map +1 -1
  39. package/src/lib/server/erase-person.js +25 -7
  40. package/src/lib/server/erase-person.js.map +1 -1
  41. package/src/lib/server/lead-campaign-carry.js +3 -2
  42. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  43. package/src/lib/server/lead-create.js +8 -6
  44. package/src/lib/server/lead-create.js.map +1 -1
  45. package/src/lib/server/leads-import.js +2 -2
  46. package/src/lib/server/leads-import.js.map +1 -1
  47. package/src/lib/server/record-email-state.js +10 -0
  48. package/src/lib/server/record-email-state.js.map +1 -1
  49. package/src/lib/server/record-facts.js +3 -3
  50. package/src/lib/server/record-facts.js.map +1 -1
@@ -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} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The site's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the site's is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked isn't a campaign of this site any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the site's own live\n * containers, and nothing else. A request can claim any id, and a lead\n * filed under another site's campaign — or one the console soft-deleted —\n * would sit on a page nobody at this site can open. Answered with the\n * names as they stand, for the timeline entries the filing writes\n * (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the site's.\n */\nexport async function siteCampaigns(\n hostRef: FirebaseFirestore.DocumentReference,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (ids.some((id) => id.includes('/'))) return null\n const containers = hostRef.collection('emailCampaigns')\n const found = await hostRef.firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link siteCampaigns}. */\nexport async function siteCampaignIds(\n hostRef: FirebaseFirestore.DocumentReference,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await siteCampaigns(hostRef, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = hostRef.collection('leads')\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await siteCampaigns(hostRef, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await leadsRef.doc(leadId).get()\n const created = !before.exists\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n const used = (await leadsRef.count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before.exists ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","siteCampaigns","hostRef","raw","ids","length","some","id","includes","containers","collection","found","firestore","getAll","map","doc","live","every","snapshot","exists","get","index","name","String","trim","siteCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","suite","org","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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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 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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"}
@@ -75,7 +75,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
75
75
  * only moment the answer is still true.
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
- import { addHostLead, firebaseAdmin } from "@aglyn/tenant-data-admin";
78
+ import { addHostLead, firebaseAdmin, orgLeadsForHost } from "@aglyn/tenant-data-admin";
79
79
  import { FieldValue } from "firebase-admin/firestore";
80
80
  import { ownerDirectory, readImportRows, resolveImportContext } from "./import-context.js";
81
81
  /**
@@ -194,7 +194,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
194
194
  });
195
195
  const firestore = firebaseAdmin.app().firestore();
196
196
  const hostRef = firestore.collection('hosts').doc(context.hostId);
197
- const leadsRef = hostRef.collection('leads');
197
+ const leadsRef = await orgLeadsForHost(context.hostId);
198
198
  const refs = normalized.map((entry)=>leadsRef.doc(entry.key));
199
199
  const namesCampaigns = normalized.some((entry)=>{
200
200
  var _entry_row_campaigns;
@@ -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 } 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 = hostRef.collection('leads')\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","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 { 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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"}
@@ -91,6 +91,16 @@ export function createCrmRecordEmailStateWriter(deps) {
91
91
  console.error('[crm] the contact could not be stamped with an email state', orgId, error);
92
92
  }
93
93
  try {
94
+ /*
95
+ * ONE ORG ROW, plus any legacy site row the backfill has not reached
96
+ * (AGL-3275). A bounce or a do-not-contact mark is the platform's
97
+ * verdict on the ADDRESS, so it has to reach every copy that still
98
+ * exists — an unmarked leftover is a person who asked not to be
99
+ * mailed and could be. AGL-3277 drops the second loop with the
100
+ * fallback.
101
+ */ if (await stampRecord(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), state, force)) {
102
+ records += 1;
103
+ }
94
104
  for (const hostId of (await orgHostIds(firestore, orgId))){
95
105
  const lead = firestore.collection('hosts').doc(hostId).collection('leads').doc(key);
96
106
  if (await stampRecord(lead, state, force)) records += 1;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.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 registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/** The sites an organization owns, by id: `hosts` where `orgId` is the org's. */\nasync function orgHostIds(firestore: Firestore, orgId: string): Promise<string[]> {\n const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).select().get()\n return hosts.docs.map((doc) => doc.id)\n}\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n await ref.set({ [EMAIL_STATE_FIELD]: next }, { merge: true })\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n for (const hostId of await orgHostIds(firestore, orgId)) {\n const lead = firestore.collection('hosts').doc(hostId).collection('leads').doc(key)\n if (await stampRecord(lead, state, force)) records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","BUNDLE_ID","NONE","records","orgHostIds","firestore","orgId","hosts","collection","where","select","get","docs","map","doc","id","stampRecord","ref","incoming","force","snapshot","exists","current","data","next","status","atMs","source","detail","set","merge","resolveOrgId","request","host","named","String","trim","hostId","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","lead","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,oCAAoC,QAI/B,wDAAuD;AAC9D,SACEC,iBAAiB,EACjBC,cAAc,EACdC,qBAAqB,EACrBC,SAAS,EACTC,cAAc,QAET,sBAAqB;AAC5B,SAASC,kBAAkB,EAAEC,aAAa,QAAQ,2BAA0B;AAC5E,SAASC,SAAS,QAAQ,gCAA4B;AAkCtD,MAAMC,OAAqC;IAAEC,SAAS;AAAE;AAExD,+EAA+E,GAC/E,eAAeC,WAAWC,SAAoB,EAAEC,KAAa;IAC3D,MAAMC,QAAQ,MAAMF,UAAUG,UAAU,CAAC,SAASC,KAAK,CAAC,SAAS,MAAMH,OAAOI,MAAM,GAAGC,GAAG;IAC1F,OAAOJ,MAAMK,IAAI,CAACC,GAAG,CAAC,CAACC,MAAQA,IAAIC,EAAE;AACvC;AAEA;;;;;CAKC,GACD,eAAeC,YACbC,GAAwC,EACxCC,QAAoB,EACpBC,KAAc;IAEd,MAAMC,WAAW,MAAMH,IAAIN,GAAG;IAC9B,IAAI,CAACS,SAASC,MAAM,EAAE,OAAO;IAC7B,MAAMC,UAAUxB,eAAesB,SAASG,IAAI;IAC5C,MAAMC,OAAO7B,eAAe2B,SAASJ,UAAU;QAAEC;IAAM;IACvD,0EAA0E;IAC1E,qDAAqD;IACrD,IACEK,SAASF,WACRA,WACCE,KAAKC,MAAM,KAAKH,QAAQG,MAAM,IAC9BD,KAAKE,IAAI,KAAKJ,QAAQI,IAAI,IAC1BF,KAAKG,MAAM,KAAKL,QAAQK,MAAM,IAC9BH,KAAKI,MAAM,KAAKN,QAAQM,MAAM,EAChC;QACA,OAAO;IACT;IACA,MAAMX,IAAIY,GAAG,CAAC;QAAE,CAACnC,kBAAkB,EAAE8B;IAAK,GAAG;QAAEM,OAAO;IAAK;IAC3D,OAAO;AACT;AAEA,sEAAsE,GACtE,eAAeC,aAAa1B,SAAoB,EAAE2B,OAAsC;QACjEA,gBAECA,iBAGMC;IAL5B,MAAMC,QAAQC,QAAOH,iBAAAA,QAAQ1B,KAAK,YAAb0B,iBAAiB,IAAII,IAAI;IAC9C,IAAIF,OAAO,OAAOA;IAClB,MAAMG,SAASF,QAAOH,kBAAAA,QAAQK,MAAM,YAAdL,kBAAkB,IAAII,IAAI;IAChD,IAAI,CAACC,QAAQ,OAAO;IACpB,MAAMJ,OAAO,MAAM5B,UAAUG,UAAU,CAAC,SAASM,GAAG,CAACuB,QAAQ1B,GAAG;IAChE,OAAOsB,KAAKZ,MAAM,GAAGc,QAAOF,YAAAA,KAAKtB,GAAG,CAAC,oBAATsB,YAAqB,MAAM;AACzD;AAEA,OAAO,SAASK,gCAAgCC,IAA6B;IAC3E,OAAO;QACL,MAAMC,OAAMR,OAAO;YACjB,MAAMS,QAAQ7C,sBAAsBoC,QAAQS,KAAK;YACjD,MAAMC,MAAMD,QAAQ5C,UAAU4C,SAAS;YACvC,IAAI,CAACA,SAAS,CAACC,KAAK,OAAOxC;YAC3B,MAAMG,YAAYkC,KAAKlC,SAAS;YAChC,IAAIC;YACJ,IAAI;gBACFA,QAAQ,MAAMyB,aAAa1B,WAAW2B;YACxC,EAAE,OAAOW,OAAO;gBACdC,QAAQD,KAAK,CAAC,sEAAsEX,QAAQK,MAAM,EAAEM;gBACpG,OAAOzC;YACT;YACA,IAAI,CAACI,OAAO,OAAOJ;YACnB,MAAM2C,QAAoB;gBACxBpB,QAAQO,QAAQa,KAAK,CAACpB,MAAM;gBAC5BC,MAAMoB,OAAOC,QAAQ,CAACf,QAAQa,KAAK,CAACnB,IAAI,KAAKM,QAAQa,KAAK,CAACnB,IAAI,GAAG,IAAIM,QAAQa,KAAK,CAACnB,IAAI,GAAGsB,KAAKC,GAAG;gBACnGtB,QAAQK,QAAQa,KAAK,CAAClB,MAAM;gBAC5BC,QAAQI,QAAQa,KAAK,CAACjB,MAAM,GACxBO,OAAOH,QAAQa,KAAK,CAACjB,MAAM,EAAEsB,OAAO,CAAC,QAAQ,KAAKd,IAAI,GAAGe,KAAK,CAAC,GAAG,OAClE;eACAnB,QAAQa,KAAK,CAACO,YAAY,GAAG;gBAAEA,cAAcpB,QAAQa,KAAK,CAACO,YAAY;YAAC,IAAI,CAAC;YAEnF,MAAMjC,QAAQa,QAAQb,KAAK,KAAK;YAChC,IAAIhB,UAAU;YACd,IAAI;gBACF,MAAMkD,WAAWhD,UAAUG,UAAU,CAAC,QAAQM,GAAG,CAACR,OAAOE,UAAU,CAAC;gBACpE,MAAM8C,UAAU,MAAMvD,mBAAmBsD,UAAUZ;gBACnD,IAAIa,WAAY,MAAMtC,YAAYsC,QAAQrC,GAAG,EAAE4B,OAAO1B,QAAShB,WAAW;YAC5E,EAAE,OAAOwC,OAAO;gBACdC,QAAQD,KAAK,CAAC,8DAA8DrC,OAAOqC;YACrF;YACA,IAAI;gBACF,KAAK,MAAMN,UAAU,CAAA,MAAMjC,WAAWC,WAAWC,MAAK,EAAG;oBACvD,MAAMiD,OAAOlD,UAAUG,UAAU,CAAC,SAASM,GAAG,CAACuB,QAAQ7B,UAAU,CAAC,SAASM,GAAG,CAAC4B;oBAC/E,IAAI,MAAM1B,YAAYuC,MAAMV,OAAO1B,QAAQhB,WAAW;gBACxD;YACF,EAAE,OAAOwC,OAAO;gBACdC,QAAQD,KAAK,CAAC,4DAA4DrC,OAAOqC;YACnF;YACA,OAAO;gBAAExC;YAAQ;QACnB;IACF;AACF;AAEA,4DAA4D,GAC5D,OAAO,SAASqD;IACd,OAAO;QAAEnD,WAAW,IAAML,cAAcyD,GAAG,GAAGpD,SAAS;IAAG;AAC5D;AAEA,gFAAgF,GAChF,OAAO,SAASqD,kCACdnB,OAAgCiB,gCAAgC;IAEhE/D,qCAAqC6C,gCAAgCC,OAAO;QAAEoB,UAAU1D;IAAU;AACpG"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-email-state.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 registerPluginRecordEmailStateWriter,\n type PluginRecordEmailStateReport,\n type PluginRecordEmailStateRequest,\n type PluginRecordEmailStateWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n EMAIL_STATE_FIELD,\n nextEmailState,\n normalizeContactEmail,\n personKey,\n readEmailState,\n type EmailState,\n} from '@aglyn/aglyn/server'\nimport { findContactByEmail, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD EMAIL-STATE SEAM (AGL-3245).\n *\n * A sender filed a verdict on an address — the outreach runtime's bounce,\n * the campaign webhook's complaint, a member's do-not-contact mark, the\n * unsubscribe link — and says so here; this writes it onto every record\n * the address is, as `emailState`, so the record a person reads and the\n * lists a send consults agree.\n *\n * One address can be several records: the contact the organization holds\n * (found through the address index, `findContactByEmail`), and a lead on\n * each site the organization owns — leads are keyed by `personKey`, so a\n * lead is addressed directly under every host. All of them are stamped;\n * the verdict is about the address, not about which record a rep opened.\n * A caller that knows only the site reads the organization off it.\n *\n * The write keeps the STRONGER verdict (`nextEmailState`): a member's\n * do-not-contact mark is not undone by a later bounce, and `ok` lands only\n * when a caller forces it. `updatedAt` is left alone — a verdict is\n * something that happened to the person, not an edit the team made, and a\n * list sorted on recency must not reshuffle on a bounce.\n *\n * Never throws: the list is the control, and a record that could not be\n * stamped is a chip a page goes without, logged.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordEmailStateDeps {\n firestore(): Firestore\n}\n\nconst NONE: PluginRecordEmailStateReport = { records: 0 }\n\n/** The sites an organization owns, by id: `hosts` where `orgId` is the org's. */\nasync function orgHostIds(firestore: Firestore, orgId: string): Promise<string[]> {\n const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).select().get()\n return hosts.docs.map((doc) => doc.id)\n}\n\n/**\n * Applies the verdict to one record; answers whether it moved. A read then\n * a merge rather than a transaction: two verdicts landing together each\n * compare against a state at least as old as their own, and the ranking\n * makes the order of two writes of the same rank immaterial.\n */\nasync function stampRecord(\n ref: FirebaseFirestore.DocumentReference,\n incoming: EmailState,\n force: boolean,\n): Promise<boolean> {\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const current = readEmailState(snapshot.data() as Record<string, unknown>)\n const next = nextEmailState(current, incoming, { force })\n // The current verdict stood, or the same verdict arrived again — a second\n // run of the caller — and there is nothing to write.\n if (\n next === current ||\n (current &&\n next.status === current.status &&\n next.atMs === current.atMs &&\n next.source === current.source &&\n next.detail === current.detail)\n ) {\n return false\n }\n await ref.set({ [EMAIL_STATE_FIELD]: next }, { merge: true })\n return true\n}\n\n/** The organization a request names, or the one its site belongs to. */\nasync function resolveOrgId(firestore: Firestore, request: PluginRecordEmailStateRequest): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return ''\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists ? String(host.get('orgId') ?? '') : ''\n}\n\nexport function createCrmRecordEmailStateWriter(deps: CrmRecordEmailStateDeps): PluginRecordEmailStateWriter {\n return {\n async stamp(request) {\n const email = normalizeContactEmail(request.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return NONE\n const firestore = deps.firestore()\n let orgId: string\n try {\n orgId = await resolveOrgId(firestore, request)\n } catch (error) {\n console.error('[crm] the site’s organization could not be read for an email state', request.hostId, error)\n return NONE\n }\n if (!orgId) return NONE\n const state: EmailState = {\n status: request.state.status,\n atMs: Number.isFinite(request.state.atMs) && request.state.atMs > 0 ? request.state.atMs : Date.now(),\n source: request.state.source,\n detail: request.state.detail\n ? String(request.state.detail).replace(/\\s+/g, ' ').trim().slice(0, 500)\n : null,\n ...(request.state.enrollmentId ? { enrollmentId: request.state.enrollmentId } : {}),\n }\n const force = request.force === true\n let records = 0\n try {\n const contacts = firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await findContactByEmail(contacts, email)\n if (contact && (await stampRecord(contact.ref, state, force))) records += 1\n } catch (error) {\n console.error('[crm] the contact could not be stamped with an email state', orgId, error)\n }\n try {\n /*\n * ONE ORG ROW, plus any legacy site row the backfill has not reached\n * (AGL-3275). A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, so it has to reach every copy that still\n * exists — an unmarked leftover is a person who asked not to be\n * mailed and could be. AGL-3277 drops the second loop with the\n * fallback.\n */\n if (await stampRecord(\n firestore.collection('orgs').doc(orgId).collection('leads').doc(key),\n state,\n force,\n )) {\n records += 1\n }\n for (const hostId of await orgHostIds(firestore, orgId)) {\n const lead = firestore.collection('hosts').doc(hostId).collection('leads').doc(key)\n if (await stampRecord(lead, state, force)) records += 1\n }\n } catch (error) {\n console.error('[crm] the leads could not be stamped with an email state', orgId, error)\n }\n return { records }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordEmailStateDeps(): CrmRecordEmailStateDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the email-state seam. */\nexport function registerCrmRecordEmailStateWriter(\n deps: CrmRecordEmailStateDeps = defaultCrmRecordEmailStateDeps(),\n): void {\n registerPluginRecordEmailStateWriter(createCrmRecordEmailStateWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordEmailStateWriter","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","BUNDLE_ID","NONE","records","orgHostIds","firestore","orgId","hosts","collection","where","select","get","docs","map","doc","id","stampRecord","ref","incoming","force","snapshot","exists","current","data","next","status","atMs","source","detail","set","merge","resolveOrgId","request","host","named","String","trim","hostId","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","lead","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
@@ -16,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { registerPluginRecordFactsReader } from "@aglyn/aglyn/plugin-manager/plugin-record-facts";
18
18
  import { CRM_COLLECTIONS, contactPrimaryGroup, crmReadTokens, isOrgWideMember } from "@aglyn/aglyn/server";
19
- import { consentGroupForSite, firebaseAdmin, getOrgDoc, memberHasOrgPermission, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
19
+ import { consentGroupForSite, firebaseAdmin, getOrgDoc, memberHasOrgPermission, readLeadForHost, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
20
20
  import { BUNDLE_ID } from "../constants/bundle-common.js";
21
21
  import { CRM_IMPORT_FACTS_RESOURCE, CRM_RECORD_FACTS_RESOURCES, companyFacts, contactFacts, dealFacts, importFacts, isCrmImportFactsCollection, leadFacts } from "../model/record-facts.js";
22
22
  import { crmSuiteRefusal } from "./suite-gate.js";
@@ -284,8 +284,8 @@ export const crmLeadFactsReader = reader(async (scope, request)=>{
284
284
  };
285
285
  const id = String((_request_id = request.id) != null ? _request_id : '').trim();
286
286
  if (!id) return refused('lead');
287
- const snapshot = await firestore().collection('hosts').doc(scope.hostId).collection('leads').doc(id).get();
288
- if (!snapshot.exists) return refused('lead');
287
+ const snapshot = await readLeadForHost(scope.hostId, id);
288
+ if (!snapshot) return refused('lead');
289
289
  const activities = await rowsNaming(scope, {
290
290
  field: 'leadId',
291
291
  id,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-facts.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 registerPluginRecordFactsReader,\n type PluginRecordFactsRead,\n type PluginRecordFactsReader,\n type PluginRecordFactsRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-facts'\nimport {\n CRM_COLLECTIONS,\n contactPrimaryGroup,\n crmReadTokens,\n isOrgWideMember,\n type ConsentGroup,\n type ContactFieldDefinition,\n type CrmActivity,\n type CrmCompany,\n type CrmDeal,\n type CrmPipeline,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgDoc,\n memberHasOrgPermission,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n CRM_IMPORT_FACTS_RESOURCE,\n CRM_RECORD_FACTS_RESOURCES,\n companyFacts,\n contactFacts,\n dealFacts,\n importFacts,\n isCrmImportFactsCollection,\n leadFacts,\n type CrmRecordFactsKind,\n} from '../model/record-facts'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * THE CRM'S READERS ON THE CORE'S RECORD-FACTS SEAM (AGL-2917).\n *\n * Another plugin asks for a contact, a company, a deal, a lead or an import's\n * field catalog by resource name, and gets the facts `model/record-facts.ts`\n * reports — read here with the rules every other CRM door applies, for the\n * member the caller names:\n *\n * 1. the site belongs to the organization;\n * 2. the member reaches it — an org-wide member at the organization level, a\n * member of that site under it — and holds `data.manage`, the key the\n * whole CRM is gated on. A verified staff caller passes, as at every CRM\n * route;\n * 3. the organization's plan carries the CRM, asked after the caller is\n * known, the order `suite-gate.ts` gives;\n * 4. the record exists and is visible where it is read: under a site, its\n * `visibleTo` meets the site's read tokens, as the console's listeners\n * filter; at the organization level every row is the member's;\n * 5. what hangs off it — its logged activity, its tasks, its deals — is held\n * to the same visibility, row by row.\n *\n * A caller in the same process skips the plugin API dispatcher, and with it\n * the dispatcher's per-site enablement, release flag, lockdown and rate\n * limit; those are the caller's to establish before it asks (the AI plugin's\n * CRM admission does). Nothing here writes.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\ntype Refusal = { ok: false; status: 400 | 403 | 404; error: string }\n\n/** The org collection a contact lives in, beside the CRM's own. */\nconst CONTACTS_COLLECTION = 'contacts'\n\n/** Logged activities read per record before visibility and the timeline cut. */\nexport const CRM_FACTS_ACTIVITIES_READ = 40\n/** Tasks read per record, the record page's own window. */\nexport const CRM_FACTS_TASKS_READ = 50\n/** Deals read per contact or company. */\nexport const CRM_FACTS_DEALS_READ = 20\n/** Custom field definitions an import catalog reads. */\nexport const CRM_FACTS_FIELD_DEFINITIONS_READ = 100\n\nexport const CRM_FACTS_UNKNOWN_SITE = 'Unknown site'\nexport const CRM_FACTS_NOT_A_MEMBER = 'You are not a member of that organization'\nexport const CRM_FACTS_SITE_REFUSAL = 'Reading CRM records requires the data.manage permission on this site'\nexport const CRM_FACTS_ORG_REFUSAL =\n 'Reading CRM records across the organization requires the data.manage permission for the whole organization'\nexport const CRM_FACTS_LEAD_NEEDS_SITE = 'A lead is read on the site that captured it'\n\nconst NOT_FOUND: Record<CrmRecordFactsKind | 'import', string> = {\n contact: 'This contact could not be found. It may have been deleted.',\n company: 'This company could not be found. It may have been deleted.',\n deal: 'This deal could not be found. It may have been deleted.',\n lead: 'This lead could not be found. It may have been removed.',\n import: 'There is no such import.',\n}\n\n/** Where a read runs, once the caller has been admitted. */\ninterface CrmFactsScope {\n orgId: string\n hostId: string | null\n org: Data\n /** The site's consent group under a site; `null` at the organization level. */\n group: ConsentGroup | null\n /** Whether a row's `visibleTo` is readable here. */\n visible: (visibleTo: unknown) => boolean\n}\n\nfunction firestore(): Firestore {\n return firebaseAdmin.app().firestore()\n}\n\n/**\n * Rules 1 to 3: the site, the member and the plan. A refusal names what the\n * member lacks in the words the console's own CRM doors use.\n */\nexport async function crmFactsScope(request: PluginRecordFactsRequest): Promise<CrmFactsScope | Refusal> {\n const orgId = String(request.orgId ?? '').trim()\n const hostId = request.hostId ? String(request.hostId).trim() : null\n if (!orgId) return { ok: false, status: 400, error: 'Open a workspace first' }\n if (hostId && (await resolveOrgIdForHost(hostId)) !== orgId) {\n return { ok: false, status: 404, error: CRM_FACTS_UNKNOWN_SITE }\n }\n if (!request.staff) {\n const membership = await resolveOrgMembership(request.uid, orgId).catch(() => null)\n const member = membership?.orgId === orgId ? membership.member : null\n if (!member) return { ok: false, status: 403, error: CRM_FACTS_NOT_A_MEMBER }\n const reaches = hostId\n ? isOrgWideMember(member) || Boolean(member.hostAccess?.[hostId])\n : isOrgWideMember(member)\n const allowed = reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return { ok: false, status: 403, error: hostId ? CRM_FACTS_SITE_REFUSAL : CRM_FACTS_ORG_REFUSAL }\n }\n }\n const org = ((request.org as Data | null) ?? ((await getOrgDoc(orgId)) as Data | null)) || null\n if (!org) return { ok: false, status: 404, error: 'Unknown organization' }\n const suite = crmSuiteRefusal(org, 'Reading a CRM record')\n if (suite) return { ok: false, status: 403, error: suite.body.error }\n if (!hostId) return { orgId, hostId: null, org, group: null, visible: () => true }\n const group = await consentGroupForSite(hostId, org)\n const readable = new Set<string>(crmReadTokens(group))\n return {\n orgId,\n hostId,\n org,\n group,\n visible: (visibleTo) =>\n Array.isArray(visibleTo) && visibleTo.some((token) => readable.has(String(token))),\n }\n}\n\nconst orgCollection = (orgId: string, name: string) =>\n firestore().collection('orgs').doc(orgId).collection(name)\n\n/** Rows of an org collection naming one record, held to the scope's visibility. */\nasync function rowsNaming<T>(\n scope: CrmFactsScope,\n input: { collection: string; field: string; id: string; orderBy: string; direction: 'asc' | 'desc'; limit: number },\n): Promise<T[]> {\n const snapshot = await orgCollection(scope.orgId, input.collection)\n .where(input.field, '==', input.id)\n .orderBy(input.orderBy, input.direction)\n .limit(input.limit)\n .get()\n return snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Data)\n .filter((row) => scope.visible(row['visibleTo'])) as T[]\n}\n\n/** The pipelines some deals name, by id, read once each. */\nasync function pipelinesFor(scope: CrmFactsScope, deals: ReadonlyArray<Partial<CrmDeal>>): Promise<Map<string, CrmPipeline>> {\n const ids = [...new Set(deals.map((deal) => String(deal.pipelineId ?? '')).filter(Boolean))]\n const snapshots = await Promise.all(ids.map((id) => orgCollection(scope.orgId, CRM_COLLECTIONS.pipelines).doc(id).get()))\n return new Map(\n snapshots\n .filter((snapshot) => snapshot.exists)\n .map((snapshot) => [snapshot.id, (snapshot.data() ?? {}) as CrmPipeline]),\n )\n}\n\n/** A record document the scope may read, or `null`. */\nasync function visibleDoc(scope: CrmFactsScope, collection: string, id: string): Promise<Data | null> {\n if (!id) return null\n const snapshot = await orgCollection(scope.orgId, collection).doc(id).get()\n const data = snapshot.exists ? ((snapshot.data() ?? {}) as Data) : null\n return data && scope.visible(data['visibleTo']) ? data : null\n}\n\nconst refused = (kind: CrmRecordFactsKind | 'import', status: 400 | 404 = 404): Refusal => ({\n ok: false,\n status,\n error: NOT_FOUND[kind],\n})\n\n/** A reader that admits the caller, then reads the record. */\nfunction reader(read: (scope: CrmFactsScope, request: PluginRecordFactsRequest) => Promise<PluginRecordFactsRead>): PluginRecordFactsReader {\n return {\n read: async (request) => {\n const scope = await crmFactsScope(request)\n if ('ok' in scope) return scope\n return read(scope, request)\n },\n }\n}\n\nexport const crmContactFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const row = await visibleDoc(scope, CONTACTS_COLLECTION, id)\n if (!row) return refused('contact')\n const group = scope.group ?? contactPrimaryGroup(row, scope.org)\n const naming = { field: 'contactId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: { ...contactFacts({ row, group, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }) },\n }\n})\n\nexport const crmCompanyFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const company = await visibleDoc(scope, CRM_COLLECTIONS.companies, id)\n if (!company) return refused('company')\n const naming = { field: 'companyId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: {\n ...companyFacts({ company: company as Partial<CrmCompany>, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }),\n },\n }\n})\n\nexport const crmDealFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const deal = (await visibleDoc(scope, CRM_COLLECTIONS.deals, id)) as (Partial<CrmDeal> & Data) | null\n if (!deal) return refused('deal')\n const naming = { field: 'dealId', id }\n const [activities, tasks, pipelines] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n pipelinesFor(scope, [deal]),\n ])\n return {\n ok: true,\n facts: {\n ...dealFacts({\n deal,\n pipeline: pipelines.get(String(deal.pipelineId ?? '')) ?? null,\n activities,\n tasks,\n nowMs: request.now.getTime(),\n }),\n },\n }\n})\n\nexport const crmLeadFactsReader = reader(async (scope, request) => {\n if (!scope.hostId) return { ok: false, status: 400, error: CRM_FACTS_LEAD_NEEDS_SITE }\n const id = String(request.id ?? '').trim()\n if (!id) return refused('lead')\n const snapshot = await firestore().collection('hosts').doc(scope.hostId).collection('leads').doc(id).get()\n if (!snapshot.exists) return refused('lead')\n const activities = await rowsNaming<CrmActivity>(scope, {\n field: 'leadId',\n id,\n collection: CRM_COLLECTIONS.activities,\n orderBy: 'atMs',\n direction: 'desc',\n limit: CRM_FACTS_ACTIVITIES_READ,\n })\n return { ok: true, facts: { ...leadFacts({ lead: (snapshot.data() ?? {}) as Data, activities }) } }\n})\n\nexport const crmImportFactsReader = reader(async (scope, request) => {\n const collection = String(request.id ?? '').trim()\n if (!isCrmImportFactsCollection(collection)) return refused('import', 400)\n const snapshot = await orgCollection(scope.orgId, CRM_COLLECTIONS.contactFields)\n .limit(CRM_FACTS_FIELD_DEFINITIONS_READ)\n .get()\n const definitions = snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Partial<ContactFieldDefinition> & Data)\n .filter((definition) => scope.visible(definition['visibleTo']))\n return { ok: true, facts: { ...importFacts(collection, definitions) } }\n})\n\n/** Every reader this plugin registers, by resource name. */\nexport const CRM_FACTS_READERS: Readonly<Record<string, PluginRecordFactsReader>> = {\n [CRM_RECORD_FACTS_RESOURCES.contact]: crmContactFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.company]: crmCompanyFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.deal]: crmDealFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.lead]: crmLeadFactsReader,\n [CRM_IMPORT_FACTS_RESOURCE]: crmImportFactsReader,\n}\n\n/**\n * Registers the readers; the console surface calls it, the one process that\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCrmRecordFactsReaders(): void {\n for (const [resource, factsReader] of Object.entries(CRM_FACTS_READERS)) {\n registerPluginRecordFactsReader(resource, factsReader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["registerPluginRecordFactsReader","CRM_COLLECTIONS","contactPrimaryGroup","crmReadTokens","isOrgWideMember","consentGroupForSite","firebaseAdmin","getOrgDoc","memberHasOrgPermission","resolveOrgIdForHost","resolveOrgMembership","BUNDLE_ID","CRM_IMPORT_FACTS_RESOURCE","CRM_RECORD_FACTS_RESOURCES","companyFacts","contactFacts","dealFacts","importFacts","isCrmImportFactsCollection","leadFacts","crmSuiteRefusal","CONTACTS_COLLECTION","CRM_FACTS_ACTIVITIES_READ","CRM_FACTS_TASKS_READ","CRM_FACTS_DEALS_READ","CRM_FACTS_FIELD_DEFINITIONS_READ","CRM_FACTS_UNKNOWN_SITE","CRM_FACTS_NOT_A_MEMBER","CRM_FACTS_SITE_REFUSAL","CRM_FACTS_ORG_REFUSAL","CRM_FACTS_LEAD_NEEDS_SITE","NOT_FOUND","contact","company","deal","lead","import","firestore","app","crmFactsScope","request","orgId","String","trim","hostId","ok","status","error","staff","member","membership","uid","catch","reaches","Boolean","hostAccess","allowed","org","suite","body","group","visible","readable","Set","visibleTo","Array","isArray","some","token","has","orgCollection","name","collection","doc","rowsNaming","scope","input","snapshot","where","field","id","orderBy","direction","limit","get","docs","map","data","filter","row","pipelinesFor","deals","ids","pipelineId","snapshots","Promise","all","pipelines","Map","exists","visibleDoc","refused","kind","reader","read","crmContactFactsReader","naming","activities","tasks","facts","nowMs","now","getTime","crmCompanyFactsReader","companies","crmDealFactsReader","pipeline","crmLeadFactsReader","crmImportFactsReader","contactFields","definitions","definition","CRM_FACTS_READERS","registerCrmRecordFactsReaders","resource","factsReader","Object","entries","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,+BAA+B,QAI1B,kDAAiD;AACxD,SACEC,eAAe,EACfC,mBAAmB,EACnBC,aAAa,EACbC,eAAe,QAQV,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,aAAa,EACbC,SAAS,EACTC,sBAAsB,EACtBC,mBAAmB,EACnBC,oBAAoB,QACf,2BAA0B;AACjC,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,yBAAyB,EACzBC,0BAA0B,EAC1BC,YAAY,EACZC,YAAY,EACZC,SAAS,EACTC,WAAW,EACXC,0BAA0B,EAC1BC,SAAS,QAEJ,2BAAuB;AAC9B,SAASC,eAAe,QAAQ,kBAAc;AAiC9C,iEAAiE,GACjE,MAAMC,sBAAsB;AAE5B,8EAA8E,GAC9E,OAAO,MAAMC,4BAA4B,GAAE;AAC3C,yDAAyD,GACzD,OAAO,MAAMC,uBAAuB,GAAE;AACtC,uCAAuC,GACvC,OAAO,MAAMC,uBAAuB,GAAE;AACtC,sDAAsD,GACtD,OAAO,MAAMC,mCAAmC,IAAG;AAEnD,OAAO,MAAMC,yBAAyB,eAAc;AACpD,OAAO,MAAMC,yBAAyB,4CAA2C;AACjF,OAAO,MAAMC,yBAAyB,uEAAsE;AAC5G,OAAO,MAAMC,wBACX,6GAA4G;AAC9G,OAAO,MAAMC,4BAA4B,8CAA6C;AAEtF,MAAMC,YAA2D;IAC/DC,SAAS;IACTC,SAAS;IACTC,MAAM;IACNC,MAAM;IACNC,QAAQ;AACV;AAaA,SAASC;IACP,OAAO/B,cAAcgC,GAAG,GAAGD,SAAS;AACtC;AAEA;;;CAGC,GACD,OAAO,eAAeE,cAAcC,OAAiC;QAC9CA,gBAkBPA;IAlBd,MAAMC,QAAQC,QAAOF,iBAAAA,QAAQC,KAAK,YAAbD,iBAAiB,IAAIG,IAAI;IAC9C,MAAMC,SAASJ,QAAQI,MAAM,GAAGF,OAAOF,QAAQI,MAAM,EAAED,IAAI,KAAK;IAChE,IAAI,CAACF,OAAO,OAAO;QAAEI,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAyB;IAC7E,IAAIH,UAAU,AAAC,MAAMnC,oBAAoBmC,YAAaH,OAAO;QAC3D,OAAO;YAAEI,IAAI;YAAOC,QAAQ;YAAKC,OAAOrB;QAAuB;IACjE;IACA,IAAI,CAACc,QAAQQ,KAAK,EAAE;YAKqBC;QAJvC,MAAMC,aAAa,MAAMxC,qBAAqB8B,QAAQW,GAAG,EAAEV,OAAOW,KAAK,CAAC,IAAM;QAC9E,MAAMH,SAASC,CAAAA,8BAAAA,WAAYT,KAAK,MAAKA,QAAQS,WAAWD,MAAM,GAAG;QACjE,IAAI,CAACA,QAAQ,OAAO;YAAEJ,IAAI;YAAOC,QAAQ;YAAKC,OAAOpB;QAAuB;QAC5E,MAAM0B,UAAUT,SACZxC,gBAAgB6C,WAAWK,SAAQL,qBAAAA,OAAOM,UAAU,qBAAjBN,kBAAmB,CAACL,OAAO,IAC9DxC,gBAAgB6C;QACpB,MAAMO,UAAUH,WAAY,MAAM7C,uBAAuBiC,OAAOQ,QAAQ;QACxE,IAAI,CAACO,SAAS;YACZ,OAAO;gBAAEX,IAAI;gBAAOC,QAAQ;gBAAKC,OAAOH,SAAShB,yBAAyBC;YAAsB;QAClG;IACF;IACA,MAAM4B,MAAM,EAAEjB,eAAAA,QAAQiB,GAAG,YAAXjB,eAAiC,MAAMjC,UAAUkC,WAA4B;IAC3F,IAAI,CAACgB,KAAK,OAAO;QAAEZ,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAuB;IACzE,MAAMW,QAAQtC,gBAAgBqC,KAAK;IACnC,IAAIC,OAAO,OAAO;QAAEb,IAAI;QAAOC,QAAQ;QAAKC,OAAOW,MAAMC,IAAI,CAACZ,KAAK;IAAC;IACpE,IAAI,CAACH,QAAQ,OAAO;QAAEH;QAAOG,QAAQ;QAAMa;QAAKG,OAAO;QAAMC,SAAS,IAAM;IAAK;IACjF,MAAMD,QAAQ,MAAMvD,oBAAoBuC,QAAQa;IAChD,MAAMK,WAAW,IAAIC,IAAY5D,cAAcyD;IAC/C,OAAO;QACLnB;QACAG;QACAa;QACAG;QACAC,SAAS,CAACG,YACRC,MAAMC,OAAO,CAACF,cAAcA,UAAUG,IAAI,CAAC,CAACC,QAAUN,SAASO,GAAG,CAAC3B,OAAO0B;IAC9E;AACF;AAEA,MAAME,gBAAgB,CAAC7B,OAAe8B,OACpClC,YAAYmC,UAAU,CAAC,QAAQC,GAAG,CAAChC,OAAO+B,UAAU,CAACD;AAEvD,iFAAiF,GACjF,eAAeG,WACbC,KAAoB,EACpBC,KAAmH;IAEnH,MAAMC,WAAW,MAAMP,cAAcK,MAAMlC,KAAK,EAAEmC,MAAMJ,UAAU,EAC/DM,KAAK,CAACF,MAAMG,KAAK,EAAE,MAAMH,MAAMI,EAAE,EACjCC,OAAO,CAACL,MAAMK,OAAO,EAAEL,MAAMM,SAAS,EACtCC,KAAK,CAACP,MAAMO,KAAK,EACjBC,GAAG;IACN,OAAOP,SAASQ,IAAI,CACjBC,GAAG,CAAC,CAACb;YAASA;gBAAAA,YAAAA,IAAIc,IAAI,cAARd,YAAc,CAAC;OAC7Be,MAAM,CAAC,CAACC,MAAQd,MAAMd,OAAO,CAAC4B,GAAG,CAAC,YAAY;AACnD;AAEA,0DAA0D,GAC1D,eAAeC,aAAaf,KAAoB,EAAEgB,KAAsC;IACtF,MAAMC,MAAM;WAAI,IAAI7B,IAAI4B,MAAML,GAAG,CAAC,CAACpD;gBAAgBA;mBAAPQ,QAAOR,mBAAAA,KAAK2D,UAAU,YAAf3D,mBAAmB;WAAKsD,MAAM,CAAClC;KAAU;IAC5F,MAAMwC,YAAY,MAAMC,QAAQC,GAAG,CAACJ,IAAIN,GAAG,CAAC,CAACN,KAAOV,cAAcK,MAAMlC,KAAK,EAAExC,gBAAgBgG,SAAS,EAAExB,GAAG,CAACO,IAAII,GAAG;IACrH,OAAO,IAAIc,IACTJ,UACGN,MAAM,CAAC,CAACX,WAAaA,SAASsB,MAAM,EACpCb,GAAG,CAAC,CAACT;YAA4BA;eAAf;YAACA,SAASG,EAAE;aAAGH,iBAAAA,SAASU,IAAI,cAAbV,iBAAmB,CAAC;SAAkB;;AAE9E;AAEA,qDAAqD,GACrD,eAAeuB,WAAWzB,KAAoB,EAAEH,UAAkB,EAAEQ,EAAU;QAG3CH;IAFjC,IAAI,CAACG,IAAI,OAAO;IAChB,MAAMH,WAAW,MAAMP,cAAcK,MAAMlC,KAAK,EAAE+B,YAAYC,GAAG,CAACO,IAAII,GAAG;IACzE,MAAMG,OAAOV,SAASsB,MAAM,IAAKtB,iBAAAA,SAASU,IAAI,cAAbV,iBAAmB,CAAC,IAAc;IACnE,OAAOU,QAAQZ,MAAMd,OAAO,CAAC0B,IAAI,CAAC,YAAY,IAAIA,OAAO;AAC3D;AAEA,MAAMc,UAAU,CAACC,MAAqCxD,SAAoB,GAAG,GAAe,CAAA;QAC1FD,IAAI;QACJC;QACAC,OAAOhB,SAAS,CAACuE,KAAK;IACxB,CAAA;AAEA,4DAA4D,GAC5D,SAASC,OAAOC,IAAiG;IAC/G,OAAO;QACLA,MAAM,OAAOhE;YACX,MAAMmC,QAAQ,MAAMpC,cAAcC;YAClC,IAAI,QAAQmC,OAAO,OAAOA;YAC1B,OAAO6B,KAAK7B,OAAOnC;QACrB;IACF;AACF;AAEA,OAAO,MAAMiE,wBAAwBF,OAAO,OAAO5B,OAAOnC;QACtCA,aAGJmC;IAHd,MAAMK,KAAKtC,QAAOF,cAAAA,QAAQwC,EAAE,YAAVxC,cAAc,IAAIG,IAAI;IACxC,MAAM8C,MAAM,MAAMW,WAAWzB,OAAOtD,qBAAqB2D;IACzD,IAAI,CAACS,KAAK,OAAOY,QAAQ;IACzB,MAAMzC,SAAQe,eAAAA,MAAMf,KAAK,YAAXe,eAAezE,oBAAoBuF,KAAKd,MAAMlB,GAAG;IAC/D,MAAMiD,SAAS;QAAE3B,OAAO;QAAaC;IAAG;IACxC,MAAM,CAAC2B,YAAYC,OAAOjB,MAAM,GAAG,MAAMI,QAAQC,GAAG,CAAC;QACnDtB,WAAwBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB0G,UAAU;YAAE1B,SAAS;YAAQC,WAAW;YAAQC,OAAO7D;;QAC/HoD,WAAoBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB2G,KAAK;YAAE3B,SAAS;YAAWC,WAAW;YAAOC,OAAO5D;;QACxHmD,WAAoBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB0F,KAAK;YAAEV,SAAS;YAAaC,WAAW;YAAQC,OAAO3D;;KAC5H;IACD,MAAMyE,YAAY,MAAMP,aAAaf,OAAOgB;IAC5C,OAAO;QACL9C,IAAI;QACJgE,OAAO,aAAK9F,aAAa;YAAE0E;YAAK7B;YAAO+C;YAAYC;YAAOjB;YAAOM;YAAWa,OAAOtE,QAAQuE,GAAG,CAACC,OAAO;QAAG;IAC3G;AACF,GAAE;AAEF,OAAO,MAAMC,wBAAwBV,OAAO,OAAO5B,OAAOnC;QACtCA;IAAlB,MAAMwC,KAAKtC,QAAOF,cAAAA,QAAQwC,EAAE,YAAVxC,cAAc,IAAIG,IAAI;IACxC,MAAMV,UAAU,MAAMmE,WAAWzB,OAAO1E,gBAAgBiH,SAAS,EAAElC;IACnE,IAAI,CAAC/C,SAAS,OAAOoE,QAAQ;IAC7B,MAAMK,SAAS;QAAE3B,OAAO;QAAaC;IAAG;IACxC,MAAM,CAAC2B,YAAYC,OAAOjB,MAAM,GAAG,MAAMI,QAAQC,GAAG,CAAC;QACnDtB,WAAwBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB0G,UAAU;YAAE1B,SAAS;YAAQC,WAAW;YAAQC,OAAO7D;;QAC/HoD,WAAoBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB2G,KAAK;YAAE3B,SAAS;YAAWC,WAAW;YAAOC,OAAO5D;;QACxHmD,WAAoBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB0F,KAAK;YAAEV,SAAS;YAAaC,WAAW;YAAQC,OAAO3D;;KAC5H;IACD,MAAMyE,YAAY,MAAMP,aAAaf,OAAOgB;IAC5C,OAAO;QACL9C,IAAI;QACJgE,OAAO,aACF/F,aAAa;YAAEmB,SAASA;YAAgC0E;YAAYC;YAAOjB;YAAOM;YAAWa,OAAOtE,QAAQuE,GAAG,CAACC,OAAO;QAAG;IAEjI;AACF,GAAE;AAEF,OAAO,MAAMG,qBAAqBZ,OAAO,OAAO5B,OAAOnC;QACnCA,aAcFyD,gBAAqB/D;IAdrC,MAAM8C,KAAKtC,QAAOF,cAAAA,QAAQwC,EAAE,YAAVxC,cAAc,IAAIG,IAAI;IACxC,MAAMT,OAAQ,MAAMkE,WAAWzB,OAAO1E,gBAAgB0F,KAAK,EAAEX;IAC7D,IAAI,CAAC9C,MAAM,OAAOmE,QAAQ;IAC1B,MAAMK,SAAS;QAAE3B,OAAO;QAAUC;IAAG;IACrC,MAAM,CAAC2B,YAAYC,OAAOX,UAAU,GAAG,MAAMF,QAAQC,GAAG,CAAC;QACvDtB,WAAwBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB0G,UAAU;YAAE1B,SAAS;YAAQC,WAAW;YAAQC,OAAO7D;;QAC/HoD,WAAoBC,OAAO,aAAK+B;YAAQlC,YAAYvE,gBAAgB2G,KAAK;YAAE3B,SAAS;YAAWC,WAAW;YAAOC,OAAO5D;;QACxHmE,aAAaf,OAAO;YAACzC;SAAK;KAC3B;IACD,OAAO;QACLW,IAAI;QACJgE,OAAO,aACF7F,UAAU;YACXkB;YACAkF,QAAQ,GAAEnB,iBAAAA,UAAUb,GAAG,CAAC1C,QAAOR,mBAAAA,KAAK2D,UAAU,YAAf3D,mBAAmB,gBAAxC+D,iBAAgD;YAC1DU;YACAC;YACAE,OAAOtE,QAAQuE,GAAG,CAACC,OAAO;QAC5B;IAEJ;AACF,GAAE;AAEF,OAAO,MAAMK,qBAAqBd,OAAO,OAAO5B,OAAOnC;QAEnCA,aAYgCqC;IAblD,IAAI,CAACF,MAAM/B,MAAM,EAAE,OAAO;QAAEC,IAAI;QAAOC,QAAQ;QAAKC,OAAOjB;IAA0B;IACrF,MAAMkD,KAAKtC,QAAOF,cAAAA,QAAQwC,EAAE,YAAVxC,cAAc,IAAIG,IAAI;IACxC,IAAI,CAACqC,IAAI,OAAOqB,QAAQ;IACxB,MAAMxB,WAAW,MAAMxC,YAAYmC,UAAU,CAAC,SAASC,GAAG,CAACE,MAAM/B,MAAM,EAAE4B,UAAU,CAAC,SAASC,GAAG,CAACO,IAAII,GAAG;IACxG,IAAI,CAACP,SAASsB,MAAM,EAAE,OAAOE,QAAQ;IACrC,MAAMM,aAAa,MAAMjC,WAAwBC,OAAO;QACtDI,OAAO;QACPC;QACAR,YAAYvE,gBAAgB0G,UAAU;QACtC1B,SAAS;QACTC,WAAW;QACXC,OAAO7D;IACT;IACA,OAAO;QAAEuB,IAAI;QAAMgE,OAAO,aAAK1F,UAAU;YAAEgB,IAAI,GAAG0C,iBAAAA,SAASU,IAAI,cAAbV,iBAAmB,CAAC;YAAY8B;QAAW;IAAK;AACpG,GAAE;AAEF,OAAO,MAAMW,uBAAuBf,OAAO,OAAO5B,OAAOnC;QAC7BA;IAA1B,MAAMgC,aAAa9B,QAAOF,cAAAA,QAAQwC,EAAE,YAAVxC,cAAc,IAAIG,IAAI;IAChD,IAAI,CAACzB,2BAA2BsD,aAAa,OAAO6B,QAAQ,UAAU;IACtE,MAAMxB,WAAW,MAAMP,cAAcK,MAAMlC,KAAK,EAAExC,gBAAgBsH,aAAa,EAC5EpC,KAAK,CAAC1D,kCACN2D,GAAG;IACN,MAAMoC,cAAc3C,SAASQ,IAAI,CAC9BC,GAAG,CAAC,CAACb;YAASA;gBAAAA,YAAAA,IAAIc,IAAI,cAARd,YAAc,CAAC;OAC7Be,MAAM,CAAC,CAACiC,aAAe9C,MAAMd,OAAO,CAAC4D,UAAU,CAAC,YAAY;IAC/D,OAAO;QAAE5E,IAAI;QAAMgE,OAAO,aAAK5F,YAAYuD,YAAYgD;IAAe;AACxE,GAAE;AAEF,0DAA0D,GAC1D,OAAO,MAAME,oBAAuE;IAClF,CAAC7G,2BAA2BmB,OAAO,CAAC,EAAEyE;IACtC,CAAC5F,2BAA2BoB,OAAO,CAAC,EAAEgF;IACtC,CAACpG,2BAA2BqB,IAAI,CAAC,EAAEiF;IACnC,CAACtG,2BAA2BsB,IAAI,CAAC,EAAEkF;IACnC,CAACzG,0BAA0B,EAAE0G;AAC/B,EAAC;AAED;;;CAGC,GACD,OAAO,SAASK;IACd,KAAK,MAAM,CAACC,UAAUC,YAAY,IAAIC,OAAOC,OAAO,CAACL,mBAAoB;QACvE1H,gCAAgC4H,UAAUC,aAAa;YAAEG,UAAUrH;QAAU;IAC/E;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-facts.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 registerPluginRecordFactsReader,\n type PluginRecordFactsRead,\n type PluginRecordFactsReader,\n type PluginRecordFactsRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-facts'\nimport {\n CRM_COLLECTIONS,\n contactPrimaryGroup,\n crmReadTokens,\n isOrgWideMember,\n type ConsentGroup,\n type ContactFieldDefinition,\n type CrmActivity,\n type CrmCompany,\n type CrmDeal,\n type CrmPipeline,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgDoc,\n memberHasOrgPermission,\n readLeadForHost,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n CRM_IMPORT_FACTS_RESOURCE,\n CRM_RECORD_FACTS_RESOURCES,\n companyFacts,\n contactFacts,\n dealFacts,\n importFacts,\n isCrmImportFactsCollection,\n leadFacts,\n type CrmRecordFactsKind,\n} from '../model/record-facts'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * THE CRM'S READERS ON THE CORE'S RECORD-FACTS SEAM (AGL-2917).\n *\n * Another plugin asks for a contact, a company, a deal, a lead or an import's\n * field catalog by resource name, and gets the facts `model/record-facts.ts`\n * reports — read here with the rules every other CRM door applies, for the\n * member the caller names:\n *\n * 1. the site belongs to the organization;\n * 2. the member reaches it — an org-wide member at the organization level, a\n * member of that site under it — and holds `data.manage`, the key the\n * whole CRM is gated on. A verified staff caller passes, as at every CRM\n * route;\n * 3. the organization's plan carries the CRM, asked after the caller is\n * known, the order `suite-gate.ts` gives;\n * 4. the record exists and is visible where it is read: under a site, its\n * `visibleTo` meets the site's read tokens, as the console's listeners\n * filter; at the organization level every row is the member's;\n * 5. what hangs off it — its logged activity, its tasks, its deals — is held\n * to the same visibility, row by row.\n *\n * A caller in the same process skips the plugin API dispatcher, and with it\n * the dispatcher's per-site enablement, release flag, lockdown and rate\n * limit; those are the caller's to establish before it asks (the AI plugin's\n * CRM admission does). Nothing here writes.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\ntype Refusal = { ok: false; status: 400 | 403 | 404; error: string }\n\n/** The org collection a contact lives in, beside the CRM's own. */\nconst CONTACTS_COLLECTION = 'contacts'\n\n/** Logged activities read per record before visibility and the timeline cut. */\nexport const CRM_FACTS_ACTIVITIES_READ = 40\n/** Tasks read per record, the record page's own window. */\nexport const CRM_FACTS_TASKS_READ = 50\n/** Deals read per contact or company. */\nexport const CRM_FACTS_DEALS_READ = 20\n/** Custom field definitions an import catalog reads. */\nexport const CRM_FACTS_FIELD_DEFINITIONS_READ = 100\n\nexport const CRM_FACTS_UNKNOWN_SITE = 'Unknown site'\nexport const CRM_FACTS_NOT_A_MEMBER = 'You are not a member of that organization'\nexport const CRM_FACTS_SITE_REFUSAL = 'Reading CRM records requires the data.manage permission on this site'\nexport const CRM_FACTS_ORG_REFUSAL =\n 'Reading CRM records across the organization requires the data.manage permission for the whole organization'\nexport const CRM_FACTS_LEAD_NEEDS_SITE = 'A lead is read on the site that captured it'\n\nconst NOT_FOUND: Record<CrmRecordFactsKind | 'import', string> = {\n contact: 'This contact could not be found. It may have been deleted.',\n company: 'This company could not be found. It may have been deleted.',\n deal: 'This deal could not be found. It may have been deleted.',\n lead: 'This lead could not be found. It may have been removed.',\n import: 'There is no such import.',\n}\n\n/** Where a read runs, once the caller has been admitted. */\ninterface CrmFactsScope {\n orgId: string\n hostId: string | null\n org: Data\n /** The site's consent group under a site; `null` at the organization level. */\n group: ConsentGroup | null\n /** Whether a row's `visibleTo` is readable here. */\n visible: (visibleTo: unknown) => boolean\n}\n\nfunction firestore(): Firestore {\n return firebaseAdmin.app().firestore()\n}\n\n/**\n * Rules 1 to 3: the site, the member and the plan. A refusal names what the\n * member lacks in the words the console's own CRM doors use.\n */\nexport async function crmFactsScope(request: PluginRecordFactsRequest): Promise<CrmFactsScope | Refusal> {\n const orgId = String(request.orgId ?? '').trim()\n const hostId = request.hostId ? String(request.hostId).trim() : null\n if (!orgId) return { ok: false, status: 400, error: 'Open a workspace first' }\n if (hostId && (await resolveOrgIdForHost(hostId)) !== orgId) {\n return { ok: false, status: 404, error: CRM_FACTS_UNKNOWN_SITE }\n }\n if (!request.staff) {\n const membership = await resolveOrgMembership(request.uid, orgId).catch(() => null)\n const member = membership?.orgId === orgId ? membership.member : null\n if (!member) return { ok: false, status: 403, error: CRM_FACTS_NOT_A_MEMBER }\n const reaches = hostId\n ? isOrgWideMember(member) || Boolean(member.hostAccess?.[hostId])\n : isOrgWideMember(member)\n const allowed = reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return { ok: false, status: 403, error: hostId ? CRM_FACTS_SITE_REFUSAL : CRM_FACTS_ORG_REFUSAL }\n }\n }\n const org = ((request.org as Data | null) ?? ((await getOrgDoc(orgId)) as Data | null)) || null\n if (!org) return { ok: false, status: 404, error: 'Unknown organization' }\n const suite = crmSuiteRefusal(org, 'Reading a CRM record')\n if (suite) return { ok: false, status: 403, error: suite.body.error }\n if (!hostId) return { orgId, hostId: null, org, group: null, visible: () => true }\n const group = await consentGroupForSite(hostId, org)\n const readable = new Set<string>(crmReadTokens(group))\n return {\n orgId,\n hostId,\n org,\n group,\n visible: (visibleTo) =>\n Array.isArray(visibleTo) && visibleTo.some((token) => readable.has(String(token))),\n }\n}\n\nconst orgCollection = (orgId: string, name: string) =>\n firestore().collection('orgs').doc(orgId).collection(name)\n\n/** Rows of an org collection naming one record, held to the scope's visibility. */\nasync function rowsNaming<T>(\n scope: CrmFactsScope,\n input: { collection: string; field: string; id: string; orderBy: string; direction: 'asc' | 'desc'; limit: number },\n): Promise<T[]> {\n const snapshot = await orgCollection(scope.orgId, input.collection)\n .where(input.field, '==', input.id)\n .orderBy(input.orderBy, input.direction)\n .limit(input.limit)\n .get()\n return snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Data)\n .filter((row) => scope.visible(row['visibleTo'])) as T[]\n}\n\n/** The pipelines some deals name, by id, read once each. */\nasync function pipelinesFor(scope: CrmFactsScope, deals: ReadonlyArray<Partial<CrmDeal>>): Promise<Map<string, CrmPipeline>> {\n const ids = [...new Set(deals.map((deal) => String(deal.pipelineId ?? '')).filter(Boolean))]\n const snapshots = await Promise.all(ids.map((id) => orgCollection(scope.orgId, CRM_COLLECTIONS.pipelines).doc(id).get()))\n return new Map(\n snapshots\n .filter((snapshot) => snapshot.exists)\n .map((snapshot) => [snapshot.id, (snapshot.data() ?? {}) as CrmPipeline]),\n )\n}\n\n/** A record document the scope may read, or `null`. */\nasync function visibleDoc(scope: CrmFactsScope, collection: string, id: string): Promise<Data | null> {\n if (!id) return null\n const snapshot = await orgCollection(scope.orgId, collection).doc(id).get()\n const data = snapshot.exists ? ((snapshot.data() ?? {}) as Data) : null\n return data && scope.visible(data['visibleTo']) ? data : null\n}\n\nconst refused = (kind: CrmRecordFactsKind | 'import', status: 400 | 404 = 404): Refusal => ({\n ok: false,\n status,\n error: NOT_FOUND[kind],\n})\n\n/** A reader that admits the caller, then reads the record. */\nfunction reader(read: (scope: CrmFactsScope, request: PluginRecordFactsRequest) => Promise<PluginRecordFactsRead>): PluginRecordFactsReader {\n return {\n read: async (request) => {\n const scope = await crmFactsScope(request)\n if ('ok' in scope) return scope\n return read(scope, request)\n },\n }\n}\n\nexport const crmContactFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const row = await visibleDoc(scope, CONTACTS_COLLECTION, id)\n if (!row) return refused('contact')\n const group = scope.group ?? contactPrimaryGroup(row, scope.org)\n const naming = { field: 'contactId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: { ...contactFacts({ row, group, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }) },\n }\n})\n\nexport const crmCompanyFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const company = await visibleDoc(scope, CRM_COLLECTIONS.companies, id)\n if (!company) return refused('company')\n const naming = { field: 'companyId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: {\n ...companyFacts({ company: company as Partial<CrmCompany>, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }),\n },\n }\n})\n\nexport const crmDealFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const deal = (await visibleDoc(scope, CRM_COLLECTIONS.deals, id)) as (Partial<CrmDeal> & Data) | null\n if (!deal) return refused('deal')\n const naming = { field: 'dealId', id }\n const [activities, tasks, pipelines] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n pipelinesFor(scope, [deal]),\n ])\n return {\n ok: true,\n facts: {\n ...dealFacts({\n deal,\n pipeline: pipelines.get(String(deal.pipelineId ?? '')) ?? null,\n activities,\n tasks,\n nowMs: request.now.getTime(),\n }),\n },\n }\n})\n\nexport const crmLeadFactsReader = reader(async (scope, request) => {\n if (!scope.hostId) return { ok: false, status: 400, error: CRM_FACTS_LEAD_NEEDS_SITE }\n const id = String(request.id ?? '').trim()\n if (!id) return refused('lead')\n const snapshot = await readLeadForHost(scope.hostId, id)\n if (!snapshot) return refused('lead')\n const activities = await rowsNaming<CrmActivity>(scope, {\n field: 'leadId',\n id,\n collection: CRM_COLLECTIONS.activities,\n orderBy: 'atMs',\n direction: 'desc',\n limit: CRM_FACTS_ACTIVITIES_READ,\n })\n return { ok: true, facts: { ...leadFacts({ lead: (snapshot.data() ?? {}) as Data, activities }) } }\n})\n\nexport const crmImportFactsReader = reader(async (scope, request) => {\n const collection = String(request.id ?? '').trim()\n if (!isCrmImportFactsCollection(collection)) return refused('import', 400)\n const snapshot = await orgCollection(scope.orgId, CRM_COLLECTIONS.contactFields)\n .limit(CRM_FACTS_FIELD_DEFINITIONS_READ)\n .get()\n const definitions = snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Partial<ContactFieldDefinition> & Data)\n .filter((definition) => scope.visible(definition['visibleTo']))\n return { ok: true, facts: { ...importFacts(collection, definitions) } }\n})\n\n/** Every reader this plugin registers, by resource name. */\nexport const CRM_FACTS_READERS: Readonly<Record<string, PluginRecordFactsReader>> = {\n [CRM_RECORD_FACTS_RESOURCES.contact]: crmContactFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.company]: crmCompanyFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.deal]: crmDealFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.lead]: crmLeadFactsReader,\n [CRM_IMPORT_FACTS_RESOURCE]: crmImportFactsReader,\n}\n\n/**\n * Registers the readers; the console surface calls it, the one process that\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCrmRecordFactsReaders(): void {\n for (const [resource, factsReader] of Object.entries(CRM_FACTS_READERS)) {\n registerPluginRecordFactsReader(resource, factsReader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["registerPluginRecordFactsReader","CRM_COLLECTIONS","contactPrimaryGroup","crmReadTokens","isOrgWideMember","consentGroupForSite","firebaseAdmin","getOrgDoc","memberHasOrgPermission","readLeadForHost","resolveOrgIdForHost","resolveOrgMembership","BUNDLE_ID","CRM_IMPORT_FACTS_RESOURCE","CRM_RECORD_FACTS_RESOURCES","companyFacts","contactFacts","dealFacts","importFacts","isCrmImportFactsCollection","leadFacts","crmSuiteRefusal","CONTACTS_COLLECTION","CRM_FACTS_ACTIVITIES_READ","CRM_FACTS_TASKS_READ","CRM_FACTS_DEALS_READ","CRM_FACTS_FIELD_DEFINITIONS_READ","CRM_FACTS_UNKNOWN_SITE","CRM_FACTS_NOT_A_MEMBER","CRM_FACTS_SITE_REFUSAL","CRM_FACTS_ORG_REFUSAL","CRM_FACTS_LEAD_NEEDS_SITE","NOT_FOUND","contact","company","deal","lead","import","firestore","app","crmFactsScope","request","orgId","String","trim","hostId","ok","status","error","staff","member","membership","uid","catch","reaches","Boolean","hostAccess","allowed","org","suite","body","group","visible","readable","Set","visibleTo","Array","isArray","some","token","has","orgCollection","name","collection","doc","rowsNaming","scope","input","snapshot","where","field","id","orderBy","direction","limit","get","docs","map","data","filter","row","pipelinesFor","deals","ids","pipelineId","snapshots","Promise","all","pipelines","Map","exists","visibleDoc","refused","kind","reader","read","crmContactFactsReader","naming","activities","tasks","facts","nowMs","now","getTime","crmCompanyFactsReader","companies","crmDealFactsReader","pipeline","crmLeadFactsReader","crmImportFactsReader","contactFields","definitions","definition","CRM_FACTS_READERS","registerCrmRecordFactsReaders","resource","factsReader","Object","entries","pluginId"],"mappings":"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"}