@aglyn/plugins-crm 1.0.0-beta.199 → 1.0.0-beta.200

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 (105) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/companies-section.d.ts +6 -6
  3. package/src/lib/components/companies-section.js +54 -84
  4. package/src/lib/components/companies-section.js.map +1 -1
  5. package/src/lib/components/company-edit-drawer.js +17 -4
  6. package/src/lib/components/company-edit-drawer.js.map +1 -1
  7. package/src/lib/components/company-picker.js +6 -2
  8. package/src/lib/components/company-picker.js.map +1 -1
  9. package/src/lib/components/contact-detail-page.js +14 -2
  10. package/src/lib/components/contact-detail-page.js.map +1 -1
  11. package/src/lib/components/contact-list-columns.d.ts +3 -5
  12. package/src/lib/components/contact-list-columns.js +6 -8
  13. package/src/lib/components/contact-list-columns.js.map +1 -1
  14. package/src/lib/components/contacts-bulk-bar.js +10 -1
  15. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  16. package/src/lib/components/contacts-section.js +115 -163
  17. package/src/lib/components/contacts-section.js.map +1 -1
  18. package/src/lib/components/crm-next-activity-column.d.ts +4 -2
  19. package/src/lib/components/crm-next-activity-column.js +6 -3
  20. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  21. package/src/lib/components/deal-edit-drawer.js +8 -4
  22. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  23. package/src/lib/components/deals-section.d.ts +2 -2
  24. package/src/lib/components/deals-section.js +59 -84
  25. package/src/lib/components/deals-section.js.map +1 -1
  26. package/src/lib/components/lead-campaigns-card.js +11 -3
  27. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  28. package/src/lib/components/lead-properties-card.js +9 -1
  29. package/src/lib/components/lead-properties-card.js.map +1 -1
  30. package/src/lib/components/leads-bulk-bar.js +16 -3
  31. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  32. package/src/lib/components/leads-section.d.ts +2 -1
  33. package/src/lib/components/leads-section.js +110 -148
  34. package/src/lib/components/leads-section.js.map +1 -1
  35. package/src/lib/components/tasks-section.js +128 -121
  36. package/src/lib/components/tasks-section.js.map +1 -1
  37. package/src/lib/constants/company-filters.d.ts +13 -0
  38. package/src/lib/constants/company-filters.js +47 -17
  39. package/src/lib/constants/company-filters.js.map +1 -1
  40. package/src/lib/constants/contact-filters.d.ts +61 -10
  41. package/src/lib/constants/contact-filters.js +373 -141
  42. package/src/lib/constants/contact-filters.js.map +1 -1
  43. package/src/lib/constants/deal-filters.d.ts +44 -0
  44. package/src/lib/constants/deal-filters.js +78 -0
  45. package/src/lib/constants/deal-filters.js.map +1 -0
  46. package/src/lib/hooks/use-crm-list-query.d.ts +68 -0
  47. package/src/lib/hooks/use-crm-list-query.js +182 -0
  48. package/src/lib/hooks/use-crm-list-query.js.map +1 -0
  49. package/src/lib/hooks/use-crm-tasks.d.ts +23 -6
  50. package/src/lib/hooks/use-crm-tasks.js +44 -42
  51. package/src/lib/hooks/use-crm-tasks.js.map +1 -1
  52. package/src/lib/hooks/use-deals.d.ts +2 -34
  53. package/src/lib/hooks/use-deals.js +3 -62
  54. package/src/lib/hooks/use-deals.js.map +1 -1
  55. package/src/lib/model/crm-list-query.d.ts +91 -0
  56. package/src/lib/model/crm-list-query.js +182 -0
  57. package/src/lib/model/crm-list-query.js.map +1 -0
  58. package/src/lib/model/lead-filters.d.ts +49 -43
  59. package/src/lib/model/lead-filters.js +194 -88
  60. package/src/lib/model/lead-filters.js.map +1 -1
  61. package/src/lib/model/list-fields-api.d.ts +26 -0
  62. package/src/lib/model/list-fields-api.js +47 -0
  63. package/src/lib/model/list-fields-api.js.map +1 -0
  64. package/src/lib/model/list-fields.d.ts +47 -0
  65. package/src/lib/model/list-fields.js +39 -0
  66. package/src/lib/model/list-fields.js.map +1 -0
  67. package/src/lib/model/task-views.d.ts +15 -10
  68. package/src/lib/model/task-views.js +68 -9
  69. package/src/lib/model/task-views.js.map +1 -1
  70. package/src/lib/server/companies-import.js +3 -3
  71. package/src/lib/server/companies-import.js.map +1 -1
  72. package/src/lib/server/company-delete.js +3 -1
  73. package/src/lib/server/company-delete.js.map +1 -1
  74. package/src/lib/server/consent-group-participant.js +4 -0
  75. package/src/lib/server/consent-group-participant.js.map +1 -1
  76. package/src/lib/server/contact-update.js +14 -8
  77. package/src/lib/server/contact-update.js.map +1 -1
  78. package/src/lib/server/contacts-import.js +4 -2
  79. package/src/lib/server/contacts-import.js.map +1 -1
  80. package/src/lib/server/deals-import.js +4 -2
  81. package/src/lib/server/deals-import.js.map +1 -1
  82. package/src/lib/server/erase-person.js +2 -1
  83. package/src/lib/server/erase-person.js.map +1 -1
  84. package/src/lib/server/lead-create.js +4 -1
  85. package/src/lib/server/lead-create.js.map +1 -1
  86. package/src/lib/server/lead-source-values.js +9 -5
  87. package/src/lib/server/lead-source-values.js.map +1 -1
  88. package/src/lib/server/leads-import.js +4 -1
  89. package/src/lib/server/leads-import.js.map +1 -1
  90. package/src/lib/server/list-fields-routes.d.ts +29 -0
  91. package/src/lib/server/list-fields-routes.js +85 -0
  92. package/src/lib/server/list-fields-routes.js.map +1 -0
  93. package/src/lib/server/record-email-state.js +5 -2
  94. package/src/lib/server/record-email-state.js.map +1 -1
  95. package/src/lib/server/record-timeline.js +3 -2
  96. package/src/lib/server/record-timeline.js.map +1 -1
  97. package/src/lib/server/task-routes.js +6 -3
  98. package/src/lib/server/task-routes.js.map +1 -1
  99. package/src/lib/server/tasks-import.js +5 -2
  100. package/src/lib/server/tasks-import.js.map +1 -1
  101. package/src/lib/server.js +10 -1
  102. package/src/lib/server.js.map +1 -1
  103. package/src/lib/hooks/use-org-leads.d.ts +0 -50
  104. package/src/lib/hooks/use-org-leads.js +0 -104
  105. package/src/lib/hooks/use-org-leads.js.map +0 -1
