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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/companies-section.d.ts +6 -6
  3. package/src/lib/components/companies-section.js +54 -84
  4. package/src/lib/components/companies-section.js.map +1 -1
  5. package/src/lib/components/company-edit-drawer.js +17 -4
  6. package/src/lib/components/company-edit-drawer.js.map +1 -1
  7. package/src/lib/components/company-picker.js +6 -2
  8. package/src/lib/components/company-picker.js.map +1 -1
  9. package/src/lib/components/contact-detail-page.js +14 -2
  10. package/src/lib/components/contact-detail-page.js.map +1 -1
  11. package/src/lib/components/contact-list-columns.d.ts +3 -5
  12. package/src/lib/components/contact-list-columns.js +6 -8
  13. package/src/lib/components/contact-list-columns.js.map +1 -1
  14. package/src/lib/components/contacts-bulk-bar.js +10 -1
  15. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  16. package/src/lib/components/contacts-section.js +115 -163
  17. package/src/lib/components/contacts-section.js.map +1 -1
  18. package/src/lib/components/crm-next-activity-column.d.ts +4 -2
  19. package/src/lib/components/crm-next-activity-column.js +6 -3
  20. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  21. package/src/lib/components/deal-edit-drawer.js +8 -4
  22. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  23. package/src/lib/components/deals-section.d.ts +2 -2
  24. package/src/lib/components/deals-section.js +59 -84
  25. package/src/lib/components/deals-section.js.map +1 -1
  26. package/src/lib/components/lead-campaigns-card.js +11 -3
  27. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  28. package/src/lib/components/lead-properties-card.js +9 -1
  29. package/src/lib/components/lead-properties-card.js.map +1 -1
  30. package/src/lib/components/leads-bulk-bar.js +16 -3
  31. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  32. package/src/lib/components/leads-section.d.ts +2 -1
  33. package/src/lib/components/leads-section.js +110 -148
  34. package/src/lib/components/leads-section.js.map +1 -1
  35. package/src/lib/components/tasks-section.js +128 -121
  36. package/src/lib/components/tasks-section.js.map +1 -1
  37. package/src/lib/constants/company-filters.d.ts +13 -0
  38. package/src/lib/constants/company-filters.js +47 -17
  39. package/src/lib/constants/company-filters.js.map +1 -1
  40. package/src/lib/constants/contact-filters.d.ts +61 -10
  41. package/src/lib/constants/contact-filters.js +373 -141
  42. package/src/lib/constants/contact-filters.js.map +1 -1
  43. package/src/lib/constants/deal-filters.d.ts +44 -0
  44. package/src/lib/constants/deal-filters.js +78 -0
  45. package/src/lib/constants/deal-filters.js.map +1 -0
  46. package/src/lib/hooks/use-crm-list-query.d.ts +68 -0
  47. package/src/lib/hooks/use-crm-list-query.js +182 -0
  48. package/src/lib/hooks/use-crm-list-query.js.map +1 -0
  49. package/src/lib/hooks/use-crm-tasks.d.ts +23 -6
  50. package/src/lib/hooks/use-crm-tasks.js +44 -42
  51. package/src/lib/hooks/use-crm-tasks.js.map +1 -1
  52. package/src/lib/hooks/use-deals.d.ts +2 -34
  53. package/src/lib/hooks/use-deals.js +3 -62
  54. package/src/lib/hooks/use-deals.js.map +1 -1
  55. package/src/lib/model/crm-list-query.d.ts +91 -0
  56. package/src/lib/model/crm-list-query.js +182 -0
  57. package/src/lib/model/crm-list-query.js.map +1 -0
  58. package/src/lib/model/lead-filters.d.ts +49 -43
  59. package/src/lib/model/lead-filters.js +194 -88
  60. package/src/lib/model/lead-filters.js.map +1 -1
  61. package/src/lib/model/list-fields-api.d.ts +26 -0
  62. package/src/lib/model/list-fields-api.js +47 -0
  63. package/src/lib/model/list-fields-api.js.map +1 -0
  64. package/src/lib/model/list-fields.d.ts +47 -0
  65. package/src/lib/model/list-fields.js +39 -0
  66. package/src/lib/model/list-fields.js.map +1 -0
  67. package/src/lib/model/task-views.d.ts +15 -10
  68. package/src/lib/model/task-views.js +68 -9
  69. package/src/lib/model/task-views.js.map +1 -1
  70. package/src/lib/server/companies-import.js +3 -3
  71. package/src/lib/server/companies-import.js.map +1 -1
  72. package/src/lib/server/company-delete.js +3 -1
  73. package/src/lib/server/company-delete.js.map +1 -1
  74. package/src/lib/server/consent-group-participant.js +4 -0
  75. package/src/lib/server/consent-group-participant.js.map +1 -1
  76. package/src/lib/server/contact-update.js +14 -8
  77. package/src/lib/server/contact-update.js.map +1 -1
  78. package/src/lib/server/contacts-import.js +4 -2
  79. package/src/lib/server/contacts-import.js.map +1 -1
  80. package/src/lib/server/deals-import.js +4 -2
  81. package/src/lib/server/deals-import.js.map +1 -1
  82. package/src/lib/server/erase-person.js +2 -1
  83. package/src/lib/server/erase-person.js.map +1 -1
  84. package/src/lib/server/lead-create.js +4 -1
  85. package/src/lib/server/lead-create.js.map +1 -1
  86. package/src/lib/server/lead-source-values.js +9 -5
  87. package/src/lib/server/lead-source-values.js.map +1 -1
  88. package/src/lib/server/leads-import.js +4 -1
  89. package/src/lib/server/leads-import.js.map +1 -1
  90. package/src/lib/server/list-fields-routes.d.ts +29 -0
  91. package/src/lib/server/list-fields-routes.js +85 -0
  92. package/src/lib/server/list-fields-routes.js.map +1 -0
  93. package/src/lib/server/record-email-state.js +5 -2
  94. package/src/lib/server/record-email-state.js.map +1 -1
  95. package/src/lib/server/record-timeline.js +3 -2
  96. package/src/lib/server/record-timeline.js.map +1 -1
  97. package/src/lib/server/task-routes.js +6 -3
  98. package/src/lib/server/task-routes.js.map +1 -1
  99. package/src/lib/server/tasks-import.js +5 -2
  100. package/src/lib/server/tasks-import.js.map +1 -1
  101. package/src/lib/server.js +10 -1
  102. package/src/lib/server.js.map +1 -1
  103. package/src/lib/hooks/use-org-leads.d.ts +0 -50
  104. package/src/lib/hooks/use-org-leads.js +0 -104
  105. package/src/lib/hooks/use-org-leads.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -13,154 +14,179 @@
