@aglyn/plugins-crm 1.0.0-beta.182 → 1.0.0-beta.184

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 (28) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/companies-section.js +97 -54
  3. package/src/lib/components/companies-section.js.map +1 -1
  4. package/src/lib/components/contacts-section.js +51 -19
  5. package/src/lib/components/contacts-section.js.map +1 -1
  6. package/src/lib/components/crm-filter-bar.d.ts +34 -32
  7. package/src/lib/components/crm-filter-bar.js +34 -235
  8. package/src/lib/components/crm-filter-bar.js.map +1 -1
  9. package/src/lib/components/crm-next-activity-column.d.ts +13 -21
  10. package/src/lib/components/crm-next-activity-column.js +21 -32
  11. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  12. package/src/lib/components/deals-section.js +65 -54
  13. package/src/lib/components/deals-section.js.map +1 -1
  14. package/src/lib/components/leads-section.js +106 -286
  15. package/src/lib/components/leads-section.js.map +1 -1
  16. package/src/lib/components/tasks-section.js +139 -34
  17. package/src/lib/components/tasks-section.js.map +1 -1
  18. package/src/lib/hooks/use-crm-grid-filter.d.ts +77 -0
  19. package/src/lib/hooks/use-crm-grid-filter.js +143 -0
  20. package/src/lib/hooks/use-crm-grid-filter.js.map +1 -0
  21. package/src/lib/model/crm-grid-filter.d.ts +64 -0
  22. package/src/lib/model/crm-grid-filter.js +144 -0
  23. package/src/lib/model/crm-grid-filter.js.map +1 -0
  24. package/src/lib/model/lead-filters.d.ts +42 -0
  25. package/src/lib/model/lead-filters.js +182 -9
  26. package/src/lib/model/lead-filters.js.map +1 -1
  27. package/src/lib/server/email-send.js +4 -2
  28. package/src/lib/server/email-send.js.map +1 -1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { gridFilterRequests, hiddenFilterColumns, listFilterColumn, listFilterOperators } from "@aglyn/shared-ui-jsx/const/list-filter";