@@ -1 +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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"}
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_KEY_FIELD,\n CRM_LEAD_SOURCE_PICKLIST,\n crmLeadSourceKey,\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, with `beside` written alongside.\n * 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 beside: Record<string, unknown> = {},\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(\n snapshot.ref,\n field,\n to ?? FieldValue.delete(),\n 'updatedAt',\n FieldValue.serverTimestamp(),\n ...Object.entries(beside).flat(),\n )\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 // The key the Leads list filters by moves with the label (AGL-3321).\n { [CRM_LEAD_SOURCE_KEY_FIELD]: crmLeadSourceKey(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_KEY_FIELD","CRM_LEAD_SOURCE_PICKLIST","crmLeadSourceKey","effectiveCrmLeadSourcePicklist","newResourceScopeFields","normalizeCrmPicklist","ORG_SCOPE_TOKEN","firebaseAdmin","FieldPath","FieldValue","deletePicklistValue","renamePicklistValue","orgHostIds","readCrmRouteScope","authorizeCrmWriter","SUITE_ACT","BATCH_SIZE","replaceEverywhere","firestore","query","field","to","beside","written","page","limit","get","empty","batch","snapshot","docs","update","ref","delete","serverTimestamp","Object","entries","flat","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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"}
@@ -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, orgLeadsForHost } from "@aglyn/tenant-data-admin";
78
+ import { addHostLead, firebaseAdmin, orgLeadsForHost, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
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";
@@ -320,6 +320,9 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
320
320
  }), {
321
321
  merge: true
322
322
  });
323
+ // The file's profile, tags, status and lead source are what the
324
+ // Leads list searches and filters by (AGL-3321).
325
+ await restampCrmListFieldsAt(leadsRef.doc(key), 'leads');
323
326
  }
