@aglyn/plugins-crm 1.0.0-beta.230 → 1.0.0-beta.232

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 (98) hide show
  1. package/package.json +14 -13
  2. package/src/lib/components/activity-timeline.js +1 -0
  3. package/src/lib/components/activity-timeline.js.map +1 -1
  4. package/src/lib/components/assignment-rule-drawer.js +1 -0
  5. package/src/lib/components/assignment-rule-drawer.js.map +1 -1
  6. package/src/lib/components/contact-associations-card.js +1 -0
  7. package/src/lib/components/contact-associations-card.js.map +1 -1
  8. package/src/lib/components/contact-duplicates-card.js +1 -0
  9. package/src/lib/components/contact-duplicates-card.js.map +1 -1
  10. package/src/lib/components/contact-field-drawer.js +1 -0
  11. package/src/lib/components/contact-field-drawer.js.map +1 -1
  12. package/src/lib/components/contact-known-by-card.js +1 -0
  13. package/src/lib/components/contact-known-by-card.js.map +1 -1
  14. package/src/lib/components/contact-timeline-card.js +1 -0
  15. package/src/lib/components/contact-timeline-card.js.map +1 -1
  16. package/src/lib/components/crm-glance-card.js +1 -0
  17. package/src/lib/components/crm-glance-card.js.map +1 -1
  18. package/src/lib/components/crm-next-activity-column.d.ts +1 -13
  19. package/src/lib/components/crm-next-activity-column.js +1 -19
  20. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  21. package/src/lib/components/crm-record-header.js +2 -0
  22. package/src/lib/components/crm-record-header.js.map +1 -1
  23. package/src/lib/components/deals-section.js +1 -0
  24. package/src/lib/components/deals-section.js.map +1 -1
  25. package/src/lib/components/fields-section.js +4 -0
  26. package/src/lib/components/fields-section.js.map +1 -1
  27. package/src/lib/components/lead-history-card.js +1 -0
  28. package/src/lib/components/lead-history-card.js.map +1 -1
  29. package/src/lib/components/lead-status-options.d.ts +2 -14
  30. package/src/lib/components/lead-status-options.js +1 -13
  31. package/src/lib/components/lead-status-options.js.map +1 -1
  32. package/src/lib/components/lead-surfaces-note.js +1 -0
  33. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  34. package/src/lib/components/lead-unqualify-dialog.d.ts +1 -2
  35. package/src/lib/components/lead-unqualify-dialog.js +2 -1
  36. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  37. package/src/lib/components/linked-deals-card.js +1 -0
  38. package/src/lib/components/linked-deals-card.js.map +1 -1
  39. package/src/lib/components/picklist-values-card.js +1 -0
  40. package/src/lib/components/picklist-values-card.js.map +1 -1
  41. package/src/lib/components/recipes-card.js +1 -0
  42. package/src/lib/components/recipes-card.js.map +1 -1
  43. package/src/lib/components/reports/activity-card.js +1 -0
  44. package/src/lib/components/reports/activity-card.js.map +1 -1
  45. package/src/lib/components/reports/closed-deals-card.js +1 -0
  46. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  47. package/src/lib/components/reports/contacts-trend-card.js +1 -0
  48. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  49. package/src/lib/components/reports/lead-funnel-card.js +1 -0
  50. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  51. package/src/lib/components/reports/lead-source-card.js +1 -0
  52. package/src/lib/components/reports/lead-source-card.js.map +1 -1
  53. package/src/lib/components/reports/pipeline-card.js +1 -0
  54. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  55. package/src/lib/components/reports/source-conversion-card.js +1 -0
  56. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  57. package/src/lib/components/reports/tasks-card.js +1 -0
  58. package/src/lib/components/reports/tasks-card.js.map +1 -1
  59. package/src/lib/components/reports-section.js +2 -0
  60. package/src/lib/components/reports-section.js.map +1 -1
  61. package/src/lib/components/settings-section.d.ts +18 -0
  62. package/src/lib/components/settings-section.js +97 -1
  63. package/src/lib/components/settings-section.js.map +1 -1
  64. package/src/lib/components/tasks-calendar.js +1 -0
  65. package/src/lib/components/tasks-calendar.js.map +1 -1
  66. package/src/lib/constants/company-filters.d.ts +2 -2
  67. package/src/lib/constants/company-filters.js +1 -1
  68. package/src/lib/constants/company-filters.js.map +1 -1
  69. package/src/lib/constants/contact-filters.d.ts +3 -3
  70. package/src/lib/constants/contact-filters.js +2 -1
  71. package/src/lib/constants/contact-filters.js.map +1 -1
  72. package/src/lib/constants/deal-filters.d.ts +3 -3
  73. package/src/lib/constants/deal-filters.js +1 -1
  74. package/src/lib/constants/deal-filters.js.map +1 -1
  75. package/src/lib/constants/field-list-query.d.ts +1 -1
  76. package/src/lib/constants/field-list-query.js +1 -1
  77. package/src/lib/constants/field-list-query.js.map +1 -1
  78. package/src/lib/declarations.server.js +9 -0
  79. package/src/lib/declarations.server.js.map +1 -1
  80. package/src/lib/model/contacts-list-seed.d.ts +1 -1
  81. package/src/lib/model/contacts-list-seed.js.map +1 -1
  82. package/src/lib/model/crm-list-query.d.ts +4 -4
  83. package/src/lib/model/crm-list-query.js +2 -2
  84. package/src/lib/model/crm-list-query.js.map +1 -1
  85. package/src/lib/model/crm-next-activity.d.ts +13 -0
  86. package/src/lib/model/crm-next-activity.js +19 -0
  87. package/src/lib/model/crm-next-activity.js.map +1 -1
  88. package/src/lib/model/lead-filters.d.ts +6 -5
  89. package/src/lib/model/lead-filters.js +3 -2
  90. package/src/lib/model/lead-filters.js.map +1 -1
  91. package/src/lib/model/lead-status-choices.d.ts +32 -0
  92. package/src/lib/model/lead-status-choices.js +31 -0
  93. package/src/lib/model/lead-status-choices.js.map +1 -0
  94. package/src/lib/model/task-views.d.ts +2 -2
  95. package/src/lib/model/task-views.js.map +1 -1
  96. package/src/lib/server/person-records.d.ts +27 -0
  97. package/src/lib/server/person-records.js +127 -4
  98. package/src/lib/server/person-records.js.map +1 -1
