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

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 (169) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/activity-list.d.ts +36 -5
  3. package/src/lib/components/activity-list.js +159 -137
  4. package/src/lib/components/activity-list.js.map +1 -1
  5. package/src/lib/components/activity-timeline.d.ts +84 -0
  6. package/src/lib/components/activity-timeline.js +224 -0
  7. package/src/lib/components/activity-timeline.js.map +1 -0
  8. package/src/lib/components/companies-section.d.ts +6 -6
  9. package/src/lib/components/companies-section.js +54 -84
  10. package/src/lib/components/companies-section.js.map +1 -1
  11. package/src/lib/components/company-edit-drawer.js +17 -4
  12. package/src/lib/components/company-edit-drawer.js.map +1 -1
  13. package/src/lib/components/company-picker.js +6 -2
  14. package/src/lib/components/company-picker.js.map +1 -1
  15. package/src/lib/components/company-properties-card.js +53 -36
  16. package/src/lib/components/company-properties-card.js.map +1 -1
  17. package/src/lib/components/contact-associations-card.d.ts +3 -2
  18. package/src/lib/components/contact-associations-card.js +12 -12
  19. package/src/lib/components/contact-associations-card.js.map +1 -1
  20. package/src/lib/components/contact-detail-page.d.ts +4 -3
  21. package/src/lib/components/contact-detail-page.js +18 -12
  22. package/src/lib/components/contact-detail-page.js.map +1 -1
  23. package/src/lib/components/contact-list-columns.d.ts +3 -5
  24. package/src/lib/components/contact-list-columns.js +6 -8
  25. package/src/lib/components/contact-list-columns.js.map +1 -1
  26. package/src/lib/components/contact-properties-card.d.ts +21 -4
  27. package/src/lib/components/contact-properties-card.js +129 -29
  28. package/src/lib/components/contact-properties-card.js.map +1 -1
  29. package/src/lib/components/contact-timeline-card.d.ts +12 -6
  30. package/src/lib/components/contact-timeline-card.js +132 -174
  31. package/src/lib/components/contact-timeline-card.js.map +1 -1
  32. package/src/lib/components/contacts-bulk-bar.js +10 -1
  33. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  34. package/src/lib/components/contacts-section.js +282 -331
  35. package/src/lib/components/contacts-section.js.map +1 -1
  36. package/src/lib/components/crm-email-state-chip.js +4 -1
  37. package/src/lib/components/crm-email-state-chip.js.map +1 -1
  38. package/src/lib/components/crm-next-activity-column.d.ts +4 -2
  39. package/src/lib/components/crm-next-activity-column.js +6 -3
  40. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  41. package/src/lib/components/deal-edit-drawer.js +8 -4
  42. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  43. package/src/lib/components/deal-products-card.js +15 -13
  44. package/src/lib/components/deal-products-card.js.map +1 -1
  45. package/src/lib/components/deal-properties-card.js +7 -0
  46. package/src/lib/components/deal-properties-card.js.map +1 -1
  47. package/src/lib/components/deal-stage-card.js +31 -29
  48. package/src/lib/components/deal-stage-card.js.map +1 -1
  49. package/src/lib/components/deals-section.d.ts +2 -2
  50. package/src/lib/components/deals-section.js +67 -97
  51. package/src/lib/components/deals-section.js.map +1 -1
  52. package/src/lib/components/fields-section.d.ts +4 -0
  53. package/src/lib/components/fields-section.js +368 -154
  54. package/src/lib/components/fields-section.js.map +1 -1
  55. package/src/lib/components/form-contact-fields-card.js +24 -20
  56. package/src/lib/components/form-contact-fields-card.js.map +1 -1
  57. package/src/lib/components/lead-campaigns-card.d.ts +1 -1
  58. package/src/lib/components/lead-campaigns-card.js +43 -38
  59. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  60. package/src/lib/components/lead-properties-card.d.ts +14 -6
  61. package/src/lib/components/lead-properties-card.js +323 -311
  62. package/src/lib/components/lead-properties-card.js.map +1 -1
  63. package/src/lib/components/lead-surfaces-note.js +13 -2
  64. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  65. package/src/lib/components/leads-bulk-bar.js +16 -3
  66. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  67. package/src/lib/components/leads-section.d.ts +2 -1
  68. package/src/lib/components/leads-section.js +110 -148
  69. package/src/lib/components/leads-section.js.map +1 -1
  70. package/src/lib/components/linked-deals-card.js +12 -10
  71. package/src/lib/components/linked-deals-card.js.map +1 -1
  72. package/src/lib/components/recent-activity-feed.d.ts +12 -8
  73. package/src/lib/components/recent-activity-feed.js +54 -29
  74. package/src/lib/components/recent-activity-feed.js.map +1 -1
  75. package/src/lib/components/record-activity-card.d.ts +4 -2
  76. package/src/lib/components/record-activity-card.js +29 -11
  77. package/src/lib/components/record-activity-card.js.map +1 -1
  78. package/src/lib/components/reports/activity-card.js +18 -12
  79. package/src/lib/components/reports/activity-card.js.map +1 -1
  80. package/src/lib/components/reports/lead-funnel-card.js +47 -37
  81. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  82. package/src/lib/components/reports/pipeline-card.js +22 -16
  83. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  84. package/src/lib/components/reports/report-export.d.ts +4 -11
  85. package/src/lib/components/reports/report-export.js +16 -33
  86. package/src/lib/components/reports/report-export.js.map +1 -1
  87. package/src/lib/components/reports/source-conversion-card.js +18 -12
  88. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  89. package/src/lib/components/reports/tasks-card.js +35 -25
  90. package/src/lib/components/reports/tasks-card.js.map +1 -1
  91. package/src/lib/components/tasks-section.js +151 -144
  92. package/src/lib/components/tasks-section.js.map +1 -1
  93. package/src/lib/constants/company-filters.d.ts +13 -0
  94. package/src/lib/constants/company-filters.js +47 -17
  95. package/src/lib/constants/company-filters.js.map +1 -1
  96. package/src/lib/constants/contact-filters.d.ts +61 -10
  97. package/src/lib/constants/contact-filters.js +373 -141
  98. package/src/lib/constants/contact-filters.js.map +1 -1
  99. package/src/lib/constants/deal-filters.d.ts +44 -0
  100. package/src/lib/constants/deal-filters.js +78 -0
  101. package/src/lib/constants/deal-filters.js.map +1 -0
  102. package/src/lib/constants/field-list-query.d.ts +23 -0
  103. package/src/lib/constants/field-list-query.js +80 -0
  104. package/src/lib/constants/field-list-query.js.map +1 -0
  105. package/src/lib/hooks/use-crm-list-query.d.ts +68 -0
  106. package/src/lib/hooks/use-crm-list-query.js +182 -0
  107. package/src/lib/hooks/use-crm-list-query.js.map +1 -0
  108. package/src/lib/hooks/use-crm-tasks.d.ts +23 -6
  109. package/src/lib/hooks/use-crm-tasks.js +44 -42
  110. package/src/lib/hooks/use-crm-tasks.js.map +1 -1
  111. package/src/lib/hooks/use-deals.d.ts +2 -34
  112. package/src/lib/hooks/use-deals.js +3 -62
  113. package/src/lib/hooks/use-deals.js.map +1 -1
  114. package/src/lib/model/crm-list-query.d.ts +91 -0
  115. package/src/lib/model/crm-list-query.js +182 -0
  116. package/src/lib/model/crm-list-query.js.map +1 -0
  117. package/src/lib/model/lead-filters.d.ts +50 -44
  118. package/src/lib/model/lead-filters.js +194 -88
  119. package/src/lib/model/lead-filters.js.map +1 -1
  120. package/src/lib/model/list-fields-api.d.ts +26 -0
  121. package/src/lib/model/list-fields-api.js +47 -0
  122. package/src/lib/model/list-fields-api.js.map +1 -0
  123. package/src/lib/model/list-fields.d.ts +47 -0
  124. package/src/lib/model/list-fields.js +39 -0
  125. package/src/lib/model/list-fields.js.map +1 -0
  126. package/src/lib/model/task-views.d.ts +15 -10
  127. package/src/lib/model/task-views.js +68 -9
  128. package/src/lib/model/task-views.js.map +1 -1
  129. package/src/lib/server/capture-contact.js +7 -5
  130. package/src/lib/server/capture-contact.js.map +1 -1
  131. package/src/lib/server/companies-import.js +3 -3
  132. package/src/lib/server/companies-import.js.map +1 -1
  133. package/src/lib/server/company-delete.js +3 -1
  134. package/src/lib/server/company-delete.js.map +1 -1
  135. package/src/lib/server/consent-group-participant.js +4 -0
  136. package/src/lib/server/consent-group-participant.js.map +1 -1
  137. package/src/lib/server/contact-update.js +14 -8
  138. package/src/lib/server/contact-update.js.map +1 -1
  139. package/src/lib/server/contacts-import.js +4 -2
  140. package/src/lib/server/contacts-import.js.map +1 -1
  141. package/src/lib/server/deals-import.js +4 -2
  142. package/src/lib/server/deals-import.js.map +1 -1
  143. package/src/lib/server/erase-person.js +2 -1
  144. package/src/lib/server/erase-person.js.map +1 -1
  145. package/src/lib/server/lead-create.js +4 -1
  146. package/src/lib/server/lead-create.js.map +1 -1
  147. package/src/lib/server/lead-source-values.js +9 -5
  148. package/src/lib/server/lead-source-values.js.map +1 -1
  149. package/src/lib/server/leads-import.js +4 -1
  150. package/src/lib/server/leads-import.js.map +1 -1
  151. package/src/lib/server/list-fields-routes.d.ts +29 -0
  152. package/src/lib/server/list-fields-routes.js +85 -0
  153. package/src/lib/server/list-fields-routes.js.map +1 -0
  154. package/src/lib/server/record-email-state.js +5 -2
  155. package/src/lib/server/record-email-state.js.map +1 -1
  156. package/src/lib/server/record-timeline.js +3 -2
  157. package/src/lib/server/record-timeline.js.map +1 -1
  158. package/src/lib/server/task-routes.js +6 -3
  159. package/src/lib/server/task-routes.js.map +1 -1
  160. package/src/lib/server/tasks-import.js +5 -2
  161. package/src/lib/server/tasks-import.js.map +1 -1
  162. package/src/lib/server.js +10 -1
  163. package/src/lib/server.js.map +1 -1
  164. package/src/lib/components/contact-custom-fields-card.d.ts +0 -39
  165. package/src/lib/components/contact-custom-fields-card.js +0 -204
  166. package/src/lib/components/contact-custom-fields-card.js.map +0 -1
  167. package/src/lib/hooks/use-org-leads.d.ts +0 -50
  168. package/src/lib/hooks/use-org-leads.js +0 -104
  169. package/src/lib/hooks/use-org-leads.js.map +0 -1