324
327
  if (isNew) {
325
328
  created += 1;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin, orgLeadsForHost } from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","visibleToHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","app","hostRef","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","leadSource","current","write","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 {\n addHostLead,\n firebaseAdmin,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\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 // The file's profile, tags, status and lead source are what the\n // Leads list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(leadsRef.doc(key), 'leads')\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","restampCrmListFieldsAt","visibleToHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","app","hostRef","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","leadSource","current","write","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
@@ -0,0 +1,29 @@
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/list-fields` — restamp the fields a CRM list queries (AGL-3321).
20
+ *
21
+ * Body: `{ hostId | orgId, collection, ids }` after a client-direct write
22
+ * the browser cannot follow up — see `model/list-fields.ts`. Authorized the
23
+ * way the next-activity route is — a member who may write this CRM — and
24
+ * never more revealing than it: the route stores what the records already
25
+ * say, derived, and answers with counts. A scoped member naming a record
26
+ * they cannot see restamps it from its own fields, which is what it should
27
+ * carry anyway; nothing about the record comes back.
28
+ */
29
+ export declare const crmListFieldsHandler: PluginApiHandler;
@@ -0,0 +1,85 @@
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 { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { firebaseAdmin, restampCrmListFields } from "@aglyn/tenant-data-admin";
18
+ import { CRM_LIST_FIELDS_COLLECTIONS, CRM_LIST_FIELDS_IDS_MAX } from "../model/list-fields.js";
19
+ import { readCrmRouteScope } from "./org-caller.js";
20
+ import { authorizeCrmWriter } from "./task-routes.js";
21
+ /**
22
+ * `POST crm/list-fields` — restamp the fields a CRM list queries (AGL-3321).
23
+ *
24
+ * Body: `{ hostId | orgId, collection, ids }` after a client-direct write
25
+ * the browser cannot follow up — see `model/list-fields.ts`. Authorized the
26
+ * way the next-activity route is — a member who may write this CRM — and
27
+ * never more revealing than it: the route stores what the records already
28
+ * say, derived, and answers with counts. A scoped member naming a record
29
+ * they cannot see restamps it from its own fields, which is what it should
30
+ * carry anyway; nothing about the record comes back.
31
+ */ export const crmListFieldsHandler = async (req, res)=>{
32
+ var _req_body;
33
+ if (req.method !== 'POST') {
34
+ res.status(405).json({
35
+ error: 'Method not allowed'
36
+ });
37
+ return;
38
+ }
39
+ const body = (_req_body = req.body) != null ? _req_body : {};
40
+ const scope = readCrmRouteScope(body);
41
+ if (!scope) {
42
+ res.status(400).json({
43
+ error: 'Missing hostId'
44
+ });
45
+ return;
46
+ }
47
+ const collection = body['collection'];
48
+ if (!CRM_LIST_FIELDS_COLLECTIONS.includes(collection)) {
49
+ res.status(400).json({
50
+ error: 'Name a CRM collection.'
51
+ });
52
+ return;
53
+ }
54
+ const ids = readIds(body['ids']);
55
+ if (!ids) {
56
+ res.status(400).json({
57
+ error: 'Name the records to restamp.'
58
+ });
59
+ return;
60
+ }
61
+ try {
62
+ const writer = await authorizeCrmWriter(req, scope);
63
+ if (writer.ok === false) {
64
+ res.status(writer.status).json(writer.body);
65
+ return;
66
+ }
67
+ const result = await restampCrmListFields(firebaseAdmin.app().firestore(), writer.orgId, collection, ids);
68
+ const answer = _extends({
69
+ ok: true
70
+ }, result);
71
+ res.status(200).json(answer);
72
+ } catch (error) {
73
+ console.error('[crm] list-fields failed', error);
74
+ res.status(500).json({
75
+ error: 'The records could not be restamped.'
76
+ });
77
+ }
78
+ };
79
+ /** The ids off the body: strings, trimmed, no path separators, capped. */ function readIds(raw) {
80
+ if (!Array.isArray(raw)) return null;
81
+ const ids = raw.map((entry)=>String(entry != null ? entry : '').trim().slice(0, 256)).filter((id)=>id && !id.includes('/')).slice(0, CRM_LIST_FIELDS_IDS_MAX);
82
+ return ids.length ? ids : null;
83
+ }
84
+
85
+ //# sourceMappingURL=list-fields-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/list-fields-routes.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 type { CrmListCollection, PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, restampCrmListFields } from '@aglyn/tenant-data-admin'\nimport {\n CRM_LIST_FIELDS_COLLECTIONS,\n CRM_LIST_FIELDS_IDS_MAX,\n type CrmListFieldsResponse,\n} from '../model/list-fields'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter } from './task-routes'\n\n/**\n * `POST crm/list-fields` — restamp the fields a CRM list queries (AGL-3321).\n *\n * Body: `{ hostId | orgId, collection, ids }` after a client-direct write\n * the browser cannot follow up — see `model/list-fields.ts`. Authorized the\n * way the next-activity route is — a member who may write this CRM — and\n * never more revealing than it: the route stores what the records already\n * say, derived, and answers with counts. A scoped member naming a record\n * they cannot see restamps it from its own fields, which is what it should\n * carry anyway; nothing about the record comes back.\n */\nexport const crmListFieldsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== '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 collection = body['collection']\n if (!CRM_LIST_FIELDS_COLLECTIONS.includes(collection as CrmListCollection)) {\n res.status(400).json({ error: 'Name a CRM collection.' })\n return\n }\n const ids = readIds(body['ids'])\n if (!ids) {\n res.status(400).json({ error: 'Name the records to restamp.' })\n return\n }\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const result = await restampCrmListFields(\n firebaseAdmin.app().firestore(),\n writer.orgId,\n collection as CrmListCollection,\n ids,\n )\n const answer: CrmListFieldsResponse = { ok: true, ...result }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] list-fields failed', error)\n res.status(500).json({ error: 'The records could not be restamped.' })\n }\n}\n\n/** The ids off the body: strings, trimmed, no path separators, capped. */\nfunction readIds(raw: unknown): string[] | null {\n if (!Array.isArray(raw)) return null\n const ids = raw\n .map((entry) => String(entry ?? '').trim().slice(0, 256))\n .filter((id) => id && !id.includes('/'))\n .slice(0, CRM_LIST_FIELDS_IDS_MAX)\n return ids.length ? ids : null\n}\n"],"names":["firebaseAdmin","restampCrmListFields","CRM_LIST_FIELDS_COLLECTIONS","CRM_LIST_FIELDS_IDS_MAX","readCrmRouteScope","authorizeCrmWriter","crmListFieldsHandler","req","res","method","status","json","error","body","scope","collection","includes","ids","readIds","writer","ok","result","app","firestore","orgId","answer","console","raw","Array","isArray","map","entry","String","trim","slice","filter","id","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,aAAa,EAAEC,oBAAoB,QAAQ,2BAA0B;AAC9E,SACEC,2BAA2B,EAC3BC,uBAAuB,QAElB,0BAAsB;AAC7B,SAASC,iBAAiB,QAAQ,kBAAc;AAChD,SAASC,kBAAkB,QAAQ,mBAAe;AAElD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,uBAAyC,OAAOC,KAAKC;QAKlDD;IAJd,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMC,QAAQN,YAAAA,IAAIM,IAAI,YAARN,YAAY,CAAC;IAC3B,MAAMO,QAAQV,kBAAkBS;IAChC,IAAI,CAACC,OAAO;QACVN,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiB;QAC/C;IACF;IACA,MAAMG,aAAaF,IAAI,CAAC,aAAa;IACrC,IAAI,CAACX,4BAA4Bc,QAAQ,CAACD,aAAkC;QAC1EP,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAyB;QACvD;IACF;IACA,MAAMK,MAAMC,QAAQL,IAAI,CAAC,MAAM;IAC/B,IAAI,CAACI,KAAK;QACRT,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;QAC7D;IACF;IACA,IAAI;QACF,MAAMO,SAAS,MAAMd,mBAAmBE,KAAKO;QAC7C,IAAIK,OAAOC,EAAE,KAAK,OAAO;YACvBZ,IAAIE,MAAM,CAACS,OAAOT,MAAM,EAAEC,IAAI,CAACQ,OAAON,IAAI;YAC1C;QACF;QACA,MAAMQ,SAAS,MAAMpB,qBACnBD,cAAcsB,GAAG,GAAGC,SAAS,IAC7BJ,OAAOK,KAAK,EACZT,YACAE;QAEF,MAAMQ,SAAgC;YAAEL,IAAI;WAASC;QACrDb,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAACc;IACvB,EAAE,OAAOb,OAAO;QACdc,QAAQd,KAAK,CAAC,4BAA4BA;QAC1CJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAsC;IACtE;AACF,EAAC;AAED,wEAAwE,GACxE,SAASM,QAAQS,GAAY;IAC3B,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAO;IAChC,MAAMV,MAAMU,IACTG,GAAG,CAAC,CAACC,QAAUC,OAAOD,gBAAAA,QAAS,IAAIE,IAAI,GAAGC,KAAK,CAAC,GAAG,MACnDC,MAAM,CAAC,CAACC,KAAOA,MAAM,CAACA,GAAGpB,QAAQ,CAAC,MAClCkB,KAAK,CAAC,GAAG/B;IACZ,OAAOc,IAAIoB,MAAM,GAAGpB,MAAM;AAC5B"}
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { registerPluginRecordEmailStateWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
18
- import { EMAIL_STATE_FIELD, nextEmailState, normalizeContactEmail, personKey, readEmailState } from "@aglyn/aglyn/server";
18
+ import { CRM_EMAIL_STATUS_FIELD, EMAIL_STATE_FIELD, nextEmailState, normalizeContactEmail, personKey, readEmailState } from "@aglyn/aglyn/server";
19
19
  import { findContactByEmail, firebaseAdmin } from "@aglyn/tenant-data-admin";
20
20
  import { BUNDLE_ID } from "../constants/bundle-common.js";
21
21
  const NONE = {
@@ -38,8 +38,11 @@ const NONE = {
38
38
  if (next === current || current && next.status === current.status && next.atMs === current.atMs && next.source === current.source && next.detail === current.detail) {
39
39
  return false;
40
40
  }
41
+ // `emailStatus` beside it: the verdict as the CRM lists filter by it
42
+ // (AGL-3321) — a key a query can ask, where the state is a map.
41
43
  await ref.set({
42
- [EMAIL_STATE_FIELD]: next
44
+ [EMAIL_STATE_FIELD]: next,
45
+ [CRM_EMAIL_STATUS_FIELD]: next.status
43
46
  }, {
44
47
  merge: true
45
48
  });
@@ -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/**\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. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\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 } 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","stampRecord","ref","incoming","force","snapshot","get","exists","current","data","next","status","atMs","source","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
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 CRM_EMAIL_STATUS_FIELD,\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/**\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 // `emailStatus` beside it: the verdict as the CRM lists filter by it\n // (AGL-3321) — a key a query can ask, where the state is a map.\n await ref.set(\n { [EMAIL_STATE_FIELD]: next, [CRM_EMAIL_STATUS_FIELD]: next.status },\n { merge: true },\n )\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. A bounce or a do-not-contact mark is the platform's\n * verdict on the ADDRESS, and since AGL-3277 there is one document\n * carrying it — the per-site loop beside this had nothing left to\n * find once AGL-3276 emptied the host path.\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 } 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","CRM_EMAIL_STATUS_FIELD","EMAIL_STATE_FIELD","nextEmailState","normalizeContactEmail","personKey","readEmailState","findContactByEmail","firebaseAdmin","BUNDLE_ID","NONE","records","stampRecord","ref","incoming","force","snapshot","get","exists","current","data","next","status","atMs","source","detail","set","merge","resolveOrgId","firestore","request","host","named","String","orgId","trim","hostId","collection","doc","createCrmRecordEmailStateWriter","deps","stamp","email","key","error","console","state","Number","isFinite","Date","now","replace","slice","enrollmentId","contacts","contact","defaultCrmRecordEmailStateDeps","app","registerCrmRecordEmailStateWriter","pluginId"],"mappings":"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"}
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { registerPluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
18
- import { buildCrmCapturedEmailActivity, checkEntitlement, consentGroupForHost, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, crmActivityLogHasRoom, crmCapturedEmailKey, crmScopeTokens, crmTaskReminderAfterEdit, isCrmActivityKind, isCrmTaskKind } from "@aglyn/aglyn/server";
18
+ import { buildCrmCapturedEmailActivity, checkEntitlement, consentGroupForHost, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, crmActivityLogHasRoom, crmCapturedEmailKey, crmScopeTokens, crmTaskListFields, crmTaskReminderAfterEdit, isCrmActivityKind, isCrmTaskKind } from "@aglyn/aglyn/server";
19
19
  import { countCrmActivitiesForRecord, createCrmEmailActivity, crmActivityRef, crmCapturedEmailActivityRef, firebaseAdmin, recomputeCrmNextTaskAt, recordCrmEmailDelivery } from "@aglyn/tenant-data-admin";
20
20
  import { createHash } from "crypto";
21
21
  import { FieldValue } from "firebase-admin/firestore";
@@ -185,7 +185,8 @@ export function createCrmRecordTimelineWriter(deps) {
185
185
  hostId: request.hostId,
186
186
  visibleTo
187
187
  });
188
- const created = await createOnce(ref, task);
188
+ // What the Tasks list searches by (AGL-3321), beside the task.
189
+ const created = await createOnce(ref, _extends({}, task, crmTaskListFields(task)));
189
190
  if (created) {
190
191
  // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure
191
192
  // that could not move is the Fields section's recompute's.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordDeliveryRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n buildCrmCapturedEmailActivity,\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmScopeTokens,\n crmTaskReminderAfterEdit,\n isCrmActivityKind,\n isCrmTaskKind,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n firebaseAdmin,\n recomputeCrmNextTaskAt,\n recordCrmEmailDelivery,\n} from '@aglyn/tenant-data-admin'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written;\n * 6. a bounce or a complaint on a filed email (AGL-3245) advances that\n * email's own delivery state — the row the send was filed under, found\n * by its `Message-ID` — exactly as the campaign webhook advances one,\n * so the timeline reads Sent, then Bounced, with what the server said.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n ...(link?.dealId ? { dealId: String(link.dealId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /** The org, its plan, the link and the scope an entry is stamped with. */\n async function admit(\n context: PluginRecordEntryContext,\n ): Promise<\n | PluginRecordWrite\n | { firestore: Firestore; orgRef: FirebaseFirestore.DocumentReference; link: CrmActivityLink; visibleTo: string[] }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const link = linkFields(context.link)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!link.contactId && !link.companyId && !link.dealId && !link.leadId) {\n return refuse(400, 'An entry is filed on a contact, a company, a deal or a lead.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n const assigneeUid = String(request.assigneeUid ?? '').trim()\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n const created = await createOnce(ref, task as unknown as Record<string, unknown>)\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n\n async recordEmailDelivery(request: PluginRecordDeliveryRequest): Promise<PluginRecordWrite> {\n const orgId = String(request.orgId ?? '').trim()\n const key = crmCapturedEmailKey(request.messageId, null)\n if (!orgId || !key) return refuse(400, 'A delivery names its organization and the email’s Message-ID.')\n if (request.state !== 'bounced' && request.state !== 'complained') {\n return refuse(400, `\"${String(request.state)}\" isn't a delivery failure.`)\n }\n const firestore = deps.firestore()\n const ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n const outcome = await recordCrmEmailDelivery(firestore, {\n orgId,\n activityId: ref.id,\n state: request.state,\n atMs: request.atMs,\n detail: request.detail ?? null,\n })\n if (outcome === 'missing') return refuse(404, 'No email by that Message-ID is filed here.')\n if (outcome === 'failed') return refuse(409, 'The delivery state could not be written.')\n return { ok: true, id: ref.id, created: outcome === 'advanced' }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","buildCrmCapturedEmailActivity","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmCapturedEmailKey","crmScopeTokens","crmTaskReminderAfterEdit","isCrmActivityKind","isCrmTaskKind","countCrmActivitiesForRecord","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","firebaseAdmin","recomputeCrmNextTaskAt","recordCrmEmailDelivery","createHash","FieldValue","BUNDLE_ID","CRM_SUITE_FEATURE","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","dealId","leadId","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","snapshot","orgId","trim","hostId","firestore","orgRef","collection","doc","get","exists","org","data","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","kind","admitted","id","activity","email","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","tasks","notes","assigneeUid","task","priority","remindAtMs","previous","createdByUid","catch","console","recordEmailDelivery","state","outcome","activityId","detail","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordDeliveryRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n buildCrmCapturedEmailActivity,\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmScopeTokens,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n isCrmActivityKind,\n isCrmTaskKind,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n firebaseAdmin,\n recomputeCrmNextTaskAt,\n recordCrmEmailDelivery,\n} from '@aglyn/tenant-data-admin'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written;\n * 6. a bounce or a complaint on a filed email (AGL-3245) advances that\n * email's own delivery state — the row the send was filed under, found\n * by its `Message-ID` — exactly as the campaign webhook advances one,\n * so the timeline reads Sent, then Bounced, with what the server said.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n ...(link?.dealId ? { dealId: String(link.dealId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /** The org, its plan, the link and the scope an entry is stamped with. */\n async function admit(\n context: PluginRecordEntryContext,\n ): Promise<\n | PluginRecordWrite\n | { firestore: Firestore; orgRef: FirebaseFirestore.DocumentReference; link: CrmActivityLink; visibleTo: string[] }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const link = linkFields(context.link)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!link.contactId && !link.companyId && !link.dealId && !link.leadId) {\n return refuse(400, 'An entry is filed on a contact, a company, a deal or a lead.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n const assigneeUid = String(request.assigneeUid ?? '').trim()\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const created = await createOnce(ref, { ...task, ...crmTaskListFields(task) })\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n\n async recordEmailDelivery(request: PluginRecordDeliveryRequest): Promise<PluginRecordWrite> {\n const orgId = String(request.orgId ?? '').trim()\n const key = crmCapturedEmailKey(request.messageId, null)\n if (!orgId || !key) return refuse(400, 'A delivery names its organization and the email’s Message-ID.')\n if (request.state !== 'bounced' && request.state !== 'complained') {\n return refuse(400, `\"${String(request.state)}\" isn't a delivery failure.`)\n }\n const firestore = deps.firestore()\n const ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n const outcome = await recordCrmEmailDelivery(firestore, {\n orgId,\n activityId: ref.id,\n state: request.state,\n atMs: request.atMs,\n detail: request.detail ?? null,\n })\n if (outcome === 'missing') return refuse(404, 'No email by that Message-ID is filed here.')\n if (outcome === 'failed') return refuse(409, 'The delivery state could not be written.')\n return { ok: true, id: ref.id, created: outcome === 'advanced' }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","buildCrmCapturedEmailActivity","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmCapturedEmailKey","crmScopeTokens","crmTaskListFields","crmTaskReminderAfterEdit","isCrmActivityKind","isCrmTaskKind","countCrmActivitiesForRecord","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","firebaseAdmin","recomputeCrmNextTaskAt","recordCrmEmailDelivery","createHash","FieldValue","BUNDLE_ID","CRM_SUITE_FEATURE","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","dealId","leadId","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","snapshot","orgId","trim","hostId","firestore","orgRef","collection","doc","get","exists","org","data","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","kind","admitted","id","activity","email","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","tasks","notes","assigneeUid","task","priority","remindAtMs","previous","createdByUid","catch","console","recordEmailDelivery","state","outcome","activityId","detail","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kCAAkC,QAO7B,qDAAoD;AAC3D,SACEC,6BAA6B,EAC7BC,gBAAgB,EAChBC,mBAAmB,EACnBC,6BAA6B,EAC7BC,eAAe,EACfC,qBAAqB,EACrBC,mBAAmB,EACnBC,cAAc,EACdC,iBAAiB,EACjBC,wBAAwB,EACxBC,iBAAiB,EACjBC,aAAa,QAIR,sBAAqB;AAC5B,SACEC,2BAA2B,EAC3BC,sBAAsB,EACtBC,cAAc,EACdC,2BAA2B,EAC3BC,aAAa,EACbC,sBAAsB,EACtBC,sBAAsB,QACjB,2BAA0B;AACjC,SAASC,UAAU,QAAQ,SAAQ;AACnC,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAASC,iBAAiB,QAAQ,kBAAc;AAqChD,qFAAqF,GACrF,MAAMC,iBAAiB;AAEvB,+DAA+D,GAC/D,MAAMC,iBAAiB;AACvB,kEAAkE,GAClE,MAAMC,WAAW;AAEjB,MAAMC,SAAS,CAACC,QAA+BC,QAAsC,CAAA;QACnFC,IAAI;QACJF;QACAC;IACF,CAAA;AAEA,yDAAyD,GACzD,SAASE,WAAWC,IAAsC;IACxD,OAAO,aACDA,CAAAA,wBAAAA,KAAMC,SAAS,IAAG;QAAEA,WAAWC,OAAOF,KAAKC,SAAS;IAAE,IAAI,CAAC,GAC3DD,CAAAA,wBAAAA,KAAMG,SAAS,IAAG;QAAEA,WAAWD,OAAOF,KAAKG,SAAS;IAAE,IAAI,CAAC,GAC3DH,CAAAA,wBAAAA,KAAMI,MAAM,IAAG;QAAEA,QAAQF,OAAOF,KAAKI,MAAM;IAAE,IAAI,CAAC,GAGlDJ,CAAAA,wBAAAA,KAAMK,MAAM,IAAG;QAAEA,QAAQH,OAAOF,KAAKK,MAAM;IAAE,IAAI,CAAC;AAE1D;AAEA,oFAAoF,GACpF,SAASC,QAAQC,cAAsB,EAAEC,GAAW;IAClD,OAAO,CAAC,IAAI,EAAEpB,WAAW,UAAUqB,MAAM,CAAC,GAAGF,eAAe,CAAC,EAAEC,KAAK,EAAEE,MAAM,CAAC,OAAOC,KAAK,CAAC,GAAG,KAAK;AACpG;AAEA,OAAO,SAASC,8BAA8BC,IAA2B;IACvE,wEAAwE,GACxE,eAAeC,MACbC,OAAiC;YAKZA,gBACCA,iBAEWA,yBAUpBC;QAbb,MAAMC,QAAQf,QAAOa,iBAAAA,QAAQE,KAAK,YAAbF,iBAAiB,IAAIG,IAAI;QAC9C,MAAMC,SAASjB,QAAOa,kBAAAA,QAAQI,MAAM,YAAdJ,kBAAkB,IAAIG,IAAI;QAChD,MAAMlB,OAAOD,WAAWgB,QAAQf,IAAI;QACpC,IAAI,CAACiB,SAAS,CAACE,UAAU,CAACjB,QAAOa,0BAAAA,QAAQR,cAAc,YAAtBQ,0BAA0B,IAAIG,IAAI,IAAI;YACrE,OAAOvB,OAAO,KAAK;QACrB;QACA,IAAI,CAACK,KAAKC,SAAS,IAAI,CAACD,KAAKG,SAAS,IAAI,CAACH,KAAKI,MAAM,IAAI,CAACJ,KAAKK,MAAM,EAAE;YACtE,OAAOV,OAAO,KAAK;QACrB;QACA,MAAMyB,YAAYP,KAAKO,SAAS;QAChC,MAAMC,SAASD,UAAUE,UAAU,CAAC,QAAQC,GAAG,CAACN;QAChD,MAAMD,WAAW,MAAMK,OAAOG,GAAG;QACjC,IAAI,CAACR,SAASS,MAAM,EAAE,OAAO9B,OAAO,KAAK;QACzC,MAAM+B,OAAOV,iBAAAA,SAASW,IAAI,cAAbX,iBAAmB,CAAC;QACjC,IAAI,CAAC9C,iBAAiBwD,KAAKnC,oBAAoB;YAC7C,OAAOI,OAAO,KAAK;QACrB;QACA,OAAO;YACLyB;YACAC;YACArB;YACA4B,WAAWpD,eAAekD,KAAKvD,oBAAoBuD,KAAKP;QAC1D;IACF;IAEA,wEAAwE,GACxE,eAAeU,WACbC,GAAwC,EACxCH,IAA6B;QAE7B,IAAI;YACF,MAAMG,IAAIC,MAAM,CAAC,aACZJ;gBACHK,WAAW3C,WAAW4C,eAAe;gBACrCC,WAAW7C,WAAW4C,eAAe;;YAEvC,OAAO;QACT,EAAE,OAAOpC,OAAO;YACd,IAAI,CAACA,yBAAD,AAACA,MAA8BsC,IAAI,MAAK3C,gBAAgB,OAAO;YACnE,MAAMK;QACR;IACF;IAEA,OAAO;QACL,MAAMuC,aAAYC,OAAoC;YACpD,IAAI,CAAC1D,kBAAkB0D,QAAQC,IAAI,GAAG,OAAO3C,OAAO,KAAK,CAAC,CAAC,EAAEO,OAAOmC,QAAQC,IAAI,EAAE,oBAAoB,CAAC;YACvG,MAAMC,WAAW,MAAMzB,MAAMuB;YAC7B,IAAI,QAAQE,UAAU,OAAOA;YAC7B,MAAM,EAAEnB,SAAS,EAAEC,MAAM,EAAErB,IAAI,EAAE4B,SAAS,EAAE,GAAGW;YAC/C,MAAMtB,QAAQI,OAAOmB,EAAE;YAEvB,IAAIV;YACJ,IAAIW;YACJ,IAAIJ,QAAQC,IAAI,KAAK,SAAS;oBAYfI,kBAEGL,gBACNA;gBAdV,MAAMK,QAAQL,QAAQK,KAAK;gBAC3B,MAAMlC,MAAMkC,QAAQnE,oBAAoBmE,MAAMC,SAAS,EAAE,QAAQ;gBACjE,IAAI,CAACD,SAAS,CAAClC,KAAK,OAAOb,OAAO,KAAK;gBACvCmC,MAAM9C,4BAA4BoC,WAAWH,OAAOT;gBACpDiC,WAAWxE,8BAA8B;oBACvC2E,WAAWF,MAAME,SAAS,KAAK,YAAY,YAAY;oBACvDC,SAASH,MAAMG,OAAO;oBACtBC,SAAST,QAAQU,IAAI;oBACrBC,MAAMN,MAAMM,IAAI;oBAChBC,IAAIP,MAAMO,EAAE;oBACZN,WAAWD,MAAMC,SAAS;oBAC1BO,SAAS,GAAER,mBAAAA,MAAMQ,SAAS,YAAfR,mBAAmB;oBAC9BS,MAAMd,QAAQc,IAAI;oBAClBC,OAAOlD,QAAOmC,iBAAAA,QAAQe,KAAK,YAAbf,iBAAiB;oBAC/BgB,MAAM,GAAEhB,kBAAAA,QAAQgB,MAAM,YAAdhB,kBAAkB;oBAC1BrC;oBACAmB,QAAQkB,QAAQlB,MAAM;oBACtBS;gBACF;YACF,OAAO;oBACcS,oBAGGA,kBAGPA,eAECA;gBARhB,MAAM7B,MAAMN,QAAOmC,qBAAAA,QAAQiB,SAAS,YAAjBjB,qBAAqB,IAAInB,IAAI;gBAChD,IAAI,CAACV,KAAK,OAAOb,OAAO,KAAK;gBAC7BmC,MAAM/C,eAAeqC,WAAWH,OAAOX,QAAQ+B,QAAQ9B,cAAc,EAAEC;gBACvE,MAAM6C,SAASnD,QAAOmC,mBAAAA,QAAQgB,MAAM,YAAdhB,mBAAkB,IAAInB,IAAI;gBAChDuB,WAAW;oBACTH,MAAMD,QAAQC,IAAI;oBAClBS,MAAM7C,QAAOmC,gBAAAA,QAAQU,IAAI,YAAZV,gBAAgB,IAAI1B,KAAK,CAAC,GAAGjB;oBAC1CyD,MAAMd,QAAQc,IAAI;oBAClBC,OAAOlD,QAAOmC,kBAAAA,QAAQe,KAAK,YAAbf,kBAAiB;mBAC3BgB,SAAS;oBAAEA;gBAAO,IAAI,CAAC,GACxBrD;oBACHmB,QAAQkB,QAAQlB,MAAM;oBACtBS;;YAEJ;YACA,yEAAyE;YACzE,IAAI,AAAC,CAAA,MAAME,IAAIN,GAAG,EAAC,EAAGC,MAAM,EAAE,OAAO;gBAAE3B,IAAI;gBAAM0C,IAAIV,IAAIU,EAAE;gBAAEe,SAAS;YAAM;YAC5E,IAAI,CAACjF,sBAAsB,MAAMO,4BAA4BwC,QAAQrB,QAAQ;gBAC3E,OAAOL,OAAO,KAAKvB;YACrB;YACA,MAAMoF,UAAU,aAAKf;gBAAUlC,gBAAgB8B,QAAQ9B,cAAc;;YACrE,MAAMgD,UACJlB,QAAQC,IAAI,KAAK,UACb,AAAC,MAAMxD,uBAAuBgD,KAAK0B,aAAc,YACjD,MAAM3B,WAAWC,KAAK0B;YAC5B,OAAO;gBAAE1D,IAAI;gBAAM0C,IAAIV,IAAIU,EAAE;gBAAEe;YAAQ;QACzC;QAEA,MAAME,YAAWpB,OAAgC;gBAC1BA,gBACFA,oBAQEA,gBACMA,sBAWJA;YArBvB,MAAMqB,QAAQxD,QAAOmC,iBAAAA,QAAQqB,KAAK,YAAbrB,iBAAiB,IAAIsB,OAAO,CAAC,QAAQ,KAAKzC,IAAI,GAAGP,KAAK,CAAC,GAAGlB;YAC/E,MAAMe,MAAMN,QAAOmC,qBAAAA,QAAQiB,SAAS,YAAjBjB,qBAAqB,IAAInB,IAAI;YAChD,IAAI,CAACwC,SAAS,CAAClD,KAAK,OAAOb,OAAO,KAAK;YACvC,IAAI,CAACf,cAAcyD,QAAQC,IAAI,GAAG,OAAO3C,OAAO,KAAK,CAAC,CAAC,EAAEO,OAAOmC,QAAQC,IAAI,EAAE,eAAe,CAAC;YAC9F,IAAI,CAACsB,OAAOC,QAAQ,CAACxB,QAAQyB,OAAO,GAAG,OAAOnE,OAAO,KAAK;YAC1D,MAAM4C,WAAW,MAAMzB,MAAMuB;YAC7B,IAAI,QAAQE,UAAU,OAAOA;YAC7B,MAAM,EAAEnB,SAAS,EAAEC,MAAM,EAAErB,IAAI,EAAE4B,SAAS,EAAE,GAAGW;YAC/C,MAAMT,MAAMT,OAAOC,UAAU,CAACjD,gBAAgB0F,KAAK,EAAExC,GAAG,CAACjB,QAAQ+B,QAAQ9B,cAAc,EAAEC;YACzF,MAAMwD,QAAQ9D,QAAOmC,iBAAAA,QAAQ2B,KAAK,YAAb3B,iBAAiB,IAAInB,IAAI,GAAGP,KAAK,CAAC,GAAGjB;YAC1D,MAAMuE,cAAc/D,QAAOmC,uBAAAA,QAAQ4B,WAAW,YAAnB5B,uBAAuB,IAAInB,IAAI;YAC1D,MAAMgD,OAAgB;gBACpBR;eACIM,QAAQ;gBAAEA;YAAM,IAAI,CAAC;gBACzB1B,MAAMD,QAAQC,IAAI;gBAClB6B,UAAU;gBACVvE,QAAQ;gBACRkE,SAASzB,QAAQyB,OAAO;gBACxB,8DAA8D;gBAC9DM,YAAY1F,yBAAyB;oBAAEoF,SAASzB,QAAQyB,OAAO;oBAAEO,UAAU;gBAAK;eAC5EJ,cAAc;gBAAEA;YAAY,IAAI,CAAC;gBACrCK,cAAcpE,QAAOmC,wBAAAA,QAAQiC,YAAY,YAApBjC,wBAAwB;gBAC7C9B,gBAAgB8B,QAAQ9B,cAAc;eACnCP;gBACHmB,QAAQkB,QAAQlB,MAAM;gBACtBS;;YAEF,+DAA+D;YAC/D,MAAM2B,UAAU,MAAM1B,WAAWC,KAAK,aAAKoC,MAASzF,kBAAkByF;YACtE,IAAIX,SAAS;gBACX,uEAAuE;gBACvE,2DAA2D;gBAC3D,MAAMrE,uBAAuBkC,WAAWC,OAAOmB,EAAE,EAAE;oBAACxC;iBAAK,EAAEuE,KAAK,CAAC,CAAC1E;oBAChE2E,QAAQ3E,KAAK,CAAC,+CAA+CwB,OAAOmB,EAAE,EAAE3C;gBAC1E;YACF;YACA,OAAO;gBAAEC,IAAI;gBAAM0C,IAAIV,IAAIU,EAAE;gBAAEe;YAAQ;QACzC;QAEA,MAAMkB,qBAAoBpC,OAAoC;gBACvCA,gBAaXA;YAbV,MAAMpB,QAAQf,QAAOmC,iBAAAA,QAAQpB,KAAK,YAAboB,iBAAiB,IAAInB,IAAI;YAC9C,MAAMV,MAAMjC,oBAAoB8D,QAAQM,SAAS,EAAE;YACnD,IAAI,CAAC1B,SAAS,CAACT,KAAK,OAAOb,OAAO,KAAK;YACvC,IAAI0C,QAAQqC,KAAK,KAAK,aAAarC,QAAQqC,KAAK,KAAK,cAAc;gBACjE,OAAO/E,OAAO,KAAK,CAAC,CAAC,EAAEO,OAAOmC,QAAQqC,KAAK,EAAE,2BAA2B,CAAC;YAC3E;YACA,MAAMtD,YAAYP,KAAKO,SAAS;YAChC,MAAMU,MAAM9C,4BAA4BoC,WAAWH,OAAOT;YAC1D,MAAMmE,UAAU,MAAMxF,uBAAuBiC,WAAW;gBACtDH;gBACA2D,YAAY9C,IAAIU,EAAE;gBAClBkC,OAAOrC,QAAQqC,KAAK;gBACpBvB,MAAMd,QAAQc,IAAI;gBAClB0B,MAAM,GAAExC,kBAAAA,QAAQwC,MAAM,YAAdxC,kBAAkB;YAC5B;YACA,IAAIsC,YAAY,WAAW,OAAOhF,OAAO,KAAK;YAC9C,IAAIgF,YAAY,UAAU,OAAOhF,OAAO,KAAK;YAC7C,OAAO;gBAAEG,IAAI;gBAAM0C,IAAIV,IAAIU,EAAE;gBAAEe,SAASoB,YAAY;YAAW;QACjE;IACF;AACF;AAEA,4DAA4D,GAC5D,OAAO,SAASG;IACd,OAAO;QAAE1D,WAAW,IAAMnC,cAAc8F,GAAG,GAAG3D,SAAS;IAAG;AAC5D;AAEA,6EAA6E,GAC7E,OAAO,SAAS4D,gCACdnE,OAA8BiE,8BAA8B;IAE5D9G,mCAAmC4C,8BAA8BC,OAAO;QAAEoE,UAAU3F;IAAU;AAChG"}
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { consentGroupForHost, CRM_COLLECTIONS, crmScopeTokens, crmTaskReminderAfterEdit, crmTaskReminderPending, isOrgWideMember, memberCanSee } from "@aglyn/aglyn/server";
17
+ */ import { consentGroupForHost, CRM_COLLECTIONS, crmScopeTokens, crmTaskListFields, crmTaskReminderAfterEdit, crmTaskReminderPending, isOrgWideMember, memberCanSee } from "@aglyn/aglyn/server";
18
18
  import { crmNextActivityLinksOf, firebaseAdmin, getOrgForHost, memberHasOrgPermission, notifyUsers, recomputeCrmNextTaskAt, resolveOrgMembership } from "@aglyn/tenant-data-admin";