@@ -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 CRM_LEAD_OPEN_STATUSES,\n CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n CRM_LEAD_SOURCE_DIRECTION_FIELD,\n CRM_LEAD_STATUSES,\n type EmailStateStatus,\n type CrmLeadStatus,\n type CrmPicklist,\n crmLeadSourceKey,\n crmPicklistKey,\n} from '@aglyn/aglyn'\nimport type { CrmViewFilterClause } from '@aglyn/aglyn'\nimport { SCOPED_SEARCH_JOIN } from '@aglyn/aglyn/app-utils/name-search'\nimport type { ListFilterField, ListFilterRequest } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport { type ListGridFilterCodec, listSelectCodec } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type { ListQueryDeclaration, ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport {\n CRM_LIST_SEARCH,\n type CrmClauseAsked,\n crmAnyOf,\n crmClauseValues,\n crmSelectField,\n} from './crm-list-query'\nimport {\n isLeadSourceDirection,\n LEAD_SOURCE_DIRECTION_LABELS,\n LEAD_SOURCE_DIRECTIONS,\n} from './lead-source-direction'\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 — asked of the query as\n * `status in [new, nurturing, working]`,\n * which every lead answers because every lead writer stores a status, `new`\n * for one nobody has touched (`crmLeadListFields`, AGL-3321).\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'nurturing',\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 nurturing: CRM_LEAD_STATUS_LABELS.nurturing,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\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/**\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 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 * leadSourceDirection `equals inbound|outbound` (AGL-3511): every lead\n * whose lead source is a value of that group\n * industry, rating `equals <key>` (AGL-3513): a value of the org's\n * Industry or Rating list, by the key the query compares\n *\n * Every one is asked of the Leads query (AGL-3321) through the field a\n * writer stores for it — see {@link leadQueryClause}.\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 {\n column: 'leadSourceDirection',\n kind: 'exact',\n path: 'leadSourceDirection',\n operators: ['equals', 'isAnyOf'],\n },\n { column: 'campaignIds', kind: 'exact', path: 'campaignIds', operators: ['equals'] },\n // Salesforce's Industry and Rating (AGL-3513), the lists companies keep.\n { column: 'industry', kind: 'exact', path: 'industry', operators: ['equals', 'isAnyOf'] },\n { column: 'rating', kind: 'exact', path: 'rating', operators: ['equals', 'isAnyOf'] },\n]\n\n/**\n * The lead picklist columns the list filters by (AGL-3513), each with the\n * list its choices come from: a choice's value is the KEY the query\n * compares, its caption the label.\n */\nexport const LEAD_PICKLIST_FILTERS = [\n { column: 'industry', picklistId: 'industry', header: 'Industry' },\n { column: 'rating', picklistId: 'rating', header: 'Rating' },\n] as const\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 leadSourceDirection: 'Lead source direction',\n campaignIds: 'Campaign',\n industry: 'Industry',\n rating: 'Rating',\n}\n\n/** The choices for Status: Open (new, nurturing 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, nurturing 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\n/** The choices for Lead source direction: each group of the Lead source picklist. */\nexport const LEAD_SOURCE_DIRECTION_FILTER_OPTIONS = LEAD_SOURCE_DIRECTIONS.map((direction) => ({\n value: direction,\n label: LEAD_SOURCE_DIRECTION_LABELS[direction],\n}))\n\n/**\n * The Status choices as an org reads them (AGL-3512): still one choice per\n * MEANING — the query asks `status` — each named by the org's values of that\n * meaning, so a list that keeps \"Contacted\" and \"Meeting set\" as Working\n * reads \"Working: Contacted, Meeting set\".\n */\nexport function leadStatusFilterOptions(\n picklist: CrmPicklist,\n): Array<{ value: string; label: string }> {\n const named = (status: CrmLeadStatus): string => {\n const labels = picklist.values\n .filter((value) => value.meaning === status && value.active)\n .map((value) => value.label)\n const meaning = picklistMeaningName(status)\n if (!labels.length) return meaning\n if (labels.length === 1 && labels[0] === meaning) return meaning\n return `${meaning}: ${labels.join(', ')}`\n }\n return LEAD_FILTERS.filter((option) => option !== 'all').map((option) => ({\n value: option,\n label: option === 'open' ? 'Open (new, nurturing or working)' : named(option),\n }))\n}\n\nconst picklistMeaningName = (status: CrmLeadStatus): string => CRM_LEAD_STATUS_LABELS[status]\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\n/** Campaign clauses were stored as `contains`; the panel's select says `is`. */\nexport const LEAD_CAMPAIGN_CODEC: ListGridFilterCodec = {\n toItem: (clause) =>\n clause.op === 'contains' || clause.op === 'equals'\n ? { operator: 'is', value: clause.value }\n : null,\n toClause: (item) => {\n const clause = listSelectCodec.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: ListGridFilterCodec = {\n toItem: (clause) =>\n clause.op === 'isEmpty'\n ? { operator: 'is', value: LEAD_SOURCE_FILTER_NONE }\n : listSelectCodec.toItem(clause),\n toClause: (item) => {\n const clause = listSelectCodec.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, ListGridFilterCodec>> = {\n campaignIds: LEAD_CAMPAIGN_CODEC,\n leadSource: LEAD_SOURCE_CODEC,\n}\n\n/*==========================================\n * THE LEADS QUERY (AGL-3321).\n *\n * `orgs/{orgId}/leads`, newest seen first, under a site narrowed by the\n * `visibleTo` scope clause. Each grid clause is asked through a field every\n * lead writer stores (`crmLeadListFields` in `@aglyn/aglyn`):\n *\n * status `status`, stored `new` on a lead nobody has touched, so\n * Open is `status in [new, nurturing, working]`\n * emailState `emailStatus`, the verdict's status or `none`; \"Cannot be\n * emailed\" is every verdict but `ok`\n * leadSource `leadSourceKey`, the label as the picklist compares it, or\n * `null` for none\n * leadSourceDirection `leadSourceDirection` (AGL-3577), the group of\n * the lead's value in the org's list, stamped by the writers\n * from that list and rewritten on every lead holding a value\n * the list regroups — so one equality asks a group however\n * many values it holds\n * ownerUid as stored\n * industry, rating `industryKey`, `ratingKey` (AGL-3513), each label\n * as the picklist compares it\n * campaignIds `array-contains` on the lead's own campaigns; under a site,\n * `scopedCampaignIds` — the campaign behind each of the\n * site's scope tokens — which stands in the scope clause's\n * place, so the one array clause answers both\n *\n * and the search box reads `searchTokens` — word prefixes of the name,\n * address, company, title and tags. Every equality rides one\n * `(field, lastSeenAtMs DESC)` composite; the campaign and the search are\n * array clauses, so the two do not stand together (nor under a site, where\n * the scope clause is the query's one array clause, save the search, which\n * folds into it for a reader who may drop it).\n *=========================================*/\n\n/** The one order the Leads list takes: newest seen first. */\nexport const LEAD_LIST_SORTS: readonly ListQuerySort[] = [{ path: 'lastSeenAtMs', direction: 'desc' }]\n\n/** The fields the Leads query asks — each a stored field, as named. */\nexport const LEAD_QUERY_FIELDS: readonly ListFilterField[] = [\n crmSelectField('status'),\n crmSelectField('emailStatus'),\n crmSelectField('ownerUid'),\n {\n column: 'leadSourceKey',\n kind: 'exact',\n path: 'leadSourceKey',\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty'],\n },\n {\n column: 'campaignIds',\n kind: 'exact',\n path: 'campaignIds',\n tokensPath: 'campaignIds',\n operators: ['contains'],\n },\n // The lead source's group (AGL-3577), as stamped on the lead.\n crmSelectField(CRM_LEAD_SOURCE_DIRECTION_FIELD),\n // Industry and Rating (AGL-3513), by the key each writer stores.\n crmSelectField('industryKey'),\n crmSelectField('ratingKey'),\n {\n // A campaign under a site: the lead's campaigns behind its scope.\n column: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n kind: 'exact',\n path: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n tokensPath: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n operators: ['isAnyOf'],\n },\n]\n\nexport const LEAD_LIST_DECLARATION: ListQueryDeclaration = {\n fields: LEAD_QUERY_FIELDS,\n sorts: LEAD_LIST_SORTS,\n search: CRM_LIST_SEARCH,\n}\n\n/**\n * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the\n * start of the address, beside the scope clause — the `(visibleTo, email)`\n * composite the Inbox's leads list reads too.\n */\nexport const LEAD_PREFIX_SEARCH = {\n field: {\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n operators: ['startsWith'],\n } satisfies ListFilterField,\n notice: 'Search matches the start of a lead’s address for access limited to specific sites.',\n}\n\n/** Whether an asked clause stands in for the scope clause: a campaign behind the site's scope. */\nexport const leadClauseImpliesScope = (clause: ListFilterRequest): boolean =>\n clause.field === CRM_LEAD_SCOPED_CAMPAIGNS_FIELD\n\n/** The verdicts a member must not email to: every one but `ok`. */\nconst EMAIL_PROBLEMS = EMAIL_STATE_STATUSES.filter((status) => status !== 'ok')\n\n/** Who is asking, for the one clause whose shape depends on it: the Campaign. */\nexport interface LeadQueryReader {\n /** The site's scope tokens, or `null` at the organization level. */\n scopeTokens: readonly string[] | null\n /** Whether the reader may run a query without the scope clause (`useCrmFoldsScope`). */\n foldsScope: boolean\n}\n\nconst ORG_READER: LeadQueryReader = { scopeTokens: null, foldsScope: true }\n\n/**\n * One grid clause as the clause the Leads query asks — see the block above.\n * A value the list does not know is refused by name rather than dropped, so\n * a stored view that names a retired choice says so.\n */\nexport function leadQueryClause(\n clause: CrmViewFilterClause | ListFilterRequest,\n reader: LeadQueryReader = ORG_READER,\n): CrmClauseAsked {\n const values = crmClauseValues(clause)\n switch (clause.field) {\n case 'status': {\n const statuses: string[] = []\n for (const value of values) {\n if (value === 'open') statuses.push(...CRM_LEAD_OPEN_STATUSES)\n else if ((CRM_LEAD_STATUSES as readonly string[]).includes(value)) statuses.push(value)\n else return { refused: `${value} is not a lead status` }\n }\n return crmAnyOf('status', statuses)\n }\n case 'emailState': {\n const keys: string[] = []\n for (const value of values) {\n if (value === 'problem') keys.push(...EMAIL_PROBLEMS)\n else if (value === 'none') keys.push('none')\n else if ((EMAIL_STATE_STATUSES as readonly string[]).includes(value)) keys.push(value)\n else return { refused: `${value} is not an email verdict` }\n }\n return crmAnyOf('emailStatus', keys)\n }\n case 'leadSource': {\n if (clause.op === 'isEmpty' || (values.length === 1 && values[0] === LEAD_SOURCE_FILTER_NONE)) {\n return { field: 'leadSourceKey', op: 'isEmpty', value: '' }\n }\n if (values.includes(LEAD_SOURCE_FILTER_NONE)) {\n return { refused: '\"No lead source\" cannot be picked beside a value' }\n }\n const keys = values.map((value) => crmLeadSourceKey(value)).filter((key): key is string => Boolean(key))\n return crmAnyOf('leadSourceKey', keys)\n }\n case 'leadSourceDirection': {\n for (const value of values) {\n if (!isLeadSourceDirection(value)) return { refused: `${value} is not a lead source direction` }\n }\n return crmAnyOf(CRM_LEAD_SOURCE_DIRECTION_FIELD, values)\n }\n case 'ownerUid':\n return crmAnyOf('ownerUid', values)\n case 'industry':\n case 'rating': {\n // A choice is the key; a label typed into a stored view keys the same way.\n const keys = values.map((value) => crmPicklistKey(value)).filter((key): key is string => Boolean(key))\n return crmAnyOf(clause.field === 'industry' ? 'industryKey' : 'ratingKey', keys)\n }\n case 'campaignIds': {\n if (values.length !== 1) return { refused: 'pick one campaign' }\n if (!reader.scopeTokens) return { field: 'campaignIds', op: 'contains', value: values[0] }\n if (!reader.foldsScope) {\n return {\n refused:\n 'your access is limited to specific sites, which this filter cannot be combined with — an organization administrator can use it',\n }\n }\n return {\n field: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n op: 'isAnyOf',\n value: reader.scopeTokens.map((token) => `${token}${SCOPED_SEARCH_JOIN}${values[0]}`).join(','),\n }\n }\n default:\n return { refused: 'this list does not filter by that' }\n }\n}\n\n/**\n * The query shape the Leads list can send beyond its own declaration, for\n * the index pin (AGL-3321): a collaborator's address prefix beside the\n * equalities they may set.\n */\nexport function leadIndexShapes(): ListQueryDeclaration[] {\n const equalities = LEAD_QUERY_FIELDS.filter((field) => !field.tokensPath)\n return [{ fields: [LEAD_PREFIX_SEARCH.field, ...equalities], sorts: [] }]\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","CRM_LEAD_OPEN_STATUSES","CRM_LEAD_SCOPED_CAMPAIGNS_FIELD","CRM_LEAD_SOURCE_DIRECTION_FIELD","CRM_LEAD_STATUSES","crmLeadSourceKey","crmPicklistKey","SCOPED_SEARCH_JOIN","listSelectCodec","CRM_LIST_SEARCH","crmAnyOf","crmClauseValues","crmSelectField","isLeadSourceDirection","LEAD_SOURCE_DIRECTION_LABELS","LEAD_SOURCE_DIRECTIONS","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","nurturing","working","qualified","unqualified","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","LEAD_SOURCE_FILTER_NONE","LEAD_LIST_FILTER_FIELDS","column","kind","path","operators","LEAD_PICKLIST_FILTERS","picklistId","header","LEAD_LIST_FILTER_HEADERS","status","emailState","ownerUid","leadSource","leadSourceDirection","campaignIds","industry","rating","LEAD_STATUS_FILTER_OPTIONS","filter","option","map","value","label","LEAD_EMAIL_FILTER_OPTIONS","LEAD_SOURCE_DIRECTION_FILTER_OPTIONS","direction","leadStatusFilterOptions","picklist","named","labels","values","meaning","active","picklistMeaningName","length","join","STATUS_OPEN","field","op","STATUS_ALL","leadClausesForGrid","stored","find","clause","leadClausesToStore","shown","LEAD_CAMPAIGN_CODEC","toItem","operator","toClause","item","LEAD_SOURCE_CODEC","LEAD_FILTER_CODECS","LEAD_LIST_SORTS","LEAD_QUERY_FIELDS","presence","tokensPath","LEAD_LIST_DECLARATION","fields","sorts","search","LEAD_PREFIX_SEARCH","lowerPath","notice","leadClauseImpliesScope","EMAIL_PROBLEMS","ORG_READER","scopeTokens","foldsScope","leadQueryClause","reader","statuses","push","includes","refused","keys","key","Boolean","token","leadIndexShapes","equalities"],"mappings":"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"}
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 CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_OPEN_STATUSES,\n CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n CRM_LEAD_SOURCE_DIRECTION_FIELD,\n CRM_LEAD_STATUSES,\n type CrmLeadStatus,\n type CrmPicklist,\n crmLeadSourceKey,\n crmPicklistKey,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, type EmailStateStatus } from '@aglyn/aglyn/app-utils/email-state'\nimport type { CrmViewFilterClause } from '@aglyn/aglyn/app-utils/crm'\nimport { SCOPED_SEARCH_JOIN } from '@aglyn/aglyn/app-utils/name-search'\nimport type { ListFilterField, ListFilterRequest } from '@aglyn/shared-util-tools/list-query/list-filter'\nimport { type ListGridFilterCodec, listSelectCodec } from '@aglyn/shared-util-tools/list-query/list-filter-codecs'\nimport type { ListQueryDeclaration, ListQuerySort } from '@aglyn/shared-util-tools/list-query/list-query-plan'\nimport {\n CRM_LIST_SEARCH,\n type CrmClauseAsked,\n crmAnyOf,\n crmClauseValues,\n crmSelectField,\n} from './crm-list-query'\nimport {\n isLeadSourceDirection,\n LEAD_SOURCE_DIRECTION_LABELS,\n LEAD_SOURCE_DIRECTIONS,\n} from './lead-source-direction'\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 — asked of the query as\n * `status in [new, nurturing, working]`,\n * which every lead answers because every lead writer stores a status, `new`\n * for one nobody has touched (`crmLeadListFields`, AGL-3321).\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'nurturing',\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 nurturing: CRM_LEAD_STATUS_LABELS.nurturing,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\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/**\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 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 * leadSourceDirection `equals inbound|outbound` (AGL-3511): every lead\n * whose lead source is a value of that group\n * industry, rating `equals <key>` (AGL-3513): a value of the org's\n * Industry or Rating list, by the key the query compares\n *\n * Every one is asked of the Leads query (AGL-3321) through the field a\n * writer stores for it — see {@link leadQueryClause}.\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 {\n column: 'leadSourceDirection',\n kind: 'exact',\n path: 'leadSourceDirection',\n operators: ['equals', 'isAnyOf'],\n },\n { column: 'campaignIds', kind: 'exact', path: 'campaignIds', operators: ['equals'] },\n // Salesforce's Industry and Rating (AGL-3513), the lists companies keep.\n { column: 'industry', kind: 'exact', path: 'industry', operators: ['equals', 'isAnyOf'] },\n { column: 'rating', kind: 'exact', path: 'rating', operators: ['equals', 'isAnyOf'] },\n]\n\n/**\n * The lead picklist columns the list filters by (AGL-3513), each with the\n * list its choices come from: a choice's value is the KEY the query\n * compares, its caption the label.\n */\nexport const LEAD_PICKLIST_FILTERS = [\n { column: 'industry', picklistId: 'industry', header: 'Industry' },\n { column: 'rating', picklistId: 'rating', header: 'Rating' },\n] as const\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 leadSourceDirection: 'Lead source direction',\n campaignIds: 'Campaign',\n industry: 'Industry',\n rating: 'Rating',\n}\n\n/** The choices for Status: Open (new, nurturing 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, nurturing 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\n/** The choices for Lead source direction: each group of the Lead source picklist. */\nexport const LEAD_SOURCE_DIRECTION_FILTER_OPTIONS = LEAD_SOURCE_DIRECTIONS.map((direction) => ({\n value: direction,\n label: LEAD_SOURCE_DIRECTION_LABELS[direction],\n}))\n\n/**\n * The Status choices as an org reads them (AGL-3512): still one choice per\n * MEANING — the query asks `status` — each named by the org's values of that\n * meaning, so a list that keeps \"Contacted\" and \"Meeting set\" as Working\n * reads \"Working: Contacted, Meeting set\".\n */\nexport function leadStatusFilterOptions(\n picklist: CrmPicklist,\n): Array<{ value: string; label: string }> {\n const named = (status: CrmLeadStatus): string => {\n const labels = picklist.values\n .filter((value) => value.meaning === status && value.active)\n .map((value) => value.label)\n const meaning = picklistMeaningName(status)\n if (!labels.length) return meaning\n if (labels.length === 1 && labels[0] === meaning) return meaning\n return `${meaning}: ${labels.join(', ')}`\n }\n return LEAD_FILTERS.filter((option) => option !== 'all').map((option) => ({\n value: option,\n label: option === 'open' ? 'Open (new, nurturing or working)' : named(option),\n }))\n}\n\nconst picklistMeaningName = (status: CrmLeadStatus): string => CRM_LEAD_STATUS_LABELS[status]\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\n/** Campaign clauses were stored as `contains`; the panel's select says `is`. */\nexport const LEAD_CAMPAIGN_CODEC: ListGridFilterCodec = {\n toItem: (clause) =>\n clause.op === 'contains' || clause.op === 'equals'\n ? { operator: 'is', value: clause.value }\n : null,\n toClause: (item) => {\n const clause = listSelectCodec.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: ListGridFilterCodec = {\n toItem: (clause) =>\n clause.op === 'isEmpty'\n ? { operator: 'is', value: LEAD_SOURCE_FILTER_NONE }\n : listSelectCodec.toItem(clause),\n toClause: (item) => {\n const clause = listSelectCodec.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, ListGridFilterCodec>> = {\n campaignIds: LEAD_CAMPAIGN_CODEC,\n leadSource: LEAD_SOURCE_CODEC,\n}\n\n/*==========================================\n * THE LEADS QUERY (AGL-3321).\n *\n * `orgs/{orgId}/leads`, newest seen first, under a site narrowed by the\n * `visibleTo` scope clause. Each grid clause is asked through a field every\n * lead writer stores (`crmLeadListFields` in `@aglyn/aglyn`):\n *\n * status `status`, stored `new` on a lead nobody has touched, so\n * Open is `status in [new, nurturing, working]`\n * emailState `emailStatus`, the verdict's status or `none`; \"Cannot be\n * emailed\" is every verdict but `ok`\n * leadSource `leadSourceKey`, the label as the picklist compares it, or\n * `null` for none\n * leadSourceDirection `leadSourceDirection` (AGL-3577), the group of\n * the lead's value in the org's list, stamped by the writers\n * from that list and rewritten on every lead holding a value\n * the list regroups — so one equality asks a group however\n * many values it holds\n * ownerUid as stored\n * industry, rating `industryKey`, `ratingKey` (AGL-3513), each label\n * as the picklist compares it\n * campaignIds `array-contains` on the lead's own campaigns; under a site,\n * `scopedCampaignIds` — the campaign behind each of the\n * site's scope tokens — which stands in the scope clause's\n * place, so the one array clause answers both\n *\n * and the search box reads `searchTokens` — word prefixes of the name,\n * address, company, title and tags. Every equality rides one\n * `(field, lastSeenAtMs DESC)` composite; the campaign and the search are\n * array clauses, so the two do not stand together (nor under a site, where\n * the scope clause is the query's one array clause, save the search, which\n * folds into it for a reader who may drop it).\n *=========================================*/\n\n/** The one order the Leads list takes: newest seen first. */\nexport const LEAD_LIST_SORTS: readonly ListQuerySort[] = [{ path: 'lastSeenAtMs', direction: 'desc' }]\n\n/** The fields the Leads query asks — each a stored field, as named. */\nexport const LEAD_QUERY_FIELDS: readonly ListFilterField[] = [\n crmSelectField('status'),\n crmSelectField('emailStatus'),\n crmSelectField('ownerUid'),\n {\n column: 'leadSourceKey',\n kind: 'exact',\n path: 'leadSourceKey',\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty'],\n },\n {\n column: 'campaignIds',\n kind: 'exact',\n path: 'campaignIds',\n tokensPath: 'campaignIds',\n operators: ['contains'],\n },\n // The lead source's group (AGL-3577), as stamped on the lead.\n crmSelectField(CRM_LEAD_SOURCE_DIRECTION_FIELD),\n // Industry and Rating (AGL-3513), by the key each writer stores.\n crmSelectField('industryKey'),\n crmSelectField('ratingKey'),\n {\n // A campaign under a site: the lead's campaigns behind its scope.\n column: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n kind: 'exact',\n path: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n tokensPath: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n operators: ['isAnyOf'],\n },\n]\n\nexport const LEAD_LIST_DECLARATION: ListQueryDeclaration = {\n fields: LEAD_QUERY_FIELDS,\n sorts: LEAD_LIST_SORTS,\n search: CRM_LIST_SEARCH,\n}\n\n/**\n * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the\n * start of the address, beside the scope clause — the `(visibleTo, email)`\n * composite the Inbox's leads list reads too.\n */\nexport const LEAD_PREFIX_SEARCH = {\n field: {\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n operators: ['startsWith'],\n } satisfies ListFilterField,\n notice: 'Search matches the start of a lead’s address for access limited to specific sites.',\n}\n\n/** Whether an asked clause stands in for the scope clause: a campaign behind the site's scope. */\nexport const leadClauseImpliesScope = (clause: ListFilterRequest): boolean =>\n clause.field === CRM_LEAD_SCOPED_CAMPAIGNS_FIELD\n\n/** The verdicts a member must not email to: every one but `ok`. */\nconst EMAIL_PROBLEMS = EMAIL_STATE_STATUSES.filter((status) => status !== 'ok')\n\n/** Who is asking, for the one clause whose shape depends on it: the Campaign. */\nexport interface LeadQueryReader {\n /** The site's scope tokens, or `null` at the organization level. */\n scopeTokens: readonly string[] | null\n /** Whether the reader may run a query without the scope clause (`useCrmFoldsScope`). */\n foldsScope: boolean\n}\n\nconst ORG_READER: LeadQueryReader = { scopeTokens: null, foldsScope: true }\n\n/**\n * One grid clause as the clause the Leads query asks — see the block above.\n * A value the list does not know is refused by name rather than dropped, so\n * a stored view that names a retired choice says so.\n */\nexport function leadQueryClause(\n clause: CrmViewFilterClause | ListFilterRequest,\n reader: LeadQueryReader = ORG_READER,\n): CrmClauseAsked {\n const values = crmClauseValues(clause)\n switch (clause.field) {\n case 'status': {\n const statuses: string[] = []\n for (const value of values) {\n if (value === 'open') statuses.push(...CRM_LEAD_OPEN_STATUSES)\n else if ((CRM_LEAD_STATUSES as readonly string[]).includes(value)) statuses.push(value)\n else return { refused: `${value} is not a lead status` }\n }\n return crmAnyOf('status', statuses)\n }\n case 'emailState': {\n const keys: string[] = []\n for (const value of values) {\n if (value === 'problem') keys.push(...EMAIL_PROBLEMS)\n else if (value === 'none') keys.push('none')\n else if ((EMAIL_STATE_STATUSES as readonly string[]).includes(value)) keys.push(value)\n else return { refused: `${value} is not an email verdict` }\n }\n return crmAnyOf('emailStatus', keys)\n }\n case 'leadSource': {\n if (clause.op === 'isEmpty' || (values.length === 1 && values[0] === LEAD_SOURCE_FILTER_NONE)) {\n return { field: 'leadSourceKey', op: 'isEmpty', value: '' }\n }\n if (values.includes(LEAD_SOURCE_FILTER_NONE)) {\n return { refused: '\"No lead source\" cannot be picked beside a value' }\n }\n const keys = values.map((value) => crmLeadSourceKey(value)).filter((key): key is string => Boolean(key))\n return crmAnyOf('leadSourceKey', keys)\n }\n case 'leadSourceDirection': {\n for (const value of values) {\n if (!isLeadSourceDirection(value)) return { refused: `${value} is not a lead source direction` }\n }\n return crmAnyOf(CRM_LEAD_SOURCE_DIRECTION_FIELD, values)\n }\n case 'ownerUid':\n return crmAnyOf('ownerUid', values)\n case 'industry':\n case 'rating': {\n // A choice is the key; a label typed into a stored view keys the same way.\n const keys = values.map((value) => crmPicklistKey(value)).filter((key): key is string => Boolean(key))\n return crmAnyOf(clause.field === 'industry' ? 'industryKey' : 'ratingKey', keys)\n }\n case 'campaignIds': {\n if (values.length !== 1) return { refused: 'pick one campaign' }\n if (!reader.scopeTokens) return { field: 'campaignIds', op: 'contains', value: values[0] }\n if (!reader.foldsScope) {\n return {\n refused:\n 'your access is limited to specific sites, which this filter cannot be combined with — an organization administrator can use it',\n }\n }\n return {\n field: CRM_LEAD_SCOPED_CAMPAIGNS_FIELD,\n op: 'isAnyOf',\n value: reader.scopeTokens.map((token) => `${token}${SCOPED_SEARCH_JOIN}${values[0]}`).join(','),\n }\n }\n default:\n return { refused: 'this list does not filter by that' }\n }\n}\n\n/**\n * The query shape the Leads list can send beyond its own declaration, for\n * the index pin (AGL-3321): a collaborator's address prefix beside the\n * equalities they may set.\n */\nexport function leadIndexShapes(): ListQueryDeclaration[] {\n const equalities = LEAD_QUERY_FIELDS.filter((field) => !field.tokensPath)\n return [{ fields: [LEAD_PREFIX_SEARCH.field, ...equalities], sorts: [] }]\n}\n"],"names":["CRM_LEAD_STATUS_LABELS","CRM_LEAD_OPEN_STATUSES","CRM_LEAD_SCOPED_CAMPAIGNS_FIELD","CRM_LEAD_SOURCE_DIRECTION_FIELD","CRM_LEAD_STATUSES","crmLeadSourceKey","crmPicklistKey","EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","SCOPED_SEARCH_JOIN","listSelectCodec","CRM_LIST_SEARCH","crmAnyOf","crmClauseValues","crmSelectField","isLeadSourceDirection","LEAD_SOURCE_DIRECTION_LABELS","LEAD_SOURCE_DIRECTIONS","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","nurturing","working","qualified","unqualified","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","LEAD_SOURCE_FILTER_NONE","LEAD_LIST_FILTER_FIELDS","column","kind","path","operators","LEAD_PICKLIST_FILTERS","picklistId","header","LEAD_LIST_FILTER_HEADERS","status","emailState","ownerUid","leadSource","leadSourceDirection","campaignIds","industry","rating","LEAD_STATUS_FILTER_OPTIONS","filter","option","map","value","label","LEAD_EMAIL_FILTER_OPTIONS","LEAD_SOURCE_DIRECTION_FILTER_OPTIONS","direction","leadStatusFilterOptions","picklist","named","labels","values","meaning","active","picklistMeaningName","length","join","STATUS_OPEN","field","op","STATUS_ALL","leadClausesForGrid","stored","find","clause","leadClausesToStore","shown","LEAD_CAMPAIGN_CODEC","toItem","operator","toClause","item","LEAD_SOURCE_CODEC","LEAD_FILTER_CODECS","LEAD_LIST_SORTS","LEAD_QUERY_FIELDS","presence","tokensPath","LEAD_LIST_DECLARATION","fields","sorts","search","LEAD_PREFIX_SEARCH","lowerPath","notice","leadClauseImpliesScope","EMAIL_PROBLEMS","ORG_READER","scopeTokens","foldsScope","leadQueryClause","reader","statuses","push","includes","refused","keys","key","Boolean","token","leadIndexShapes","equalities"],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmLeadFields, type CrmLeadStatus, type CrmPicklist } from '@aglyn/aglyn/app-utils/crm';
18
+ /**
19
+ * The meanings a person sets by hand from a lead's status select: every one
20
+ * but Qualified, which only a conversion sets (AGL-2608, AGL-3512).
21
+ */
22
+ export declare const LEAD_STATUS_HAND_SET: readonly CrmLeadStatus[];
23
+ /** One choice of a lead status select: the org's label, and the meaning it sets. */
24
+ export interface LeadStatusChoice {
25
+ label: string;
26
+ status: CrmLeadStatus;
27
+ inactive: boolean;
28
+ }
29
+ /** What a lead status select offers a lead: the org's active values of the hand-set meanings, and its own. */
30
+ export declare function leadStatusChoices(picklist: CrmPicklist, lead: Pick<CrmLeadFields, 'status' | 'statusLabel'>): LeadStatusChoice[];
31
+ /** The most a reason may say — shared with the bulk bar's one-reason-for-all (AGL-2662). */
32
+ export declare const UNQUALIFY_REASON_MAX = 500;
@@ -0,0 +1,31 @@
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 { crmLeadStatusOptions } from "@aglyn/aglyn/app-utils/crm";
17
+ /**
18
+ * The meanings a person sets by hand from a lead's status select: every one
19
+ * but Qualified, which only a conversion sets (AGL-2608, AGL-3512).
20
+ */ export const LEAD_STATUS_HAND_SET = [
21
+ 'new',
22
+ 'nurturing',
23
+ 'working',
24
+ 'unqualified'
25
+ ];
26
+ /** What a lead status select offers a lead: the org's active values of the hand-set meanings, and its own. */ export function leadStatusChoices(picklist, lead) {
27
+ return crmLeadStatusOptions(picklist, LEAD_STATUS_HAND_SET, lead);
28
+ }
29
+ /** The most a reason may say — shared with the bulk bar's one-reason-for-all (AGL-2662). */ export const UNQUALIFY_REASON_MAX = 500;
30
+
31
+ //# sourceMappingURL=lead-status-choices.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-status-choices.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 */\nimport {\n type CrmLeadFields,\n type CrmLeadStatus,\n type CrmPicklist,\n crmLeadStatusOptions,\n} from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The meanings a person sets by hand from a lead's status select: every one\n * but Qualified, which only a conversion sets (AGL-2608, AGL-3512).\n */\nexport const LEAD_STATUS_HAND_SET: readonly CrmLeadStatus[] = [\n 'new',\n 'nurturing',\n 'working',\n 'unqualified',\n]\n\n/** One choice of a lead status select: the org's label, and the meaning it sets. */\nexport interface LeadStatusChoice {\n label: string\n status: CrmLeadStatus\n inactive: boolean\n}\n\n/** What a lead status select offers a lead: the org's active values of the hand-set meanings, and its own. */\nexport function leadStatusChoices(\n picklist: CrmPicklist,\n lead: Pick<CrmLeadFields, 'status' | 'statusLabel'>,\n): LeadStatusChoice[] {\n return crmLeadStatusOptions(picklist, LEAD_STATUS_HAND_SET, lead)\n}\n\n/** The most a reason may say — shared with the bulk bar's one-reason-for-all (AGL-2662). */\nexport const UNQUALIFY_REASON_MAX = 500\n"],"names":["crmLeadStatusOptions","LEAD_STATUS_HAND_SET","leadStatusChoices","picklist","lead","UNQUALIFY_REASON_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,SAIEA,oBAAoB,QACf,6BAA4B;AAEnC;;;CAGC,GACD,OAAO,MAAMC,uBAAiD;IAC5D;IACA;IACA;IACA;CACD,CAAA;AASD,4GAA4G,GAC5G,OAAO,SAASC,kBACdC,QAAqB,EACrBC,IAAmD;IAEnD,OAAOJ,qBAAqBG,UAAUF,sBAAsBG;AAC9D;AAEA,0FAA0F,GAC1F,OAAO,MAAMC,uBAAuB,IAAG"}
@@ -15,8 +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
+ import type { ListFilterField } from '@aglyn/shared-util-tools/list-query/list-filter';
19
+ import type { ListQueryDeclaration, ListQueryFilter, ListQuerySort } from '@aglyn/shared-util-tools/list-query/list-query-plan';
20
20
  import type { CrmRoutes } from './crm-routes';
21
21
  /**
22
22
  * The tasks section's views and the query each one is (AGL-2599).
@@ -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 { 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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"}
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-util-tools/list-query/list-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-util-tools/list-query/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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"}
@@ -63,6 +63,33 @@ export interface CrmPersonRecordsDeps {
63
63
  }>;
64
64
  }
65
65
  export declare function defaultCrmPersonRecordsDeps(): CrmPersonRecordsDeps;
66
+ /** The most people one search answers, whatever the caller asks for. */
67
+ export declare const CRM_PERSON_SEARCH_MAX = 25;
68
+ /**
69
+ * The one token a typed search asks the contacts' search index for
70
+ * (AGL-3609), or `''` for nothing searchable.
71
+ *
72
+ * A run of digits and phone punctuation is a phone number, read as its
73
+ * digits: `crmPhoneSearchWords` stores the whole number, the number without
74
+ * a country code, its last seven and its last four, so `(555) 123-4567`,
75
+ * `5551234567` and `4567` all find it. Anything else is a name or an
76
+ * address, and the LONGEST word asks — it is the most selective one, and a
77
+ * stored token is a prefix of a word, so `dana@acme.com` and `dana` both
78
+ * reach the contact. Capped at the twelve characters a token keeps.
79
+ */
80
+ export declare function crmPersonSearchWord(text: unknown): string;
81
+ /**
82
+ * A contacts walk's cursor (AGL-3639): the last contact's `updatedAt` to the
83
+ * nanosecond, and its id for the tie. Opaque to every caller; a millisecond
84
+ * alone would answer a contact written later in the same millisecond twice.
85
+ */
86
+ export declare function encodeContactChangesCursor(updatedAt: unknown, id: string): string | null;
87
+ /** Reads a cursor this module handed out, or `null` for anything else. */
88
+ export declare function decodeContactChangesCursor(cursor: string | null | undefined): {
89
+ seconds: number;
90
+ nanoseconds: number;
91
+ id: string;
92
+ } | null;
66
93
  export declare function createCrmPersonRecords(deps: CrmPersonRecordsDeps): PluginPersonRecords;
67
94
  /** The service as the CRM registers it, its storage the admin app's. */
68
95
  export declare const crmPersonRecords: Required<PluginPersonRecords>;
@@ -14,9 +14,10 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { containerMembershipField, contactContainerFieldPath, normalizeContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
17
- import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
18
- import { CRM_COLLECTIONS, crmLeadStatus, crmViewIsListed, isCrmLeadOpen, normalizeCrmViewFilters } from "@aglyn/aglyn/app-utils/crm";
17
+ import { normalizeContactEmail, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { CRM_COLLECTIONS, CRM_SCOPED_SEARCH_JOIN, CRM_SCOPED_SEARCH_TOKENS_FIELD, crmLeadStatus, crmViewIsListed, isCrmLeadOpen, normalizeCrmViewFilters } from "@aglyn/aglyn/app-utils/crm";
19
19
  import { dynamicListDimensionsForCrmView } from "@aglyn/aglyn/app-utils/dynamic-list-rule";
20
+ import { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from "@aglyn/aglyn/app-utils/name-search";
20
21
  import { personKey } from "@aglyn/aglyn/app-utils/person-key";
21
22
  import { scopeTokensForHost, seenOnlyThroughGrant, visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
22
23
  import { consentGroupForSite, firebaseAdmin, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
@@ -25,7 +26,7 @@ import { consentGroupForSite, firebaseAdmin, resolveOrgIdForHost } from "@aglyn/
25
26
  import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
26
27
  import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
27
28
  import { collectDynamicListCandidates } from "@aglyn/tenant-data-admin/server/dynamic-list-materialize";
28
- import { FieldValue } from "firebase-admin/firestore";
29
+ import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
29
30
  import { recordPersonRefund } from "./person-refund.js";
30
31
  /** The person kinds, as `plugin-record-routes` addresses them. */ export const CRM_PERSON_KINDS = {
31
32
  contact: 'contact',
@@ -88,6 +89,55 @@ function personOf(kind, snapshot) {
88
89
  if (!resolved) throw new Error(`[crm] no organization to find a person in for host ${hostId || '(none)'}`);
89
90
  return resolved;
90
91
  }
92
+ /** The most people one search answers, whatever the caller asks for. */ export const CRM_PERSON_SEARCH_MAX = 25;
93
+ /**
94
+ * The one token a typed search asks the contacts' search index for
95
+ * (AGL-3609), or `''` for nothing searchable.
96
+ *
97
+ * A run of digits and phone punctuation is a phone number, read as its
98
+ * digits: `crmPhoneSearchWords` stores the whole number, the number without
99
+ * a country code, its last seven and its last four, so `(555) 123-4567`,
100
+ * `5551234567` and `4567` all find it. Anything else is a name or an
101
+ * address, and the LONGEST word asks — it is the most selective one, and a
102
+ * stored token is a prefix of a word, so `dana@acme.com` and `dana` both
103
+ * reach the contact. Capped at the twelve characters a token keeps.
104
+ */ export function crmPersonSearchWord(text) {
105
+ const raw = typeof text === 'string' ? text.trim() : '';
106
+ if (!raw) return '';
107
+ if (/^[\d\s()+.-]+$/.test(raw)) {
108
+ const digits = raw.replace(/\D/g, '');
109
+ return digits.length >= 4 ? digits.slice(0, NAME_TOKEN_MAX_PREFIX) : '';
110
+ }
111
+ const words = nameSearchKey(raw).split(' ').filter(Boolean);
112
+ const longest = words.reduce((best, word)=>word.length > best.length ? word : best, '');
113
+ return longest.slice(0, NAME_TOKEN_MAX_PREFIX);
114
+ }
115
+ /**
116
+ * A contacts walk's cursor (AGL-3639): the last contact's `updatedAt` to the
117
+ * nanosecond, and its id for the tie. Opaque to every caller; a millisecond
118
+ * alone would answer a contact written later in the same millisecond twice.
119
+ */ export function encodeContactChangesCursor(updatedAt, id) {
120
+ var _ref;
121
+ const stamp = updatedAt;
122
+ const seconds = Number(stamp == null ? void 0 : stamp.seconds);
123
+ const nanoseconds = Number((_ref = stamp == null ? void 0 : stamp.nanoseconds) != null ? _ref : 0);
124
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanoseconds) || !id) return null;
125
+ return `${Math.trunc(seconds)}.${Math.trunc(nanoseconds)}.${id}`;
126
+ }
127
+ /** Reads a cursor this module handed out, or `null` for anything else. */ export function decodeContactChangesCursor(cursor) {
128
+ const match = /^(\d{1,12})\.(\d{1,9})\.([A-Za-z0-9_-]{1,200})$/.exec(String(cursor != null ? cursor : ''));
129
+ if (!match) return null;
130
+ return {
131
+ seconds: Number(match[1]),
132
+ nanoseconds: Number(match[2]),
133
+ id: match[3]
134
+ };
135
+ }
136
+ const millisOf = (value)=>{
137
+ const stamp = value;
138
+ return typeof (stamp == null ? void 0 : stamp.toMillis) === 'function' ? stamp.toMillis() : 0;
139
+ };
140
+ const integerOrNull = (value)=>typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
91
141
  export function createCrmPersonRecords(deps) {
92
142
  return {
93
143
  /**
@@ -284,6 +334,59 @@ export function createCrmPersonRecords(deps) {
284
334
  };
285
335
  },
286
336
  /**
337
+ * The contacts a site may see, oldest change first (AGL-3639): the walk a
338
+ * connector copies people out by. On the Contacts list's own index
339
+ * (`visibleTo` array-contains-any, `updatedAt` ascending), with the id as
340
+ * the tie-break. A contact the site sees only through a sharing grant is
341
+ * read and stepped over — another holder's person is not this site's to
342
+ * export — and the cursor still moves past it. The profile is the site's
343
+ * consent group's facet, never another holder's.
344
+ */ async changedSince (request) {
345
+ var _request_hostId;
346
+ const hostId = String((_request_hostId = request.hostId) != null ? _request_hostId : '').trim();
347
+ if (!hostId) return {
348
+ people: [],
349
+ next: null
350
+ };
351
+ const orgId = await orgOf(deps, request);
352
+ const groupId = await deps.groupIdForHost(hostId);
353
+ const after = decodeContactChangesCursor(request.after);
354
+ let query = deps.firestore().collection('orgs').doc(orgId).collection(STORED_IN[CRM_PERSON_KINDS.contact]).where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)).orderBy('updatedAt', 'asc').orderBy(FieldPath.documentId(), 'asc');
355
+ if (after) {
356
+ query = query.startAfter(new Timestamp(after.seconds, after.nanoseconds), after.id);
357
+ }
358
+ const snapshot = await query.limit(Math.max(1, Math.min(500, request.limit))).get();
359
+ const people = [];
360
+ let next = null;
361
+ for (const doc of snapshot.docs){
362
+ var _doc_data, _encodeContactChangesCursor;
363
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
364
+ next = (_encodeContactChangesCursor = encodeContactChangesCursor(data['updatedAt'], doc.id)) != null ? _encodeContactChangesCursor : next;
365
+ if (seenOnlyThroughGrant(data, hostId)) continue;
366
+ const email = normalizeContactEmail(data['email']);
367
+ if (!email) continue;
368
+ const facet = readContactFacet(data, groupId);
369
+ people.push({
370
+ kind: CRM_PERSON_KINDS.contact,
371
+ id: doc.id,
372
+ email,
373
+ data,
374
+ changedAtMs: millisOf(data['updatedAt']),
375
+ profile: {
376
+ name: typeof facet.name === 'string' && facet.name.trim() ? facet.name.trim() : null,
377
+ phone: typeof facet.phone === 'string' && facet.phone.trim() ? facet.phone.trim() : null,
378
+ tags: Array.isArray(facet.tags) ? facet.tags.filter((tag)=>typeof tag === 'string') : [],
379
+ lifetimeValueCents: integerOrNull(facet.ltvCents),
380
+ ordersCount: integerOrNull(facet.ordersCount)
381
+ }
382
+ });
383
+ }
384
+ return {
385
+ people,
386
+ next
387
+ };
388
+ },
389
+ /**
287
390
  * Whether each person has ever written in: an inbound email on their
288
391
  * timeline, filed on the contact, or on the lead while they were one
289
392
  * (AGL-3234). One keyed query each; a failed one is unknown.
@@ -302,6 +405,24 @@ export function createCrmPersonRecords(deps) {
302
405
  return null;
303
406
  }
304
407
  }));
408
+ },
409
+ /**
410
+ * Contacts the site may see whose name, address, company or phone
411
+ * starts with the typed word (AGL-3609): ONE query on the scoped search
412
+ * tokens every contact writer stamps, `array-contains-any` over the
413
+ * site's scope tokens joined to the word — the same clause the Contacts
414
+ * list runs under a site, so a record the site cannot see is never
415
+ * answered and no index beyond the field's own is needed.
416
+ */ async search (request) {
417
+ var _request_hostId;
418
+ const word = crmPersonSearchWord(request.text);
419
+ const hostId = String((_request_hostId = request.hostId) != null ? _request_hostId : '').trim();
420
+ const limit = Math.min(CRM_PERSON_SEARCH_MAX, Math.max(0, Math.floor(Number(request.limit) || 0)));
421
+ if (!word || !hostId || !limit) return [];
422
+ const orgId = await orgOf(deps, request);
423
+ const tokens = scopeTokensForHost(hostId).map((scope)=>`${scope}${CRM_SCOPED_SEARCH_JOIN}${word}`);
424
+ const snapshot = await deps.firestore().collection('orgs').doc(orgId).collection(STORED_IN[CRM_PERSON_KINDS.contact]).where(CRM_SCOPED_SEARCH_TOKENS_FIELD, 'array-contains-any', tokens).limit(limit).get();
425
+ return snapshot.docs.map((doc)=>personOf(CRM_PERSON_KINDS.contact, doc));
305
426
  }
306
427
  };
307
428
  }
@@ -311,7 +432,9 @@ export function createCrmPersonRecords(deps) {
311
432
  fileUnder: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).fileUnder(request),
312
433
  recordRefund: (request)=>recordPersonRefund(request),
313
434
  peopleInView: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).peopleInView(request),
314
- wroteIn: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).wroteIn(request)
435
+ wroteIn: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).wroteIn(request),
436
+ search: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).search(request),
437
+ changedSince: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).changedSince(request)
315
438
  };
316
439
 
317
440
  //# sourceMappingURL=person-records.js.map