@aglyn/plugins-crm 1.0.0-beta.178 → 1.0.0-beta.180
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contact-detail-page.js +1 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-properties-card.d.ts +6 -0
- package/src/lib/components/contact-properties-card.js +25 -2
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/csv-import-drawer.d.ts +7 -0
- package/src/lib/components/csv-import-drawer.js +7 -0
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/fields-section.js +6 -0
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-import-drawer.d.ts +3 -0
- package/src/lib/components/lead-import-drawer.js +29 -6
- package/src/lib/components/lead-import-drawer.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +10 -12
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-source-select.d.ts +30 -0
- package/src/lib/components/lead-source-select.js +96 -0
- package/src/lib/components/lead-source-select.js.map +1 -0
- package/src/lib/components/lead-source-values-card.d.ts +27 -0
- package/src/lib/components/lead-source-values-card.js +606 -0
- package/src/lib/components/lead-source-values-card.js.map +1 -0
- package/src/lib/components/leads-section.js +100 -4
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +27 -11
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/hooks/use-lead-source-picklist.d.ts +23 -0
- package/src/lib/hooks/use-lead-source-picklist.js +50 -0
- package/src/lib/hooks/use-lead-source-picklist.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +2 -0
- package/src/lib/model/contact-record.js +2 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +10 -2
- package/src/lib/model/crm-lead-import.js +19 -1
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/crm-record-routes.js +4 -4
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +13 -0
- package/src/lib/model/lead-filters.js +17 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/lead-source-values.d.ts +82 -0
- package/src/lib/model/lead-source-values.js +149 -0
- package/src/lib/model/lead-source-values.js.map +1 -0
- package/src/lib/server/contact-update.js +25 -2
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/lead-create.js +19 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-source-picklist.d.ts +42 -0
- package/src/lib/server/lead-source-picklist.js +52 -0
- package/src/lib/server/lead-source-picklist.js.map +1 -0
- package/src/lib/server/lead-source-values.d.ts +50 -0
- package/src/lib/server/lead-source-values.js +187 -0
- package/src/lib/server/lead-source-values.js.map +1 -0
- package/src/lib/server/leads-import.js +31 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.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 orgLeadsForHost,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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"}
|
|
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 { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value\n * outside the list is refused under the field naming what the list\n * allows, a lead the site already held keeps the value it has, and a\n * new lead that named none starts from the list's default.\n */\n const leadSource = resolveLeadSourceWrite(\n await readLeadSourcePicklist(firestore, resolved.orgId),\n patch.leadSource,\n { current: before?.get('leadSource'), created },\n )\n if (leadSource.ok === false) {\n res.status(400).json({ error: leadSource.error, field: 'leadSource' })\n return\n }\n if (leadSource.write === undefined) delete patch.leadSource\n else patch.leadSource = leadSource.write\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","readLeadSourcePicklist","resolveLeadSourceWrite","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","leadSource","current","ok","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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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type CrmPicklist } from '@aglyn/aglyn/server';
|
|
18
|
+
/** The org's lead source list as every server door judges against it (AGL-3298). */
|
|
19
|
+
export declare function readLeadSourcePicklist(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<CrmPicklist>;
|
|
20
|
+
/**
|
|
21
|
+
* What a lead write stores as its lead source (AGL-3298), from what the
|
|
22
|
+
* request said about it:
|
|
23
|
+
*
|
|
24
|
+
* - `undefined` (not named): nothing — except on a CREATE, which starts
|
|
25
|
+
* from the list's default when it has one, as Salesforce's does.
|
|
26
|
+
* - `null` (cleared): the clear.
|
|
27
|
+
* - a label: {@link judgeCrmLeadSource} — an active value's label, the
|
|
28
|
+
* record's own current value kept, anything else refused naming the
|
|
29
|
+
* values the list allows.
|
|
30
|
+
*
|
|
31
|
+
* `write` undefined means leave the field alone.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveLeadSourceWrite(picklist: CrmPicklist, requested: string | null | undefined, options: {
|
|
34
|
+
current?: unknown;
|
|
35
|
+
created: boolean;
|
|
36
|
+
}): {
|
|
37
|
+
ok: true;
|
|
38
|
+
write: string | null | undefined;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
error: string;
|
|
42
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, crmPicklistDefaultLabel, effectiveCrmLeadSourcePicklist, judgeCrmLeadSource } from "@aglyn/aglyn/server";
|
|
17
|
+
/** The org's lead source list as every server door judges against it (AGL-3298). */ export async function readLeadSourcePicklist(firestore, orgId) {
|
|
18
|
+
const snapshot = await firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get();
|
|
19
|
+
return effectiveCrmLeadSourcePicklist(snapshot.data());
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What a lead write stores as its lead source (AGL-3298), from what the
|
|
23
|
+
* request said about it:
|
|
24
|
+
*
|
|
25
|
+
* - `undefined` (not named): nothing — except on a CREATE, which starts
|
|
26
|
+
* from the list's default when it has one, as Salesforce's does.
|
|
27
|
+
* - `null` (cleared): the clear.
|
|
28
|
+
* - a label: {@link judgeCrmLeadSource} — an active value's label, the
|
|
29
|
+
* record's own current value kept, anything else refused naming the
|
|
30
|
+
* values the list allows.
|
|
31
|
+
*
|
|
32
|
+
* `write` undefined means leave the field alone.
|
|
33
|
+
*/ export function resolveLeadSourceWrite(picklist, requested, options) {
|
|
34
|
+
if (requested === undefined) {
|
|
35
|
+
const fallback = options.created ? crmPicklistDefaultLabel(picklist) : null;
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
write: fallback != null ? fallback : undefined
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (requested === null) return {
|
|
42
|
+
ok: true,
|
|
43
|
+
write: null
|
|
44
|
+
};
|
|
45
|
+
const judged = judgeCrmLeadSource(picklist, requested, options.current);
|
|
46
|
+
return judged.ok === false ? judged : {
|
|
47
|
+
ok: true,
|
|
48
|
+
write: judged.value
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=lead-source-picklist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-source-picklist.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 CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmPicklist,\n crmPicklistDefaultLabel,\n effectiveCrmLeadSourcePicklist,\n judgeCrmLeadSource,\n} from '@aglyn/aglyn/server'\n\n/** The org's lead source list as every server door judges against it (AGL-3298). */\nexport async function readLeadSourcePicklist(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmPicklist> {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.picklists)\n .doc(CRM_LEAD_SOURCE_PICKLIST)\n .get()\n return effectiveCrmLeadSourcePicklist(snapshot.data())\n}\n\n/**\n * What a lead write stores as its lead source (AGL-3298), from what the\n * request said about it:\n *\n * - `undefined` (not named): nothing — except on a CREATE, which starts\n * from the list's default when it has one, as Salesforce's does.\n * - `null` (cleared): the clear.\n * - a label: {@link judgeCrmLeadSource} — an active value's label, the\n * record's own current value kept, anything else refused naming the\n * values the list allows.\n *\n * `write` undefined means leave the field alone.\n */\nexport function resolveLeadSourceWrite(\n picklist: CrmPicklist,\n requested: string | null | undefined,\n options: { current?: unknown; created: boolean },\n): { ok: true; write: string | null | undefined } | { ok: false; error: string } {\n if (requested === undefined) {\n const fallback = options.created ? crmPicklistDefaultLabel(picklist) : null\n return { ok: true, write: fallback ?? undefined }\n }\n if (requested === null) return { ok: true, write: null }\n const judged = judgeCrmLeadSource(picklist, requested, options.current)\n return judged.ok === false ? judged : { ok: true, write: judged.value }\n}\n"],"names":["CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","crmPicklistDefaultLabel","effectiveCrmLeadSourcePicklist","judgeCrmLeadSource","readLeadSourcePicklist","firestore","orgId","snapshot","collection","doc","picklists","get","data","resolveLeadSourceWrite","picklist","requested","options","undefined","fallback","created","ok","write","judged","current","value"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,eAAe,EACfC,wBAAwB,EAExBC,uBAAuB,EACvBC,8BAA8B,EAC9BC,kBAAkB,QACb,sBAAqB;AAE5B,kFAAkF,GAClF,OAAO,eAAeC,uBACpBC,SAAsC,EACtCC,KAAa;IAEb,MAAMC,WAAW,MAAMF,UACpBG,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACT,gBAAgBW,SAAS,EACpCD,GAAG,CAACT,0BACJW,GAAG;IACN,OAAOT,+BAA+BK,SAASK,IAAI;AACrD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,uBACdC,QAAqB,EACrBC,SAAoC,EACpCC,OAAgD;IAEhD,IAAID,cAAcE,WAAW;QAC3B,MAAMC,WAAWF,QAAQG,OAAO,GAAGlB,wBAAwBa,YAAY;QACvE,OAAO;YAAEM,IAAI;YAAMC,KAAK,EAAEH,mBAAAA,WAAYD;QAAU;IAClD;IACA,IAAIF,cAAc,MAAM,OAAO;QAAEK,IAAI;QAAMC,OAAO;IAAK;IACvD,MAAMC,SAASnB,mBAAmBW,UAAUC,WAAWC,QAAQO,OAAO;IACtE,OAAOD,OAAOF,EAAE,KAAK,QAAQE,SAAS;QAAEF,IAAI;QAAMC,OAAOC,OAAOE,KAAK;IAAC;AACxE"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
/**
|
|
19
|
+
* `POST crm/lead-source-values` — rename a lead source value, or delete one
|
|
20
|
+
* and move its records to another (AGL-3298).
|
|
21
|
+
*
|
|
22
|
+
* Body: `{ hostId | orgId, action: 'rename', valueId, label }` or
|
|
23
|
+
* `{ hostId | orgId, action: 'delete', valueId, replaceWith: label | null }`.
|
|
24
|
+
*
|
|
25
|
+
* Records store the value's LABEL (see the picklist block in `crm.ts`), so
|
|
26
|
+
* both moves change records as well as the list: every lead and every
|
|
27
|
+
* contact holding the old label is rewritten to the new one — or, for a
|
|
28
|
+
* delete with no replacement, cleared. That is Salesforce's Replace, done
|
|
29
|
+
* as part of the move, so a report grouped by lead source follows a rename
|
|
30
|
+
* instead of splitting into the old name and the new.
|
|
31
|
+
*
|
|
32
|
+
* ## The list first, then the records
|
|
33
|
+
*
|
|
34
|
+
* The list is written in a transaction before any record: from that moment
|
|
35
|
+
* no picker offers the old label and no import or API write accepts it, so
|
|
36
|
+
* the sweep that follows cannot be outrun by a new record taking the name
|
|
37
|
+
* it is removing. A sweep interrupted part-way leaves records holding a
|
|
38
|
+
* label the list no longer has, which they show as "(not in the list)"; the
|
|
39
|
+
* same request sent again finishes it, because the rename of a value to its
|
|
40
|
+
* own current label is a no-op on the list and still sweeps.
|
|
41
|
+
*
|
|
42
|
+
* ## Who may
|
|
43
|
+
*
|
|
44
|
+
* Whoever may write the CRM — `authorizeCrmWriter`, the question every CRM
|
|
45
|
+
* route asks — which is who the rules let edit the list itself. The sweep
|
|
46
|
+
* reaches every lead and contact in the org whatever the caller's scope:
|
|
47
|
+
* it changes one label to another that the same caller could already have
|
|
48
|
+
* picked, and reveals nothing about the records but their count.
|
|
49
|
+
*/
|
|
50
|
+
export declare const crmLeadSourceValuesHandler: PluginApiHandler;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { consentGroupForHost, CONTACT_FACETS_FIELD, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, effectiveCrmLeadSourcePicklist, newResourceScopeFields, normalizeCrmPicklist, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
|
|
18
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
20
|
+
import { deletePicklistValue, renamePicklistValue } from "../model/lead-source-values.js";
|
|
21
|
+
import { orgHostIds, readCrmRouteScope } from "./org-caller.js";
|
|
22
|
+
import { authorizeCrmWriter } from "./task-routes.js";
|
|
23
|
+
/** What the suite gate names when a plan without the CRM asks. */ const SUITE_ACT = "Editing the organization's lead sources";
|
|
24
|
+
/** Writes per batch — under Firestore's 500, with room to spare. */ const BATCH_SIZE = 400;
|
|
25
|
+
/**
|
|
26
|
+
* Set `field` to `to` (or remove it, for `null`) on every document `query`
|
|
27
|
+
* finds holding `from`, a batch at a time. Answers how many were written.
|
|
28
|
+
*
|
|
29
|
+
* The query is re-run after each batch rather than paged, because each
|
|
30
|
+
* batch moves its documents OUT of the query's answer — the next run finds
|
|
31
|
+
* only what is left, and a run that finds nothing is the end.
|
|
32
|
+
*/ async function replaceEverywhere(firestore, query, field, to) {
|
|
33
|
+
let written = 0;
|
|
34
|
+
for(;;){
|
|
35
|
+
const page = await query.limit(BATCH_SIZE).get();
|
|
36
|
+
if (page.empty) return written;
|
|
37
|
+
const batch = firestore.batch();
|
|
38
|
+
for (const snapshot of page.docs){
|
|
39
|
+
batch.update(snapshot.ref, field, to != null ? to : FieldValue.delete(), 'updatedAt', FieldValue.serverTimestamp());
|
|
40
|
+
}
|
|
41
|
+
await batch.commit();
|
|
42
|
+
written += page.size;
|
|
43
|
+
if (page.size < BATCH_SIZE) return written;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `POST crm/lead-source-values` — rename a lead source value, or delete one
|
|
48
|
+
* and move its records to another (AGL-3298).
|
|
49
|
+
*
|
|
50
|
+
* Body: `{ hostId | orgId, action: 'rename', valueId, label }` or
|
|
51
|
+
* `{ hostId | orgId, action: 'delete', valueId, replaceWith: label | null }`.
|
|
52
|
+
*
|
|
53
|
+
* Records store the value's LABEL (see the picklist block in `crm.ts`), so
|
|
54
|
+
* both moves change records as well as the list: every lead and every
|
|
55
|
+
* contact holding the old label is rewritten to the new one — or, for a
|
|
56
|
+
* delete with no replacement, cleared. That is Salesforce's Replace, done
|
|
57
|
+
* as part of the move, so a report grouped by lead source follows a rename
|
|
58
|
+
* instead of splitting into the old name and the new.
|
|
59
|
+
*
|
|
60
|
+
* ## The list first, then the records
|
|
61
|
+
*
|
|
62
|
+
* The list is written in a transaction before any record: from that moment
|
|
63
|
+
* no picker offers the old label and no import or API write accepts it, so
|
|
64
|
+
* the sweep that follows cannot be outrun by a new record taking the name
|
|
65
|
+
* it is removing. A sweep interrupted part-way leaves records holding a
|
|
66
|
+
* label the list no longer has, which they show as "(not in the list)"; the
|
|
67
|
+
* same request sent again finishes it, because the rename of a value to its
|
|
68
|
+
* own current label is a no-op on the list and still sweeps.
|
|
69
|
+
*
|
|
70
|
+
* ## Who may
|
|
71
|
+
*
|
|
72
|
+
* Whoever may write the CRM — `authorizeCrmWriter`, the question every CRM
|
|
73
|
+
* route asks — which is who the rules let edit the list itself. The sweep
|
|
74
|
+
* reaches every lead and contact in the org whatever the caller's scope:
|
|
75
|
+
* it changes one label to another that the same caller could already have
|
|
76
|
+
* picked, and reveals nothing about the records but their count.
|
|
77
|
+
*/ export const crmLeadSourceValuesHandler = async (req, res)=>{
|
|
78
|
+
var _req_body, _body_valueId;
|
|
79
|
+
if (req.method !== 'POST') {
|
|
80
|
+
res.setHeader('Allow', 'POST');
|
|
81
|
+
res.status(405).json({
|
|
82
|
+
error: 'Method not allowed'
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const body = (_req_body = req.body) != null ? _req_body : {};
|
|
87
|
+
const scope = readCrmRouteScope(body);
|
|
88
|
+
if (!scope) {
|
|
89
|
+
res.status(400).json({
|
|
90
|
+
error: 'Missing hostId'
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const action = body['action'];
|
|
95
|
+
const valueId = String((_body_valueId = body['valueId']) != null ? _body_valueId : '').trim().slice(0, 64);
|
|
96
|
+
if (action !== 'rename' && action !== 'delete' || !valueId) {
|
|
97
|
+
res.status(400).json({
|
|
98
|
+
error: 'Name a value, and whether to rename or delete it.'
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const replaceWith = body['replaceWith'] === null || body['replaceWith'] === undefined ? null : String(body['replaceWith']);
|
|
103
|
+
try {
|
|
104
|
+
const writer = await authorizeCrmWriter(req, scope, {
|
|
105
|
+
suiteAct: SUITE_ACT
|
|
106
|
+
});
|
|
107
|
+
if (writer.ok === false) {
|
|
108
|
+
res.status(writer.status).json(writer.body);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
112
|
+
const orgRef = firestore.collection('orgs').doc(writer.orgId);
|
|
113
|
+
const listRef = orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST);
|
|
114
|
+
/*
|
|
115
|
+
* THE LIST. Read and written in one transaction, so two admins editing
|
|
116
|
+
* at once cannot each write a list missing the other's change.
|
|
117
|
+
*/ const moved = await firestore.runTransaction(async (transaction)=>{
|
|
118
|
+
var _body_label, _ref, _ref1;
|
|
119
|
+
var _move_picklist_values_find, _move_picklist_values_find1;
|
|
120
|
+
const snapshot = await transaction.get(listRef);
|
|
121
|
+
const before = effectiveCrmLeadSourcePicklist(snapshot.data());
|
|
122
|
+
const value = before.values.find((entry)=>entry.id === valueId);
|
|
123
|
+
if (!value) return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: 'That value is no longer in the list.'
|
|
126
|
+
};
|
|
127
|
+
const move = action === 'rename' ? renamePicklistValue(before, valueId, String((_body_label = body['label']) != null ? _body_label : '')) : deletePicklistValue(before, valueId, replaceWith);
|
|
128
|
+
if (move.ok === false) return move;
|
|
129
|
+
const created = normalizeCrmPicklist(snapshot.data()) === null;
|
|
130
|
+
transaction.set(listRef, _extends({
|
|
131
|
+
values: move.picklist.values,
|
|
132
|
+
defaultValueId: move.picklist.defaultValueId,
|
|
133
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
134
|
+
}, created ? _extends({
|
|
135
|
+
hostId: scope.hostId || null,
|
|
136
|
+
createdAt: FieldValue.serverTimestamp()
|
|
137
|
+
}, newResourceScopeFields([
|
|
138
|
+
ORG_SCOPE_TOKEN
|
|
139
|
+
])) : {}), {
|
|
140
|
+
merge: true
|
|
141
|
+
});
|
|
142
|
+
const to = action === 'rename' ? (_ref = (_move_picklist_values_find = move.picklist.values.find((entry)=>entry.id === valueId)) == null ? void 0 : _move_picklist_values_find.label) != null ? _ref : null : replaceWith === null ? null : (_ref1 = (_move_picklist_values_find1 = move.picklist.values.find((entry)=>entry.label.toLowerCase() === replaceWith.trim().toLowerCase())) == null ? void 0 : _move_picklist_values_find1.label) != null ? _ref1 : null;
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
from: value.label,
|
|
146
|
+
to
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
if (moved.ok === false) {
|
|
150
|
+
res.status(400).json({
|
|
151
|
+
error: moved.error
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
/*
|
|
156
|
+
* THE RECORDS. Leads by the field itself; contacts by each holder's
|
|
157
|
+
* facet, one query per consent group the org's sites belong to — a
|
|
158
|
+
* contact's lead source is the holder's own, like the rest of its
|
|
159
|
+
* profile. A rename to the same label moves nothing, and says so.
|
|
160
|
+
*/ let leads = 0;
|
|
161
|
+
let contacts = 0;
|
|
162
|
+
if (moved.from !== moved.to) {
|
|
163
|
+
leads = await replaceEverywhere(firestore, orgRef.collection('leads').where('leadSource', '==', moved.from), 'leadSource', moved.to);
|
|
164
|
+
const hostIds = await orgHostIds(firestore, writer.orgId);
|
|
165
|
+
const groupIds = [
|
|
166
|
+
...new Set(hostIds.map((hostId)=>consentGroupForHost(writer.org, hostId).groupId))
|
|
167
|
+
].filter(Boolean);
|
|
168
|
+
for (const groupId of groupIds){
|
|
169
|
+
const path = new FieldPath(CONTACT_FACETS_FIELD, groupId, 'leadSource');
|
|
170
|
+
contacts += await replaceEverywhere(firestore, orgRef.collection('contacts').where(path, '==', moved.from), path, moved.to);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const answer = {
|
|
174
|
+
ok: true,
|
|
175
|
+
leads,
|
|
176
|
+
contacts
|
|
177
|
+
};
|
|
178
|
+
res.status(200).json(answer);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error('[crm] lead-source-values failed', error);
|
|
181
|
+
res.status(500).json({
|
|
182
|
+
error: 'The lead sources could not be saved.'
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
//# sourceMappingURL=lead-source-values.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-source-values.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 consentGroupForHost,\n CONTACT_FACETS_FIELD,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmPicklist,\n effectiveCrmLeadSourcePicklist,\n newResourceScopeFields,\n normalizeCrmPicklist,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport {\n deletePicklistValue,\n type LeadSourceValuesResponse,\n type PicklistMove,\n renamePicklistValue,\n} from '../model/lead-source-values'\nimport { orgHostIds, readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter } from './task-routes'\n\n/** What the suite gate names when a plan without the CRM asks. */\nconst SUITE_ACT = \"Editing the organization's lead sources\"\n\n/** Writes per batch — under Firestore's 500, with room to spare. */\nconst BATCH_SIZE = 400\n\n/**\n * Set `field` to `to` (or remove it, for `null`) on every document `query`\n * finds holding `from`, a batch at a time. Answers how many were written.\n *\n * The query is re-run after each batch rather than paged, because each\n * batch moves its documents OUT of the query's answer — the next run finds\n * only what is left, and a run that finds nothing is the end.\n */\nasync function replaceEverywhere(\n firestore: FirebaseFirestore.Firestore,\n query: FirebaseFirestore.Query,\n field: string | FirebaseFirestore.FieldPath,\n to: string | null,\n): Promise<number> {\n let written = 0\n for (;;) {\n const page = await query.limit(BATCH_SIZE).get()\n if (page.empty) return written\n const batch = firestore.batch()\n for (const snapshot of page.docs) {\n batch.update(snapshot.ref, field, to ?? FieldValue.delete(), 'updatedAt', FieldValue.serverTimestamp())\n }\n await batch.commit()\n written += page.size\n if (page.size < BATCH_SIZE) return written\n }\n}\n\n/**\n * `POST crm/lead-source-values` — rename a lead source value, or delete one\n * and move its records to another (AGL-3298).\n *\n * Body: `{ hostId | orgId, action: 'rename', valueId, label }` or\n * `{ hostId | orgId, action: 'delete', valueId, replaceWith: label | null }`.\n *\n * Records store the value's LABEL (see the picklist block in `crm.ts`), so\n * both moves change records as well as the list: every lead and every\n * contact holding the old label is rewritten to the new one — or, for a\n * delete with no replacement, cleared. That is Salesforce's Replace, done\n * as part of the move, so a report grouped by lead source follows a rename\n * instead of splitting into the old name and the new.\n *\n * ## The list first, then the records\n *\n * The list is written in a transaction before any record: from that moment\n * no picker offers the old label and no import or API write accepts it, so\n * the sweep that follows cannot be outrun by a new record taking the name\n * it is removing. A sweep interrupted part-way leaves records holding a\n * label the list no longer has, which they show as \"(not in the list)\"; the\n * same request sent again finishes it, because the rename of a value to its\n * own current label is a no-op on the list and still sweeps.\n *\n * ## Who may\n *\n * Whoever may write the CRM — `authorizeCrmWriter`, the question every CRM\n * route asks — which is who the rules let edit the list itself. The sweep\n * reaches every lead and contact in the org whatever the caller's scope:\n * it changes one label to another that the same caller could already have\n * picked, and reveals nothing about the records but their count.\n */\nexport const crmLeadSourceValuesHandler: 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 body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const action = body['action']\n const valueId = String(body['valueId'] ?? '').trim().slice(0, 64)\n if ((action !== 'rename' && action !== 'delete') || !valueId) {\n res.status(400).json({ error: 'Name a value, and whether to rename or delete it.' })\n return\n }\n const replaceWith =\n body['replaceWith'] === null || body['replaceWith'] === undefined\n ? null\n : String(body['replaceWith'])\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: SUITE_ACT })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const listRef = orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST)\n\n /*\n * THE LIST. Read and written in one transaction, so two admins editing\n * at once cannot each write a list missing the other's change.\n */\n const moved = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(listRef)\n const before: CrmPicklist = effectiveCrmLeadSourcePicklist(snapshot.data())\n const value = before.values.find((entry) => entry.id === valueId)\n if (!value) return { ok: false as const, error: 'That value is no longer in the list.' }\n const move: PicklistMove =\n action === 'rename'\n ? renamePicklistValue(before, valueId, String(body['label'] ?? ''))\n : deletePicklistValue(before, valueId, replaceWith)\n if (move.ok === false) return move\n const created = normalizeCrmPicklist(snapshot.data()) === null\n transaction.set(\n listRef,\n {\n values: move.picklist.values,\n defaultValueId: move.picklist.defaultValueId,\n updatedAt: FieldValue.serverTimestamp(),\n // The stamp every org-wide CRM document carries (see the Fields\n // page), written when this move is what creates the document.\n ...(created\n ? {\n hostId: scope.hostId || null,\n createdAt: FieldValue.serverTimestamp(),\n ...newResourceScopeFields([ORG_SCOPE_TOKEN]),\n }\n : {}),\n },\n { merge: true },\n )\n const to =\n action === 'rename'\n ? (move.picklist.values.find((entry) => entry.id === valueId)?.label ?? null)\n : replaceWith === null\n ? null\n : (move.picklist.values.find(\n (entry) => entry.label.toLowerCase() === replaceWith.trim().toLowerCase(),\n )?.label ?? null)\n return { ok: true as const, from: value.label, to }\n })\n if (moved.ok === false) {\n res.status(400).json({ error: moved.error })\n return\n }\n\n /*\n * THE RECORDS. Leads by the field itself; contacts by each holder's\n * facet, one query per consent group the org's sites belong to — a\n * contact's lead source is the holder's own, like the rest of its\n * profile. A rename to the same label moves nothing, and says so.\n */\n let leads = 0\n let contacts = 0\n if (moved.from !== moved.to) {\n leads = await replaceEverywhere(\n firestore,\n orgRef.collection('leads').where('leadSource', '==', moved.from),\n 'leadSource',\n moved.to,\n )\n const hostIds = await orgHostIds(firestore, writer.orgId)\n const groupIds = [\n ...new Set(hostIds.map((hostId) => consentGroupForHost(writer.org, hostId).groupId)),\n ].filter(Boolean)\n for (const groupId of groupIds) {\n const path = new FieldPath(CONTACT_FACETS_FIELD, groupId, 'leadSource')\n contacts += await replaceEverywhere(\n firestore,\n orgRef.collection('contacts').where(path, '==', moved.from),\n path,\n moved.to,\n )\n }\n }\n const answer: LeadSourceValuesResponse = { ok: true, leads, contacts }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] lead-source-values failed', error)\n res.status(500).json({ error: 'The lead sources could not be saved.' })\n }\n}\n"],"names":["consentGroupForHost","CONTACT_FACETS_FIELD","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","effectiveCrmLeadSourcePicklist","newResourceScopeFields","normalizeCrmPicklist","ORG_SCOPE_TOKEN","firebaseAdmin","FieldPath","FieldValue","deletePicklistValue","renamePicklistValue","orgHostIds","readCrmRouteScope","authorizeCrmWriter","SUITE_ACT","BATCH_SIZE","replaceEverywhere","firestore","query","field","to","written","page","limit","get","empty","batch","snapshot","docs","update","ref","delete","serverTimestamp","commit","size","crmLeadSourceValuesHandler","req","res","body","method","setHeader","status","json","error","scope","action","valueId","String","trim","slice","replaceWith","undefined","writer","suiteAct","ok","app","orgRef","collection","doc","orgId","listRef","picklists","moved","runTransaction","transaction","move","before","data","value","values","find","entry","id","created","set","picklist","defaultValueId","updatedAt","hostId","createdAt","merge","label","toLowerCase","from","leads","contacts","where","hostIds","groupIds","Set","map","org","groupId","filter","Boolean","path","answer","console"],"mappings":"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"}
|
|
@@ -79,6 +79,7 @@ import { addHostLead, firebaseAdmin, orgLeadsForHost } from "@aglyn/tenant-data-
|
|
|
79
79
|
import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
80
80
|
import { FieldValue } from "firebase-admin/firestore";
|
|
81
81
|
import { ownerDirectory, readImportRows, resolveImportContext } from "./import-context.js";
|
|
82
|
+
import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-picklist.js";
|
|
82
83
|
/**
|
|
83
84
|
* The surface an imported lead names, beside `signup`, `booking` and
|
|
84
85
|
* `form:{formId}`.
|
|
@@ -210,11 +211,18 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
210
211
|
var _entry_row_campaigns;
|
|
211
212
|
return (_entry_row_campaigns = entry.row.campaigns) == null ? void 0 : _entry_row_campaigns.length;
|
|
212
213
|
});
|
|
213
|
-
const [owners, before, campaigns] = await Promise.all([
|
|
214
|
+
const [owners, before, campaigns, leadSources] = await Promise.all([
|
|
214
215
|
ownerDirectory(context.orgId, normalized.map((entry)=>entry.row)),
|
|
215
216
|
refs.length ? firestore.getAll(...refs) : Promise.resolve([]),
|
|
216
|
-
namesCampaigns ? campaignDirectory(firestore, context.orgId, context.hostId) : Promise.resolve(new Map())
|
|
217
|
+
namesCampaigns ? campaignDirectory(firestore, context.orgId, context.hostId) : Promise.resolve(new Map()),
|
|
218
|
+
// The org's lead source list (AGL-3298): one read per chunk, for the
|
|
219
|
+
// values rows name and the default a new lead starts from.
|
|
220
|
+
readLeadSourcePicklist(firestore, context.orgId)
|
|
217
221
|
]);
|
|
222
|
+
const beforeById = new Map(before.map((snapshot)=>[
|
|
223
|
+
snapshot.id,
|
|
224
|
+
snapshot
|
|
225
|
+
]));
|
|
218
226
|
const held = new Set(before.filter((snapshot)=>snapshot.exists).map((snapshot)=>snapshot.id));
|
|
219
227
|
const ownersUnresolved = new Set();
|
|
220
228
|
let counted = null;
|
|
@@ -223,6 +231,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
223
231
|
let merged = 0;
|
|
224
232
|
for (const { index, row, key } of normalized){
|
|
225
233
|
var _row_campaigns;
|
|
234
|
+
var _beforeById_get;
|
|
226
235
|
// The campaigns the row names, every one of them the org's, or the
|
|
227
236
|
// row is refused whole and named (AGL-3254).
|
|
228
237
|
const campaignIds = ((_row_campaigns = row.campaigns) != null ? _row_campaigns : []).map((name)=>{
|
|
@@ -239,6 +248,26 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
239
248
|
}
|
|
240
249
|
const isNew = !held.has(key);
|
|
241
250
|
/*
|
|
251
|
+
* THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value
|
|
252
|
+
* the list does not hold is refused whole and named, as a campaign
|
|
253
|
+
* the org does not have is — the drawer's notice has already listed
|
|
254
|
+
* the values the list allows — and a lead the site already held
|
|
255
|
+
* keeps the value it has. A new lead with no value starts from the
|
|
256
|
+
* list's default.
|
|
257
|
+
*/ const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {
|
|
258
|
+
current: (_beforeById_get = beforeById.get(key)) == null ? void 0 : _beforeById_get.get('leadSource'),
|
|
259
|
+
created: isNew
|
|
260
|
+
});
|
|
261
|
+
if (leadSource.ok === false) {
|
|
262
|
+
skipped.push({
|
|
263
|
+
index,
|
|
264
|
+
email: row.email,
|
|
265
|
+
reason: 'lead-source-unknown'
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (leadSource.write) row.profile.leadSource = leadSource.write;
|
|
270
|
+
/*
|
|
242
271
|
* The platform lead ceiling, judged the way the deals import judges
|
|
243
272
|
* the records band: one count at the first create, re-judged locally
|
|
244
273
|
* as the request creates more. The door re-judges it authoritatively
|