@@ -15,6 +15,8 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type CrmTask, type CrmTaskKind, type CrmTaskPriority, type TaskDueState } from '@aglyn/aglyn';
18
+ import type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter';
19
+ import type { ListQueryDeclaration, ListQueryFilter, ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
20
  import type { CrmRoutes } from './crm-routes';
19
21
  /**
20
22
  * The tasks section's views and the query each one is (AGL-2599).
@@ -35,16 +37,6 @@ export declare const CRM_TASK_KINDS: readonly CrmTaskKind[];
35
37
  export declare const CRM_TASK_KIND_LABELS: Record<CrmTaskKind, string>;
36
38
  export declare const CRM_TASK_PRIORITIES: readonly CrmTaskPriority[];
37
39
  export declare const CRM_TASK_PRIORITY_LABELS: Record<CrmTaskPriority, string>;
38
- /**
39
- * How many rows a view reads at most.
40
- *
41
- * Every view is a listener, and a listener with no cap is a bill that grows
42
- * with the org. Two hundred is more than a person works through in a sitting
43
- * and far under what a table paints comfortably; the list says when the
44
- * window is full so a reader knows to narrow the view rather than assume
45
- * the end of the table is the end of the work.
46
- */
47
- export declare const CRM_TASK_VIEW_LIMIT = 200;
48
40
  /** Midnight at the START of the local calendar day `nowMs` falls in. */
49
41
  export declare function startOfLocalDay(nowMs: number): number;
50
42
  /**
@@ -76,6 +68,19 @@ export declare function crmTaskViewPlan(view: CrmTaskView, options: {
76
68
  nowMs: number;
77
69
  uid: string | null | undefined;
78
70
  }): CrmTaskViewPlan;
71
+ /** The orders the list takes: soonest first, and Done's most recently due first. */
72
+ export declare const TASK_LIST_SORTS: readonly ListQuerySort[];
73
+ /** What the Filters panel may name on a task, besides the view. */
74
+ export declare const TASK_LIST_QUERY_FIELDS: readonly ListFilterField[];
75
+ export declare const TASK_LIST_DECLARATION: ListQueryDeclaration;
76
+ /** The view's own predicates, which the plan takes as its base beside the scope. */
77
+ export declare function crmTaskViewBase(plan: CrmTaskViewPlan): ListQueryFilter[];
78
+ /** The base's shape for `listQueryIndexes`: the view's two equalities. */
79
+ export declare const TASK_LIST_VIEW_BASE_INDEX: readonly [{
80
+ readonly path: "status";
81
+ }, {
82
+ readonly path: "assigneeUid";
83
+ }];
79
84
  /**
80
85
  * Rows in the order a view should paint them.
81
86
  *
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { taskDueState } from "@aglyn/aglyn";
17
+ import { CRM_LIST_SEARCH, crmSelectField } from "./crm-list-query.js";
17
18
  export const CRM_TASK_VIEWS = [
18
19
  {
19
20
  id: 'mine',
@@ -62,15 +63,6 @@ export const CRM_TASK_PRIORITY_LABELS = {
62
63
  normal: 'Normal',
63
64
  high: 'High'
64
65
  };
65
- /**
66
- * How many rows a view reads at most.
67
- *
68
- * Every view is a listener, and a listener with no cap is a bill that grows
69
- * with the org. Two hundred is more than a person works through in a sitting
70
- * and far under what a table paints comfortably; the list says when the
71
- * window is full so a reader knows to narrow the view rather than assume
72
- * the end of the table is the end of the work.
73
- */ export const CRM_TASK_VIEW_LIMIT = 200;
74
66
  /** Midnight at the START of the local calendar day `nowMs` falls in. */ export function startOfLocalDay(nowMs) {
75
67
  const date = new Date(nowMs);
76
68
  return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
@@ -129,6 +121,73 @@ export function crmTaskViewPlan(view, options) {
129
121
  };
130
122
  }
131
123
  }