18
+ import { getGridSingleSelectOperators } from "@mui/x-data-grid";
19
+ /** The grid operator each stored select operator shows as. */ const SELECT_TO_GRID = {
20
+ equals: 'is',
21
+ doesNotEqual: 'not',
22
+ isAnyOf: 'isAnyOf'
23
+ };
24
+ const SELECT_FROM_GRID = {
25
+ is: 'equals',
26
+ not: 'doesNotEqual',
27
+ isAnyOf: 'isAnyOf'
28
+ };
29
+ /** The panel's item as a clause, through the grammar's one notion of "usable". */ const plainClause = (item)=>{
30
+ var _gridFilterRequests_;
31
+ return (_gridFilterRequests_ = gridFilterRequests({
32
+ items: [
33
+ item
34
+ ]
35
+ })[0]) != null ? _gridFilterRequests_ : null;
36
+ };
37
+ /** A typed field: the stored operator IS the grid's. */ export const crmPlainCodec = {
38
+ toItem: (clause)=>({
39
+ operator: clause.op,
40
+ value: clause.value === '' ? undefined : clause.value
41
+ }),
42
+ toClause: plainClause
43
+ };
44
+ /** A picked field: `equals` shows as `is`, and `isAnyOf` holds a comma list. */ export const crmSelectCodec = {
45
+ toItem: (clause)=>{
46
+ const operator = SELECT_TO_GRID[clause.op];
47
+ if (!operator) return null;
48
+ return {
49
+ operator,
50
+ value: operator === 'isAnyOf' ? clause.value.split(',').map((entry)=>entry.trim()).filter(Boolean) : clause.value
51
+ };
52
+ },
53
+ toClause: (item)=>{
54
+ const op = SELECT_FROM_GRID[String(item.operator)];
55
+ const request = op ? plainClause(item) : null;
56
+ return request && op ? _extends({}, request, {
57
+ op
58
+ }) : null;
59
+ }
60
+ };
61
+ /** The select operators a field's allowed clause operators map to. */ export function crmSelectOperators(allowed) {
62
+ const shown = allowed.map((op)=>SELECT_TO_GRID[op]).filter(Boolean);
63
+ return getGridSingleSelectOperators().filter((operator)=>shown.includes(operator.value));
64
+ }
65
+ /**
66
+ * The list's columns, each made filterable exactly as far as its field is
67
+ * declared, and every declared field that is not a column added as a
68
+ * permanently hidden one — the panel lists columns, so a field with none
69
+ * could not be reached (`hiddenFilterColumns`).
70
+ *
71
+ * A field with choices becomes a `singleSelect` over them, whatever the
72
+ * column rendered before; one without keeps the grammar's operators; a
73
+ * column no field declares offers no filter, because the list, not the
74
+ * grid, answers every filter and would have nothing to answer it with.
75
+ */ export function crmFilterColumns(columns, fields, options = {}, headers = {}) {
76
+ const selectProps = (field)=>{
77
+ var _options_field_column;
78
+ const choices = (_options_field_column = options[field.column]) != null ? _options_field_column : [];
79
+ const operators = crmSelectOperators(listFilterOperators(field));
80
+ return {
81
+ type: 'singleSelect',
82
+ valueOptions: choices.map((choice)=>({
83
+ value: choice.value,
84
+ label: choice.label
85
+ })),
86
+ filterable: choices.length > 0 && operators.length > 0,
87
+ filterOperators: operators
88
+ };
89
+ };
90
+ const shown = columns.map((column)=>{
91
+ if (column.filterable === false && column.hideable === false) return column;
92
+ const field = fields.find((entry)=>entry.column === column.field);
93
+ if (!field) return _extends({}, column, {
94
+ filterable: false
95
+ });
96
+ if (options[field.column]) return _extends({}, column, selectProps(field));
97
+ return _extends({}, column, listFilterColumn(fields, field.column));
98
+ });
99
+ const present = columns.map((column)=>column.field);
100
+ const hidden = hiddenFilterColumns(fields, present, headers).map((column)=>{
101
+ const field = fields.find((entry)=>entry.column === column.field);
102
+ return field && options[field.column] ? _extends({}, column, selectProps(field)) : column;
103
+ });
104
+ return [
105
+ ...shown,
106
+ ...hidden
107
+ ];
108
+ }
109
+ /**
110
+ * Whether a row answers a quick search: every whitespace-separated word
111
+ * appears, case-insensitively, in SOME of the row's searched values. A
112
+ * blank search matches every row, so an emptied box is the list unfiltered.
113
+ * An array value (tags) is searched member by member.
114
+ */ export function crmRowMatchesSearch(row, paths, words) {
115
+ const asked = words.map((word)=>word.trim().toLowerCase()).filter(Boolean);
116
+ if (!asked.length) return true;
117
+ const values = paths.flatMap((path)=>{
118
+ const value = path.split('.').reduce((at, key)=>at == null ? undefined : at[key], row);
119
+ return Array.isArray(value) ? value : [
120
+ value
121
+ ];
122
+ }).filter((value)=>typeof value === 'string' || typeof value === 'number').map((value)=>String(value).toLowerCase()).filter(Boolean);
123
+ return asked.every((word)=>values.some((value)=>value.includes(word)));
124
+ }
125
+ /**
126
+ * The clauses with one field's clause set: replaced in place where the field
127
+ * already had one, appended where it did not, removed when `next` is null.
128
+ * A field holds ONE clause, which is what the panel edits.
129
+ */ export function crmUpsertClause(clauses, field, next) {
130
+ const at = clauses.findIndex((clause)=>clause.field === field);
131
+ const rest = clauses.filter((clause)=>clause.field !== field);
132
+ if (!next) return rest;
133
+ if (at === -1) return [
134
+ ...rest,
135
+ next
136
+ ];
137
+ return [
138
+ ...rest.slice(0, at),
139
+ next,
140
+ ...rest.slice(at)
141
+ ];
142
+ }
143
+
144
+ //# sourceMappingURL=crm-grid-filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-grid-filter.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 { CrmViewFilterClause } from '@aglyn/aglyn'\nimport {\n gridFilterRequests,\n hiddenFilterColumns,\n type ListFilterField,\n listFilterColumn,\n listFilterOperators,\n} from '@aglyn/shared-ui-jsx/const/list-filter'\nimport {\n getGridSingleSelectOperators,\n type GridColDef,\n type GridFilterItem,\n} from '@mui/x-data-grid'\n\n/*\n * ONE path from a CRM list's saved-view clauses to the data grid's own\n * Filters panel and quick search, and back (AGL-3313).\n *\n * Every CRM list stores what it is narrowed by as view clauses —\n * `{ field, op, value }`, the shape saved views have held since AGL-2617 —\n * and every one of them now edits those clauses through the grid's toolbar\n * rather than a control of its own. What a list declares is its fields,\n * as the `ListFilterField` grammar the console's other paged lists speak\n * (the content entries, the staff lists), plus the choices of the fields\n * whose values are picked rather than typed. The grid then offers each as a\n * typed column: a `singleSelect` with its choices where there are choices,\n * the operators the grammar allows otherwise.\n *\n * The clauses keep their stored shape, so a view saved with the old\n * dropdowns reads unchanged: the panel shows `equals` as the select's `is`\n * and writes `is` back as `equals`, and a list whose clauses were stored in\n * some other shape (Leads' campaign `contains`, its lead source `isEmpty`)\n * says so with a codec.\n */\n\n/** A choice for a field whose value is picked rather than typed. */\nexport interface CrmFilterOption {\n value: string\n label: string\n}\n\n/** The grid operator each stored select operator shows as. */\nconst SELECT_TO_GRID: Readonly<Record<string, string>> = {\n equals: 'is',\n doesNotEqual: 'not',\n isAnyOf: 'isAnyOf',\n}\nconst SELECT_FROM_GRID: Readonly<Record<string, string>> = {\n is: 'equals',\n not: 'doesNotEqual',\n isAnyOf: 'isAnyOf',\n}\n\n/**\n * How one field's stored clause and the panel's item translate. A field\n * with choices uses {@link crmSelectCodec}; a typed one, {@link crmPlainCodec}.\n * A list whose clauses predate the panel supplies its own.\n */\nexport interface CrmGridFilterCodec {\n toItem: (clause: CrmViewFilterClause) => Pick<GridFilterItem, 'operator' | 'value'> | null\n toClause: (item: GridFilterItem) => CrmViewFilterClause | null\n}\n\n/** The panel's item as a clause, through the grammar's one notion of \"usable\". */\nconst plainClause = (item: GridFilterItem): CrmViewFilterClause | null =>\n gridFilterRequests({ items: [item] })[0] ?? null\n\n/** A typed field: the stored operator IS the grid's. */\nexport const crmPlainCodec: CrmGridFilterCodec = {\n toItem: (clause) => ({\n operator: clause.op,\n value: clause.value === '' ? undefined : clause.value,\n }),\n toClause: plainClause,\n}\n\n/** A picked field: `equals` shows as `is`, and `isAnyOf` holds a comma list. */\nexport const crmSelectCodec: CrmGridFilterCodec = {\n toItem: (clause) => {\n const operator = SELECT_TO_GRID[clause.op]\n if (!operator) return null\n return {\n operator,\n value:\n operator === 'isAnyOf'\n ? clause.value.split(',').map((entry) => entry.trim()).filter(Boolean)\n : clause.value,\n }\n },\n toClause: (item) => {\n const op = SELECT_FROM_GRID[String(item.operator)]\n const request = op ? plainClause(item) : null\n return request && op ? { ...request, op } : null\n },\n}\n\n/** The select operators a field's allowed clause operators map to. */\nexport function crmSelectOperators(allowed: readonly string[]) {\n const shown = allowed.map((op) => SELECT_TO_GRID[op]).filter(Boolean)\n return getGridSingleSelectOperators().filter((operator) =>\n shown.includes(operator.value),\n )\n}\n\n/**\n * The list's columns, each made filterable exactly as far as its field is\n * declared, and every declared field that is not a column added as a\n * permanently hidden one — the panel lists columns, so a field with none\n * could not be reached (`hiddenFilterColumns`).\n *\n * A field with choices becomes a `singleSelect` over them, whatever the\n * column rendered before; one without keeps the grammar's operators; a\n * column no field declares offers no filter, because the list, not the\n * grid, answers every filter and would have nothing to answer it with.\n */\nexport function crmFilterColumns(\n columns: readonly GridColDef[],\n fields: readonly ListFilterField[],\n options: Readonly<Record<string, readonly CrmFilterOption[]>> = {},\n headers: Readonly<Record<string, string>> = {},\n): GridColDef[] {\n const selectProps = (field: ListFilterField) => {\n const choices = options[field.column] ?? []\n const operators = crmSelectOperators(listFilterOperators(field))\n return {\n type: 'singleSelect' as const,\n valueOptions: choices.map((choice) => ({ value: choice.value, label: choice.label })),\n filterable: choices.length > 0 && operators.length > 0,\n filterOperators: operators,\n }\n }\n const shown = columns.map((column): GridColDef => {\n if (column.filterable === false && column.hideable === false) return column\n const field = fields.find((entry) => entry.column === column.field)\n if (!field) return { ...column, filterable: false }\n if (options[field.column]) return { ...column, ...selectProps(field) } as GridColDef\n return { ...column, ...listFilterColumn(fields, field.column) }\n })\n const present = columns.map((column) => column.field)\n const hidden = hiddenFilterColumns(fields, present, headers).map(\n (column): GridColDef => {\n const field = fields.find((entry) => entry.column === column.field)\n return field && options[field.column]\n ? ({ ...column, ...selectProps(field) } as GridColDef)\n : column\n },\n )\n return [...shown, ...hidden]\n}\n\n/**\n * Whether a row answers a quick search: every whitespace-separated word\n * appears, case-insensitively, in SOME of the row's searched values. A\n * blank search matches every row, so an emptied box is the list unfiltered.\n * An array value (tags) is searched member by member.\n */\nexport function crmRowMatchesSearch(\n row: object,\n paths: readonly string[],\n words: readonly string[],\n): boolean {\n const asked = words.map((word) => word.trim().toLowerCase()).filter(Boolean)\n if (!asked.length) return true\n const values = paths\n .flatMap((path) => {\n const value = path\n .split('.')\n .reduce<unknown>(\n (at, key) => (at == null ? undefined : (at as Record<string, unknown>)[key]),\n row,\n )\n return Array.isArray(value) ? value : [value]\n })\n .filter((value): value is string | number => typeof value === 'string' || typeof value === 'number')\n .map((value) => String(value).toLowerCase())\n .filter(Boolean)\n return asked.every((word) => values.some((value) => value.includes(word)))\n}\n\n/**\n * The clauses with one field's clause set: replaced in place where the field\n * already had one, appended where it did not, removed when `next` is null.\n * A field holds ONE clause, which is what the panel edits.\n */\nexport function crmUpsertClause(\n clauses: readonly CrmViewFilterClause[],\n field: string,\n next: CrmViewFilterClause | null,\n): CrmViewFilterClause[] {\n const at = clauses.findIndex((clause) => clause.field === field)\n const rest = clauses.filter((clause) => clause.field !== field)\n if (!next) return rest\n if (at === -1) return [...rest, next]\n return [...rest.slice(0, at), next, ...rest.slice(at)]\n}\n"],"names":["gridFilterRequests","hiddenFilterColumns","listFilterColumn","listFilterOperators","getGridSingleSelectOperators","SELECT_TO_GRID","equals","doesNotEqual","isAnyOf","SELECT_FROM_GRID","is","not","plainClause","item","items","crmPlainCodec","toItem","clause","operator","op","value","undefined","toClause","crmSelectCodec","split","map","entry","trim","filter","Boolean","String","request","crmSelectOperators","allowed","shown","includes","crmFilterColumns","columns","fields","options","headers","selectProps","field","choices","column","operators","type","valueOptions","choice","label","filterable","length","filterOperators","hideable","find","present","hidden","crmRowMatchesSearch","row","paths","words","asked","word","toLowerCase","values","flatMap","path","reduce","at","key","Array","isArray","every","some","crmUpsertClause","clauses","next","findIndex","rest","slice"],"mappings":"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"}
@@ -15,6 +15,9 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type EmailStateStatus, type CrmLeadFields, type CrmLeadStatus } from '@aglyn/aglyn';
18
+ import type { CrmViewFilterClause } from '@aglyn/aglyn';
19
+ import type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter';
20
+ import { type CrmGridFilterCodec } from './crm-grid-filter';
18
21
  /**
19
22
  * What the Leads section's `Show` control offers (AGL-2608): the two
20
23
  * aggregate views and each status on its own. `open` is the default — the
@@ -47,6 +50,14 @@ export declare function leadMatchesEmailFilter(lead: Readonly<Record<string, unk
47
50
  * section resolves their names for the menu.
48
51
  */