13
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
15
  * See the License for the specific language governing permissions and
15
16
  * limitations under the License.
16
- */ import { CRM_CONTACT_VIEW_FIELDS, crmContactCustomColumn } from "@aglyn/aglyn";
17
- /*
18
- * Contacts (`orgs/{orgId}/contacts`, read scoped to a host).
17
+ */ import { CRM_CONTACT_VIEW_FIELDS, CRM_FACET_KEY_ANY_GROUP, crmContactCustomColumn, crmFacetKey, crmFacetKeyValue, EMAIL_STATE_STATUSES } from "@aglyn/aglyn";
18
+ import { CRM_LIST_SEARCH, crmAnyOf, crmClauseValues } from "../model/crm-list-query.js";
19
+ /*==========================================
20
+ * CONTACTS (`orgs/{orgId}/contacts`, read scoped to a host) — WHAT THE
21
+ * PANEL OFFERS, AND WHAT THE QUERY ASKS (AGL-3321).
19
22
  *
20
- * The card's listener is `limit(1000)` and stays that way — nobody needs
21
- * forty thousand rows streamed into a table. What that cap must NOT do is
22
- * decide what a search can find, and it did: an org with 40,000 contacts
23
- * searched 1,000 of them and answered "no contacts match" for the rest. The
24
- * head-count already learned this lesson (AGL-1706, a server aggregate); the
25
- * search is the same mistake one field over.
23
+ * Every clause and the search word are on the list's one Firestore query,
24
+ * newest change first, paged by it. A contact is one row shared by every
25
+ * holder, and most of what a holder filters by — the owner, the stage, the
26
+ * sources, the company, the tags, a custom value, whether the person has
27
+ * bought — lives on that holder's FACET, a path per holder no index can
28
+ * reach. So every writer stamps `facetKeys` (`crmContactFacetKeys` in
29
+ * `@aglyn/aglyn`): `{group}:{field}={value}` for each holder's value,
30
+ * `{group}:{field}` for "has one", and the same under `*` for any holder.
31
+ * Under a site a facet clause asks the viewing holder's key; at the
32
+ * organization level, any holder's.
26
33
  *
27
- * ⛔ `marketingConsent`, `ordersCount` and `ltvCents` are written only when
28
- * they apply, so a `false`/`0` filter would return nothing rather than
29
- * everyone else. `isNotEmpty` is exact on them and is what the menu offers.
30
- */ export const CONTACT_LIST_FILTER_FIELDS = [
31
- {
32
- column: 'name',
33
- kind: 'text',
34
- path: 'name',
35
- lowerPath: 'nameLower',
36
- tokensPath: 'nameTokens',
37
- reversedPath: 'nameReversed'
38
- },
39
- {
40
- // `normalizeContactEmail` lower-cases before every write, so the stored
41
- // value IS its own normalized key.
42
- column: 'email',
43
- kind: 'text',
44
- path: 'email',
45
- lowerPath: 'email',
46
- presence: 'always'
47
- },
34
+ * What one query holds decides the rest:
35
+ *
36
+ * - ONE array clause. The scope clause (`visibleTo array-contains-any`),
37
+ * the search (`searchTokens`), a name word (`nameTokens`), a form
38
+ * (`formIds`) and a facet clause (`facetKeys`) all are one. The search
39
+ * folds into the scope clause for a reader who may drop it; a facet or
40
+ * form clause stands in the scope clause's place for that reader, since
41
+ * only a record the viewing holder keeps carries its key. Two of them
42
+ * at once is refused by name.
43
+ * - ONE range. `updatedAt`, the field the list is ordered by, composes
44
+ * with anything; Created and a name or address prefix order the list by
45
+ * their own field and so stand alone beside the scope clause
46
+ * (`contactSoloClause`), served by the `(visibleTo, field)` composites.
47
+ * - equalities (the address, the site, the verdict, "No next activity")
48
+ * compose freely, one `(field, updatedAt DESC)` composite each.
49
+ *
50
+ * The per-holder FIGURES (orders, lifetime value) live on each holder's
51
+ * facet, where no range reaches — a top-level `ordersCount` was never
52
+ * written — so they offer "is not empty" alone: "has bought".
53
+ *
54
+ * A collaborator's search is the start of the name (`CONTACT_PREFIX_SEARCH`).
55
+ *=========================================*/ /**
56
+ * A word of the canonical name (`nameTokens`), the whole of it, or its start
57
+ * or end — `nameLower`, which every contact carries, and `nameReversed`.
58
+ */ const NAME_FIELD = {
59
+ column: 'name',
60
+ kind: 'text',
61
+ path: 'name',
62
+ lowerPath: 'nameLower',
63
+ tokensPath: 'nameTokens',
64
+ reversedPath: 'nameReversed',
65
+ operators: [
66
+ 'contains',
67
+ 'equals',
68
+ 'startsWith',
69
+ 'endsWith'
70
+ ]
71
+ };
72
+ /**
73
+ * The address. `normalizeContactEmail` lower-cases before every write, so the
74
+ * stored value IS its own normalized key.
75
+ */ const EMAIL_FIELD = {
76
+ column: 'email',
77
+ kind: 'text',
78
+ path: 'email',
79
+ lowerPath: 'email',
80
+ presence: 'always',
81
+ operators: [
82
+ 'equals',
83
+ 'startsWith'
84
+ ]
85
+ };
86
+ /** When the person was first captured — a range the list is then ordered by. */ const CREATED_FIELD = {
87
+ column: CRM_CONTACT_VIEW_FIELDS.createdAt,
88
+ kind: 'date',
89
+ path: 'createdAt',
90
+ presence: 'always',
91
+ operators: [
92
+ 'is',
93
+ 'after',
94
+ 'onOrAfter',
95
+ 'before',
96
+ 'onOrBefore'
97
+ ]
98
+ };
99
+ /**
100
+ * The forms a person came in through — the top-level `formIds` mirror of the
101
+ * interactions' `formId` (AGL-2612), matched whole and as typed because a
102
+ * form id is minted with mixed case. The form's own page links here with its
103
+ * id; nobody types one.
104
+ */ const FORM_FIELD = {
105
+ column: 'formIds',
106
+ kind: 'text',
107
+ path: 'formIds',
108
+ tokensPath: 'formIds',
109
+ verbatimTokens: true,
110
+ operators: [
111
+ 'contains'
112
+ ]
113
+ };
114
+ /** What the Filters panel offers on a contact, in the grid's grammar. */ export const CONTACT_LIST_FILTER_FIELDS = [
115
+ NAME_FIELD,
116
+ EMAIL_FIELD,
48
117
  {
49
- /*
50
- * Already an array of lower-cased tags, so `contains` is a plain
51
- * `array-contains` — the one place the word-level caveat does not apply,
52
- * because a tag is matched whole rather than by word prefix.
53
- */ column: 'tags',
54
- kind: 'text',
118
+ // A holder's tags, matched whole and case-insensitively.
119
+ column: CRM_CONTACT_VIEW_FIELDS.tags,
120
+ kind: 'exact',
55
121
  path: 'tags',
56
- tokensPath: 'tags',
57
- // Ordered by the timestamp the list already sorts on, not by the tag
58
- // array — `orderBy` on the same field an `array-contains` matches is a
59
- // composite index nobody wants to explain.
60
- containsOrderBy: 'updatedAt',
61
- // Newest first, as the unfiltered list, which is the composite declared
62
- // for it (AGL-3321).
63
- containsOrderDirection: 'desc',
64
- // `isAnyOf` is a saved segment's "any of these tags" (AGL-2617). The
65
- // query cannot serve it — `array-contains-any` is the scope clause's
66
- // — so the translator refuses it and the list matches it over the
67
- // window, which the caption says.
68
122
  operators: [
69
123
  'contains',
70
124
  'isAnyOf',
71
125
  'isNotEmpty'
72
126
  ]
73
127
  },
74
- {
75
- /*
76
- * The forms a person came in through — the top-level `formIds` mirror
77
- * of the interactions' `formId` (AGL-2612), matched whole and as typed
78
- * because a form id is minted with mixed case. The form's own page links
79
- * here with its id; nobody types one. Ordered by the list's own
80
- * timestamp for the reason `tags` is, served by the
81
- * `(formIds CONTAINS, updatedAt DESC)` index.
82
- *
83
- * ⚠️ An `array-contains` on the mirror cannot share a query with the
84
- * `visibleTo` scope clause, so the list drops that clause for this one
85
- * filter and the rules admit the read to an org-wide member only — the
86
- * caveat the company contacts card states, stated again on the list.
87
- */ column: 'formIds',
88
- kind: 'text',
89
- path: 'formIds',
90
- tokensPath: 'formIds',
91
- verbatimTokens: true,
92
- containsOrderBy: 'updatedAt',
93
- // Newest first, as the unfiltered list, which is the composite declared
94
- // for it (AGL-3321).
95
- containsOrderDirection: 'desc',
96
- operators: [
97
- 'contains'
98
- ]
99
- },
128
+ FORM_FIELD,
100
129
  {
101
130
  column: 'hostId',
102
131
  kind: 'exact',
103
132
  path: 'hostId',
104
133
  presence: 'always'
105
134
  },
135
+ // Per-holder figures: "has bought", never a range across holders.
106
136
  {
107
- column: 'ordersCount',
137
+ column: CRM_CONTACT_VIEW_FIELDS.orders,
108
138
  kind: 'number',
109
- path: 'ordersCount'
139
+ path: 'ordersCount',
140
+ operators: [
141
+ 'isNotEmpty'
142
+ ]
110
143
  },
111
144
  {
112
- column: 'ltvCents',
145
+ column: CRM_CONTACT_VIEW_FIELDS.ltv,
113
146
  kind: 'number',
114
- path: 'ltvCents'
115
- },
116
- {
117
- column: 'createdAt',
118
- kind: 'date',
119
- path: 'createdAt',
120
- presence: 'always'
147
+ path: 'ltvCents',
148
+ operators: [
149
+ 'isNotEmpty'
150
+ ]
121
151
  },
152
+ CREATED_FIELD,
122
153
  {
123
- column: 'updatedAt',
154
+ column: CRM_CONTACT_VIEW_FIELDS.updatedAt,
124
155
  kind: 'date',
125
156
  path: 'updatedAt',
126
- presence: 'always'
157
+ presence: 'always',
158
+ operators: [
159
+ 'is',
160
+ 'after',
161
+ 'onOrAfter',
162
+ 'before',
163
+ 'onOrBefore'
164
+ ]
127
165
  },
128
166
  /*
129
- * When the earliest open task is due (AGL-2661) — window-only, because
130
- * the value is on the SHARED row but a record written before the field
131
- * existed has none, and Firestore cannot query for absence. `nullable`
132
- * is honest: the writer stores `null` for nothing scheduled, and the
133
- * matcher reads absent the same way, so "is empty" is the "No next
134
- * activity" filter over the loaded window.
167
+ * "No next activity" (AGL-2661): `nextTaskAtMs == null`, which every
168
+ * contact carries from its creation until a task is scheduled against it.
135
169
  */ {
136
170
  column: 'nextTaskAtMs',
137
171
  kind: 'date',
138
172
  path: 'nextTaskAtMs',
139
- windowOnly: true,
140
- presence: 'nullable'
173
+ presence: 'nullable',
174
+ storedAs: 'millis',
175
+ operators: [
176
+ 'isEmpty'
177
+ ]
141
178
  },
142
179
  /*
143
- * THE FACET FIELDS (AGL-2617) — window-only, every one of them.
144
- *
145
- * An owner, a stage, a company and the capture sources live on the
146
- * viewing group's facet of the shared contact row, and a facet path is
147
- * per group: a `where` on it would need an index per group, and the
148
- * translator would send a scoped member a query the rules deny. So these
149
- * are declared for the menu and for a saved view, matched over the loaded
150
- * window against the flattened row (`ContactRecord` carries each at the
151
- * top), and never put on a query — `windowOnly` is what holds that line.
152
- * The paths are the ROW'S, which is what the matcher reads.
153
- *
154
- * The values are picked, not typed — a uid from the roster, a stage from
155
- * the fixed list, a source from its labels, a company from the picker —
156
- * so `equals` and `isAnyOf` are what they offer; the section supplies
157
- * the choices. The column names are the contract `CRM_CONTACT_VIEW_FIELDS`
158
- * states, because the dynamic-list translator reads a view by them.
180
+ * THE FACET FIELDS (AGL-2617). The values are picked, not typed — a uid
181
+ * from the roster, a stage from the fixed list, a source from its labels,
182
+ * a company from the picker — so `equals` and `isAnyOf` are what they
183
+ * offer; the section supplies the choices. The column names are the
184
+ * contract `CRM_CONTACT_VIEW_FIELDS` states, because the dynamic-list
185
+ * translator reads a view by them. The paths are the flattened ROW'S.
159
186
  */ {
160
187
  column: CRM_CONTACT_VIEW_FIELDS.owner,
161
188
  kind: 'exact',
162
189
  path: 'ownerUid',
163
- windowOnly: true,
164
190
  operators: [
165
191
  'equals',
166
192
  'isAnyOf',
@@ -171,7 +197,6 @@
171
197
  column: CRM_CONTACT_VIEW_FIELDS.stage,
172
198
  kind: 'exact',
173
199
  path: 'lifecycleStage',
174
- windowOnly: true,
175
200
  operators: [
176
201
  'equals',
177
202
  'isAnyOf',
@@ -184,7 +209,6 @@
184
209
  kind: 'exact',
185
210
  path: 'sources',
186
211
  keysOf: true,
187
- windowOnly: true,
188
212
  operators: [
189
213
  'equals',
190
214
  'isAnyOf'
@@ -194,21 +218,19 @@
194
218
  column: CRM_CONTACT_VIEW_FIELDS.company,
195
219
  kind: 'exact',
196
220
  path: 'companyId',
197
- windowOnly: true,
198
221
  operators: [
199
222
  'equals',
200
223
  'isNotEmpty'
201
224
  ]
202
225
  },
203
226
  /*
204
- * The verdict on the address (AGL-3245) — window-only, on the shared row,
205
- * and `nullable` because most records carry none: "is empty" is the
206
- * filter for people nothing has been said about.
227
+ * The verdict on the address (AGL-3245), asked through `emailStatus` —
228
+ * `none` for a person nothing has been said about, which is what "is
229
+ * empty" asks.
207
230
  */ {
208
231
  column: 'emailState',
209
232
  kind: 'exact',
210
233
  path: 'emailState.status',
211
- windowOnly: true,
212
234
  presence: 'nullable',
213
235
  operators: [
214
236
  'equals',
@@ -219,15 +241,11 @@
219
241
  }
220
242
  ];
221
243
  /**
222
- * One filter field per active custom contact field (AGL-2617).
223
- *
224
- * A custom value lives under the facet's `custom` map, so every one is
225
- * window-only for the reason the facet fields above are, and its column is
226
- * the one the table shows it under — `crmContactCustomColumn(key)` — so a
227
- * clause and a column agree on the name and a saved view's columns and
228
- * filters name the same thing. The kind follows the definition's type, and
229
- * `nullable` is how a cleared value is stored, which is what makes both
230
- * empty operators honest here.
244
+ * One filter field per active custom contact field (AGL-2617). A custom
245
+ * value lives under the holder's `custom` map, so it is asked through the
246
+ * holder's facet key: equality on a picked, typed or ticked value, and
247
+ * "is not empty". Its column is the one the table shows it under —
248
+ * `crmContactCustomColumn(key)` — so a clause and a column agree on the name.
231
249
  */ export function contactCustomFilterFields(definitions) {
232
250
  return definitions.filter((definition)=>!definition.retiredAt).map((definition)=>{
233
251
  const column = crmContactCustomColumn(definition.key);
@@ -238,35 +256,34 @@
238
256
  column,
239
257
  kind: 'number',
240
258
  path,
241
- windowOnly: true,
242
- presence: 'nullable'
259
+ operators: [
260
+ '=',
261
+ 'isNotEmpty'
262
+ ]
243
263
  };
244
264
  case 'date':
245
265
  return {
246
266
  column,
247
267
  kind: 'date',
248
268
  path,
249
- windowOnly: true,
250
- presence: 'nullable'
269
+ operators: [
270
+ 'isNotEmpty'
271
+ ]
251
272
  };
252
273
  case 'checkbox':
253
274
  return {
254
275
  column,
255
276
  kind: 'boolean',
256
- path,
257
- windowOnly: true
277
+ path
258
278
  };
259
279
  case 'select':
260
280
  return {
261
281
  column,
262
282
  kind: 'exact',
263
283
  path,
264
- windowOnly: true,
265
- presence: 'nullable',
266
284
  operators: [
267
285
  'equals',
268
286
  'isAnyOf',
269
- 'isEmpty',
270
287
  'isNotEmpty'
271
288
  ]
272
289
  };
@@ -275,13 +292,9 @@
275
292
  column,
276
293
  kind: 'text',
277
294
  path,
278
- windowOnly: true,
279
- presence: 'nullable',
295
+ lowerPath: path,
280
296
  operators: [
281
- 'contains',
282
297
  'equals',
283
- 'startsWith',
284
- 'isEmpty',
285
298
  'isNotEmpty'
286
299
  ]
287
300
  };
@@ -314,5 +327,224 @@
314
327
  [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',
315
328
  emailState: 'Email'
316
329
  };
330
+ /*------------------------------------------
331
+ * THE QUERY THE CONTACTS LIST RUNS.
332
+ *-----------------------------------------*/ /** The list's one order: newest change first. */ export const CONTACT_LIST_SORTS = [
333
+ {
334
+ path: 'updatedAt',
335
+ direction: 'desc'
336
+ }
337
+ ];
338
+ /** The stored array a facet clause is asked of. */ export const CONTACT_FACET_KEYS_PATH = 'facetKeys';
339
+ /** The fields the Contacts query asks — each a stored field, as named. */ export const CONTACT_QUERY_FIELDS = [
340
+ NAME_FIELD,
341
+ EMAIL_FIELD,
342
+ FORM_FIELD,
343
+ CREATED_FIELD,
344
+ {
345
+ column: 'hostId',
346
+ kind: 'exact',
347
+ path: 'hostId',
348
+ presence: 'always'
349
+ },
350
+ {
351
+ column: 'updatedAt',
352
+ kind: 'date',
353
+ path: 'updatedAt',
354
+ presence: 'always',
355
+ operators: [
356
+ 'is',
357
+ 'after',
358
+ 'onOrAfter',
359
+ 'before',
360
+ 'onOrBefore'
361
+ ]
362
+ },
363
+ {
364
+ column: 'nextTaskAtMs',
365
+ kind: 'date',
366
+ path: 'nextTaskAtMs',
367
+ presence: 'nullable',
368
+ storedAs: 'millis',
369
+ operators: [
370
+ 'isEmpty'
371
+ ]
372
+ },
373
+ {
374
+ column: 'emailStatus',
375
+ kind: 'exact',
376
+ path: 'emailStatus',
377
+ operators: [
378
+ 'equals',
379
+ 'isAnyOf'
380
+ ]
381
+ },
382
+ {
383
+ column: CONTACT_FACET_KEYS_PATH,
384
+ kind: 'exact',
385
+ path: CONTACT_FACET_KEYS_PATH,
386
+ tokensPath: CONTACT_FACET_KEYS_PATH,
387
+ operators: [
388
+ 'contains',
389
+ 'isAnyOf'
390
+ ]
391
+ }
392
+ ];
393
+ export const CONTACT_LIST_DECLARATION = {
394
+ fields: CONTACT_QUERY_FIELDS,
395
+ sorts: CONTACT_LIST_SORTS,
396
+ search: CRM_LIST_SEARCH
397
+ };
398
+ /** The grid fields a stored clause passes to the query unchanged. */ const AS_STORED = new Set([
399
+ 'name',
400
+ 'email',
401
+ 'formIds',
402
+ 'hostId',
403
+ 'createdAt',
404
+ 'updatedAt',
405
+ 'nextTaskAtMs'
406
+ ]);
407
+ /**
408
+ * The ranges that stand ALONE beside the scope clause: Created and a name
409
+ * or address prefix each order the list by their own field, which the
410
+ * `(visibleTo, field)` composites serve and nothing beside them. Updated is
411
+ * not one: the list is ordered by it already, so it composes freely.
412
+ */ export function contactSoloClause(clause) {
413
+ if (clause.field === 'createdAt') return true;
414
+ if (clause.field === 'name') return clause.op === 'startsWith' || clause.op === 'endsWith';
415
+ return clause.field === 'email' && clause.op === 'startsWith';
416
+ }
417
+ /**
418
+ * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the
419
+ * start of the contact's name, beside the scope clause.
420
+ */ export const CONTACT_PREFIX_SEARCH = {
421
+ field: NAME_FIELD,
422
+ notice: 'Search matches the start of a contact’s name for access limited to specific sites.'
423
+ };
424
+ /** The facet field each grid column is keyed under. */ const FACET_FIELDS = {
425
+ [CRM_CONTACT_VIEW_FIELDS.owner]: 'owner',
426
+ [CRM_CONTACT_VIEW_FIELDS.stage]: 'stage',
427
+ [CRM_CONTACT_VIEW_FIELDS.source]: 'source',
428
+ [CRM_CONTACT_VIEW_FIELDS.company]: 'company',
429
+ [CRM_CONTACT_VIEW_FIELDS.tags]: 'tag',
430
+ [CRM_CONTACT_VIEW_FIELDS.orders]: 'orders',
431
+ [CRM_CONTACT_VIEW_FIELDS.ltv]: 'ltv'
432
+ };
433
+ /** Why a reader whose access is some sites cannot filter by a holder's field. */ const LIMITED_ACCESS = 'your access is limited to specific sites, which this filter cannot be combined with — an organization administrator can use it';
434
+ /** The facet field a grid column names, or null for one that is not a facet field. */ function facetFieldOf(column) {
435
+ if (FACET_FIELDS[column]) return FACET_FIELDS[column];
436
+ if (column.startsWith(CRM_CONTACT_VIEW_FIELDS.custom)) {
437
+ return `custom.${column.slice(CRM_CONTACT_VIEW_FIELDS.custom.length)}`;
438
+ }
439
+ return null;
440
+ }
441
+ /**
442
+ * One grid clause as the clause the Contacts query asks — see the block at
443
+ * the top. A clause a query cannot serve for this reader is refused by
444
+ * name, never matched over the rows a page loaded.
445
+ */ export function contactQueryClause(clause, reader) {
446
+ var _reader_groupId;
447
+ if (AS_STORED.has(clause.field)) {
448
+ if (clause.field === 'formIds' && !reader.foldsScope) return {
449
+ refused: LIMITED_ACCESS
450
+ };
451
+ return {
452
+ field: clause.field,
453
+ op: clause.op,
454
+ value: clause.value
455
+ };
456
+ }
457
+ if (clause.field === 'emailState') {
458
+ if (clause.op === 'isEmpty') return {
459
+ field: 'emailStatus',
460
+ op: 'equals',
461
+ value: 'none'
462
+ };
463
+ if (clause.op === 'isNotEmpty') return crmAnyOf('emailStatus', EMAIL_STATE_STATUSES);
464
+ const values = crmClauseValues(clause);
465
+ if (values.some((value)=>!EMAIL_STATE_STATUSES.includes(value))) {
466
+ return {
467
+ refused: 'pick one of the choices'
468
+ };
469
+ }
470
+ return crmAnyOf('emailStatus', values);
471
+ }
472
+ const facet = facetFieldOf(clause.field);
473
+ if (!facet) return {
474
+ refused: 'this list does not filter by that'
475
+ };
476
+ if (!reader.foldsScope) return {
477
+ refused: LIMITED_ACCESS
478
+ };
479
+ const group = (_reader_groupId = reader.groupId) != null ? _reader_groupId : CRM_FACET_KEY_ANY_GROUP;
480
+ if (clause.op === 'isNotEmpty') {
481
+ return {
482
+ field: CONTACT_FACET_KEYS_PATH,
483
+ op: 'contains',
484
+ value: crmFacetKey(group, facet)
485
+ };
486
+ }
487
+ const keys = [];
488
+ for (const value of crmClauseValues(clause)){
489
+ const key = crmFacetKeyValue(clause.op === '=' ? Number(value) : value);
490
+ if (key === null) return {
491
+ refused: 'no value yet'
492
+ };
493
+ keys.push(crmFacetKey(group, facet, key));
494
+ }
495
+ const asked = crmAnyOf(CONTACT_FACET_KEYS_PATH, keys);
496
+ // One key is an `array-contains`; several, an `array-contains-any`.
497
+ return 'refused' in asked || asked.op === 'isAnyOf' ? asked : _extends({}, asked, {
498
+ op: 'contains'
499
+ });
500
+ }
501
+ /**
502
+ * Whether an asked clause stands in for the scope clause: a facet key of
503
+ * the viewing holder, which only a record that holder keeps carries, or a
504
+ * form, which only its site's captures carry.
505
+ */ export function contactClauseImpliesScope(groupId) {
506
+ return (clause)=>{
507
+ if (clause.field === 'formIds') return true;
508
+ if (clause.field !== CONTACT_FACET_KEYS_PATH || !groupId) return false;
509
+ return crmClauseValues(clause).every((key)=>key.startsWith(`${groupId}:`));
510
+ };
511
+ }
512
+ /**
513
+ * The query shapes the Contacts list can send, as declarations the index
514
+ * pin enumerates (AGL-3321): the list's own, without its solo ranges, whose
515
+ * every equality rides `(field, updatedAt DESC)`; and each solo range beside
516
+ * the scope clause alone.
517
+ */ export function contactIndexShapes() {
518
+ const soloOps = new Set([
519
+ 'startsWith',
520
+ 'endsWith'
521
+ ]);
522
+ const main = _extends({}, CONTACT_LIST_DECLARATION, {
523
+ fields: CONTACT_QUERY_FIELDS.filter((field)=>field !== CREATED_FIELD).map((field)=>field.operators ? _extends({}, field, {
524
+ operators: field.operators.filter((op)=>!soloOps.has(op))
525
+ }) : field)
526
+ });
527
+ const solo = {
528
+ fields: [
529
+ _extends({}, NAME_FIELD, {
530
+ operators: [
531
+ 'startsWith',
532
+ 'endsWith'
533
+ ]
534
+ }),
535
+ _extends({}, EMAIL_FIELD, {
536
+ operators: [
537
+ 'startsWith'
538
+ ]
539
+ }),
540
+ CREATED_FIELD
541
+ ],
542
+ sorts: []
543
+ };
544
+ return [
545
+ main,
546
+ solo
547
+ ];
548
+ }
317
549
 
318
550
  //# sourceMappingURL=contact-filters.js.map