@aglyn/plugins-crm 1.0.0-beta.296 → 1.1.0

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 (161) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/activity-link-fields.d.ts +34 -0
  3. package/src/lib/components/activity-link-fields.js +141 -0
  4. package/src/lib/components/activity-link-fields.js.map +1 -0
  5. package/src/lib/components/activity-list.d.ts +8 -0
  6. package/src/lib/components/activity-list.js +4 -2
  7. package/src/lib/components/activity-list.js.map +1 -1
  8. package/src/lib/components/activity-queries.d.ts +7 -2
  9. package/src/lib/components/activity-queries.js +7 -3
  10. package/src/lib/components/activity-queries.js.map +1 -1
  11. package/src/lib/components/company-contact-roles-card.d.ts +42 -0
  12. package/src/lib/components/company-contact-roles-card.js +135 -0
  13. package/src/lib/components/company-contact-roles-card.js.map +1 -0
  14. package/src/lib/components/company-detail-page.js +12 -1
  15. package/src/lib/components/company-detail-page.js.map +1 -1
  16. package/src/lib/components/company-edit-drawer.js +9 -6
  17. package/src/lib/components/company-edit-drawer.js.map +1 -1
  18. package/src/lib/components/company-picker.js +4 -2
  19. package/src/lib/components/company-picker.js.map +1 -1
  20. package/src/lib/components/contact-associations-card.js +15 -1
  21. package/src/lib/components/contact-associations-card.js.map +1 -1
  22. package/src/lib/components/contact-detail-page.js +13 -14
  23. package/src/lib/components/contact-detail-page.js.map +1 -1
  24. package/src/lib/components/contact-list-columns.js +5 -4
  25. package/src/lib/components/contact-list-columns.js.map +1 -1
  26. package/src/lib/components/contact-properties-card.js +41 -12
  27. package/src/lib/components/contact-properties-card.js.map +1 -1
  28. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  29. package/src/lib/components/contact-roles-editor.js +270 -0
  30. package/src/lib/components/contact-roles-editor.js.map +1 -0
  31. package/src/lib/components/contacts-section.js +6 -4
  32. package/src/lib/components/contacts-section.js.map +1 -1
  33. package/src/lib/components/crm-console-sections.js +1 -1
  34. package/src/lib/components/crm-console-sections.js.map +1 -1
  35. package/src/lib/components/crm-record-picker.d.ts +19 -0
  36. package/src/lib/components/crm-record-picker.js +40 -11
  37. package/src/lib/components/crm-record-picker.js.map +1 -1
  38. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  39. package/src/lib/components/crm-related-to-picker.js +135 -0
  40. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  41. package/src/lib/components/crm-site-picker.d.ts +13 -1
  42. package/src/lib/components/crm-site-picker.js +15 -6
  43. package/src/lib/components/crm-site-picker.js.map +1 -1
  44. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  45. package/src/lib/components/crm-transfer-buttons.js +9 -6
  46. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  47. package/src/lib/components/deal-contact-roles-card.js +21 -239
  48. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  49. package/src/lib/components/deal-detail-page.js +2 -1
  50. package/src/lib/components/deal-detail-page.js.map +1 -1
  51. package/src/lib/components/deal-edit-drawer.js +11 -7
  52. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  53. package/src/lib/components/deals-section.js +5 -4
  54. package/src/lib/components/deals-section.js.map +1 -1
  55. package/src/lib/components/lead-campaigns-card.js +26 -14
  56. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  57. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  58. package/src/lib/components/log-activity-dialog.js +96 -32
  59. package/src/lib/components/log-activity-dialog.js.map +1 -1
  60. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  61. package/src/lib/components/new-contact-drawer.js +27 -15
  62. package/src/lib/components/new-contact-drawer.js.map +1 -1
  63. package/src/lib/components/picklist-values-card.js +7 -4
  64. package/src/lib/components/picklist-values-card.js.map +1 -1
  65. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  66. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  67. package/src/lib/components/record-activity-card.d.ts +11 -2
  68. package/src/lib/components/record-activity-card.js +90 -5
  69. package/src/lib/components/record-activity-card.js.map +1 -1
  70. package/src/lib/components/settings-section.d.ts +4 -2
  71. package/src/lib/components/settings-section.js +7 -5
  72. package/src/lib/components/settings-section.js.map +1 -1
  73. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  74. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  75. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  76. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  77. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  78. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  79. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  80. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  81. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  82. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  83. package/src/lib/hooks/use-crm-scope.js +12 -5
  84. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  85. package/src/lib/model/contact-holder.d.ts +12 -0
  86. package/src/lib/model/contact-holder.js +20 -1
  87. package/src/lib/model/contact-holder.js.map +1 -1
  88. package/src/lib/model/contact-record.d.ts +5 -0
  89. package/src/lib/model/contact-record.js +7 -1
  90. package/src/lib/model/contact-record.js.map +1 -1
  91. package/src/lib/model/contact-update.d.ts +6 -0
  92. package/src/lib/model/contact-update.js +1 -0
  93. package/src/lib/model/contact-update.js.map +1 -1
  94. package/src/lib/model/crm-activity-link.d.ts +117 -0
  95. package/src/lib/model/crm-activity-link.js +113 -0
  96. package/src/lib/model/crm-activity-link.js.map +1 -0
  97. package/src/lib/model/crm-api.d.ts +4 -1
  98. package/src/lib/model/crm-api.js +9 -5
  99. package/src/lib/model/crm-api.js.map +1 -1
  100. package/src/lib/model/deal-board-model.js +5 -3
  101. package/src/lib/model/deal-board-model.js.map +1 -1
  102. package/src/lib/model/deal-contact-roles.d.ts +15 -1
  103. package/src/lib/model/deal-contact-roles.js +17 -3
  104. package/src/lib/model/deal-contact-roles.js.map +1 -1
  105. package/src/lib/model/settle-stage-names.d.ts +25 -0
  106. package/src/lib/model/settle-stage-names.js +29 -0
  107. package/src/lib/model/settle-stage-names.js.map +1 -0
  108. package/src/lib/native-nav.js +1 -1
  109. package/src/lib/native-nav.js.map +1 -1
  110. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  111. package/src/lib/server/add-contact-by-hand.js +212 -0
  112. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  113. package/src/lib/server/api-v1/contacts.js +61 -23
  114. package/src/lib/server/api-v1/contacts.js.map +1 -1
  115. package/src/lib/server/api-v1/crm-activities.js +13 -3
  116. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  117. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  118. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  119. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  120. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  121. package/src/lib/server/api-v1/crm-shared.js +23 -11
  122. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  123. package/src/lib/server/api-v1/openapi.js +3 -3
  124. package/src/lib/server/api-v1/openapi.js.map +1 -1
  125. package/src/lib/server/automation-steps.js +11 -9
  126. package/src/lib/server/automation-steps.js.map +1 -1
  127. package/src/lib/server/campaign-filing-activity.js +7 -2
  128. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  129. package/src/lib/server/contact-update.d.ts +4 -0
  130. package/src/lib/server/contact-update.js +71 -10
  131. package/src/lib/server/contact-update.js.map +1 -1
  132. package/src/lib/server/convert-host-lead.js +13 -2
  133. package/src/lib/server/convert-host-lead.js.map +1 -1
  134. package/src/lib/server/convert-open-lead.js +8 -1
  135. package/src/lib/server/convert-open-lead.js.map +1 -1
  136. package/src/lib/server/crm-activity-company.d.ts +25 -0
  137. package/src/lib/server/crm-activity-company.js +34 -0
  138. package/src/lib/server/crm-activity-company.js.map +1 -0
  139. package/src/lib/server/crm-inbound-email.js +21 -15
  140. package/src/lib/server/crm-inbound-email.js.map +1 -1
  141. package/src/lib/server/crm-sharing.js +9 -2
  142. package/src/lib/server/crm-sharing.js.map +1 -1
  143. package/src/lib/server/email-send.js +10 -8
  144. package/src/lib/server/email-send.js.map +1 -1
  145. package/src/lib/server/merge-contacts.js +6 -2
  146. package/src/lib/server/merge-contacts.js.map +1 -1
  147. package/src/lib/server/org-caller.d.ts +8 -0
  148. package/src/lib/server/org-caller.js +10 -0
  149. package/src/lib/server/org-caller.js.map +1 -1
  150. package/src/lib/server/record-timeline.d.ts +3 -1
  151. package/src/lib/server/record-timeline.js +11 -3
  152. package/src/lib/server/record-timeline.js.map +1 -1
  153. package/src/lib/server/task-reminders-route.d.ts +7 -5
  154. package/src/lib/server/task-reminders-route.js +82 -15
  155. package/src/lib/server/task-reminders-route.js.map +1 -1
  156. package/src/lib/server.js +152 -43
  157. package/src/lib/server.js.map +1 -1
  158. package/src/lib/transfer/contacts.js +83 -48
  159. package/src/lib/transfer/contacts.js.map +1 -1
  160. package/src/lib/transfer/fields.js +1 -2
  161. package/src/lib/transfer/fields.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/native-nav.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 type NativeNavContribution,\n type NativeNavCreateContribution,\n type NativeNavRow,\n nativeNavHubSection,\n nativeNavIcon,\n nativeNavRef,\n} from '@aglyn/aglyn/app-utils/native-nav'\n\n/**\n * The CRM in the native apps' navigation (AGL-3943): the rows this plugin\n * adds to the manifest the shells draw, placed into the core's lists by\n * `order` (`assembleNativeNav`, `generate:native-contracts`). Labels are the\n * console's words, held by `apps/console/specs/native-nav-manifest.spec.ts`.\n *\n * Imports only the core's plain nav module, so the native contracts read it.\n */\n\n/**\n * The row at either scope. The workspace's opens the console's org page\n * (`orgNavItems`, `/[orgSlug]/crm`), the site's the site's hub; both list\n * the same sections under the same ids.\n */\nfunction row(scope: 'org' | 'host', order: number): NativeNavRow {\n return {\n id: 'crm',\n label: 'CRM',\n icon: nativeNavIcon('person.text.rectangle', 'contact_mail'),\n screen: 'crm.contacts',\n pluginId: 'crm',\n order,\n keywords: ['contacts', 'people', 'deals', 'tasks'],\n sections: [\n nativeNavHubSection(scope, '/crm', 'contacts', 'Contacts', 'crm.contacts'),\n nativeNavHubSection(scope, '/crm', 'leads', 'Leads', 'crm.leads'),\n nativeNavHubSection(scope, '/crm', 'companies', 'Companies', 'crm.companies'),\n nativeNavHubSection(scope, '/crm', 'deals', 'Deals', 'crm.deals'),\n nativeNavHubSection(scope, '/crm', 'tasks', 'Tasks', 'crm.tasks'),\n nativeNavHubSection(scope, '/crm', 'reports', 'Reports', 'crm.reports'),\n nativeNavHubSection(scope, '/crm', 'fields', 'Fields', 'crm.fields'),\n nativeNavHubSection(scope, '/crm', 'settings', 'Settings', 'crm.settings'),\n ],\n docs: scope === 'org' ? '/content-and-data/crm/overview#at-the-organization-level' : '/content-and-data/crm/overview',\n console: nativeNavRef(scope === 'org' ? 'orgPluginTab' : 'hostPluginTab', '/crm'),\n }\n}\n\nexport const NATIVE_NAV_ROWS: readonly NativeNavContribution[] = [\n { list: 'workspace', group: 'workspace', row: row('org', 20) },\n { list: 'site', group: 'customers', row: row('host', 20) },\n]\n\n/** A contact, at either scope: the console's \"New contact\" on the CRM's Contacts. */\nfunction contact(scope: 'org' | 'host', order: number): NativeNavCreateContribution['item'] {\n return {\n id: 'contact',\n label: 'Contact',\n icon: nativeNavIcon('person.badge.plus', 'person_add'),\n screen: 'crm.contacts',\n params: { new: '1' },\n pluginId: 'crm',\n permission: 'data.manage',\n planFeature: 'crm',\n order,\n console: nativeNavRef(scope === 'org' ? 'orgPluginSection' : 'hostPluginSection', '/crm/contacts'),\n }\n}\n\nexport const NATIVE_NAV_CREATE: readonly NativeNavCreateContribution[] = [\n { scope: 'workspace', item: contact('org', 20) },\n { scope: 'site', item: contact('host', 70) },\n]\n"],"names":["nativeNavHubSection","nativeNavIcon","nativeNavRef","row","scope","order","id","label","icon","screen","pluginId","keywords","sections","docs","console","NATIVE_NAV_ROWS","list","group","contact","params","new","permission","planFeature","NATIVE_NAV_CREATE","item"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAIEA,mBAAmB,EACnBC,aAAa,EACbC,YAAY,QACP,oCAAmC;AAE1C;;;;;;;CAOC,GAED;;;;CAIC,GACD,SAASC,IAAIC,KAAqB,EAAEC,KAAa;IAC/C,OAAO;QACLC,IAAI;QACJC,OAAO;QACPC,MAAMP,cAAc,yBAAyB;QAC7CQ,QAAQ;QACRC,UAAU;QACVL;QACAM,UAAU;YAAC;YAAY;YAAU;YAAS;SAAQ;QAClDC,UAAU;YACRZ,oBAAoBI,OAAO,QAAQ,YAAY,YAAY;YAC3DJ,oBAAoBI,OAAO,QAAQ,SAAS,SAAS;YACrDJ,oBAAoBI,OAAO,QAAQ,aAAa,aAAa;YAC7DJ,oBAAoBI,OAAO,QAAQ,SAAS,SAAS;YACrDJ,oBAAoBI,OAAO,QAAQ,SAAS,SAAS;YACrDJ,oBAAoBI,OAAO,QAAQ,WAAW,WAAW;YACzDJ,oBAAoBI,OAAO,QAAQ,UAAU,UAAU;YACvDJ,oBAAoBI,OAAO,QAAQ,YAAY,YAAY;SAC5D;QACDS,MAAMT,UAAU,QAAQ,6DAA6D;QACrFU,SAASZ,aAAaE,UAAU,QAAQ,iBAAiB,iBAAiB;IAC5E;AACF;AAEA,OAAO,MAAMW,kBAAoD;IAC/D;QAAEC,MAAM;QAAaC,OAAO;QAAad,KAAKA,IAAI,OAAO;IAAI;IAC7D;QAAEa,MAAM;QAAQC,OAAO;QAAad,KAAKA,IAAI,QAAQ;IAAI;CAC1D,CAAA;AAED,mFAAmF,GACnF,SAASe,QAAQd,KAAqB,EAAEC,KAAa;IACnD,OAAO;QACLC,IAAI;QACJC,OAAO;QACPC,MAAMP,cAAc,qBAAqB;QACzCQ,QAAQ;QACRU,QAAQ;YAAEC,KAAK;QAAI;QACnBV,UAAU;QACVW,YAAY;QACZC,aAAa;QACbjB;QACAS,SAASZ,aAAaE,UAAU,QAAQ,qBAAqB,qBAAqB;IACpF;AACF;AAEA,OAAO,MAAMmB,oBAA4D;IACvE;QAAEnB,OAAO;QAAaoB,MAAMN,QAAQ,OAAO;IAAI;IAC/C;QAAEd,OAAO;QAAQoB,MAAMN,QAAQ,QAAQ;IAAI;CAC5C,CAAA"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/native-nav.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 type NativeNavContribution,\n type NativeNavCreateContribution,\n type NativeNavRow,\n nativeNavHubSection,\n nativeNavIcon,\n nativeNavRef,\n} from '@aglyn/aglyn/app-utils/native-nav'\n\n/**\n * The CRM in the native apps' navigation (AGL-3943): the rows this plugin\n * adds to the manifest the shells draw, placed into the core's lists by\n * `order` (`assembleNativeNav`, `generate:native-contracts`). Labels are the\n * console's words, held by `apps/console/specs/native-nav-manifest.spec.ts`.\n *\n * Imports only the core's plain nav module, so the native contracts read it.\n */\n\n/**\n * The row at either scope. The workspace's opens the console's org page\n * (`orgNavItems`, `/[orgSlug]/crm`), the site's the site's hub; both list\n * the same sections under the same ids.\n */\nfunction row(scope: 'org' | 'host', order: number): NativeNavRow {\n return {\n id: 'crm',\n label: 'CRM',\n icon: nativeNavIcon('person.text.rectangle', 'contact_mail'),\n screen: 'crm.contacts',\n pluginId: 'crm',\n order,\n keywords: ['contacts', 'people', 'deals', 'tasks'],\n sections: [\n nativeNavHubSection(scope, '/crm', 'contacts', 'Contacts', 'crm.contacts'),\n nativeNavHubSection(scope, '/crm', 'leads', 'Leads', 'crm.leads'),\n nativeNavHubSection(scope, '/crm', 'companies', 'Companies', 'crm.companies'),\n nativeNavHubSection(scope, '/crm', 'deals', 'Deals / Opportunities', 'crm.deals'),\n nativeNavHubSection(scope, '/crm', 'tasks', 'Tasks', 'crm.tasks'),\n nativeNavHubSection(scope, '/crm', 'reports', 'Reports', 'crm.reports'),\n nativeNavHubSection(scope, '/crm', 'fields', 'Fields', 'crm.fields'),\n nativeNavHubSection(scope, '/crm', 'settings', 'Settings', 'crm.settings'),\n ],\n docs: scope === 'org' ? '/content-and-data/crm/overview#at-the-organization-level' : '/content-and-data/crm/overview',\n console: nativeNavRef(scope === 'org' ? 'orgPluginTab' : 'hostPluginTab', '/crm'),\n }\n}\n\nexport const NATIVE_NAV_ROWS: readonly NativeNavContribution[] = [\n { list: 'workspace', group: 'workspace', row: row('org', 20) },\n { list: 'site', group: 'customers', row: row('host', 20) },\n]\n\n/** A contact, at either scope: the console's \"New contact\" on the CRM's Contacts. */\nfunction contact(scope: 'org' | 'host', order: number): NativeNavCreateContribution['item'] {\n return {\n id: 'contact',\n label: 'Contact',\n icon: nativeNavIcon('person.badge.plus', 'person_add'),\n screen: 'crm.contacts',\n params: { new: '1' },\n pluginId: 'crm',\n permission: 'data.manage',\n planFeature: 'crm',\n order,\n console: nativeNavRef(scope === 'org' ? 'orgPluginSection' : 'hostPluginSection', '/crm/contacts'),\n }\n}\n\nexport const NATIVE_NAV_CREATE: readonly NativeNavCreateContribution[] = [\n { scope: 'workspace', item: contact('org', 20) },\n { scope: 'site', item: contact('host', 70) },\n]\n"],"names":["nativeNavHubSection","nativeNavIcon","nativeNavRef","row","scope","order","id","label","icon","screen","pluginId","keywords","sections","docs","console","NATIVE_NAV_ROWS","list","group","contact","params","new","permission","planFeature","NATIVE_NAV_CREATE","item"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAIEA,mBAAmB,EACnBC,aAAa,EACbC,YAAY,QACP,oCAAmC;AAE1C;;;;;;;CAOC,GAED;;;;CAIC,GACD,SAASC,IAAIC,KAAqB,EAAEC,KAAa;IAC/C,OAAO;QACLC,IAAI;QACJC,OAAO;QACPC,MAAMP,cAAc,yBAAyB;QAC7CQ,QAAQ;QACRC,UAAU;QACVL;QACAM,UAAU;YAAC;YAAY;YAAU;YAAS;SAAQ;QAClDC,UAAU;YACRZ,oBAAoBI,OAAO,QAAQ,YAAY,YAAY;YAC3DJ,oBAAoBI,OAAO,QAAQ,SAAS,SAAS;YACrDJ,oBAAoBI,OAAO,QAAQ,aAAa,aAAa;YAC7DJ,oBAAoBI,OAAO,QAAQ,SAAS,yBAAyB;YACrEJ,oBAAoBI,OAAO,QAAQ,SAAS,SAAS;YACrDJ,oBAAoBI,OAAO,QAAQ,WAAW,WAAW;YACzDJ,oBAAoBI,OAAO,QAAQ,UAAU,UAAU;YACvDJ,oBAAoBI,OAAO,QAAQ,YAAY,YAAY;SAC5D;QACDS,MAAMT,UAAU,QAAQ,6DAA6D;QACrFU,SAASZ,aAAaE,UAAU,QAAQ,iBAAiB,iBAAiB;IAC5E;AACF;AAEA,OAAO,MAAMW,kBAAoD;IAC/D;QAAEC,MAAM;QAAaC,OAAO;QAAad,KAAKA,IAAI,OAAO;IAAI;IAC7D;QAAEa,MAAM;QAAQC,OAAO;QAAad,KAAKA,IAAI,QAAQ;IAAI;CAC1D,CAAA;AAED,mFAAmF,GACnF,SAASe,QAAQd,KAAqB,EAAEC,KAAa;IACnD,OAAO;QACLC,IAAI;QACJC,OAAO;QACPC,MAAMP,cAAc,qBAAqB;QACzCQ,QAAQ;QACRU,QAAQ;YAAEC,KAAK;QAAI;QACnBV,UAAU;QACVW,YAAY;QACZC,aAAa;QACbjB;QACAS,SAASZ,aAAaE,UAAU,QAAQ,qBAAqB,qBAAqB;IACpF;AACF;AAEA,OAAO,MAAMmB,oBAA4D;IACvE;QAAEnB,OAAO;QAAaoB,MAAMN,QAAQ,OAAO;IAAI;IAC/C;QAAEd,OAAO;QAAQoB,MAAMN,QAAQ,QAAQ;IAAI;CAC5C,CAAA"}