49
52
  export declare function leadMatchesCampaignFilter(lead: Readonly<Record<string, unknown>>, campaignId: string): boolean;
53
+ /**
54
+ * The fields the Leads section's search box reads (AGL-3246): who the lead
55
+ * is and where they work — the words a person types to find one — plus the
56
+ * tags, which are how an import names its batch (`sal-15`). `name` and
57
+ * `email` are the capture door's fields rather than the CRM's, so the row is
58
+ * read as a record rather than through `CrmLeadFields`.
59
+ */
60
+ export declare const LEAD_SEARCH_FIELDS: readonly ["name", "email", "company", "jobTitle", "tags"];
50
61
  /**
51
62
  * Whether a lead answers a search term.
52
63
  *
@@ -69,3 +80,34 @@ export declare const LEAD_SOURCE_FILTER_NONE = "\0none";
69
80
  * still found — or the ones holding none.
70
81
  */
71
82
  export declare function leadMatchesLeadSourceFilter(lead: Readonly<Record<string, unknown>>, filter: string): boolean;
83
+ /** The fields the grid's panel offers on Leads. */
84
+ export declare const LEAD_LIST_FILTER_FIELDS: readonly ListFilterField[];
85
+ /** What each filter field reads as on a chip. */
86
+ export declare const LEAD_LIST_FILTER_HEADERS: Readonly<Record<string, string>>;
87
+ /** The choices for Status: Open (new or working) and each status on its own. */
88
+ export declare const LEAD_STATUS_FILTER_OPTIONS: {
89
+ value: "new" | "working" | "qualified" | "unqualified" | "open";
90
+ label: string;
91
+ }[];
92
+ /** The choices for Email: every verdict, plus the two aggregates. */
93
+ export declare const LEAD_EMAIL_FILTER_OPTIONS: {
94
+ value: "none" | "bounced" | "complained" | "ok" | "blocked" | "unsubscribed" | "do_not_contact" | "problem";
95
+ label: string;
96
+ }[];
97
+ /**
98
+ * The clauses the grid shows for a view's stored ones. A stored list with
99
+ * no status clause is Open, which the list opened on before views existed,
100
+ * so it is shown as the clause it means; `status equals all` is every lead
101
+ * and so no clause at all.
102
+ */
103
+ export declare function leadClausesForGrid(stored: readonly CrmViewFilterClause[]): CrmViewFilterClause[];
104
+ /** The inverse of {@link leadClausesForGrid}: what the view stores. */
105
+ export declare function leadClausesToStore(shown: readonly CrmViewFilterClause[]): CrmViewFilterClause[];
106
+ /** Whether a lead answers every clause the grid shows. */
107
+ export declare function leadMatchesClauses(lead: Readonly<Record<string, unknown>> & Pick<CrmLeadFields, 'status'>, clauses: readonly CrmViewFilterClause[]): boolean;
108
+ /** Campaign clauses were stored as `contains`; the panel's select says `is`. */
109
+ export declare const LEAD_CAMPAIGN_CODEC: CrmGridFilterCodec;
110
+ /** "No lead source" is an `isEmpty` clause, shown as a choice of the select. */
111
+ export declare const LEAD_SOURCE_CODEC: CrmGridFilterCodec;
112
+ /** The Leads fields whose stored clauses translate their own way. */
113
+ export declare const LEAD_FILTER_CODECS: Readonly<Record<string, CrmGridFilterCodec>>;
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, normalizeCrmPicklistLabel, readCampaignIds, readEmailState } from "@aglyn/aglyn";
18
+ import { crmSelectCodec, crmRowMatchesSearch } from "./crm-grid-filter.js";
18
19
  export const LEAD_FILTERS = [
19
20
  'open',
20
21
  'new',
@@ -70,11 +71,12 @@ export const LEAD_EMAIL_FILTER_LABELS = _extends({
70
71
  * tags, which are how an import names its batch (`sal-15`). `name` and
71
72
  * `email` are the capture door's fields rather than the CRM's, so the row is
72
73
  * read as a record rather than through `CrmLeadFields`.
73
- */ const LEAD_SEARCH_FIELDS = [
74
+ */ export const LEAD_SEARCH_FIELDS = [
74
75
  'name',
75
76
  'email',
76
77
  'company',
77
- 'jobTitle'
78
+ 'jobTitle',
79
+ 'tags'
78
80
  ];
79
81
  /**
80
82
  * Whether a lead answers a search term.
@@ -84,13 +86,11 @@ export const LEAD_EMAIL_FILTER_LABELS = _extends({
84
86
  * both find Morgan Lamphere, `morgan smith` does not. A blank term matches
85
87
  * every lead, so the box emptied is the list unfiltered.
86
88
  */ export function leadMatchesSearch(lead, term) {
87
- const words = term.trim().toLowerCase().split(/\s+/).filter(Boolean);
88
- if (!words.length) return true;
89
- const values = [
90
- ...LEAD_SEARCH_FIELDS.map((field)=>lead[field]),
91
- ...Array.isArray(lead['tags']) ? lead['tags'] : []
92
- ].filter((value)=>typeof value === 'string' && value !== '').map((value)=>value.toLowerCase());
93
- return words.every((word)=>values.some((value)=>value.includes(word)));
89
+ // Tags are a list; a stray string there is no tag of the lead's.
90
+ const tags = Array.isArray(lead['tags']) ? lead['tags'] : undefined;
91
+ return crmRowMatchesSearch(_extends({}, lead, {
92
+ tags
93
+ }), LEAD_SEARCH_FIELDS, term.trim().split(/\s+/));
94
94
  }
95
95
  /**
96
96
  * The `Lead source` control's "no lead source" choice (AGL-3298). Held in
@@ -108,5 +108,178 @@ export const LEAD_EMAIL_FILTER_LABELS = _extends({
108
108
  if (filter === LEAD_SOURCE_FILTER_NONE) return held === '';
109
109
  return held === normalizeCrmPicklistLabel(filter).toLowerCase();
110
110
  }
111
+ /*==========================================
112
+ * THE LEADS GRID'S FILTERS (AGL-3313).
113
+ *
114
+ * The Show, Email, Campaign and Lead source dropdowns became columns of the
115
+ * grid's own Filters panel. Their clauses keep the shape the dropdowns
116
+ * stored, so every saved view of leads reads as it did:
117
+ *
118
+ * status `equals <filter>`; none at all is Open, `all` is every lead
119
+ * emailState `equals <filter>`
120
+ * campaignIds `contains <campaign id>`
121
+ * leadSource `equals <label>`, or `isEmpty` for the leads holding none
122
+ *
123
+ * Every one narrows the loaded window, never the query — see the section's
124
+ * `LEADS_WINDOW` for why a status cannot be asked of Firestore.
125
+ *=========================================*/ /** The fields the grid's panel offers on Leads. */ export const LEAD_LIST_FILTER_FIELDS = [
126
+ {
127
+ column: 'status',
128
+ kind: 'exact',
129
+ path: 'status',
130
+ operators: [
131
+ 'equals',
132
+ 'isAnyOf'
133
+ ]
134
+ },
135
+ {
136
+ column: 'emailState',
137
+ kind: 'exact',
138
+ path: 'emailState',
139
+ operators: [
140
+ 'equals',
141
+ 'isAnyOf'
142
+ ]
143
+ },
144
+ {
145
+ column: 'ownerUid',
146
+ kind: 'exact',
147
+ path: 'ownerUid',
148
+ operators: [
149
+ 'equals',
150
+ 'isAnyOf'
151
+ ]
152
+ },
153
+ {
154
+ column: 'leadSource',
155
+ kind: 'exact',
156
+ path: 'leadSource',
157
+ operators: [
158
+ 'equals',
159
+ 'isAnyOf'
160
+ ]
161
+ },
162
+ {
163
+ column: 'campaignIds',
164
+ kind: 'exact',
165
+ path: 'campaignIds',
166
+ operators: [
167
+ 'equals'
168
+ ]
169
+ }
170
+ ];
171
+ /** What each filter field reads as on a chip. */ export const LEAD_LIST_FILTER_HEADERS = {
172
+ status: 'Status',
173
+ emailState: 'Email',
174
+ ownerUid: 'Owner',
175
+ leadSource: 'Lead source',
176
+ campaignIds: 'Campaign'
177
+ };
178
+ /** The choices for Status: Open (new or working) and each status on its own. */ export const LEAD_STATUS_FILTER_OPTIONS = LEAD_FILTERS.filter((option)=>option !== 'all').map((option)=>({
179
+ value: option,
180
+ label: option === 'open' ? 'Open (new or working)' : LEAD_FILTER_LABELS[option]
181
+ }));
182
+ /** The choices for Email: every verdict, plus the two aggregates. */ export const LEAD_EMAIL_FILTER_OPTIONS = LEAD_EMAIL_FILTERS.filter((option)=>option !== 'any').map((option)=>({
183
+ value: option,
184
+ label: LEAD_EMAIL_FILTER_LABELS[option]
185
+ }));
186
+ const STATUS_OPEN = {
187
+ field: 'status',
188
+ op: 'equals',
189
+ value: 'open'
190
+ };
191
+ const STATUS_ALL = {
192
+ field: 'status',
193
+ op: 'equals',
194
+ value: 'all'
195
+ };
196
+ /**
197
+ * The clauses the grid shows for a view's stored ones. A stored list with
198
+ * no status clause is Open, which the list opened on before views existed,
199
+ * so it is shown as the clause it means; `status equals all` is every lead
200
+ * and so no clause at all.
201
+ */ export function leadClausesForGrid(stored) {
202
+ const status = stored.find((clause)=>clause.field === 'status');
203
+ if (!status) return [
204
+ STATUS_OPEN,
205
+ ...stored
206
+ ];
207
+ if (status.op === 'equals' && status.value === 'all') {
208
+ return stored.filter((clause)=>clause.field !== 'status');
209
+ }
210
+ return [
211
+ ...stored
212
+ ];
213
+ }
214
+ /** The inverse of {@link leadClausesForGrid}: what the view stores. */ export function leadClausesToStore(shown) {
215
+ const status = shown.find((clause)=>clause.field === 'status');
216
+ if (!status) return [
217
+ ...shown,
218
+ STATUS_ALL
219
+ ];
220
+ if (status.op === 'equals' && status.value === 'open') {
221
+ return shown.filter((clause)=>clause.field !== 'status');
222
+ }
223
+ return [
224
+ ...shown
225
+ ];
226
+ }
227
+ const listed = (clause)=>clause.op === 'isAnyOf' ? clause.value.split(',').map((entry)=>entry.trim()).filter(Boolean) : [
228
+ clause.value
229
+ ];
230
+ /** Whether a lead answers every clause the grid shows. */ export function leadMatchesClauses(lead, clauses) {
231
+ return clauses.every((clause)=>{
232
+ switch(clause.field){
233
+ case 'status':
234
+ return listed(clause).some((value)=>LEAD_FILTERS.includes(value) ? leadMatchesFilter(lead, value) : true);
235
+ case 'emailState':
236
+ return listed(clause).some((value)=>LEAD_EMAIL_FILTERS.includes(value) ? leadMatchesEmailFilter(lead, value) : true);
237
+ case 'campaignIds':
238
+ return listed(clause).some((value)=>leadMatchesCampaignFilter(lead, value));
239
+ case 'leadSource':
240
+ return clause.op === 'isEmpty' ? leadMatchesLeadSourceFilter(lead, LEAD_SOURCE_FILTER_NONE) : listed(clause).some((value)=>leadMatchesLeadSourceFilter(lead, value));
241
+ case 'ownerUid':
242
+ var _lead_ownerUid;
243
+ return listed(clause).includes(String((_lead_ownerUid = lead['ownerUid']) != null ? _lead_ownerUid : ''));
244
+ default:
245
+ // A clause on a field this list does not answer narrows nothing,
246
+ // rather than emptying the list.
247
+ return true;
248
+ }
249
+ });
250
+ }
251
+ /** Campaign clauses were stored as `contains`; the panel's select says `is`. */ export const LEAD_CAMPAIGN_CODEC = {
252
+ toItem: (clause)=>clause.op === 'contains' || clause.op === 'equals' ? {
253
+ operator: 'is',
254
+ value: clause.value
255
+ } : null,
256
+ toClause: (item)=>{
257
+ const clause = crmSelectCodec.toClause(item);
258
+ return clause && clause.op === 'equals' ? _extends({}, clause, {
259
+ op: 'contains'
260
+ }) : null;
261
+ }
262
+ };
263
+ /** "No lead source" is an `isEmpty` clause, shown as a choice of the select. */ export const LEAD_SOURCE_CODEC = {
264
+ toItem: (clause)=>clause.op === 'isEmpty' ? {
265
+ operator: 'is',
266
+ value: LEAD_SOURCE_FILTER_NONE
267
+ } : crmSelectCodec.toItem(clause),
268
+ toClause: (item)=>{
269
+ const clause = crmSelectCodec.toClause(item);
270
+ if ((clause == null ? void 0 : clause.op) === 'equals' && clause.value === LEAD_SOURCE_FILTER_NONE) {
271
+ return {
272
+ field: clause.field,
273
+ op: 'isEmpty',
274
+ value: ''
275
+ };
276
+ }
277
+ return clause;
278
+ }
279
+ };
280
+ /** The Leads fields whose stored clauses translate their own way. */ export const LEAD_FILTER_CODECS = {
281
+ campaignIds: LEAD_CAMPAIGN_CODEC,
282
+ leadSource: LEAD_SOURCE_CODEC
283
+ };
111
284
 
112
285
  //# sourceMappingURL=lead-filters.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.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 EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n normalizeCrmPicklistLabel,\n readCampaignIds,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The Leads section's `Campaign` control (AGL-3254): every lead, or the\n * ones filed under one campaign — read off the lead's own `campaignIds`,\n * the field every campaign member carries. `''` is every lead, which is\n * what the control opens on; the ids are the site's containers', and the\n * section resolves their names for the menu.\n */\nexport function leadMatchesCampaignFilter(\n lead: Readonly<Record<string, unknown>>,\n campaignId: string,\n): boolean {\n if (!campaignId) return true\n return readCampaignIds(lead).includes(campaignId)\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n\n/**\n * The `Lead source` control's \"no lead source\" choice (AGL-3298). Held in\n * the saved view as an `isEmpty` clause, never as this string; it is only\n * the select's own value for the choice.\n */\nexport const LEAD_SOURCE_FILTER_NONE = '\\u0000none'\n\n/**\n * The Leads section's `Lead source` control (AGL-3298): every lead (`''`),\n * the ones holding one value — compared the way the picklist compares\n * labels, so a value typed in another case before the list existed is\n * still found — or the ones holding none.\n */\nexport function leadMatchesLeadSourceFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: string,\n): boolean {\n if (!filter) return true\n const held = normalizeCrmPicklistLabel(lead['leadSource']).toLowerCase()\n if (filter === LEAD_SOURCE_FILTER_NONE) return held === ''\n return held === normalizeCrmPicklistLabel(filter).toLowerCase()\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","normalizeCrmPicklistLabel","readCampaignIds","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","leadMatchesCampaignFilter","campaignId","includes","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some","LEAD_SOURCE_FILTER_NONE","leadMatchesLeadSourceFilter","held"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EAItBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,yBAAyB,EACzBC,eAAe,EACfC,cAAc,QACT,eAAc;AAWrB,OAAO,MAAMC,eAAsC;IACjD;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,OAAO,MAAMC,qBAAiD;IAC5DC,MAAM;IACNC,KAAK;IACLC,KAAKX,uBAAuBW,GAAG;IAC/BC,SAASZ,uBAAuBY,OAAO;IACvCC,WAAWb,uBAAuBa,SAAS;IAC3CC,aAAad,uBAAuBc,WAAW;AACjD,EAAC;AAED,0EAA0E,GAC1E,OAAO,SAASC,kBACdC,IAAmC,EACnCC,MAAkB;IAElB,IAAIA,WAAW,OAAO,OAAO;IAC7B,IAAIA,WAAW,QAAQ,OAAOd,cAAca;IAC5C,OAAOd,cAAcc,UAAUC;AACjC;AAWA,OAAO,MAAMC,qBAAiD;IAC5D;IACA;OACGnB;IACH;CACD,CAAA;AAED,OAAO,MAAMoB,2BAA4D;IACvEC,KAAK;IACLC,SAAS;IACTC,MAAM;GACHxB,oBACJ;AAED,sDAAsD,GACtD,OAAO,SAASyB,uBACdP,IAAuC,EACvCC,MAAuB;IAEvB,IAAIA,WAAW,OAAO,OAAO;IAC7B,MAAMO,QAAQlB,eAAeU;IAC7B,IAAIC,WAAW,QAAQ,OAAOO,UAAU;IACxC,IAAIP,WAAW,WAAW,OAAOhB,uBAAuBuB;IACxD,OAAOA,CAAAA,yBAAAA,MAAOC,MAAM,MAAKR;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,0BACdV,IAAuC,EACvCW,UAAkB;IAElB,IAAI,CAACA,YAAY,OAAO;IACxB,OAAOtB,gBAAgBW,MAAMY,QAAQ,CAACD;AACxC;AAEA;;;;;;CAMC,GACD,MAAME,qBAAqB;IAAC;IAAQ;IAAS;IAAW;CAAW;AAEnE;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdd,IAAuC,EACvCe,IAAY;IAEZ,MAAMC,QAAQD,KAAKE,IAAI,GAAGC,WAAW,GAAGC,KAAK,CAAC,OAAOlB,MAAM,CAACmB;IAC5D,IAAI,CAACJ,MAAMK,MAAM,EAAE,OAAO;IAC1B,MAAMC,SAAS;WACVT,mBAAmBU,GAAG,CAAC,CAACC,QAAUxB,IAAI,CAACwB,MAAM;WAC5CC,MAAMC,OAAO,CAAC1B,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,OAAO,GAAG,EAAE;KACpD,CACEC,MAAM,CAAC,CAAC0B,QAA2B,OAAOA,UAAU,YAAYA,UAAU,IAC1EJ,GAAG,CAAC,CAACI,QAAUA,MAAMT,WAAW;IACnC,OAAOF,MAAMY,KAAK,CAAC,CAACC,OAASP,OAAOQ,IAAI,CAAC,CAACH,QAAUA,MAAMf,QAAQ,CAACiB;AACrE;AAEA;;;;CAIC,GACD,OAAO,MAAME,0BAA0B,aAAY;AAEnD;;;;;CAKC,GACD,OAAO,SAASC,4BACdhC,IAAuC,EACvCC,MAAc;IAEd,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMgC,OAAO7C,0BAA0BY,IAAI,CAAC,aAAa,EAAEkB,WAAW;IACtE,IAAIjB,WAAW8B,yBAAyB,OAAOE,SAAS;IACxD,OAAOA,SAAS7C,0BAA0Ba,QAAQiB,WAAW;AAC/D"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.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 EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n normalizeCrmPicklistLabel,\n readCampaignIds,\n readEmailState,\n} from '@aglyn/aglyn'\nimport type { CrmViewFilterClause } from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport { type CrmGridFilterCodec, crmSelectCodec, crmRowMatchesSearch } from './crm-grid-filter'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The Leads section's `Campaign` control (AGL-3254): every lead, or the\n * ones filed under one campaign — read off the lead's own `campaignIds`,\n * the field every campaign member carries. `''` is every lead, which is\n * what the control opens on; the ids are the site's containers', and the\n * section resolves their names for the menu.\n */\nexport function leadMatchesCampaignFilter(\n lead: Readonly<Record<string, unknown>>,\n campaignId: string,\n): boolean {\n if (!campaignId) return true\n return readCampaignIds(lead).includes(campaignId)\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nexport const LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle', 'tags'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n // Tags are a list; a stray string there is no tag of the lead's.\n const tags = Array.isArray(lead['tags']) ? lead['tags'] : undefined\n return crmRowMatchesSearch({ ...lead, tags }, LEAD_SEARCH_FIELDS, term.trim().split(/\\s+/))\n}\n\n/**\n * The `Lead source` control's \"no lead source\" choice (AGL-3298). Held in\n * the saved view as an `isEmpty` clause, never as this string; it is only\n * the select's own value for the choice.\n */\nexport const LEAD_SOURCE_FILTER_NONE = '\\u0000none'\n\n/**\n * The Leads section's `Lead source` control (AGL-3298): every lead (`''`),\n * the ones holding one value — compared the way the picklist compares\n * labels, so a value typed in another case before the list existed is\n * still found — or the ones holding none.\n */\nexport function leadMatchesLeadSourceFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: string,\n): boolean {\n if (!filter) return true\n const held = normalizeCrmPicklistLabel(lead['leadSource']).toLowerCase()\n if (filter === LEAD_SOURCE_FILTER_NONE) return held === ''\n return held === normalizeCrmPicklistLabel(filter).toLowerCase()\n}\n\n/*==========================================\n * THE LEADS GRID'S FILTERS (AGL-3313).\n *\n * The Show, Email, Campaign and Lead source dropdowns became columns of the\n * grid's own Filters panel. Their clauses keep the shape the dropdowns\n * stored, so every saved view of leads reads as it did:\n *\n * status `equals <filter>`; none at all is Open, `all` is every lead\n * emailState `equals <filter>`\n * campaignIds `contains <campaign id>`\n * leadSource `equals <label>`, or `isEmpty` for the leads holding none\n *\n * Every one narrows the loaded window, never the query — see the section's\n * `LEADS_WINDOW` for why a status cannot be asked of Firestore.\n *=========================================*/\n\n/** The fields the grid's panel offers on Leads. */\nexport const LEAD_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n { column: 'status', kind: 'exact', path: 'status', operators: ['equals', 'isAnyOf'] },\n { column: 'emailState', kind: 'exact', path: 'emailState', operators: ['equals', 'isAnyOf'] },\n { column: 'ownerUid', kind: 'exact', path: 'ownerUid', operators: ['equals', 'isAnyOf'] },\n { column: 'leadSource', kind: 'exact', path: 'leadSource', operators: ['equals', 'isAnyOf'] },\n { column: 'campaignIds', kind: 'exact', path: 'campaignIds', operators: ['equals'] },\n]\n\n/** What each filter field reads as on a chip. */\nexport const LEAD_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n status: 'Status',\n emailState: 'Email',\n ownerUid: 'Owner',\n leadSource: 'Lead source',\n campaignIds: 'Campaign',\n}\n\n/** The choices for Status: Open (new or working) and each status on its own. */\nexport const LEAD_STATUS_FILTER_OPTIONS = LEAD_FILTERS.filter(\n (option) => option !== 'all',\n).map((option) => ({\n value: option,\n label: option === 'open' ? 'Open (new or working)' : LEAD_FILTER_LABELS[option],\n}))\n\n/** The choices for Email: every verdict, plus the two aggregates. */\nexport const LEAD_EMAIL_FILTER_OPTIONS = LEAD_EMAIL_FILTERS.filter(\n (option) => option !== 'any',\n).map((option) => ({ value: option, label: LEAD_EMAIL_FILTER_LABELS[option] }))\n\nconst STATUS_OPEN: CrmViewFilterClause = { field: 'status', op: 'equals', value: 'open' }\nconst STATUS_ALL: CrmViewFilterClause = { field: 'status', op: 'equals', value: 'all' }\n\n/**\n * The clauses the grid shows for a view's stored ones. A stored list with\n * no status clause is Open, which the list opened on before views existed,\n * so it is shown as the clause it means; `status equals all` is every lead\n * and so no clause at all.\n */\nexport function leadClausesForGrid(\n stored: readonly CrmViewFilterClause[],\n): CrmViewFilterClause[] {\n const status = stored.find((clause) => clause.field === 'status')\n if (!status) return [STATUS_OPEN, ...stored]\n if (status.op === 'equals' && status.value === 'all') {\n return stored.filter((clause) => clause.field !== 'status')\n }\n return [...stored]\n}\n\n/** The inverse of {@link leadClausesForGrid}: what the view stores. */\nexport function leadClausesToStore(\n shown: readonly CrmViewFilterClause[],\n): CrmViewFilterClause[] {\n const status = shown.find((clause) => clause.field === 'status')\n if (!status) return [...shown, STATUS_ALL]\n if (status.op === 'equals' && status.value === 'open') {\n return shown.filter((clause) => clause.field !== 'status')\n }\n return [...shown]\n}\n\nconst listed = (clause: CrmViewFilterClause): string[] =>\n clause.op === 'isAnyOf'\n ? clause.value.split(',').map((entry) => entry.trim()).filter(Boolean)\n : [clause.value]\n\n/** Whether a lead answers every clause the grid shows. */\nexport function leadMatchesClauses(\n lead: Readonly<Record<string, unknown>> & Pick<CrmLeadFields, 'status'>,\n clauses: readonly CrmViewFilterClause[],\n): boolean {\n return clauses.every((clause) => {\n switch (clause.field) {\n case 'status':\n return listed(clause).some((value) =>\n (LEAD_FILTERS as readonly string[]).includes(value)\n ? leadMatchesFilter(lead, value as LeadFilter)\n : true,\n )\n case 'emailState':\n return listed(clause).some((value) =>\n (LEAD_EMAIL_FILTERS as readonly string[]).includes(value)\n ? leadMatchesEmailFilter(lead, value as LeadEmailFilter)\n : true,\n )\n case 'campaignIds':\n return listed(clause).some((value) => leadMatchesCampaignFilter(lead, value))\n case 'leadSource':\n return clause.op === 'isEmpty'\n ? leadMatchesLeadSourceFilter(lead, LEAD_SOURCE_FILTER_NONE)\n : listed(clause).some((value) => leadMatchesLeadSourceFilter(lead, value))\n case 'ownerUid':\n return listed(clause).includes(String(lead['ownerUid'] ?? ''))\n default:\n // A clause on a field this list does not answer narrows nothing,\n // rather than emptying the list.\n return true\n }\n })\n}\n\n/** Campaign clauses were stored as `contains`; the panel's select says `is`. */\nexport const LEAD_CAMPAIGN_CODEC: CrmGridFilterCodec = {\n toItem: (clause) =>\n clause.op === 'contains' || clause.op === 'equals'\n ? { operator: 'is', value: clause.value }\n : null,\n toClause: (item) => {\n const clause = crmSelectCodec.toClause(item)\n return clause && clause.op === 'equals' ? { ...clause, op: 'contains' } : null\n },\n}\n\n/** \"No lead source\" is an `isEmpty` clause, shown as a choice of the select. */\nexport const LEAD_SOURCE_CODEC: CrmGridFilterCodec = {\n toItem: (clause) =>\n clause.op === 'isEmpty'\n ? { operator: 'is', value: LEAD_SOURCE_FILTER_NONE }\n : crmSelectCodec.toItem(clause),\n toClause: (item) => {\n const clause = crmSelectCodec.toClause(item)\n if (clause?.op === 'equals' && clause.value === LEAD_SOURCE_FILTER_NONE) {\n return { field: clause.field, op: 'isEmpty', value: '' }\n }\n return clause\n },\n}\n\n/** The Leads fields whose stored clauses translate their own way. */\nexport const LEAD_FILTER_CODECS: Readonly<Record<string, CrmGridFilterCodec>> = {\n campaignIds: LEAD_CAMPAIGN_CODEC,\n leadSource: LEAD_SOURCE_CODEC,\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","normalizeCrmPicklistLabel","readCampaignIds","readEmailState","crmSelectCodec","crmRowMatchesSearch","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","leadMatchesCampaignFilter","campaignId","includes","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","tags","Array","isArray","undefined","trim","split","LEAD_SOURCE_FILTER_NONE","leadMatchesLeadSourceFilter","held","toLowerCase","LEAD_LIST_FILTER_FIELDS","column","kind","path","operators","LEAD_LIST_FILTER_HEADERS","emailState","ownerUid","leadSource","campaignIds","LEAD_STATUS_FILTER_OPTIONS","option","map","value","label","LEAD_EMAIL_FILTER_OPTIONS","STATUS_OPEN","field","op","STATUS_ALL","leadClausesForGrid","stored","find","clause","leadClausesToStore","shown","listed","entry","Boolean","leadMatchesClauses","clauses","every","some","String","LEAD_CAMPAIGN_CODEC","toItem","operator","toClause","item","LEAD_SOURCE_CODEC","LEAD_FILTER_CODECS"],"mappings":"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"}
@@ -488,10 +488,12 @@ export const crmEmailSendHandler = async (req, res)=>{
488
488
  reason: 'ceiling'
489
489
  }));
490
490
  }
491
- // Both suppression lists, then the person's own refusal.
491
+ // Both suppression lists, then the person's own refusal — each read
492
+ // across the sending site's consent group, so leaving one site of a
493
+ // declared group stops this one writing too.
492
494
  const sendable = await filterSendableForHost(hostId, [
493
495
  recipient.email
494
- ]);
496
+ ], undefined, group);
495
497
  if (!sendable.length) {
496
498
  return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, {
497
499
  reason: 'suppressed'