19
19
  import { emitHostEvent } from "@aglyn/tenant-runtime";
20
20
  import { FieldValue } from "firebase-admin/firestore";
@@ -276,7 +276,8 @@ const tasksCollection = (orgId)=>firebaseAdmin.app().firestore().collection('org
276
276
  }
277
277
  const previousAssignee = String((_existing_get = existing.get('assigneeUid')) != null ? _existing_get : '') || null;
278
278
  const previousLinks = crmNextActivityLinksOf(existing.data());
279
- await tasks.doc(taskId).update(_extends({}, storedFields(fields, 'update'), reminderFields(fields, existing), {
279
+ const stored = storedFields(fields, 'update');
280
+ await tasks.doc(taskId).update(_extends({}, stored, reminderFields(fields, existing), crmTaskListFields(_extends({}, existing.data(), stored)), {
280
281
  updatedAt: FieldValue.serverTimestamp()
281
282
  }));
282
283
  // Both sides: a task moved off one deal onto another leaves neither stale.
@@ -338,7 +339,9 @@ const tasksCollection = (orgId)=>firebaseAdmin.app().firestore().collection('org
338
339
  return refuse(403, 'Your access to this organization does not reach this site.');
339
340
  }
340
341
  const ref = tasks.doc();
341
- await ref.set(_extends({}, storedFields(fields, 'create'), reminderFields(fields, null), {
342
+ await ref.set(_extends({}, storedFields(fields, 'create'), reminderFields(fields, null), crmTaskListFields(_extends({}, storedFields(fields, 'create'), {
343
+ visibleTo
344
+ })), {
342
345
  status: 'open',
343
346
  completedAtMs: null,
344
347
  visibleTo,