@@ -0,0 +1,84 @@
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 ContactProfileInput } from '@aglyn/tenant-data-admin/server/upsert-contact';
18
+ /**
19
+ * A PERSON THE TEAM ADDS WHO DID NOT COME THROUGH A SITE'S CAPTURE (AGL-4122).
20
+ *
21
+ * `upsertHostContact` is the door for somebody a site met, and it needs what a
22
+ * site's capture always has: a site and an address. A person typed in by the
23
+ * team may have neither. They may be a name and a phone number, added at the
24
+ * organization level, because the team is working a deal with someone whose
25
+ * email comes later. That is this path, and only that: a site AND an address
26
+ * still go through the capture door, which is why the route asks.
27
+ *
28
+ * ## Who the person already is
29
+ *
30
+ * The address when there is one, through the same index the capture reads. With
31
+ * none, the phone number. With neither, nobody: two blank emails are not the
32
+ * same person, and a name is not an identity.
33
+ *
34
+ * The phone lookup is the one narrowing the capture's address lookup refuses,
35
+ * deliberately. An address names one human, so the capture recognizes them
36
+ * across every site. A phone number can be a shared office line, so it only
37
+ * matches somebody this caller's view already holds, and an agency's two
38
+ * clients who share a switchboard stay two records.
39
+ *
40
+ * ## The facet, and what names no site
41
+ *
42
+ * With a Source site the profile lives in that site's group's facet, as a
43
+ * capture's would. With none it lives in `ORG_CONTACT_HOLDER_ID`, and the row
44
+ * has no capturing site: `hostId` is `null`, `capturedByHostIds` is empty, and
45
+ * the record says so instead of guessing a brand that never met the person.
46
+ *
47
+ * ## Consent
48
+ *
49
+ * Only with an address, since a basis for mail needs somewhere to send it. With
50
+ * a site, for that site or the group it disclosed. With none, the person told
51
+ * the organization yes, so the grant is written on every site it has. One entry
52
+ * per site, each a group of one: `byHost[hostId]` stays a lookup, and a site
53
+ * added next month inherits nothing.
54
+ *
55
+ * ## What is not announced
56
+ *
57
+ * A created person with no site or no address fires no `contactCreated`: the
58
+ * event belongs to a site, and what listens for it is mostly mail. The team
59
+ * added them on purpose, so owner rules and domain matching have nothing to add
60
+ * either.
61
+ */
62
+ export interface AddContactByHandInput {
63
+ orgId: string;
64
+ org: Record<string, unknown> | null | undefined;
65
+ /** The site they came from, or `null` for none. */
66
+ sourceHostId: string | null;
67
+ /** Normalized, or `null` for a person with no address yet. */
68
+ email: string | null;
69
+ name?: string;
70
+ facet?: ContactProfileInput;
71
+ tags?: readonly string[];
72
+ /** An opt-in the person gave. Ignored with no address. */
73
+ marketingConsent?: boolean;
74
+ /** The consent-group sentence shown beside the box, with a Source site. */
75
+ disclosedConsentGroup?: string;
76
+ }
77
+ export type AddContactByHandVerdict = {
78
+ contactId: string;
79
+ created: boolean;
80
+ } | {
81
+ refused: 'band' | 'erased' | 'error';
82
+ };
83
+ /** Adds the person, or merges into who they already are. Never rejects. */
84
+ export declare function addContactByHand(input: AddContactByHandInput): Promise<AddContactByHandVerdict>;
@@ -0,0 +1,212 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ import { CAPTURED_BY_HOST_FIELD, checkCrmRecordsQuota, composeContactName, consentGroupForGrant, consentGroupForHost, consentGroupScope, CONTACT_COMPANY_IDS_FIELD, CONTACT_FACETS_FIELD, contactFacetPath, crmDefaultScopeOf, crmNewRecordListFields, crmReadTokens, createResourceUid, MARKETING_CONSENT_BY_HOST_FIELD, marketingConsentFieldsForGroup, marketingConsentFieldsForHost, nameSearchFields, ORG_CONTACT_HOLDER_ID, ORG_SCOPE_TOKEN, planContactCompanyLink, readContactCompanyLink } from "@aglyn/aglyn/server";
19
+ import { contactCompanyLinkFields, firebaseAdmin, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
20
+ import { scheduleCapturedEmailCheck } from "@aglyn/tenant-data-admin/server/capture-email-check";
21
+ import { settleCompanyContactsCounts } from "@aglyn/tenant-data-admin/server/contact-company-link";
22
+ import { emailIndexBeside, findContactByEmail, writeContactEmailIndex } from "@aglyn/tenant-data-admin/server/contact-email-index";
23
+ import { countCrmRecords } from "@aglyn/tenant-data-admin/server/crm-records";
24
+ import { hostRefusesCaptureForErasure } from "@aglyn/tenant-data-admin/server/email-suppression";
25
+ import { storableProfile } from "@aglyn/tenant-data-admin/server/upsert-contact";
26
+ import { FieldValue } from "firebase-admin/firestore";
27
+ import { ORG_HOLDER_GROUP, contactPrimaryGroup } from "../model/contact-holder.js";
28
+ import { orgHostIds } from "./org-caller.js";
29
+ /** The Consent entries for the opt-in, keyed `byHost[hostId]`. */ function consentEntries(input, group, hostIds) {
30
+ if (!input.marketingConsent || !input.email) return {};
31
+ const now = Date.now();
32
+ const fields = input.sourceHostId ? marketingConsentFieldsForGroup(consentGroupForGrant(group, input.disclosedConsentGroup), now) : Object.assign({}, ...hostIds.map((hostId)=>marketingConsentFieldsForHost(hostId, now)[MARKETING_CONSENT_BY_HOST_FIELD]));
33
+ return input.sourceHostId ? fields[MARKETING_CONSENT_BY_HOST_FIELD] : fields;
34
+ }
35
+ /** Who a person with this number already is, among those the caller can see. */ async function findVisibleContactByPhone(contacts, phone, readable) {
36
+ const matches = await contacts.where('phone', '==', phone).limit(10).get();
37
+ const visible = matches.docs.find((doc)=>{
38
+ if (!readable) return true;
39
+ const tokens = doc.get('visibleTo');
40
+ return Array.isArray(tokens) && tokens.some((token)=>readable.includes(String(token)));
41
+ });
42
+ return visible != null ? visible : null;
43
+ }
44
+ /** The profile written into a holder's existing facet, one dotted path per field. */ function facetUpdate(groupId, profile, name, tags) {
45
+ const { custom } = profile, fields = _object_without_properties_loose(profile, [
46
+ "custom"
47
+ ]);
48
+ const update = {};
49
+ for (const [field, value] of Object.entries(fields)){
50
+ if (value !== undefined) update[contactFacetPath(groupId, field)] = value;
51
+ }
52
+ for (const [key, value] of Object.entries(custom != null ? custom : {})){
53
+ update[contactFacetPath(groupId, `custom.${key}`)] = value;
54
+ }
55
+ if (name) update[contactFacetPath(groupId, 'name')] = name;
56
+ if (tags.length) update[contactFacetPath(groupId, 'tags')] = FieldValue.arrayUnion(...tags);
57
+ return update;
58
+ }
59
+ /** Adds the person, or merges into who they already are. Never rejects. */ export async function addContactByHand(input) {
60
+ try {
61
+ var _input_tags;
62
+ const { orgId, org, sourceHostId, email } = input;
63
+ const firestore = firebaseAdmin.app().firestore();
64
+ const orgRef = firestore.collection('orgs').doc(orgId);
65
+ const contacts = orgRef.collection('contacts');
66
+ const group = sourceHostId ? consentGroupForHost(org, sourceHostId) : ORG_HOLDER_GROUP;
67
+ const hostIds = sourceHostId ? [
68
+ sourceHostId
69
+ ] : await orgHostIds(firestore, orgId);
70
+ const tags = (_input_tags = input.tags) != null ? _input_tags : [];
71
+ const profile = storableProfile(input.facet);
72
+ if (profile.address === null) delete profile.address;
73
+ if (profile.otherAddress === null) delete profile.otherAddress;
74
+ const composedName = composeContactName(profile.firstName, profile.lastName);
75
+ const canonicalName = (input.name || composedName || '').slice(0, 120);
76
+ const holderName = (composedName || input.name || '').slice(0, 120);
77
+ // The one number the record can be found by; a person who gave only a
78
+ // mobile is found by that.
79
+ const phone = profile.phone || profile.mobilePhone || '';
80
+ const scope = crmDefaultScopeOf(org) === 'org' ? [
81
+ ORG_SCOPE_TOKEN
82
+ ] : consentGroupScope(group);
83
+ const existing = email ? await findContactByEmail(contacts, email) : phone ? await findVisibleContactByPhone(contacts, phone, sourceHostId && crmDefaultScopeOf(org) !== 'org' ? crmReadTokens(group) : null) : null;
84
+ if (existing) {
85
+ var _existing_data;
86
+ const data = (_existing_data = existing.data()) != null ? _existing_data : {};
87
+ const groupId = sourceHostId ? group.groupId : contactPrimaryGroup(data, org).groupId || ORG_CONTACT_HOLDER_ID;
88
+ const { companyId } = profile, withoutCompany = _object_without_properties_loose(profile, [
89
+ "companyId"
90
+ ]);
91
+ const link = companyId ? planContactCompanyLink(readContactCompanyLink(data, groupId), companyId) : null;
92
+ const consent = consentEntries(input, group, hostIds);
93
+ await existing.ref.update(_extends({}, facetUpdate(groupId, withoutCompany, holderName, tags), link ? contactCompanyLinkFields(link, groupId) : {}, phone && !data['phone'] ? {
94
+ phone
95
+ } : {}, Object.fromEntries(Object.entries(consent).map(([hostId, entry])=>[
96
+ `${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}`,
97
+ entry
98
+ ])), sourceHostId ? {
99
+ [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(sourceHostId),
100
+ visibleTo: FieldValue.arrayUnion(...scope)
101
+ } : {}, {
102
+ updatedAt: FieldValue.serverTimestamp()
103
+ }));
104
+ if (link) await settleCompanyContactsCounts(orgRef.collection('companies'), link);
105
+ await restampCrmListFieldsAt(existing.ref, 'contacts');
106
+ return {
107
+ contactId: existing.id,
108
+ created: false
109
+ };
110
+ }
111
+ // An erased person stays erased, whichever door they come back through.
112
+ if (email) {
113
+ for (const hostId of hostIds){
114
+ if (await hostRefusesCaptureForErasure(hostId, email, firestore)) {
115
+ return {
116
+ refused: 'erased'
117
+ };
118
+ }
119
+ }
120
+ }
121
+ const { crmRecordsCount } = await countCrmRecords(orgRef, contacts);
122
+ if (!checkCrmRecordsQuota(org != null ? org : null, crmRecordsCount).allowed) {
123
+ return {
124
+ refused: 'band'
125
+ };
126
+ }
127
+ const facet = _extends({
128
+ sources: {
129
+ manual: true
130
+ },
131
+ interactions: [
132
+ _extends({
133
+ type: 'manual',
134
+ atMs: Date.now()
135
+ }, sourceHostId ? {
136
+ hostId: sourceHostId
137
+ } : {}, {
138
+ summary: 'Added by hand'
139
+ })
140
+ ],
141
+ tags: [
142
+ ...tags
143
+ ]
144
+ }, holderName ? {
145
+ name: holderName
146
+ } : {}, profile);
147
+ const consent = consentEntries(input, group, hostIds);
148
+ const record = _extends({
149
+ hostId: sourceHostId,
150
+ [CAPTURED_BY_HOST_FIELD]: sourceHostId ? [
151
+ sourceHostId
152
+ ] : [],
153
+ visibleTo: scope,
154
+ /*
155
+ * BLANK RATHER THAN ABSENT. The Contacts table sorts by every column, and
156
+ * a Firestore `orderBy` drops a document that lacks the field, so a person
157
+ * with no address would vanish from the list the moment someone sorted it
158
+ * by email. Blank sorts first and reads as nothing, and no reader treats
159
+ * it as an address: `normalizeContactEmail` answers `null`.
160
+ */ email: email != null ? email : ''
161
+ }, canonicalName ? nameSearchFields(canonicalName) : {}, phone ? {
162
+ phone
163
+ } : {}, profile.companyName ? {
164
+ companyName: profile.companyName
165
+ } : {}, profile.companyId ? {
166
+ [CONTACT_COMPANY_IDS_FIELD]: [
167
+ profile.companyId
168
+ ]
169
+ } : {}, {
170
+ [CONTACT_FACETS_FIELD]: {
171
+ [group.groupId]: facet
172
+ }
173
+ }, Object.keys(consent).length ? {
174
+ [MARKETING_CONSENT_BY_HOST_FIELD]: consent
175
+ } : {}, {
176
+ createdAt: FieldValue.serverTimestamp(),
177
+ updatedAt: FieldValue.serverTimestamp()
178
+ });
179
+ const created = contacts.doc(createResourceUid());
180
+ await created.set(_extends({}, record, crmNewRecordListFields('contacts', record)));
181
+ if (email) {
182
+ await writeContactEmailIndex(emailIndexBeside(contacts), created.id, [
183
+ email
184
+ ]);
185
+ scheduleCapturedEmailCheck(_extends({
186
+ orgId
187
+ }, sourceHostId ? {
188
+ hostId: sourceHostId
189
+ } : {}, {
190
+ email
191
+ }));
192
+ }
193
+ if (profile.companyId) {
194
+ await settleCompanyContactsCounts(orgRef.collection('companies'), planContactCompanyLink({
195
+ companyId: null,
196
+ companyIds: [],
197
+ heldElsewhere: []
198
+ }, profile.companyId));
199
+ }
200
+ return {
201
+ contactId: created.id,
202
+ created: true
203
+ };
204
+ } catch (error) {
205
+ console.error('[crm] addContactByHand failed', error);
206
+ return {
207
+ refused: 'error'
208
+ };
209
+ }
210
+ }
211
+
212
+ //# sourceMappingURL=add-contact-by-hand.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/add-contact-by-hand.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 CAPTURED_BY_HOST_FIELD,\n checkCrmRecordsQuota,\n composeContactName,\n type ConsentGroup,\n consentGroupForGrant,\n consentGroupForHost,\n consentGroupScope,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_FACETS_FIELD,\n type ContactFacet,\n contactFacetPath,\n crmDefaultScopeOf,\n crmNewRecordListFields,\n crmReadTokens,\n createResourceUid,\n MARKETING_CONSENT_BY_HOST_FIELD,\n marketingConsentFieldsForGroup,\n marketingConsentFieldsForHost,\n nameSearchFields,\n ORG_CONTACT_HOLDER_ID,\n ORG_SCOPE_TOKEN,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/server'\nimport {\n contactCompanyLinkFields,\n firebaseAdmin,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { scheduleCapturedEmailCheck } from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { settleCompanyContactsCounts } from '@aglyn/tenant-data-admin/server/contact-company-link'\nimport {\n emailIndexBeside,\n findContactByEmail,\n writeContactEmailIndex,\n} from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { countCrmRecords } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { hostRefusesCaptureForErasure } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n type ContactProfileInput,\n storableProfile,\n} from '@aglyn/tenant-data-admin/server/upsert-contact'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { ORG_HOLDER_GROUP, contactPrimaryGroup } from '../model/contact-holder'\nimport { orgHostIds } from './org-caller'\n\n/**\n * A PERSON THE TEAM ADDS WHO DID NOT COME THROUGH A SITE'S CAPTURE (AGL-4122).\n *\n * `upsertHostContact` is the door for somebody a site met, and it needs what a\n * site's capture always has: a site and an address. A person typed in by the\n * team may have neither. They may be a name and a phone number, added at the\n * organization level, because the team is working a deal with someone whose\n * email comes later. That is this path, and only that: a site AND an address\n * still go through the capture door, which is why the route asks.\n *\n * ## Who the person already is\n *\n * The address when there is one, through the same index the capture reads. With\n * none, the phone number. With neither, nobody: two blank emails are not the\n * same person, and a name is not an identity.\n *\n * The phone lookup is the one narrowing the capture's address lookup refuses,\n * deliberately. An address names one human, so the capture recognizes them\n * across every site. A phone number can be a shared office line, so it only\n * matches somebody this caller's view already holds, and an agency's two\n * clients who share a switchboard stay two records.\n *\n * ## The facet, and what names no site\n *\n * With a Source site the profile lives in that site's group's facet, as a\n * capture's would. With none it lives in `ORG_CONTACT_HOLDER_ID`, and the row\n * has no capturing site: `hostId` is `null`, `capturedByHostIds` is empty, and\n * the record says so instead of guessing a brand that never met the person.\n *\n * ## Consent\n *\n * Only with an address, since a basis for mail needs somewhere to send it. With\n * a site, for that site or the group it disclosed. With none, the person told\n * the organization yes, so the grant is written on every site it has. One entry\n * per site, each a group of one: `byHost[hostId]` stays a lookup, and a site\n * added next month inherits nothing.\n *\n * ## What is not announced\n *\n * A created person with no site or no address fires no `contactCreated`: the\n * event belongs to a site, and what listens for it is mostly mail. The team\n * added them on purpose, so owner rules and domain matching have nothing to add\n * either.\n */\n\nexport interface AddContactByHandInput {\n orgId: string\n org: Record<string, unknown> | null | undefined\n /** The site they came from, or `null` for none. */\n sourceHostId: string | null\n /** Normalized, or `null` for a person with no address yet. */\n email: string | null\n name?: string\n facet?: ContactProfileInput\n tags?: readonly string[]\n /** An opt-in the person gave. Ignored with no address. */\n marketingConsent?: boolean\n /** The consent-group sentence shown beside the box, with a Source site. */\n disclosedConsentGroup?: string\n}\n\nexport type AddContactByHandVerdict =\n | { contactId: string; created: boolean }\n | { refused: 'band' | 'erased' | 'error' }\n\n/** The Consent entries for the opt-in, keyed `byHost[hostId]`. */\nfunction consentEntries(\n input: AddContactByHandInput,\n group: ConsentGroup,\n hostIds: readonly string[],\n): Record<string, unknown> {\n if (!input.marketingConsent || !input.email) return {}\n const now = Date.now()\n const fields = input.sourceHostId\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, input.disclosedConsentGroup),\n now,\n )\n : Object.assign(\n {},\n ...hostIds.map(\n (hostId) =>\n marketingConsentFieldsForHost(hostId, now)[MARKETING_CONSENT_BY_HOST_FIELD],\n ),\n )\n return input.sourceHostId\n ? (fields[MARKETING_CONSENT_BY_HOST_FIELD] as Record<string, unknown>)\n : fields\n}\n\n/** Who a person with this number already is, among those the caller can see. */\nasync function findVisibleContactByPhone(\n contacts: FirebaseFirestore.CollectionReference,\n phone: string,\n readable: readonly string[] | null,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const matches = await contacts.where('phone', '==', phone).limit(10).get()\n const visible = matches.docs.find((doc) => {\n if (!readable) return true\n const tokens = doc.get('visibleTo')\n return Array.isArray(tokens) && tokens.some((token) => readable.includes(String(token)))\n })\n return visible ?? null\n}\n\n/** The profile written into a holder's existing facet, one dotted path per field. */\nfunction facetUpdate(\n groupId: string,\n profile: ReturnType<typeof storableProfile>,\n name: string,\n tags: readonly string[],\n): Record<string, unknown> {\n const { custom, ...fields } = profile\n const update: Record<string, unknown> = {}\n for (const [field, value] of Object.entries(fields)) {\n if (value !== undefined) update[contactFacetPath(groupId, field)] = value\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[contactFacetPath(groupId, `custom.${key}`)] = value\n }\n if (name) update[contactFacetPath(groupId, 'name')] = name\n if (tags.length) update[contactFacetPath(groupId, 'tags')] = FieldValue.arrayUnion(...tags)\n return update\n}\n\n/** Adds the person, or merges into who they already are. Never rejects. */\nexport async function addContactByHand(\n input: AddContactByHandInput,\n): Promise<AddContactByHandVerdict> {\n try {\n const { orgId, org, sourceHostId, email } = input\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const group = sourceHostId ? consentGroupForHost(org, sourceHostId) : ORG_HOLDER_GROUP\n const hostIds = sourceHostId ? [sourceHostId] : await orgHostIds(firestore, orgId)\n const tags = input.tags ?? []\n\n const profile = storableProfile(input.facet)\n if (profile.address === null) delete profile.address\n if (profile.otherAddress === null) delete profile.otherAddress\n const composedName = composeContactName(profile.firstName, profile.lastName)\n const canonicalName = (input.name || composedName || '').slice(0, 120)\n const holderName = (composedName || input.name || '').slice(0, 120)\n // The one number the record can be found by; a person who gave only a\n // mobile is found by that.\n const phone = profile.phone || profile.mobilePhone || ''\n const scope = crmDefaultScopeOf(org) === 'org' ? [ORG_SCOPE_TOKEN] : consentGroupScope(group)\n\n const existing = email\n ? await findContactByEmail(contacts, email)\n : phone\n ? await findVisibleContactByPhone(\n contacts,\n phone,\n sourceHostId && crmDefaultScopeOf(org) !== 'org' ? crmReadTokens(group) : null,\n )\n : null\n\n if (existing) {\n const data = (existing.data() ?? {}) as Record<string, unknown>\n const groupId = sourceHostId\n ? group.groupId\n : contactPrimaryGroup(data, org).groupId || ORG_CONTACT_HOLDER_ID\n const { companyId, ...withoutCompany } = profile\n const link = companyId\n ? planContactCompanyLink(readContactCompanyLink(data, groupId), companyId)\n : null\n const consent = consentEntries(input, group, hostIds)\n await existing.ref.update({\n ...facetUpdate(groupId, withoutCompany, holderName, tags),\n ...(link ? contactCompanyLinkFields(link, groupId) : {}),\n ...(phone && !data['phone'] ? { phone } : {}),\n ...Object.fromEntries(\n Object.entries(consent).map(([hostId, entry]) => [\n `${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}`,\n entry,\n ]),\n ),\n ...(sourceHostId\n ? {\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(sourceHostId),\n visibleTo: FieldValue.arrayUnion(...scope),\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n if (link) await settleCompanyContactsCounts(orgRef.collection('companies'), link)\n await restampCrmListFieldsAt(existing.ref, 'contacts')\n return { contactId: existing.id, created: false }\n }\n\n // An erased person stays erased, whichever door they come back through.\n if (email) {\n for (const hostId of hostIds) {\n if (await hostRefusesCaptureForErasure(hostId, email, firestore)) {\n return { refused: 'erased' }\n }\n }\n }\n\n const { crmRecordsCount } = await countCrmRecords(orgRef, contacts)\n if (!checkCrmRecordsQuota((org as never) ?? null, crmRecordsCount).allowed) {\n return { refused: 'band' }\n }\n\n const facet: Partial<ContactFacet> & Record<string, unknown> = {\n sources: { manual: true },\n interactions: [\n {\n type: 'manual',\n atMs: Date.now(),\n ...(sourceHostId ? { hostId: sourceHostId } : {}),\n summary: 'Added by hand',\n },\n ],\n tags: [...tags],\n ...(holderName ? { name: holderName } : {}),\n ...profile,\n }\n const consent = consentEntries(input, group, hostIds)\n const record: Record<string, unknown> = {\n hostId: sourceHostId,\n [CAPTURED_BY_HOST_FIELD]: sourceHostId ? [sourceHostId] : [],\n visibleTo: scope,\n /*\n * BLANK RATHER THAN ABSENT. The Contacts table sorts by every column, and\n * a Firestore `orderBy` drops a document that lacks the field, so a person\n * with no address would vanish from the list the moment someone sorted it\n * by email. Blank sorts first and reads as nothing, and no reader treats\n * it as an address: `normalizeContactEmail` answers `null`.\n */\n email: email ?? '',\n ...(canonicalName ? nameSearchFields(canonicalName) : {}),\n ...(phone ? { phone } : {}),\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n ...(profile.companyId ? { [CONTACT_COMPANY_IDS_FIELD]: [profile.companyId] } : {}),\n [CONTACT_FACETS_FIELD]: { [group.groupId]: facet },\n ...(Object.keys(consent).length ? { [MARKETING_CONSENT_BY_HOST_FIELD]: consent } : {}),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = contacts.doc(createResourceUid())\n await created.set({\n ...record,\n ...crmNewRecordListFields('contacts', record),\n })\n if (email) {\n await writeContactEmailIndex(emailIndexBeside(contacts), created.id, [email])\n scheduleCapturedEmailCheck({ orgId, ...(sourceHostId ? { hostId: sourceHostId } : {}), email })\n }\n if (profile.companyId) {\n await settleCompanyContactsCounts(\n orgRef.collection('companies'),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n profile.companyId,\n ),\n )\n }\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('[crm] addContactByHand failed', error)\n return { refused: 'error' }\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkCrmRecordsQuota","composeContactName","consentGroupForGrant","consentGroupForHost","consentGroupScope","CONTACT_COMPANY_IDS_FIELD","CONTACT_FACETS_FIELD","contactFacetPath","crmDefaultScopeOf","crmNewRecordListFields","crmReadTokens","createResourceUid","MARKETING_CONSENT_BY_HOST_FIELD","marketingConsentFieldsForGroup","marketingConsentFieldsForHost","nameSearchFields","ORG_CONTACT_HOLDER_ID","ORG_SCOPE_TOKEN","planContactCompanyLink","readContactCompanyLink","contactCompanyLinkFields","firebaseAdmin","restampCrmListFieldsAt","scheduleCapturedEmailCheck","settleCompanyContactsCounts","emailIndexBeside","findContactByEmail","writeContactEmailIndex","countCrmRecords","hostRefusesCaptureForErasure","storableProfile","FieldValue","ORG_HOLDER_GROUP","contactPrimaryGroup","orgHostIds","consentEntries","input","group","hostIds","marketingConsent","email","now","Date","fields","sourceHostId","disclosedConsentGroup","Object","assign","map","hostId","findVisibleContactByPhone","contacts","phone","readable","matches","where","limit","get","visible","docs","find","doc","tokens","Array","isArray","some","token","includes","String","facetUpdate","groupId","profile","name","tags","custom","update","field","value","entries","undefined","key","length","arrayUnion","addContactByHand","orgId","org","firestore","app","orgRef","collection","facet","address","otherAddress","composedName","firstName","lastName","canonicalName","slice","holderName","mobilePhone","scope","existing","data","companyId","withoutCompany","link","consent","ref","fromEntries","entry","visibleTo","updatedAt","serverTimestamp","contactId","id","created","refused","crmRecordsCount","allowed","sources","manual","interactions","type","atMs","summary","record","companyName","keys","createdAt","set","companyIds","heldElsewhere","error","console"],"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 { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
18
- import { CAPTURED_BY_HOST_FIELD, composeContactName, CONTACT_BIRTHDATE_REFUSAL, CONTACT_COMPANY_IDS_FIELD, CONTACT_EXTRA_PHONE_FIELDS, CONTACT_FACETS_FIELD, CONTACT_FIELDS_MAX_PER_ORG, CONTACT_LIFECYCLE_STAGES, CONTACT_NAME_PART_MAX, consentGroupForHost, contactFacetPath, createResourceUid, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_MAX, CRM_SALUTATION_PICKLIST, isContactLifecycleStage, judgeCrmPicklistValue, MARKETING_CONSENT_BY_HOST_FIELD, marketingConsentFieldsForGroup, marketingConsentHostIds, normalizeAddress, normalizeContactBirthdate, normalizeContactEmail, normalizeCrmMediaIds, normalizePhone, ORG_SCOPE_TOKEN, planContactCompanyLink, readContactCompanyLink, readContactCustomInput, readContactFacet, soloConsentGroup } from "@aglyn/aglyn/server";
18
+ import { CAPTURED_BY_HOST_FIELD, composeContactName, CONTACT_BIRTHDATE_REFUSAL, CONTACT_COMPANY_IDS_FIELD, CONTACT_EXTRA_PHONE_FIELDS, CONTACT_FACETS_FIELD, ORG_CONTACT_HOLDER_ID, CONTACT_FIELDS_MAX_PER_ORG, CONTACT_LIFECYCLE_STAGES, CONTACT_NAME_PART_MAX, consentGroupForHost, crmDefaultScopeOf, crmScopeTokens, contactFacetPath, createResourceUid, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_MAX, CRM_SALUTATION_PICKLIST, isContactLifecycleStage, judgeCrmPicklistValue, MARKETING_CONSENT_BY_HOST_FIELD, marketingConsentFieldsForGroup, marketingConsentHostIds, normalizeAddress, normalizeContactBirthdate, normalizeContactEmail, normalizeCrmMediaIds, normalizePhone, ORG_SCOPE_TOKEN, planContactCompanyLink, readContactCompanyLink, readContactCustomInput, readContactFacet, soloConsentGroup } from "@aglyn/aglyn/server";
19
19
  import { ApiErrors, apiJson, contactCompanyMirrorValue, crmRecordsQuotaForOrg, listResponse, restampCrmListFieldsAt, settleCompanyContactsCounts } from "@aglyn/tenant-data-admin";
20
20
  import { claimWrite, orgOwnsHost, paginate, readJsonBody, requireScope, serialize } from "@aglyn/tenant-data-admin/server/api-v1-kit";
21
21
  // The leaf, so a spec that stands a partial barrel in still reaches it.
@@ -163,12 +163,13 @@ function contactCrmProfile(data, groupId) {
163
163
  * @param groupId - the holder whose CRM profile to publish, or `null` for
164
164
  * the union — see {@link contactCrmProfile}.
165
165
  */ function contactView(doc, groupId = null) {
166
- var _doc_data, _data_email, _data_name, _data_tags, _data_notes, _serialize, _serialize1;
166
+ var _doc_data, _data_name, _data_tags, _data_notes, _serialize, _serialize1;
167
167
  const data = (_doc_data = doc.data()) != null ? _doc_data : {};
168
168
  return _extends({
169
169
  id: doc.id,
170
170
  object: 'contact',
171
- email: (_data_email = data.email) != null ? _data_email : null,
171
+ // Blank in the store so a sort lists them; no address to an integrator.
172
+ email: data.email || null,
172
173
  name: (_data_name = data.name) != null ? _data_name : null,
173
174
  tags: (_data_tags = data.tags) != null ? _data_tags : [],
174
175
  notes: (_data_notes = data.notes) != null ? _data_notes : null
@@ -259,9 +260,13 @@ function readContactInput(body, { partial }) {
259
260
  // The SAME normalizer every capture point uses. Re-implementing the
260
261
  // check here would let the API accept an address `upsertHostContact`
261
262
  // would reject, and the two would then disagree about who is a duplicate.
262
- const email = normalizeContactEmail(body.email);
263
- if (!email) errors.email = 'A valid email address is required';
264
- else values.email = email;
263
+ // Optional (AGL-4122): an address that was sent still has to be one, and
264
+ // a person with none is named below instead.
265
+ if (body.email !== undefined && body.email !== null && body.email !== '') {
266
+ const email = normalizeContactEmail(body.email);
267
+ if (!email) errors.email = 'A valid email address is required';
268
+ else values.email = email;
269
+ }
265
270
  }
266
271
  if (body.name !== undefined) {
267
272
  var _body_name;
@@ -449,9 +454,16 @@ function readContactInput(body, { partial }) {
449
454
  * sites is this write made on behalf of — and an integrator should not
450
455
  * learn two names for it. Beside a refusal the site is accepted, since the
451
456
  * refusal still applies to every site and the site now names the facet.
452
- */ if (writesCrm && !values.consentSiteId) {
457
+ */ if (writesCrm && !values.consentSiteId && partial) {
453
458
  errors.consentSiteId = 'Required with a CRM profile field — name the site whose profile of this person to write';
454
459
  }
460
+ /*
461
+ * WHO THIS IS (AGL-4122). A contact needs no address, so a create needs
462
+ * something else to call them by. With no site and no address the profile is
463
+ * the organization's own, and nothing here narrows that.
464
+ */ if (!partial && !values.email && !values.name && !values.crm.phone && !values.crm.mobilePhone && !values.crm.firstName && !values.crm.lastName && !errors.email) {
465
+ errors.email = 'Send an email, a name or a phone number';
466
+ }
455
467
  if (values.marketingConsent === false && values.consentSiteId && !writesCrm) {
456
468
  errors.consentSiteId = 'Not accepted with marketingConsent: false — a refusal applies to every site';
457
469
  }
@@ -580,10 +592,11 @@ function readContactInput(body, { partial }) {
580
592
  // The holder's name is the first and last names' when either was given.
581
593
  const composed = composeContactName(crm.firstName, crm.lastName);
582
594
  if (composed) stored['name'] = composed;
583
- const group = consentGroupForHost(ctx.org, siteId);
595
+ // The person's holder, or the organization when no site was named (AGL-4122).
596
+ const groupId = siteId ? consentGroupForHost(ctx.org, siteId).groupId : ORG_CONTACT_HOLDER_ID;
584
597
  return _extends({
585
598
  [CONTACT_FACETS_FIELD]: {
586
- [group.groupId]: _extends({
599
+ [groupId]: _extends({
587
600
  sources: {
588
601
  api: true
589
602
  },
@@ -717,10 +730,11 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
717
730
  * taking-and-releasing on a genuine refusal. AGL-2278 applies it to the two
718
731
  * older creates.
719
732
  *
720
- * `visibleTo` is stamped with `ORG_SCOPE_TOKEN`, as capture does. A contact
721
- * written without it matches no `array-contains-any` and is therefore visible
722
- * on NO site (AGL-1044) — the API would create data nobody can see, which is
723
- * worse than refusing.
733
+ * `visibleTo` is stamped with `ORG_SCOPE_TOKEN`, as capture does — or, in an
734
+ * org that keeps each site's records apart, the named site's group, which
735
+ * that org must then name. A contact written without one matches no
736
+ * `array-contains-any` and is therefore visible on NO site (AGL-1044) — the
737
+ * API would create data nobody can see, which is worse than refusing.
724
738
  */ async function createContact(request, ctx) {
725
739
  // Validation stays above the claim, as `createRecord` argues: a
726
740
  // deterministic 400 must never take the key at all, so an integrator fixes
@@ -747,6 +761,25 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
747
761
  headers: ctx.headers
748
762
  });
749
763
  }
764
+ /*
765
+ * Who may see the new contact: the whole org, unless the org keeps each
766
+ * site's records apart — then the named site's group, and a contact named
767
+ * by no site has nowhere to belong. A deterministic 400, so above the claim.
768
+ */ const orgDocument = ctx.org;
769
+ const keepsSitesApart = crmDefaultScopeOf(orgDocument) === 'host';
770
+ if (keepsSitesApart && !consentSiteId) {
771
+ return ApiErrors.badRequest({
772
+ message: 'Contact failed validation',
773
+ code: 'validation_failed',
774
+ fields: {
775
+ consentSiteId: "Required — this organization keeps each site's records apart, so name the site this contact belongs to"
776
+ },
777
+ headers: ctx.headers
778
+ });
779
+ }
780
+ const visibleTo = keepsSitesApart && consentSiteId ? crmScopeTokens(orgDocument, consentGroupForHost(orgDocument, consentSiteId)) : [
781
+ ORG_SCOPE_TOKEN
782
+ ];
750
783
  // Judged against the org's definitions, above the claim like every other
751
784
  // deterministic 400: an unknown key is named, never dropped (AGL-2601).
752
785
  const custom = parsed.values.custom ? readContactCustomInput(parsed.values.custom, await readOrgContactFieldDefinitions(ctx)) : null;
@@ -780,16 +813,20 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
780
813
  if ('replay' in claimed) return claimed.replay;
781
814
  const { claim } = claimed;
782
815
  try {
816
+ var _docs_;
783
817
  // Through the org's address index (AGL-2633): an address a merge folded
784
818
  // into another record is that record's, and a create on it would mint
785
819
  // the duplicate the merge removed.
786
- const existing = await findContactByEmail(collection, email);
820
+ // With no address the number is who they are, and with neither nobody is:
821
+ // two blank emails are not the same person (AGL-4122).
822
+ const phone = crm.phone || crm.mobilePhone || '';
823
+ const existing = email ? await findContactByEmail(collection, email) : phone ? (_docs_ = (await collection.where('phone', '==', phone).limit(1).get()).docs[0]) != null ? _docs_ : null : null;
787
824
  if (existing) {
788
825
  // Released: the conflict clears if that contact is deleted or merged
789
826
  // upstream, and the retry that should then succeed must not replay it.
790
827
  await claim.release();
791
828
  return ApiErrors.conflict({
792
- message: `A contact with this email already exists (${existing.id}). Update it instead.`,
829
+ message: `A contact with this ${email ? 'email' : 'phone number'} already exists (${existing.id}). Update it instead.`,
793
830
  code: 'contact_exists',
794
831
  headers: ctx.headers
795
832
  });
@@ -813,8 +850,11 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
813
850
  }
814
851
  const id = createResourceUid();
815
852
  await collection.doc(id).create(_extends({
816
- email
817
- }, canonicalName ? nameSearchFields(canonicalName) : {}, {
853
+ // Blank rather than absent, so a sort by email still lists them.
854
+ email: email != null ? email : ''
855
+ }, !email && phone ? {
856
+ phone
857
+ } : {}, canonicalName ? nameSearchFields(canonicalName) : {}, {
818
858
  tags: tags != null ? tags : []
819
859
  }, notes ? {
820
860
  notes
@@ -826,7 +866,7 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
826
866
  ] : []
827
867
  }, marketingConsent === false ? {
828
868
  marketingConsent: false
829
- } : {}, consentSiteId ? contactCrmCreateFields(ctx, crm, consentSiteId) : {}, {
869
+ } : {}, contactCrmCreateFields(ctx, crm, consentSiteId), {
830
870
  // `sources.api` — a first-class provenance value beside `form`,
831
871
  // `member`, `order` and `booking`, so a merchant reading the console
832
872
  // can see which people an integration put there.
@@ -838,9 +878,7 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
838
878
  // "No next activity" filter asks as `null` (AGL-3321).
839
879
  nextTaskAtMs: null,
840
880
  // AGL-1044/AGL-1037: without this the contact matches no scoped read.
841
- visibleTo: [
842
- ORG_SCOPE_TOKEN
843
- ],
881
+ visibleTo,
844
882
  createdAt: Timestamp.now(),
845
883
  updatedAt: Timestamp.now()
846
884
  }));
@@ -849,14 +887,14 @@ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId
849
887
  await restampCrmListFieldsAt(collection.doc(id), 'contacts');
850
888
  // The address's domain, checked after the response (AGL-3328): a domain
851
889
  // that takes no mail makes the new contact read "Would bounce".
852
- scheduleCapturedEmailCheck({
890
+ if (email) scheduleCapturedEmailCheck({
853
891
  orgId: ctx.orgId,
854
892
  email
855
893
  });
856
894
  // The company the body named has one more contact naming it (AGL-2613);
857
895
  // a fresh row's plan is the trivial one, and `crmRefErrors` has already
858
896
  // required the company to exist.
859
- if (consentSiteId && crm.companyId) {
897
+ if (crm.companyId) {
860
898
  await settleCompanyContactsCounts(companiesCollection(ctx), planContactCompanyLink({
861
899
  companyId: null,
862
900
  companyIds: [],