124
+ /*==========================================
125
+ * THE TASKS LIST'S QUERY (AGL-3321).
126
+ *
127
+ * The view is the query's base — its status, the reader as assignee for
128
+ * "My tasks", and the `dueAtMs` window for Overdue, Today and Upcoming —
129
+ * and Kind, Priority, Assignee and the search word are predicates on the
130
+ * same query, paged by it. Every equality rides one `(field, dueAtMs)`
131
+ * composite per direction: ascending for the working views, descending for
132
+ * Done.
133
+ *=========================================*/ /** The orders the list takes: soonest first, and Done's most recently due first. */ export const TASK_LIST_SORTS = [
134
+ {
135
+ path: 'dueAtMs',
136
+ direction: 'asc'
137
+ },
138
+ {
139
+ path: 'dueAtMs',
140
+ direction: 'desc'
141
+ }
142
+ ];
143
+ /** What the Filters panel may name on a task, besides the view. */ export const TASK_LIST_QUERY_FIELDS = [
144
+ crmSelectField('kind'),
145
+ crmSelectField('priority'),
146
+ crmSelectField('assigneeUid')
147
+ ];
148
+ export const TASK_LIST_DECLARATION = {
149
+ fields: TASK_LIST_QUERY_FIELDS,
150
+ sorts: TASK_LIST_SORTS,
151
+ search: CRM_LIST_SEARCH
152
+ };
153
+ /** The view's own predicates, which the plan takes as its base beside the scope. */ export function crmTaskViewBase(plan) {
154
+ return [
155
+ {
156
+ path: 'status',
157
+ op: '==',
158
+ value: plan.status
159
+ },
160
+ ...plan.assigneeUid ? [
161
+ {
162
+ path: 'assigneeUid',
163
+ op: '==',
164
+ value: plan.assigneeUid
165
+ }
166
+ ] : [],
167
+ ...plan.dueFrom !== undefined ? [
168
+ {
169
+ path: 'dueAtMs',
170
+ op: '>=',
171
+ value: plan.dueFrom
172
+ }
173
+ ] : [],
174
+ ...plan.dueBefore !== undefined ? [
175
+ {
176
+ path: 'dueAtMs',
177
+ op: '<',
178
+ value: plan.dueBefore
179
+ }
180
+ ] : []
181
+ ];
182
+ }
183
+ /** The base's shape for `listQueryIndexes`: the view's two equalities. */ export const TASK_LIST_VIEW_BASE_INDEX = [
184
+ {
185
+ path: 'status'
186
+ },
187
+ {
188
+ path: 'assigneeUid'
189
+ }
190
+ ];
132
191
  /**
133
192
  * Rows in the order a view should paint them.
134
193
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/task-views.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 CrmTask,\n type CrmTaskKind,\n type CrmTaskPriority,\n type TaskDueState,\n taskDueState,\n} from '@aglyn/aglyn'\nimport type { CrmRoutes } from './crm-routes'\n\n/**\n * The tasks section's views and the query each one is (AGL-2599).\n *\n * Pure: what a view MEANS is written here once, as a plan the hook turns into\n * Firestore constraints and a spec can read back without Firestore. \"Overdue\"\n * and \"today\" are not stored on the task — nothing runs at midnight to stamp\n * them — so each view is a window over `dueAtMs` computed at read time from\n * the reader's own clock, and the same day boundaries {@link taskDueState}\n * paints a row with.\n */\n\nexport type CrmTaskView =\n | 'mine'\n | 'overdue'\n | 'today'\n | 'upcoming'\n | 'open'\n | 'done'\n\nexport const CRM_TASK_VIEWS: ReadonlyArray<{ id: CrmTaskView; label: string }> =\n [\n { id: 'mine', label: 'My tasks' },\n { id: 'overdue', label: 'Overdue' },\n { id: 'today', label: 'Today' },\n { id: 'upcoming', label: 'Upcoming' },\n { id: 'open', label: 'All open' },\n { id: 'done', label: 'Done' },\n ]\n\nexport const CRM_TASK_KINDS: readonly CrmTaskKind[] = [\n 'call',\n 'email',\n 'meeting',\n 'todo',\n]\n\nexport const CRM_TASK_KIND_LABELS: Record<CrmTaskKind, string> = {\n call: 'Call',\n email: 'Email',\n meeting: 'Meeting',\n todo: 'To-do',\n}\n\nexport const CRM_TASK_PRIORITIES: readonly CrmTaskPriority[] = [\n 'low',\n 'normal',\n 'high',\n]\n\nexport const CRM_TASK_PRIORITY_LABELS: Record<CrmTaskPriority, string> = {\n low: 'Low',\n normal: 'Normal',\n high: 'High',\n}\n\n/**\n * How many rows a view reads at most.\n *\n * Every view is a listener, and a listener with no cap is a bill that grows\n * with the org. Two hundred is more than a person works through in a sitting\n * and far under what a table paints comfortably; the list says when the\n * window is full so a reader knows to narrow the view rather than assume\n * the end of the table is the end of the work.\n */\nexport const CRM_TASK_VIEW_LIMIT = 200\n\n/** Midnight at the START of the local calendar day `nowMs` falls in. */\nexport function startOfLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()\n}\n\n/**\n * Midnight at the start of the NEXT local day — built from the calendar\n * rather than by adding 24 hours, because a day that a clock change falls in\n * is 23 or 25 hours long and a task due at 00:30 on that morning belongs to\n * it either way.\n */\nexport function startOfNextLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(\n date.getFullYear(),\n date.getMonth(),\n date.getDate() + 1,\n ).getTime()\n}\n\n/**\n * One view as constraints, in the vocabulary of the `crmTasks` indexes.\n *\n * `assigneeUid` narrows to the reader and rides the\n * `(visibleTo, assigneeUid, status, dueAtMs)` index; every other view rides\n * `(visibleTo, status, dueAtMs)`. `dueFrom` is inclusive and `dueBefore`\n * exclusive so that two adjacent windows share a boundary and no task falls\n * between \"today\" and \"upcoming\". A task with no due date has `dueAtMs:\n * null`, which a range excludes and a bare order sorts first, so it appears\n * in \"My tasks\" and \"All open\" and in none of the dated views.\n */\nexport interface CrmTaskViewPlan {\n status: 'open' | 'done'\n assigneeUid?: string\n dueFrom?: number\n dueBefore?: number\n direction: 'asc' | 'desc'\n}\n\nexport function crmTaskViewPlan(\n view: CrmTaskView,\n options: { nowMs: number; uid: string | null | undefined },\n): CrmTaskViewPlan {\n const { nowMs, uid } = options\n const today = startOfLocalDay(nowMs)\n const tomorrow = startOfNextLocalDay(nowMs)\n switch (view) {\n case 'mine':\n return { status: 'open', assigneeUid: uid ?? '', direction: 'asc' }\n case 'overdue':\n return { status: 'open', dueBefore: today, direction: 'asc' }\n case 'today':\n return { status: 'open', dueFrom: today, dueBefore: tomorrow, direction: 'asc' }\n case 'upcoming':\n return { status: 'open', dueFrom: tomorrow, direction: 'asc' }\n case 'open':\n return { status: 'open', direction: 'asc' }\n case 'done':\n // Most recently due first: what was finished lately is what a reader\n // scrolls the Done view for, and the oldest completed task is the one\n // nobody comes back to.\n return { status: 'done', direction: 'desc' }\n }\n}\n\n/**\n * Rows in the order a view should paint them.\n *\n * Firestore sorts `null` before every number, so an ascending order by\n * `dueAtMs` puts the undated tasks at the TOP of \"All open\" — above the one\n * due in an hour. The query stays as it is (the index decides that); the\n * undated rows are moved to the end here, in a stable pass that leaves the\n * dated order the server chose alone.\n */\nexport function orderTaskRows<T extends Pick<CrmTask, 'dueAtMs'>>(\n rows: readonly T[],\n): T[] {\n const dated: T[] = []\n const undated: T[] = []\n for (const row of rows) {\n ;(typeof row.dueAtMs === 'number' ? dated : undated).push(row)\n }\n return [...dated, ...undated]\n}\n\n/** The MUI palette key a due date is painted with, by its state. */\nexport const TASK_DUE_COLORS: Record<TaskDueState, string> = {\n overdue: 'error.main',\n today: 'warning.main',\n upcoming: 'text.primary',\n none: 'text.secondary',\n done: 'text.secondary',\n}\n\n/**\n * The due date as a person reads it: \"Overdue · Tue, Sep 1, 9:00 AM\",\n * \"Today, 3:30 PM\", \"No due date\". The state word comes first because it is\n * the part a reader scans a column for; the timestamp is what they read once\n * a row has their attention.\n */\nexport function describeTaskDue(\n task: Pick<CrmTask, 'status' | 'dueAtMs'>,\n nowMs: number,\n locale?: string,\n): { state: TaskDueState; label: string } {\n const state = taskDueState(task, nowMs)\n const dueAtMs = task.dueAtMs\n if (typeof dueAtMs !== 'number' || !Number.isFinite(dueAtMs)) {\n return { state, label: 'No due date' }\n }\n const due = new Date(dueAtMs)\n const time = due.toLocaleTimeString(locale, {\n hour: 'numeric',\n minute: '2-digit',\n })\n switch (state) {\n case 'today':\n return { state, label: `Today, ${time}` }\n case 'overdue':\n return {\n state,\n label: `Overdue · ${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n default:\n return {\n state,\n label: `${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n }\n}\n\n/**\n * `datetime-local` ↔ epoch milliseconds, in the reader's own zone.\n *\n * The input's value is a wall-clock string with no zone; `new Date(string)`\n * reads such a string as LOCAL time, which is what a person typing \"9:00\"\n * into a due-date field means. Built by hand in the other direction because\n * `toISOString()` would render the UTC clock, and a task due at nine would\n * reopen in the drawer at whatever nine is in Greenwich.\n */\nexport function dueAtToLocalInput(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const date = new Date(ms)\n const pad = (value: number) => String(value).padStart(2, '0')\n return (\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +\n `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n )\n}\n\nexport function localInputToDueAt(value: string): number | null {\n const text = String(value ?? '').trim()\n if (!text) return null\n const ms = new Date(text).getTime()\n return Number.isFinite(ms) ? ms : null\n}\n\n/**\n * The two snoozes a row and the drawer offer by name (AGL-2619), in the\n * order a person reaches for them; \"Pick a date\" is the third and takes a\n * date rather than an option.\n */\nexport type CrmTaskSnoozeOption = 'tomorrow' | 'nextWeek'\n\nexport const CRM_TASK_SNOOZE_OPTIONS: ReadonlyArray<{\n id: CrmTaskSnoozeOption\n label: string\n}> = [\n { id: 'tomorrow', label: 'Tomorrow' },\n { id: 'nextWeek', label: 'Next week' },\n]\n\n/** The hour a snoozed task lands on when it had no time of day of its own. */\nexport const CRM_TASK_SNOOZE_DEFAULT_HOUR = 9\n\n/**\n * Where a snoozed task is due next.\n *\n * Counted from NOW, not from the old due date: a task a week overdue\n * snoozed to \"tomorrow\" is due tomorrow, which is what the word means to the\n * person pressing it, and not due six days ago. The time of day is kept —\n * a nine o'clock call stays a nine o'clock call — and a task that had no\n * time lands at {@link CRM_TASK_SNOOZE_DEFAULT_HOUR}, a working hour rather\n * than midnight. Built from the calendar for the reason\n * {@link startOfNextLocalDay} gives.\n */\nexport function snoozeDueAt(\n option: CrmTaskSnoozeOption,\n dueAtMs: number | null | undefined,\n nowMs: number,\n): number {\n const had = typeof dueAtMs === 'number' && Number.isFinite(dueAtMs) ? new Date(dueAtMs) : null\n const hours = had ? had.getHours() : CRM_TASK_SNOOZE_DEFAULT_HOUR\n const minutes = had ? had.getMinutes() : 0\n const now = new Date(nowMs)\n const days = option === 'tomorrow' ? 1 : 7\n return new Date(\n now.getFullYear(),\n now.getMonth(),\n now.getDate() + days,\n hours,\n minutes,\n ).getTime()\n}\n\n/**\n * The record a task hangs off, as a link into the hub — or `null` for a\n * task that is nobody's in particular.\n *\n * One record per task on screen even when the document names more than one:\n * the contact wins, then the deal, then the company, because that is the\n * order of how specific each is about WHO the work is for. All three ids\n * stay on the document for the record pages' own queries.\n */\nexport function taskRecordLink(\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n routes: CrmRoutes,\n): { kind: 'contact' | 'deal' | 'company'; id: string; href: string } | null {\n if (task.contactId) {\n return { kind: 'contact', id: task.contactId, href: routes.contact(task.contactId) }\n }\n if (task.dealId) {\n return { kind: 'deal', id: task.dealId, href: routes.deal(task.dealId) }\n }\n if (task.companyId) {\n return { kind: 'company', id: task.companyId, href: routes.company(task.companyId) }\n }\n return null\n}\n"],"names":["taskDueState","CRM_TASK_VIEWS","id","label","CRM_TASK_KINDS","CRM_TASK_KIND_LABELS","call","email","meeting","todo","CRM_TASK_PRIORITIES","CRM_TASK_PRIORITY_LABELS","low","normal","high","CRM_TASK_VIEW_LIMIT","startOfLocalDay","nowMs","date","Date","getFullYear","getMonth","getDate","getTime","startOfNextLocalDay","crmTaskViewPlan","view","options","uid","today","tomorrow","status","assigneeUid","direction","dueBefore","dueFrom","orderTaskRows","rows","dated","undated","row","dueAtMs","push","TASK_DUE_COLORS","overdue","upcoming","none","done","describeTaskDue","task","locale","state","Number","isFinite","due","time","toLocaleTimeString","hour","minute","toLocaleDateString","weekday","month","day","dueAtToLocalInput","ms","pad","value","String","padStart","getHours","getMinutes","localInputToDueAt","text","trim","CRM_TASK_SNOOZE_OPTIONS","CRM_TASK_SNOOZE_DEFAULT_HOUR","snoozeDueAt","option","had","hours","minutes","now","days","taskRecordLink","routes","contactId","kind","href","contact","dealId","deal","companyId","company"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/task-views.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 CrmTask,\n type CrmTaskKind,\n type CrmTaskPriority,\n type TaskDueState,\n taskDueState,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { CRM_LIST_SEARCH, crmSelectField } from './crm-list-query'\nimport type { CrmRoutes } from './crm-routes'\n\n/**\n * The tasks section's views and the query each one is (AGL-2599).\n *\n * Pure: what a view MEANS is written here once, as a plan the hook turns into\n * Firestore constraints and a spec can read back without Firestore. \"Overdue\"\n * and \"today\" are not stored on the task — nothing runs at midnight to stamp\n * them — so each view is a window over `dueAtMs` computed at read time from\n * the reader's own clock, and the same day boundaries {@link taskDueState}\n * paints a row with.\n */\n\nexport type CrmTaskView =\n | 'mine'\n | 'overdue'\n | 'today'\n | 'upcoming'\n | 'open'\n | 'done'\n\nexport const CRM_TASK_VIEWS: ReadonlyArray<{ id: CrmTaskView; label: string }> =\n [\n { id: 'mine', label: 'My tasks' },\n { id: 'overdue', label: 'Overdue' },\n { id: 'today', label: 'Today' },\n { id: 'upcoming', label: 'Upcoming' },\n { id: 'open', label: 'All open' },\n { id: 'done', label: 'Done' },\n ]\n\nexport const CRM_TASK_KINDS: readonly CrmTaskKind[] = [\n 'call',\n 'email',\n 'meeting',\n 'todo',\n]\n\nexport const CRM_TASK_KIND_LABELS: Record<CrmTaskKind, string> = {\n call: 'Call',\n email: 'Email',\n meeting: 'Meeting',\n todo: 'To-do',\n}\n\nexport const CRM_TASK_PRIORITIES: readonly CrmTaskPriority[] = [\n 'low',\n 'normal',\n 'high',\n]\n\nexport const CRM_TASK_PRIORITY_LABELS: Record<CrmTaskPriority, string> = {\n low: 'Low',\n normal: 'Normal',\n high: 'High',\n}\n\n\n/** Midnight at the START of the local calendar day `nowMs` falls in. */\nexport function startOfLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()\n}\n\n/**\n * Midnight at the start of the NEXT local day — built from the calendar\n * rather than by adding 24 hours, because a day that a clock change falls in\n * is 23 or 25 hours long and a task due at 00:30 on that morning belongs to\n * it either way.\n */\nexport function startOfNextLocalDay(nowMs: number): number {\n const date = new Date(nowMs)\n return new Date(\n date.getFullYear(),\n date.getMonth(),\n date.getDate() + 1,\n ).getTime()\n}\n\n/**\n * One view as constraints, in the vocabulary of the `crmTasks` indexes.\n *\n * `assigneeUid` narrows to the reader and rides the\n * `(visibleTo, assigneeUid, status, dueAtMs)` index; every other view rides\n * `(visibleTo, status, dueAtMs)`. `dueFrom` is inclusive and `dueBefore`\n * exclusive so that two adjacent windows share a boundary and no task falls\n * between \"today\" and \"upcoming\". A task with no due date has `dueAtMs:\n * null`, which a range excludes and a bare order sorts first, so it appears\n * in \"My tasks\" and \"All open\" and in none of the dated views.\n */\nexport interface CrmTaskViewPlan {\n status: 'open' | 'done'\n assigneeUid?: string\n dueFrom?: number\n dueBefore?: number\n direction: 'asc' | 'desc'\n}\n\nexport function crmTaskViewPlan(\n view: CrmTaskView,\n options: { nowMs: number; uid: string | null | undefined },\n): CrmTaskViewPlan {\n const { nowMs, uid } = options\n const today = startOfLocalDay(nowMs)\n const tomorrow = startOfNextLocalDay(nowMs)\n switch (view) {\n case 'mine':\n return { status: 'open', assigneeUid: uid ?? '', direction: 'asc' }\n case 'overdue':\n return { status: 'open', dueBefore: today, direction: 'asc' }\n case 'today':\n return { status: 'open', dueFrom: today, dueBefore: tomorrow, direction: 'asc' }\n case 'upcoming':\n return { status: 'open', dueFrom: tomorrow, direction: 'asc' }\n case 'open':\n return { status: 'open', direction: 'asc' }\n case 'done':\n // Most recently due first: what was finished lately is what a reader\n // scrolls the Done view for, and the oldest completed task is the one\n // nobody comes back to.\n return { status: 'done', direction: 'desc' }\n }\n}\n\n/*==========================================\n * THE TASKS LIST'S QUERY (AGL-3321).\n *\n * The view is the query's base — its status, the reader as assignee for\n * \"My tasks\", and the `dueAtMs` window for Overdue, Today and Upcoming —\n * and Kind, Priority, Assignee and the search word are predicates on the\n * same query, paged by it. Every equality rides one `(field, dueAtMs)`\n * composite per direction: ascending for the working views, descending for\n * Done.\n *=========================================*/\n\n/** The orders the list takes: soonest first, and Done's most recently due first. */\nexport const TASK_LIST_SORTS: readonly ListQuerySort[] = [\n { path: 'dueAtMs', direction: 'asc' },\n { path: 'dueAtMs', direction: 'desc' },\n]\n\n/** What the Filters panel may name on a task, besides the view. */\nexport const TASK_LIST_QUERY_FIELDS: readonly ListFilterField[] = [\n crmSelectField('kind'),\n crmSelectField('priority'),\n crmSelectField('assigneeUid'),\n]\n\nexport const TASK_LIST_DECLARATION: ListQueryDeclaration = {\n fields: TASK_LIST_QUERY_FIELDS,\n sorts: TASK_LIST_SORTS,\n search: CRM_LIST_SEARCH,\n}\n\n/** The view's own predicates, which the plan takes as its base beside the scope. */\nexport function crmTaskViewBase(plan: CrmTaskViewPlan): ListQueryFilter[] {\n return [\n { path: 'status', op: '==', value: plan.status },\n ...(plan.assigneeUid ? [{ path: 'assigneeUid', op: '==' as const, value: plan.assigneeUid }] : []),\n ...(plan.dueFrom !== undefined ? [{ path: 'dueAtMs', op: '>=' as const, value: plan.dueFrom }] : []),\n ...(plan.dueBefore !== undefined ? [{ path: 'dueAtMs', op: '<' as const, value: plan.dueBefore }] : []),\n ]\n}\n\n/** The base's shape for `listQueryIndexes`: the view's two equalities. */\nexport const TASK_LIST_VIEW_BASE_INDEX = [{ path: 'status' }, { path: 'assigneeUid' }] as const\n\n/**\n * Rows in the order a view should paint them.\n *\n * Firestore sorts `null` before every number, so an ascending order by\n * `dueAtMs` puts the undated tasks at the TOP of \"All open\" — above the one\n * due in an hour. The query stays as it is (the index decides that); the\n * undated rows are moved to the end here, in a stable pass that leaves the\n * dated order the server chose alone.\n */\nexport function orderTaskRows<T extends Pick<CrmTask, 'dueAtMs'>>(\n rows: readonly T[],\n): T[] {\n const dated: T[] = []\n const undated: T[] = []\n for (const row of rows) {\n ;(typeof row.dueAtMs === 'number' ? dated : undated).push(row)\n }\n return [...dated, ...undated]\n}\n\n/** The MUI palette key a due date is painted with, by its state. */\nexport const TASK_DUE_COLORS: Record<TaskDueState, string> = {\n overdue: 'error.main',\n today: 'warning.main',\n upcoming: 'text.primary',\n none: 'text.secondary',\n done: 'text.secondary',\n}\n\n/**\n * The due date as a person reads it: \"Overdue · Tue, Sep 1, 9:00 AM\",\n * \"Today, 3:30 PM\", \"No due date\". The state word comes first because it is\n * the part a reader scans a column for; the timestamp is what they read once\n * a row has their attention.\n */\nexport function describeTaskDue(\n task: Pick<CrmTask, 'status' | 'dueAtMs'>,\n nowMs: number,\n locale?: string,\n): { state: TaskDueState; label: string } {\n const state = taskDueState(task, nowMs)\n const dueAtMs = task.dueAtMs\n if (typeof dueAtMs !== 'number' || !Number.isFinite(dueAtMs)) {\n return { state, label: 'No due date' }\n }\n const due = new Date(dueAtMs)\n const time = due.toLocaleTimeString(locale, {\n hour: 'numeric',\n minute: '2-digit',\n })\n switch (state) {\n case 'today':\n return { state, label: `Today, ${time}` }\n case 'overdue':\n return {\n state,\n label: `Overdue · ${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n default:\n return {\n state,\n label: `${due.toLocaleDateString(locale, {\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n })}, ${time}`,\n }\n }\n}\n\n/**\n * `datetime-local` ↔ epoch milliseconds, in the reader's own zone.\n *\n * The input's value is a wall-clock string with no zone; `new Date(string)`\n * reads such a string as LOCAL time, which is what a person typing \"9:00\"\n * into a due-date field means. Built by hand in the other direction because\n * `toISOString()` would render the UTC clock, and a task due at nine would\n * reopen in the drawer at whatever nine is in Greenwich.\n */\nexport function dueAtToLocalInput(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const date = new Date(ms)\n const pad = (value: number) => String(value).padStart(2, '0')\n return (\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +\n `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n )\n}\n\nexport function localInputToDueAt(value: string): number | null {\n const text = String(value ?? '').trim()\n if (!text) return null\n const ms = new Date(text).getTime()\n return Number.isFinite(ms) ? ms : null\n}\n\n/**\n * The two snoozes a row and the drawer offer by name (AGL-2619), in the\n * order a person reaches for them; \"Pick a date\" is the third and takes a\n * date rather than an option.\n */\nexport type CrmTaskSnoozeOption = 'tomorrow' | 'nextWeek'\n\nexport const CRM_TASK_SNOOZE_OPTIONS: ReadonlyArray<{\n id: CrmTaskSnoozeOption\n label: string\n}> = [\n { id: 'tomorrow', label: 'Tomorrow' },\n { id: 'nextWeek', label: 'Next week' },\n]\n\n/** The hour a snoozed task lands on when it had no time of day of its own. */\nexport const CRM_TASK_SNOOZE_DEFAULT_HOUR = 9\n\n/**\n * Where a snoozed task is due next.\n *\n * Counted from NOW, not from the old due date: a task a week overdue\n * snoozed to \"tomorrow\" is due tomorrow, which is what the word means to the\n * person pressing it, and not due six days ago. The time of day is kept —\n * a nine o'clock call stays a nine o'clock call — and a task that had no\n * time lands at {@link CRM_TASK_SNOOZE_DEFAULT_HOUR}, a working hour rather\n * than midnight. Built from the calendar for the reason\n * {@link startOfNextLocalDay} gives.\n */\nexport function snoozeDueAt(\n option: CrmTaskSnoozeOption,\n dueAtMs: number | null | undefined,\n nowMs: number,\n): number {\n const had = typeof dueAtMs === 'number' && Number.isFinite(dueAtMs) ? new Date(dueAtMs) : null\n const hours = had ? had.getHours() : CRM_TASK_SNOOZE_DEFAULT_HOUR\n const minutes = had ? had.getMinutes() : 0\n const now = new Date(nowMs)\n const days = option === 'tomorrow' ? 1 : 7\n return new Date(\n now.getFullYear(),\n now.getMonth(),\n now.getDate() + days,\n hours,\n minutes,\n ).getTime()\n}\n\n/**\n * The record a task hangs off, as a link into the hub — or `null` for a\n * task that is nobody's in particular.\n *\n * One record per task on screen even when the document names more than one:\n * the contact wins, then the deal, then the company, because that is the\n * order of how specific each is about WHO the work is for. All three ids\n * stay on the document for the record pages' own queries.\n */\nexport function taskRecordLink(\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n routes: CrmRoutes,\n): { kind: 'contact' | 'deal' | 'company'; id: string; href: string } | null {\n if (task.contactId) {\n return { kind: 'contact', id: task.contactId, href: routes.contact(task.contactId) }\n }\n if (task.dealId) {\n return { kind: 'deal', id: task.dealId, href: routes.deal(task.dealId) }\n }\n if (task.companyId) {\n return { kind: 'company', id: task.companyId, href: routes.company(task.companyId) }\n }\n return null\n}\n"],"names":["taskDueState","CRM_LIST_SEARCH","crmSelectField","CRM_TASK_VIEWS","id","label","CRM_TASK_KINDS","CRM_TASK_KIND_LABELS","call","email","meeting","todo","CRM_TASK_PRIORITIES","CRM_TASK_PRIORITY_LABELS","low","normal","high","startOfLocalDay","nowMs","date","Date","getFullYear","getMonth","getDate","getTime","startOfNextLocalDay","crmTaskViewPlan","view","options","uid","today","tomorrow","status","assigneeUid","direction","dueBefore","dueFrom","TASK_LIST_SORTS","path","TASK_LIST_QUERY_FIELDS","TASK_LIST_DECLARATION","fields","sorts","search","crmTaskViewBase","plan","op","value","undefined","TASK_LIST_VIEW_BASE_INDEX","orderTaskRows","rows","dated","undated","row","dueAtMs","push","TASK_DUE_COLORS","overdue","upcoming","none","done","describeTaskDue","task","locale","state","Number","isFinite","due","time","toLocaleTimeString","hour","minute","toLocaleDateString","weekday","month","day","dueAtToLocalInput","ms","pad","String","padStart","getHours","getMinutes","localInputToDueAt","text","trim","CRM_TASK_SNOOZE_OPTIONS","CRM_TASK_SNOOZE_DEFAULT_HOUR","snoozeDueAt","option","had","hours","minutes","now","days","taskRecordLink","routes","contactId","kind","href","contact","dealId","deal","companyId","company"],"mappings":"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"}
@@ -15,10 +15,11 @@
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { formLeadSource } from "@aglyn/aglyn/app-utils/forms";
18
19
  import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
19
20
  import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
20
21
  import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
21
- import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
22
+ import { addHostLeadOutcome, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
22
23
  import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
23
24
  /**
24
25
  * The CRM answering the platform's contact-capture contract (AGL-3080).
@@ -142,8 +143,8 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
142
143
  // this only needs to know whether the person is new, which decides the event.
143
144
  const created = !await readLeadForHost(request.hostId, key);
144
145
  const { formId } = entryPointOf(request.detail);
145
- const source = request.interaction.source === 'form' && formId ? `form:${formId}` : request.interaction.source;
146
- const stored = await addHostLead(_extends({
146
+ const source = request.interaction.source === 'form' && formId ? formLeadSource(formId) : request.interaction.source;
147
+ const outcome = await addHostLeadOutcome(_extends({
147
148
  hostRef,
148
149
  hostId: request.hostId,
149
150
  lead: _extends({
@@ -158,7 +159,7 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
158
159
  }, campaignTouchOf(request.detail).campaignTouch ? {
159
160
  touch: campaignTouchOf(request.detail).campaignTouch
160
161
  } : {}));
161
- if (!stored) {
162
+ if (!outcome.stored) {
162
163
  return {
163
164
  ok: false,
164
165
  reason: 'band',
@@ -178,7 +179,8 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
178
179
  ok: true,
179
180
  record: 'lead',
180
181
  leadId: key,
181
- created
182
+ created,
183
+ sourceAdded: outcome.sourceAdded
182
184
  };
183
185
  }
184
186
  /** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","stored","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
@@ -46,7 +46,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
46
46
  * hundred. A metered plan never refuses; a hard band refuses the rows past
47
47
  * it as `records-band`, and updates to matched companies go through either
48
48
  * way, because an update adds no record.
49
- */ import { checkCrmRecordsQuota, CRM_COLLECTIONS, fieldDefinitionsForObject, nameSearchFields, visibleToTokens } from "@aglyn/aglyn/server";
49
+ */ import { checkCrmRecordsQuota, CRM_COLLECTIONS, crmCompanyListFields, crmNewRecordListFields, fieldDefinitionsForObject, nameSearchFields, visibleToTokens } from "@aglyn/aglyn/server";
50
50
  import { COMPANY_IMPORT_CHUNK_SIZE, COMPANY_IMPORT_MAX_BODY_BYTES, companyImportMatchKey, normalizeCompanyImportRow } from "../model/crm-company-import.js";
51
51
  import { crmRecordsQuotaForOrg, firebaseAdmin } from "@aglyn/tenant-data-admin";
52
52
  import { FieldValue } from "firebase-admin/firestore";
@@ -181,7 +181,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
181
181
  const existing = await findCompany(companies, context.readTokens, row);
182
182
  const stored = storedFields(row, ownerUid);
183
183
  if (existing) {
184
- await existing.ref.update(_extends({}, stored, row.tags.length ? {
184
+ await existing.ref.update(_extends({}, stored, crmCompanyListFields(_extends({}, existing.data(), stored)), row.tags.length ? {
185
185
  tags: FieldValue.arrayUnion(...row.tags)
186
186
  } : {}, customUpdate(row), {
187
187
  updatedAt: FieldValue.serverTimestamp()
@@ -203,7 +203,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
203
203
  tags: row.tags
204
204
  }, Object.keys(row.custom).length ? {
205
205
  custom: row.custom
206
- } : {}, stamp));
206
+ } : {}, stamp, crmNewRecordListFields('companies', _extends({}, stored, stamp))));
207
207
  createdHere += 1;
208
208
  created += 1;
209
209
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/companies-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/companies-import` — one chunk of a companies file, written\n * (AGL-2621).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link COMPANY_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-company-import.ts` defines. This route judges\n * each one through the same normalizers the company drawer runs, and\n * writes it — the ONLY place either happens, so a stale tab cannot store\n * something the server would have refused.\n *\n * ## Matched by domain, then by name\n *\n * A row is one company the org may already have. The domain is a key and\n * the name a spelling, so a row with a domain is looked up by it first,\n * and by the name's search key when no company carries the domain or the\n * row has none — two files calling one business \"Acme\" and \"Acme Inc\" meet\n * at `acme.com`, and a file with no domain column still finds \"Acme\" by\n * name. Both lookups are narrowed to what the caller's scope can see: two\n * clients of one agency may each have an \"Acme\" and must each get their\n * own. A match is UPDATED with what the row carries, tags unioned; no\n * match is CREATED with the stamp every CRM creator writes.\n *\n * ## The band is counted once per request\n *\n * A created company is a CRM record and meets the records band (AGL-2611).\n * The aggregate is taken once at the first create and the verdict re-judged\n * locally as the request creates more — `checkCrmRecordsQuota` is pure over\n * a count — so two hundred rows cost three aggregate reads, not six\n * hundred. A metered plan never refuses; a hard band refuses the rows past\n * it as `records-band`, and updates to matched companies go through either\n * way, because an update adds no record.\n */\n\nimport {\n checkCrmRecordsQuota,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n fieldDefinitionsForObject,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n COMPANY_IMPORT_CHUNK_SIZE,\n COMPANY_IMPORT_MAX_BODY_BYTES,\n type CompanyImportChunkResult,\n type CompanyImportRawRow,\n type CompanyImportRow,\n type CompanyImportSkippedRow,\n companyImportMatchKey,\n normalizeCompanyImportRow,\n} from '../model/crm-company-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\ntype Companies = FirebaseFirestore.CollectionReference\n\n/** The company a row refers to in this scope — by domain, then by name — or `null`. */\nasync function findCompany(\n companies: Companies,\n readTokens: readonly string[],\n row: Pick<CompanyImportRow, 'name' | 'domain'>,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | null> {\n const seen = (snapshot: FirebaseFirestore.QuerySnapshot) =>\n snapshot.docs.find((doc) => visibleToTokens(doc.get('visibleTo'), readTokens)) ?? null\n if (row.domain) {\n const byDomain = seen(\n await companies.where('domain', '==', row.domain).limit(10).get(),\n )\n if (byDomain) return byDomain\n }\n return seen(\n await companies\n .where('nameLower', '==', nameSearchFields(row.name).nameLower)\n .limit(10)\n .get(),\n )\n}\n\n/**\n * The fields a row writes — on a create and on an update alike. Only what\n * the row carries: an update must not clear a website the file did not\n * mention, so an absent optional is left absent here rather than deleted.\n */\nfunction storedFields(\n row: CompanyImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> {\n return {\n ...nameSearchFields(row.name),\n ...(row.domain ? { domain: row.domain } : {}),\n ...(row.website ? { website: row.website } : {}),\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.industry ? { industry: row.industry } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n }\n}\n\n/**\n * The row's custom values as a MERGE writes them: one dotted path per key,\n * so a company already filed keeps every value the file did not carry\n * (AGL-2661). A create spreads the map itself — see the loop below.\n */\nfunction customUpdate(row: CompanyImportRow): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(row.custom).map(([key, value]) => [`custom.${key}`, value]),\n )\n}\n\n/**\n * The org's live COMPANY field definitions (AGL-2661) — the contact\n * import's read, narrowed to the object this import writes. Read in full\n * and filtered in memory: a holder has a handful of fields, and a `where`\n * on `object` would need an index for a collection that fits in one page.\n */\nasync function loadCompanyFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return fieldDefinitionsForObject(\n snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'company',\n ).filter(\n (field) =>\n !!field.key && !field.retiredAt && visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/**\n * `POST crm/companies-import` — `{ hostId, rows }` → a {@link CompanyImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel: two rows\n * for one company racing each other would each miss the other's create.\n */\nexport const crmCompaniesImportHandler: 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<CompanyImportRawRow>(req, {\n maxBodyBytes: COMPANY_IMPORT_MAX_BODY_BYTES,\n chunkSize: COMPANY_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: CompanyImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: CompanyImportRow }[] = []\n const fields = await loadCompanyFieldDefinitions(context.orgId, context.readTokens)\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would update the first's record and the count would read\n * one created and one merged for one company in one file. Across\n * requests the lookup answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeCompanyImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, name: verdict.input, reason: verdict.reason })\n return\n }\n const key = companyImportMatchKey(verdict.row)\n if (seen.has(key)) {\n skipped.push({ index, name: verdict.row.name, 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 })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const stamp = creationStamp(context)\n let counted: { crmRecordsCount: number } | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\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 existing = await findCompany(companies, context.readTokens, row)\n const stored = storedFields(row, ownerUid)\n if (existing) {\n await existing.ref.update({\n ...stored,\n ...(row.tags.length ? { tags: FieldValue.arrayUnion(...row.tags) } : {}),\n ...customUpdate(row),\n updatedAt: FieldValue.serverTimestamp(),\n })\n merged += 1\n continue\n }\n if (!counted) counted = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n const room = checkCrmRecordsQuota(\n context.org as never,\n counted.crmRecordsCount + createdHere,\n )\n if (!room.allowed) {\n skipped.push({ index, name: row.name, reason: 'records-band' })\n continue\n }\n await companies.add({\n ...stored,\n tags: row.tags,\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n ...stamp,\n })\n createdHere += 1\n created += 1\n }\n\n const result: CompanyImportChunkResult = {\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/companies-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/**\n * What every company this import CREATES carries beside its fields: the\n * scope every CRM creator stamps, the site as provenance, who imported it,\n * and the clocks. A record written without `visibleTo` is seen by nobody.\n */\nfunction creationStamp(\n context: Extract<ImportContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n"],"names":["checkCrmRecordsQuota","CRM_COLLECTIONS","fieldDefinitionsForObject","nameSearchFields","visibleToTokens","COMPANY_IMPORT_CHUNK_SIZE","COMPANY_IMPORT_MAX_BODY_BYTES","companyImportMatchKey","normalizeCompanyImportRow","crmRecordsQuotaForOrg","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","findCompany","companies","readTokens","row","seen","snapshot","docs","find","doc","get","domain","byDomain","where","limit","name","nameLower","storedFields","ownerUid","website","phone","industry","address","notes","customUpdate","Object","fromEntries","entries","custom","map","key","value","loadCompanyFieldDefinitions","orgId","app","firestore","collection","contactFields","data","filter","field","retiredAt","visibleTo","crmCompaniesImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","fields","Set","rows","forEach","raw","index","verdict","push","input","reason","has","add","entry","owners","ownersUnresolved","orgRef","stamp","creationStamp","counted","createdHere","created","merged","ownerEmail","existing","stored","ref","update","tags","length","arrayUnion","updatedAt","serverTimestamp","org","room","crmRecordsCount","allowed","keys","result","received","sort","a","b","console","hostId","scopeTokens","createdByUid","uid","createdAt"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/companies-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/companies-import` — one chunk of a companies file, written\n * (AGL-2621).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link COMPANY_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-company-import.ts` defines. This route judges\n * each one through the same normalizers the company drawer runs, and\n * writes it — the ONLY place either happens, so a stale tab cannot store\n * something the server would have refused.\n *\n * ## Matched by domain, then by name\n *\n * A row is one company the org may already have. The domain is a key and\n * the name a spelling, so a row with a domain is looked up by it first,\n * and by the name's search key when no company carries the domain or the\n * row has none — two files calling one business \"Acme\" and \"Acme Inc\" meet\n * at `acme.com`, and a file with no domain column still finds \"Acme\" by\n * name. Both lookups are narrowed to what the caller's scope can see: two\n * clients of one agency may each have an \"Acme\" and must each get their\n * own. A match is UPDATED with what the row carries, tags unioned; no\n * match is CREATED with the stamp every CRM creator writes.\n *\n * ## The band is counted once per request\n *\n * A created company is a CRM record and meets the records band (AGL-2611).\n * The aggregate is taken once at the first create and the verdict re-judged\n * locally as the request creates more — `checkCrmRecordsQuota` is pure over\n * a count — so two hundred rows cost three aggregate reads, not six\n * hundred. A metered plan never refuses; a hard band refuses the rows past\n * it as `records-band`, and updates to matched companies go through either\n * way, because an update adds no record.\n */\n\nimport {\n checkCrmRecordsQuota,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmCompanyListFields,\n crmNewRecordListFields,\n fieldDefinitionsForObject,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n COMPANY_IMPORT_CHUNK_SIZE,\n COMPANY_IMPORT_MAX_BODY_BYTES,\n type CompanyImportChunkResult,\n type CompanyImportRawRow,\n type CompanyImportRow,\n type CompanyImportSkippedRow,\n companyImportMatchKey,\n normalizeCompanyImportRow,\n} from '../model/crm-company-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\ntype Companies = FirebaseFirestore.CollectionReference\n\n/** The company a row refers to in this scope — by domain, then by name — or `null`. */\nasync function findCompany(\n companies: Companies,\n readTokens: readonly string[],\n row: Pick<CompanyImportRow, 'name' | 'domain'>,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | null> {\n const seen = (snapshot: FirebaseFirestore.QuerySnapshot) =>\n snapshot.docs.find((doc) => visibleToTokens(doc.get('visibleTo'), readTokens)) ?? null\n if (row.domain) {\n const byDomain = seen(\n await companies.where('domain', '==', row.domain).limit(10).get(),\n )\n if (byDomain) return byDomain\n }\n return seen(\n await companies\n .where('nameLower', '==', nameSearchFields(row.name).nameLower)\n .limit(10)\n .get(),\n )\n}\n\n/**\n * The fields a row writes — on a create and on an update alike. Only what\n * the row carries: an update must not clear a website the file did not\n * mention, so an absent optional is left absent here rather than deleted.\n */\nfunction storedFields(\n row: CompanyImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> {\n return {\n ...nameSearchFields(row.name),\n ...(row.domain ? { domain: row.domain } : {}),\n ...(row.website ? { website: row.website } : {}),\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.industry ? { industry: row.industry } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n }\n}\n\n/**\n * The row's custom values as a MERGE writes them: one dotted path per key,\n * so a company already filed keeps every value the file did not carry\n * (AGL-2661). A create spreads the map itself — see the loop below.\n */\nfunction customUpdate(row: CompanyImportRow): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(row.custom).map(([key, value]) => [`custom.${key}`, value]),\n )\n}\n\n/**\n * The org's live COMPANY field definitions (AGL-2661) — the contact\n * import's read, narrowed to the object this import writes. Read in full\n * and filtered in memory: a holder has a handful of fields, and a `where`\n * on `object` would need an index for a collection that fits in one page.\n */\nasync function loadCompanyFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return fieldDefinitionsForObject(\n snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'company',\n ).filter(\n (field) =>\n !!field.key && !field.retiredAt && visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/**\n * `POST crm/companies-import` — `{ hostId, rows }` → a {@link CompanyImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel: two rows\n * for one company racing each other would each miss the other's create.\n */\nexport const crmCompaniesImportHandler: 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<CompanyImportRawRow>(req, {\n maxBodyBytes: COMPANY_IMPORT_MAX_BODY_BYTES,\n chunkSize: COMPANY_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: CompanyImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: CompanyImportRow }[] = []\n const fields = await loadCompanyFieldDefinitions(context.orgId, context.readTokens)\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would update the first's record and the count would read\n * one created and one merged for one company in one file. Across\n * requests the lookup answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeCompanyImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, name: verdict.input, reason: verdict.reason })\n return\n }\n const key = companyImportMatchKey(verdict.row)\n if (seen.has(key)) {\n skipped.push({ index, name: verdict.row.name, 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 })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const stamp = creationStamp(context)\n let counted: { crmRecordsCount: number } | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\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 existing = await findCompany(companies, context.readTokens, row)\n const stored = storedFields(row, ownerUid)\n if (existing) {\n await existing.ref.update({\n ...stored,\n // The file may rename the company: what the list searches follows.\n ...crmCompanyListFields({ ...existing.data(), ...stored }),\n ...(row.tags.length ? { tags: FieldValue.arrayUnion(...row.tags) } : {}),\n ...customUpdate(row),\n updatedAt: FieldValue.serverTimestamp(),\n })\n merged += 1\n continue\n }\n if (!counted) counted = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n const room = checkCrmRecordsQuota(\n context.org as never,\n counted.crmRecordsCount + createdHere,\n )\n if (!room.allowed) {\n skipped.push({ index, name: row.name, reason: 'records-band' })\n continue\n }\n await companies.add({\n ...stored,\n tags: row.tags,\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n ...stamp,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...stored, ...stamp }),\n })\n createdHere += 1\n created += 1\n }\n\n const result: CompanyImportChunkResult = {\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/companies-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/**\n * What every company this import CREATES carries beside its fields: the\n * scope every CRM creator stamps, the site as provenance, who imported it,\n * and the clocks. A record written without `visibleTo` is seen by nobody.\n */\nfunction creationStamp(\n context: Extract<ImportContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n"],"names":["checkCrmRecordsQuota","CRM_COLLECTIONS","crmCompanyListFields","crmNewRecordListFields","fieldDefinitionsForObject","nameSearchFields","visibleToTokens","COMPANY_IMPORT_CHUNK_SIZE","COMPANY_IMPORT_MAX_BODY_BYTES","companyImportMatchKey","normalizeCompanyImportRow","crmRecordsQuotaForOrg","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","findCompany","companies","readTokens","row","seen","snapshot","docs","find","doc","get","domain","byDomain","where","limit","name","nameLower","storedFields","ownerUid","website","phone","industry","address","notes","customUpdate","Object","fromEntries","entries","custom","map","key","value","loadCompanyFieldDefinitions","orgId","app","firestore","collection","contactFields","data","filter","field","retiredAt","visibleTo","crmCompaniesImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","fields","Set","rows","forEach","raw","index","verdict","push","input","reason","has","add","entry","owners","ownersUnresolved","orgRef","stamp","creationStamp","counted","createdHere","created","merged","ownerEmail","existing","stored","ref","update","tags","length","arrayUnion","updatedAt","serverTimestamp","org","room","crmRecordsCount","allowed","keys","result","received","sort","a","b","console","hostId","scopeTokens","createdByUid","uid","createdAt"],"mappings":"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"}
@@ -52,7 +52,7 @@
52
52
  * known, staff included, before anything is read or unlinked
53
53
  * (`suite-gate.ts`).
54
54
  */ import { CONTACT_COMPANY_IDS_FIELD, contactFacetPath, contactGroupsNamingCompany, CRM_COLLECTIONS, isOrgWideMember } from "@aglyn/aglyn/server";
55
- import { firebaseAdmin } from "@aglyn/tenant-data-admin";
55
+ import { firebaseAdmin, restampCrmListFields } from "@aglyn/tenant-data-admin";
56
56
  import { FieldValue } from "firebase-admin/firestore";
57
57
  import { COMPANY_DETACH_LIMIT } from "../model/company-delete-route.js";
58
58
  import { typed } from "./contact-profile.js";
@@ -126,6 +126,8 @@ export const crmCompanyDeleteHandler = async (req, res)=>{
126
126
  batch.update(snapshot.ref, companyDetachFields(snapshot.data(), companyId));
127
127
  }
128
128
  await batch.commit();
129
+ // The company is what the Contacts list filters by (AGL-3321).
130
+ await restampCrmListFields(firestore, writer.orgId, 'contacts', linked.map((snapshot)=>snapshot.id));
129
131
  }
130
132
  if (!moreRemain) await companyRef.delete();
131
133
  const answer = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-delete.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/company-delete` — unlink the contacts that name a company,\n * then delete it (AGL-2804).\n *\n * Body: `{ hostId, companyId }` under a site, or `{ orgId, companyId }` at\n * the organization level. Answers `CompanyDeleteResponse`.\n *\n * ## Why a route\n *\n * Firestore does not cascade. A company deleted on its own leaves every\n * contact at it naming a record that no longer exists: their page links to\n * nothing and the `companyIds` mirror the company list queries keeps\n * matching a ghost. So a delete is a detach pass and then a delete, and the\n * detach clears each holder's facet that named the company — which the rules\n * refuse a client, because they cannot tell a company link inside a facet\n * from an owner or a stage beside it.\n *\n * ## Bounded, and honest past the bound\n *\n * One pass unlinks at most `COMPANY_DETACH_LIMIT` contacts, a batch's worth.\n * A company past that is left standing with a pass's worth unlinked, and the\n * answer says more remain, so the next delete continues where this one\n * stopped. A company is never deleted with a link still on a contact.\n *\n * ## Who may call it\n *\n * A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.\n * The contacts are found by the mirror, and that query cannot carry a scope\n * clause beside its `array-contains`: a member scoped to particular sites\n * could not read them in the browser either, and the route does not unlink\n * people on behalf of someone who may not see them.\n *\n * ## The plan\n *\n * Companies are the CRM's, and the CRM is included from Starter (AGL-2851):\n * a workspace whose plan does not carry it is refused once the writer is\n * known, staff included, before anything is read or unlinked\n * (`suite-gate.ts`).\n */\n\nimport {\n CONTACT_COMPANY_IDS_FIELD,\n contactFacetPath,\n contactGroupsNamingCompany,\n CRM_COLLECTIONS,\n isOrgWideMember,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n COMPANY_DETACH_LIMIT,\n type CompanyDeleteResponse,\n} from '../model/company-delete-route'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter, canReach } from './task-routes'\n\n/** What a member scoped to particular sites is told. */\nexport const COMPANY_DELETE_SCOPED_REFUSAL =\n 'Your access is limited to specific sites, so the contacts at this company ' +\n 'could not be read to unlink them. Ask an organization administrator to delete it.'\n\n/**\n * A contact's update when a company it names is deleted: the id out of the\n * mirror, and out of every holder's facet that named it. Another holder's\n * link to a different company is not read or written.\n */\nfunction companyDetachFields(\n contact: Record<string, unknown>,\n companyId: string,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {\n [CONTACT_COMPANY_IDS_FIELD]: FieldValue.arrayRemove(companyId),\n updatedAt: FieldValue.serverTimestamp(),\n }\n for (const groupId of contactGroupsNamingCompany(contact, companyId)) {\n update[contactFacetPath(groupId, 'companyId')] = FieldValue.delete()\n }\n return update\n}\n\nexport const crmCompanyDeleteHandler: 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 const companyId = typed(body['companyId'], 200)\n if (!scope || !companyId || companyId.includes('/')) {\n res.status(400).json({ error: 'Missing hostId or companyId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: 'Deleting a company' })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (!(writer.staff || writer.level === 'org' || isOrgWideMember(writer.member))) {\n res.status(403).json({ error: COMPANY_DELETE_SCOPED_REFUSAL })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n const company = await companyRef.get()\n if (!company.exists || !canReach(writer, company.get('visibleTo'))) {\n res.status(404).json({ error: 'That company no longer exists.' })\n return\n }\n\n // One past the bound, so \"more remain\" is a fact from the probe row\n // rather than a guess from a full page.\n const probe = await orgRef\n .collection('contacts')\n .where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId)\n .limit(COMPANY_DETACH_LIMIT + 1)\n .get()\n const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT)\n const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT\n if (linked.length) {\n const batch = firestore.batch()\n for (const snapshot of linked) {\n batch.update(\n snapshot.ref,\n companyDetachFields(snapshot.data() as Record<string, unknown>, companyId),\n )\n }\n await batch.commit()\n }\n if (!moreRemain) await companyRef.delete()\n\n const answer: CompanyDeleteResponse = {\n ok: true,\n deleted: !moreRemain,\n detached: linked.length,\n moreRemain,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] company-delete failed', companyId, error)\n res.status(500).json({ error: 'The company could not be deleted.' })\n }\n}\n"],"names":["CONTACT_COMPANY_IDS_FIELD","contactFacetPath","contactGroupsNamingCompany","CRM_COLLECTIONS","isOrgWideMember","firebaseAdmin","FieldValue","COMPANY_DETACH_LIMIT","typed","readCrmRouteScope","authorizeCrmWriter","canReach","COMPANY_DELETE_SCOPED_REFUSAL","companyDetachFields","contact","companyId","update","arrayRemove","updatedAt","serverTimestamp","groupId","delete","crmCompanyDeleteHandler","req","res","method","setHeader","status","json","error","body","scope","includes","writer","suiteAct","ok","staff","level","member","firestore","app","orgRef","collection","doc","orgId","companyRef","companies","company","get","exists","probe","where","limit","linked","docs","slice","moreRemain","length","batch","snapshot","ref","data","commit","answer","deleted","detached","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/company-delete.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/company-delete` — unlink the contacts that name a company,\n * then delete it (AGL-2804).\n *\n * Body: `{ hostId, companyId }` under a site, or `{ orgId, companyId }` at\n * the organization level. Answers `CompanyDeleteResponse`.\n *\n * ## Why a route\n *\n * Firestore does not cascade. A company deleted on its own leaves every\n * contact at it naming a record that no longer exists: their page links to\n * nothing and the `companyIds` mirror the company list queries keeps\n * matching a ghost. So a delete is a detach pass and then a delete, and the\n * detach clears each holder's facet that named the company — which the rules\n * refuse a client, because they cannot tell a company link inside a facet\n * from an owner or a stage beside it.\n *\n * ## Bounded, and honest past the bound\n *\n * One pass unlinks at most `COMPANY_DETACH_LIMIT` contacts, a batch's worth.\n * A company past that is left standing with a pass's worth unlinked, and the\n * answer says more remain, so the next delete continues where this one\n * stopped. A company is never deleted with a link still on a contact.\n *\n * ## Who may call it\n *\n * A CRM writer (`authorizeCrmWriter`) whose reach is the whole organization.\n * The contacts are found by the mirror, and that query cannot carry a scope\n * clause beside its `array-contains`: a member scoped to particular sites\n * could not read them in the browser either, and the route does not unlink\n * people on behalf of someone who may not see them.\n *\n * ## The plan\n *\n * Companies are the CRM's, and the CRM is included from Starter (AGL-2851):\n * a workspace whose plan does not carry it is refused once the writer is\n * known, staff included, before anything is read or unlinked\n * (`suite-gate.ts`).\n */\n\nimport {\n CONTACT_COMPANY_IDS_FIELD,\n contactFacetPath,\n contactGroupsNamingCompany,\n CRM_COLLECTIONS,\n isOrgWideMember,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, restampCrmListFields } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n COMPANY_DETACH_LIMIT,\n type CompanyDeleteResponse,\n} from '../model/company-delete-route'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter, canReach } from './task-routes'\n\n/** What a member scoped to particular sites is told. */\nexport const COMPANY_DELETE_SCOPED_REFUSAL =\n 'Your access is limited to specific sites, so the contacts at this company ' +\n 'could not be read to unlink them. Ask an organization administrator to delete it.'\n\n/**\n * A contact's update when a company it names is deleted: the id out of the\n * mirror, and out of every holder's facet that named it. Another holder's\n * link to a different company is not read or written.\n */\nfunction companyDetachFields(\n contact: Record<string, unknown>,\n companyId: string,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {\n [CONTACT_COMPANY_IDS_FIELD]: FieldValue.arrayRemove(companyId),\n updatedAt: FieldValue.serverTimestamp(),\n }\n for (const groupId of contactGroupsNamingCompany(contact, companyId)) {\n update[contactFacetPath(groupId, 'companyId')] = FieldValue.delete()\n }\n return update\n}\n\nexport const crmCompanyDeleteHandler: 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 const companyId = typed(body['companyId'], 200)\n if (!scope || !companyId || companyId.includes('/')) {\n res.status(400).json({ error: 'Missing hostId or companyId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: 'Deleting a company' })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (!(writer.staff || writer.level === 'org' || isOrgWideMember(writer.member))) {\n res.status(403).json({ error: COMPANY_DELETE_SCOPED_REFUSAL })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const companyRef = orgRef.collection(CRM_COLLECTIONS.companies).doc(companyId)\n const company = await companyRef.get()\n if (!company.exists || !canReach(writer, company.get('visibleTo'))) {\n res.status(404).json({ error: 'That company no longer exists.' })\n return\n }\n\n // One past the bound, so \"more remain\" is a fact from the probe row\n // rather than a guess from a full page.\n const probe = await orgRef\n .collection('contacts')\n .where(CONTACT_COMPANY_IDS_FIELD, 'array-contains', companyId)\n .limit(COMPANY_DETACH_LIMIT + 1)\n .get()\n const linked = probe.docs.slice(0, COMPANY_DETACH_LIMIT)\n const moreRemain = probe.docs.length > COMPANY_DETACH_LIMIT\n if (linked.length) {\n const batch = firestore.batch()\n for (const snapshot of linked) {\n batch.update(\n snapshot.ref,\n companyDetachFields(snapshot.data() as Record<string, unknown>, companyId),\n )\n }\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFields(\n firestore,\n writer.orgId,\n 'contacts',\n linked.map((snapshot) => snapshot.id),\n )\n }\n if (!moreRemain) await companyRef.delete()\n\n const answer: CompanyDeleteResponse = {\n ok: true,\n deleted: !moreRemain,\n detached: linked.length,\n moreRemain,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] company-delete failed', companyId, error)\n res.status(500).json({ error: 'The company could not be deleted.' })\n }\n}\n"],"names":["CONTACT_COMPANY_IDS_FIELD","contactFacetPath","contactGroupsNamingCompany","CRM_COLLECTIONS","isOrgWideMember","firebaseAdmin","restampCrmListFields","FieldValue","COMPANY_DETACH_LIMIT","typed","readCrmRouteScope","authorizeCrmWriter","canReach","COMPANY_DELETE_SCOPED_REFUSAL","companyDetachFields","contact","companyId","update","arrayRemove","updatedAt","serverTimestamp","groupId","delete","crmCompanyDeleteHandler","req","res","method","setHeader","status","json","error","body","scope","includes","writer","suiteAct","ok","staff","level","member","firestore","app","orgRef","collection","doc","orgId","companyRef","companies","company","get","exists","probe","where","limit","linked","docs","slice","moreRemain","length","batch","snapshot","ref","data","commit","map","id","answer","deleted","detached","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GAED,SACEA,yBAAyB,EACzBC,gBAAgB,EAChBC,0BAA0B,EAC1BC,eAAe,EACfC,eAAe,QAEV,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,oBAAoB,QAAQ,2BAA0B;AAC9E,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,oBAAoB,QAEf,mCAA+B;AACtC,SAASC,KAAK,QAAQ,uBAAmB;AACzC,SAASC,iBAAiB,QAAQ,kBAAc;AAChD,SAASC,kBAAkB,EAAEC,QAAQ,QAAQ,mBAAe;AAE5D,sDAAsD,GACtD,OAAO,MAAMC,gCACX,+EACA,oFAAmF;AAErF;;;;CAIC,GACD,SAASC,oBACPC,OAAgC,EAChCC,SAAiB;IAEjB,MAAMC,SAAkC;QACtC,CAACjB,0BAA0B,EAAEO,WAAWW,WAAW,CAACF;QACpDG,WAAWZ,WAAWa,eAAe;IACvC;IACA,KAAK,MAAMC,WAAWnB,2BAA2Ba,SAASC,WAAY;QACpEC,MAAM,CAAChB,iBAAiBoB,SAAS,aAAa,GAAGd,WAAWe,MAAM;IACpE;IACA,OAAOL;AACT;AAEA,OAAO,MAAMM,0BAA4C,OAAOC,KAAKC;QAMrDD;IALd,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMC,QAAQP,YAAAA,IAAIO,IAAI,YAARP,YAAY,CAAC;IAC3B,MAAMQ,QAAQtB,kBAAkBqB;IAChC,MAAMf,YAAYP,MAAMsB,IAAI,CAAC,YAAY,EAAE;IAC3C,IAAI,CAACC,SAAS,CAAChB,aAAaA,UAAUiB,QAAQ,CAAC,MAAM;QACnDR,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;QAC5D;IACF;IAEA,IAAI;QACF,MAAMI,SAAS,MAAMvB,mBAAmBa,KAAKQ,OAAO;YAAEG,UAAU;QAAqB;QACrF,IAAID,OAAOE,EAAE,KAAK,OAAO;YACvBX,IAAIG,MAAM,CAACM,OAAON,MAAM,EAAEC,IAAI,CAACK,OAAOH,IAAI;YAC1C;QACF;QACA,IAAI,CAAEG,CAAAA,OAAOG,KAAK,IAAIH,OAAOI,KAAK,KAAK,SAASlC,gBAAgB8B,OAAOK,MAAM,CAAA,GAAI;YAC/Ed,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAOjB;YAA8B;YAC5D;QACF;QAEA,MAAM2B,YAAYnC,cAAcoC,GAAG,GAAGD,SAAS;QAC/C,MAAME,SAASF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACV,OAAOW,KAAK;QAC5D,MAAMC,aAAaJ,OAAOC,UAAU,CAACxC,gBAAgB4C,SAAS,EAAEH,GAAG,CAAC5B;QACpE,MAAMgC,UAAU,MAAMF,WAAWG,GAAG;QACpC,IAAI,CAACD,QAAQE,MAAM,IAAI,CAACtC,SAASsB,QAAQc,QAAQC,GAAG,CAAC,eAAe;YAClExB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAiC;YAC/D;QACF;QAEA,oEAAoE;QACpE,wCAAwC;QACxC,MAAMqB,QAAQ,MAAMT,OACjBC,UAAU,CAAC,YACXS,KAAK,CAACpD,2BAA2B,kBAAkBgB,WACnDqC,KAAK,CAAC7C,uBAAuB,GAC7ByC,GAAG;QACN,MAAMK,SAASH,MAAMI,IAAI,CAACC,KAAK,CAAC,GAAGhD;QACnC,MAAMiD,aAAaN,MAAMI,IAAI,CAACG,MAAM,GAAGlD;QACvC,IAAI8C,OAAOI,MAAM,EAAE;YACjB,MAAMC,QAAQnB,UAAUmB,KAAK;YAC7B,KAAK,MAAMC,YAAYN,OAAQ;gBAC7BK,MAAM1C,MAAM,CACV2C,SAASC,GAAG,EACZ/C,oBAAoB8C,SAASE,IAAI,IAA+B9C;YAEpE;YACA,MAAM2C,MAAMI,MAAM;YAClB,+DAA+D;YAC/D,MAAMzD,qBACJkC,WACAN,OAAOW,KAAK,EACZ,YACAS,OAAOU,GAAG,CAAC,CAACJ,WAAaA,SAASK,EAAE;QAExC;QACA,IAAI,CAACR,YAAY,MAAMX,WAAWxB,MAAM;QAExC,MAAM4C,SAAgC;YACpC9B,IAAI;YACJ+B,SAAS,CAACV;YACVW,UAAUd,OAAOI,MAAM;YACvBD;QACF;QACAhC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAACqC;IACvB,EAAE,OAAOpC,OAAO;QACduC,QAAQvC,KAAK,CAAC,+BAA+Bd,WAAWc;QACxDL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAoC;IACpE;AACF,EAAC"}
@@ -60,6 +60,7 @@ import { CONTACT_FACETS_FIELD, interactionsForGroup } from "@aglyn/aglyn/app-uti
60
60
  import { COMPANY_CONTACTS_COUNT_FIELD, CONTACT_COMPANY_IDS_FIELD, CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
61
61
  import { CAPTURED_BY_HOST_FIELD, CONSENT_GROUP_ID_FIELD, MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "@aglyn/aglyn/app-utils/marketing-consent";
62
62
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
63
+ import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
63
64
  import { FieldPath, FieldValue } from "firebase-admin/firestore";
64
65
  import { summarizeConsentGroupChange } from "../model/consent-group-summary.js";
65
66
  /** Contacts one page of the refusal carry reads. */ export const REFUSAL_PAGE = 300;
@@ -414,6 +415,9 @@ function readCursor(raw, first) {
414
415
  patch: planned.patch,
415
416
  after: async ()=>{
416
417
  count(planned);
418
+ // The holders moved, and with them the facet keys the Contacts
419
+ // list filters by (AGL-3321).
420
+ await restampCrmListFieldsAt(snapshot.ref, 'contacts');
417
421
  // A count that could not move is logged and left: the company's own
418
422
  // page takes the live aggregate, which corrects it.
419
423
  await Promise.all(planned.companyCounts.map((change)=>companies.doc(change.companyId).update({