@aglyn/plugins-crm 1.0.0-beta.164 → 1.0.0-beta.166

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 (71) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +21 -5
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/contact-field-drawer.js +19 -2
  5. package/src/lib/components/contact-field-drawer.js.map +1 -1
  6. package/src/lib/components/crm-console-page.js +12 -7
  7. package/src/lib/components/crm-console-page.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +4 -2
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/fields-section.d.ts +2 -1
  11. package/src/lib/components/fields-section.js +6 -3
  12. package/src/lib/components/fields-section.js.map +1 -1
  13. package/src/lib/components/lead-campaigns-card.d.ts +59 -0
  14. package/src/lib/components/lead-campaigns-card.js +166 -0
  15. package/src/lib/components/lead-campaigns-card.js.map +1 -0
  16. package/src/lib/components/lead-detail-page.js +54 -4
  17. package/src/lib/components/lead-detail-page.js.map +1 -1
  18. package/src/lib/components/lead-history-card.js +5 -0
  19. package/src/lib/components/lead-history-card.js.map +1 -1
  20. package/src/lib/components/lead-properties-card.d.ts +12 -0
  21. package/src/lib/components/lead-properties-card.js +64 -6
  22. package/src/lib/components/lead-properties-card.js.map +1 -1
  23. package/src/lib/components/lead-unqualify-dialog.js +7 -1
  24. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  25. package/src/lib/components/leads-bulk-bar.d.ts +7 -2
  26. package/src/lib/components/leads-bulk-bar.js +42 -6
  27. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  28. package/src/lib/components/leads-section.js +43 -17
  29. package/src/lib/components/leads-section.js.map +1 -1
  30. package/src/lib/components/new-lead-drawer.d.ts +14 -1
  31. package/src/lib/components/new-lead-drawer.js +41 -3
  32. package/src/lib/components/new-lead-drawer.js.map +1 -1
  33. package/src/lib/components/reports/lead-counts.d.ts +16 -9
  34. package/src/lib/components/reports/lead-counts.js +29 -18
  35. package/src/lib/components/reports/lead-counts.js.map +1 -1
  36. package/src/lib/components/reports/lead-funnel-card.js +44 -40
  37. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  38. package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
  39. package/src/lib/hooks/use-campaign-filing-log.js +91 -0
  40. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
  41. package/src/lib/hooks/use-org-leads.d.ts +29 -32
  42. package/src/lib/hooks/use-org-leads.js +58 -68
  43. package/src/lib/hooks/use-org-leads.js.map +1 -1
  44. package/src/lib/model/campaign-filing-activity.d.ts +82 -0
  45. package/src/lib/model/campaign-filing-activity.js +88 -0
  46. package/src/lib/model/campaign-filing-activity.js.map +1 -0
  47. package/src/lib/model/crm-routes.d.ts +9 -6
  48. package/src/lib/model/crm-routes.js +9 -6
  49. package/src/lib/model/crm-routes.js.map +1 -1
  50. package/src/lib/server/campaign-filing-activity.d.ts +79 -0
  51. package/src/lib/server/campaign-filing-activity.js +93 -0
  52. package/src/lib/server/campaign-filing-activity.js.map +1 -0
  53. package/src/lib/server/capture-contact.js +11 -5
  54. package/src/lib/server/capture-contact.js.map +1 -1
  55. package/src/lib/server/convert-open-lead.js +6 -2
  56. package/src/lib/server/convert-open-lead.js.map +1 -1
  57. package/src/lib/server/email-send.js +3 -3
  58. package/src/lib/server/email-send.js.map +1 -1
  59. package/src/lib/server/erase-person.js +25 -7
  60. package/src/lib/server/erase-person.js.map +1 -1
  61. package/src/lib/server/lead-campaign-carry.js +28 -2
  62. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  63. package/src/lib/server/lead-create.d.ts +15 -4
  64. package/src/lib/server/lead-create.js +75 -15
  65. package/src/lib/server/lead-create.js.map +1 -1
  66. package/src/lib/server/leads-import.js +2 -2
  67. package/src/lib/server/leads-import.js.map +1 -1
  68. package/src/lib/server/record-email-state.js +10 -0
  69. package/src/lib/server/record-email-state.js.map +1 -1
  70. package/src/lib/server/record-facts.js +3 -3
  71. package/src/lib/server/record-facts.js.map +1 -1
@@ -1,53 +1,50 @@
1
1
  import { type DocumentData } from 'firebase/firestore';
2
- /** One site's lead, as the organization-level list carries it. */
2
+ /** A lead, as the organization-level list carries it. */
3
3
  export interface OrgLeadRow extends DocumentData {
4
4
  /**
5
- * The grid's row id: `{hostId}/{leadId}`. A lead's own id is a PERSON
6
- * KEY — the same person on two sites has the same id on both — so the
7
- * document id alone cannot key a list that spans sites.
5
+ * The grid's row id, which is the lead's own document id.
6
+ *
7
+ * It used to be `{hostId}/{leadId}`, because a lead lived under its site
8
+ * and the same person on two sites was two documents with the SAME id —
9
+ * so the id alone could not key a list spanning sites. AGL-3275 made that
10
+ * one document, and the compound key went with it.
8
11
  */
9
12
  $id: string;
10
- /** `hosts/{hostId}/leads/{leadId}` — the document id. */
13
+ /** `orgs/{orgId}/leads/{leadId}` — the document id, a person key. */
11
14
  leadId: string;
12
- /** The site the lead lives under; what every write and link names. */
13
- hostId: string;
14
15
  }
15
16
  export interface OrgLeadsResult {
16
- /** Every site's window, merged and ordered newest-seen first, cut to the window. */
17
+ /** The window, ordered newest-seen first. */
17
18
  data: OrgLeadRow[];
18
- /** `loading` until every site has answered once; `error` when any refused. */
19
+ /** `loading` until the listener has answered once; `error` when it refused. */
19
20
  status: 'loading' | 'success' | 'error';
20
- /** Some site had more than its window — there are leads this list is not showing. */
21
+ /** There are leads beyond the window this list is showing. */
21
22
  truncated: boolean;
22
23
  }
23
24
  /**
24
- * THE ORGANIZATION'S LEADS, read a site at a time (AGL-2630).
25
+ * THE ORGANIZATION'S LEADS, in one listener (AGL-3275).
25
26
  *
26
- * A lead lives under its site `hosts/{hostId}/leads`, host-scoped by
27
- * PATHand there is no org-level collection to listen to, no `orgId` on
28
- * the document to group by, and no rule admitting a collection-group read.
29
- * So the org hub opens one listener per site the org has, each the exact
30
- * query the site's own Leads section runs, and merges the answers. An
31
- * org-wide member is a member of every site, which is what admits each
32
- * listener under the rules the site section already relies on; an org has
33
- * a handful of sites and at most thirty in a consent group, so the fan-out
34
- * is bounded by the org, not by the data.
27
+ * This opened one listener PER SITE and merged the answers, because a lead
28
+ * lived at `hosts/{hostId}/leads` host-scoped by path, with no org-level
29
+ * collection to listen to, no `orgId` on the document to group by, and no
30
+ * rule admitting a collection-group read. All three of those are now false:
31
+ * the collection is `orgs/{orgId}/leads` and a lead says who may see it.
35
32
  *
36
- * Each site's window is cut at `windowSize + 1`, the way the site section
37
- * cuts its own, and the merged list is cut again at `windowSize`: the list
38
- * is "the most recently seen across the org", and a site with a thousand
39
- * leads must not crowd out a site with ten. `truncated` says some site had
40
- * more, so the caption beneath the table can say the window is not the
41
- * whole collection.
33
+ * So the fan-out is gone, and with it the merge that had to cut each site's
34
+ * window at `windowSize + 1` and the whole list again, so that a site with a
35
+ * thousand leads could not crowd out a site with ten. One ordered query needs
36
+ * neither: `truncated` is simply "the window came back full".
42
37
  *
43
- * The listeners are torn down together when the site list changes a site
44
- * added or removed re-opens the set and nothing opens for an empty list,
45
- * which is also how the hook stays quiet under a site, where the section
46
- * hands it no sites at all.
38
+ * `visibleTo` is the caller's, and `null` means "ask for everything"which
39
+ * is what an org-wide member's listeners do, since the rules short-circuit on
40
+ * `isOrgWideMember()` and a clause would only narrow what they may already
41
+ * read. A scoped member passes their tokens. See `crmVisibleToClause`, which
42
+ * builds the same clause for every other CRM listener.
47
43
  */
48
44
  export declare function useOrgLeads(options: {
49
- /** The org's sites, by document id; empty opens nothing. */
50
- hostIds: readonly string[];
45
+ orgId: string | null | undefined;
46
+ /** The scope clause, or `null` for an unscoped org-wide read. */
47
+ visibleTo: readonly string[] | null;
51
48
  windowSize: number;
52
49
  }): OrgLeadsResult;
53
50
  export default useOrgLeads;
@@ -16,97 +16,87 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { useFirestore } from "@aglyn/tenant-feature-instance";
19
- import { collection, limit, onSnapshot, orderBy, query } from "firebase/firestore";
19
+ import { collection, limit, onSnapshot, orderBy, query, where } from "firebase/firestore";
20
20
  import { useEffect, useMemo, useState } from "react";
21
21
  /**
22
- * THE ORGANIZATION'S LEADS, read a site at a time (AGL-2630).
22
+ * THE ORGANIZATION'S LEADS, in one listener (AGL-3275).
23
23
  *
24
- * A lead lives under its site `hosts/{hostId}/leads`, host-scoped by
25
- * PATHand there is no org-level collection to listen to, no `orgId` on
26
- * the document to group by, and no rule admitting a collection-group read.
27
- * So the org hub opens one listener per site the org has, each the exact
28
- * query the site's own Leads section runs, and merges the answers. An
29
- * org-wide member is a member of every site, which is what admits each
30
- * listener under the rules the site section already relies on; an org has
31
- * a handful of sites and at most thirty in a consent group, so the fan-out
32
- * is bounded by the org, not by the data.
24
+ * This opened one listener PER SITE and merged the answers, because a lead
25
+ * lived at `hosts/{hostId}/leads` host-scoped by path, with no org-level
26
+ * collection to listen to, no `orgId` on the document to group by, and no
27
+ * rule admitting a collection-group read. All three of those are now false:
28
+ * the collection is `orgs/{orgId}/leads` and a lead says who may see it.
33
29
  *
34
- * Each site's window is cut at `windowSize + 1`, the way the site section
35
- * cuts its own, and the merged list is cut again at `windowSize`: the list
36
- * is "the most recently seen across the org", and a site with a thousand
37
- * leads must not crowd out a site with ten. `truncated` says some site had
38
- * more, so the caption beneath the table can say the window is not the
39
- * whole collection.
30
+ * So the fan-out is gone, and with it the merge that had to cut each site's
31
+ * window at `windowSize + 1` and the whole list again, so that a site with a
32
+ * thousand leads could not crowd out a site with ten. One ordered query needs
33
+ * neither: `truncated` is simply "the window came back full".
40
34
  *
41
- * The listeners are torn down together when the site list changes a site
42
- * added or removed re-opens the set and nothing opens for an empty list,
43
- * which is also how the hook stays quiet under a site, where the section
44
- * hands it no sites at all.
35
+ * `visibleTo` is the caller's, and `null` means "ask for everything"which
36
+ * is what an org-wide member's listeners do, since the rules short-circuit on
37
+ * `isOrgWideMember()` and a clause would only narrow what they may already
38
+ * read. A scoped member passes their tokens. See `crmVisibleToClause`, which
39
+ * builds the same clause for every other CRM listener.
45
40
  */ export function useOrgLeads(options) {
46
- const { hostIds, windowSize } = options;
41
+ const { orgId, visibleTo, windowSize } = options;
47
42
  const firestore = useFirestore();
48
- const key = hostIds.join('\n');
49
- const [bySite, setBySite] = useState({});
43
+ const scopeKey = visibleTo ? visibleTo.join('\n') : null;
44
+ const [answer, setAnswer] = useState(null);
50
45
  useEffect(()=>{
51
- setBySite({});
52
- const sites = key ? key.split('\n') : [];
53
- if (!sites.length) return undefined;
54
- const stops = sites.map((hostId)=>onSnapshot(query(collection(firestore, 'hosts', hostId, 'leads'), orderBy('lastSeenAtMs', 'desc'), limit(windowSize + 1)), (snapshot)=>{
55
- const rows = snapshot.docs.map((entry)=>_extends({}, entry.data(), {
56
- $id: `${hostId}/${entry.id}`,
57
- leadId: entry.id,
58
- hostId
59
- }));
60
- setBySite((current)=>_extends({}, current, {
61
- [hostId]: {
62
- rows: rows.slice(0, windowSize),
63
- truncated: rows.length > windowSize
64
- }
65
- }));
66
- }, (error)=>{
67
- console.error(error);
68
- setBySite((current)=>_extends({}, current, {
69
- [hostId]: 'error'
70
- }));
71
- }));
72
- return ()=>{
73
- for (const stop of stops)stop();
74
- };
46
+ setAnswer(null);
47
+ if (!orgId) return undefined;
48
+ // An `array-contains-any` over nothing is a query Firestore refuses, so a
49
+ // scoped member with no tokens lists nothing rather than asking.
50
+ const tokens = scopeKey === null ? null : scopeKey ? scopeKey.split('\n') : [];
51
+ if (tokens && !tokens.length) {
52
+ setAnswer({
53
+ rows: [],
54
+ truncated: false
55
+ });
56
+ return undefined;
57
+ }
58
+ const clauses = tokens ? [
59
+ where('visibleTo', 'array-contains-any', tokens)
60
+ ] : [];
61
+ return onSnapshot(query(collection(firestore, 'orgs', orgId, 'leads'), ...clauses, orderBy('lastSeenAtMs', 'desc'), limit(windowSize + 1)), (snapshot)=>{
62
+ const rows = snapshot.docs.map((entry)=>_extends({}, entry.data(), {
63
+ $id: entry.id,
64
+ leadId: entry.id
65
+ }));
66
+ setAnswer({
67
+ rows: rows.slice(0, windowSize),
68
+ truncated: rows.length > windowSize
69
+ });
70
+ }, (error)=>{
71
+ console.error(error);
72
+ setAnswer('error');
73
+ });
75
74
  }, [
76
75
  firestore,
77
- key,
76
+ orgId,
77
+ scopeKey,
78
78
  windowSize
79
79
  ]);
80
80
  return useMemo(()=>{
81
- const sites = key ? key.split('\n') : [];
82
- const answers = sites.map((hostId)=>bySite[hostId]);
83
- if (answers.some((answer)=>answer === 'error')) {
81
+ if (answer === 'error') {
84
82
  return {
85
83
  data: [],
86
84
  status: 'error',
87
85
  truncated: false
88
86
  };
89
87
  }
90
- if (!sites.length || answers.some((answer)=>!answer)) {
91
- return {
92
- data: [],
93
- status: 'loading',
94
- truncated: false
95
- };
96
- }
97
- const merged = answers.flatMap((answer)=>answer === 'error' || !answer ? [] : answer.rows).sort((a, b)=>{
98
- var _b_lastSeenAtMs, _a_lastSeenAtMs;
99
- return Number((_b_lastSeenAtMs = b['lastSeenAtMs']) != null ? _b_lastSeenAtMs : 0) - Number((_a_lastSeenAtMs = a['lastSeenAtMs']) != null ? _a_lastSeenAtMs : 0);
100
- });
88
+ if (!answer) return {
89
+ data: [],
90
+ status: 'loading',
91
+ truncated: false
92
+ };
101
93
  return {
102
- data: merged.slice(0, windowSize),
94
+ data: answer.rows,
103
95
  status: 'success',
104
- truncated: merged.length > windowSize || answers.some((answer)=>answer !== 'error' && (answer == null ? void 0 : answer.truncated))
96
+ truncated: answer.truncated
105
97
  };
106
98
  }, [
107
- bySite,
108
- key,
109
- windowSize
99
+ answer
110
100
  ]);
111
101
  }
112
102
  export default useOrgLeads;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-org-leads.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'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n onSnapshot,\n orderBy,\n query,\n type DocumentData,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/** One site's lead, as the organization-level list carries it. */\nexport interface OrgLeadRow extends DocumentData {\n /**\n * The grid's row id: `{hostId}/{leadId}`. A lead's own id is a PERSON\n * KEY the same person on two sites has the same id on both so the\n * document id alone cannot key a list that spans sites.\n */\n $id: string\n /** `hosts/{hostId}/leads/{leadId}` — the document id. */\n leadId: string\n /** The site the lead lives under; what every write and link names. */\n hostId: string\n}\n\nexport interface OrgLeadsResult {\n /** Every site's window, merged and ordered newest-seen first, cut to the window. */\n data: OrgLeadRow[]\n /** `loading` until every site has answered once; `error` when any refused. */\n status: 'loading' | 'success' | 'error'\n /** Some site had more than its window — there are leads this list is not showing. */\n truncated: boolean\n}\n\n/**\n * THE ORGANIZATION'S LEADS, read a site at a time (AGL-2630).\n *\n * A lead lives under its site `hosts/{hostId}/leads`, host-scoped by\n * PATH — and there is no org-level collection to listen to, no `orgId` on\n * the document to group by, and no rule admitting a collection-group read.\n * So the org hub opens one listener per site the org has, each the exact\n * query the site's own Leads section runs, and merges the answers. An\n * org-wide member is a member of every site, which is what admits each\n * listener under the rules the site section already relies on; an org has\n * a handful of sites and at most thirty in a consent group, so the fan-out\n * is bounded by the org, not by the data.\n *\n * Each site's window is cut at `windowSize + 1`, the way the site section\n * cuts its own, and the merged list is cut again at `windowSize`: the list\n * is \"the most recently seen across the org\", and a site with a thousand\n * leads must not crowd out a site with ten. `truncated` says some site had\n * more, so the caption beneath the table can say the window is not the\n * whole collection.\n *\n * The listeners are torn down together when the site list changes a site\n * added or removed re-opens the set and nothing opens for an empty list,\n * which is also how the hook stays quiet under a site, where the section\n * hands it no sites at all.\n */\nexport function useOrgLeads(options: {\n /** The org's sites, by document id; empty opens nothing. */\n hostIds: readonly string[]\n windowSize: number\n}): OrgLeadsResult {\n const { hostIds, windowSize } = options\n const firestore = useFirestore()\n const key = hostIds.join('\\n')\n const [bySite, setBySite] = useState<\n Record<string, { rows: OrgLeadRow[]; truncated: boolean } | 'error'>\n >({})\n\n useEffect(() => {\n setBySite({})\n const sites = key ? key.split('\\n') : []\n if (!sites.length) return undefined\n const stops = sites.map((hostId) =>\n onSnapshot(\n query(\n collection(firestore, 'hosts', hostId, 'leads'),\n orderBy('lastSeenAtMs', 'desc'),\n limit(windowSize + 1),\n ),\n (snapshot) => {\n const rows = snapshot.docs.map((entry) => ({\n ...(entry.data() as DocumentData),\n $id: `${hostId}/${entry.id}`,\n leadId: entry.id,\n hostId,\n }))\n setBySite((current) => ({\n ...current,\n [hostId]: {\n rows: rows.slice(0, windowSize),\n truncated: rows.length > windowSize,\n },\n }))\n },\n (error) => {\n console.error(error)\n setBySite((current) => ({ ...current, [hostId]: 'error' }))\n },\n ),\n )\n return () => {\n for (const stop of stops) stop()\n }\n }, [firestore, key, windowSize])\n\n return useMemo(() => {\n const sites = key ? key.split('\\n') : []\n const answers = sites.map((hostId) => bySite[hostId])\n if (answers.some((answer) => answer === 'error')) {\n return { data: [], status: 'error' as const, truncated: false }\n }\n if (!sites.length || answers.some((answer) => !answer)) {\n return { data: [], status: 'loading' as const, truncated: false }\n }\n const merged = answers\n .flatMap((answer) => (answer === 'error' || !answer ? [] : answer.rows))\n .sort(\n (a, b) =>\n Number(b['lastSeenAtMs'] ?? 0) - Number(a['lastSeenAtMs'] ?? 0),\n )\n return {\n data: merged.slice(0, windowSize),\n status: 'success' as const,\n truncated:\n merged.length > windowSize ||\n answers.some((answer) => answer !== 'error' && answer?.truncated),\n }\n }, [bySite, key, windowSize])\n}\n\nexport default useOrgLeads\n"],"names":["useFirestore","collection","limit","onSnapshot","orderBy","query","useEffect","useMemo","useState","useOrgLeads","options","hostIds","windowSize","firestore","key","join","bySite","setBySite","sites","split","length","undefined","stops","map","hostId","snapshot","rows","docs","entry","data","$id","id","leadId","current","slice","truncated","error","console","stop","answers","some","answer","status","merged","flatMap","sort","a","b","Number"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,UAAU,EACVC,KAAK,EACLC,UAAU,EACVC,OAAO,EACPC,KAAK,QAEA,qBAAoB;AAC3B,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AAyBpD;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,YAAYC,OAI3B;IACC,MAAM,EAAEC,OAAO,EAAEC,UAAU,EAAE,GAAGF;IAChC,MAAMG,YAAYb;IAClB,MAAMc,MAAMH,QAAQI,IAAI,CAAC;IACzB,MAAM,CAACC,QAAQC,UAAU,GAAGT,SAE1B,CAAC;IAEHF,UAAU;QACRW,UAAU,CAAC;QACX,MAAMC,QAAQJ,MAAMA,IAAIK,KAAK,CAAC,QAAQ,EAAE;QACxC,IAAI,CAACD,MAAME,MAAM,EAAE,OAAOC;QAC1B,MAAMC,QAAQJ,MAAMK,GAAG,CAAC,CAACC,SACvBrB,WACEE,MACEJ,WAAWY,WAAW,SAASW,QAAQ,UACvCpB,QAAQ,gBAAgB,SACxBF,MAAMU,aAAa,KAErB,CAACa;gBACC,MAAMC,OAAOD,SAASE,IAAI,CAACJ,GAAG,CAAC,CAACK,QAAW,aACrCA,MAAMC,IAAI;wBACdC,KAAK,GAAGN,OAAO,CAAC,EAAEI,MAAMG,EAAE,EAAE;wBAC5BC,QAAQJ,MAAMG,EAAE;wBAChBP;;gBAEFP,UAAU,CAACgB,UAAa,aACnBA;wBACH,CAACT,OAAO,EAAE;4BACRE,MAAMA,KAAKQ,KAAK,CAAC,GAAGtB;4BACpBuB,WAAWT,KAAKN,MAAM,GAAGR;wBAC3B;;YAEJ,GACA,CAACwB;gBACCC,QAAQD,KAAK,CAACA;gBACdnB,UAAU,CAACgB,UAAa,aAAKA;wBAAS,CAACT,OAAO,EAAE;;YAClD;QAGJ,OAAO;YACL,KAAK,MAAMc,QAAQhB,MAAOgB;QAC5B;IACF,GAAG;QAACzB;QAAWC;QAAKF;KAAW;IAE/B,OAAOL,QAAQ;QACb,MAAMW,QAAQJ,MAAMA,IAAIK,KAAK,CAAC,QAAQ,EAAE;QACxC,MAAMoB,UAAUrB,MAAMK,GAAG,CAAC,CAACC,SAAWR,MAAM,CAACQ,OAAO;QACpD,IAAIe,QAAQC,IAAI,CAAC,CAACC,SAAWA,WAAW,UAAU;YAChD,OAAO;gBAAEZ,MAAM,EAAE;gBAAEa,QAAQ;gBAAkBP,WAAW;YAAM;QAChE;QACA,IAAI,CAACjB,MAAME,MAAM,IAAImB,QAAQC,IAAI,CAAC,CAACC,SAAW,CAACA,SAAS;YACtD,OAAO;gBAAEZ,MAAM,EAAE;gBAAEa,QAAQ;gBAAoBP,WAAW;YAAM;QAClE;QACA,MAAMQ,SAASJ,QACZK,OAAO,CAAC,CAACH,SAAYA,WAAW,WAAW,CAACA,SAAS,EAAE,GAAGA,OAAOf,IAAI,EACrEmB,IAAI,CACH,CAACC,GAAGC;gBACKA,iBAAiCD;mBAAxCE,QAAOD,kBAAAA,CAAC,CAAC,eAAe,YAAjBA,kBAAqB,KAAKC,QAAOF,kBAAAA,CAAC,CAAC,eAAe,YAAjBA,kBAAqB;;QAEnE,OAAO;YACLjB,MAAMc,OAAOT,KAAK,CAAC,GAAGtB;YACtB8B,QAAQ;YACRP,WACEQ,OAAOvB,MAAM,GAAGR,cAChB2B,QAAQC,IAAI,CAAC,CAACC,SAAWA,WAAW,YAAWA,0BAAAA,OAAQN,SAAS;QACpE;IACF,GAAG;QAACnB;QAAQF;QAAKF;KAAW;AAC9B;AAEA,eAAeH,YAAW"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-org-leads.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'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n type DocumentData,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\n\n/** A lead, as the organization-level list carries it. */\nexport interface OrgLeadRow extends DocumentData {\n /**\n * The grid's row id, which is the lead's own document id.\n *\n * It used to be `{hostId}/{leadId}`, because a lead lived under its site\n * and the same person on two sites was two documents with the SAME id —\n * so the id alone could not key a list spanning sites. AGL-3275 made that\n * one document, and the compound key went with it.\n */\n $id: string\n /** `orgs/{orgId}/leads/{leadId}` — the document id, a person key. */\n leadId: string\n}\n\nexport interface OrgLeadsResult {\n /** The window, ordered newest-seen first. */\n data: OrgLeadRow[]\n /** `loading` until the listener has answered once; `error` when it refused. */\n status: 'loading' | 'success' | 'error'\n /** There are leads beyond the window this list is showing. */\n truncated: boolean\n}\n\n/**\n * THE ORGANIZATION'S LEADS, in one listener (AGL-3275).\n *\n * This opened one listener PER SITE and merged the answers, because a lead\n * lived at `hosts/{hostId}/leads` host-scoped by path, with no org-level\n * collection to listen to, no `orgId` on the document to group by, and no\n * rule admitting a collection-group read. All three of those are now false:\n * the collection is `orgs/{orgId}/leads` and a lead says who may see it.\n *\n * So the fan-out is gone, and with it the merge that had to cut each site's\n * window at `windowSize + 1` and the whole list again, so that a site with a\n * thousand leads could not crowd out a site with ten. One ordered query needs\n * neither: `truncated` is simply \"the window came back full\".\n *\n * `visibleTo` is the caller's, and `null` means \"ask for everything\"which\n * is what an org-wide member's listeners do, since the rules short-circuit on\n * `isOrgWideMember()` and a clause would only narrow what they may already\n * read. A scoped member passes their tokens. See `crmVisibleToClause`, which\n * builds the same clause for every other CRM listener.\n */\nexport function useOrgLeads(options: {\n orgId: string | null | undefined\n /** The scope clause, or `null` for an unscoped org-wide read. */\n visibleTo: readonly string[] | null\n windowSize: number\n}): OrgLeadsResult {\n const { orgId, visibleTo, windowSize } = options\n const firestore = useFirestore()\n const scopeKey = visibleTo ? visibleTo.join('\\n') : null\n const [answer, setAnswer] = useState<\n { rows: OrgLeadRow[]; truncated: boolean } | 'error' | null\n >(null)\n\n useEffect(() => {\n setAnswer(null)\n if (!orgId) return undefined\n // An `array-contains-any` over nothing is a query Firestore refuses, so a\n // scoped member with no tokens lists nothing rather than asking.\n const tokens = scopeKey === null ? null : scopeKey ? scopeKey.split('\\n') : []\n if (tokens && !tokens.length) {\n setAnswer({ rows: [], truncated: false })\n return undefined\n }\n const clauses: QueryConstraint[] = tokens\n ? [where('visibleTo', 'array-contains-any', tokens)]\n : []\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...clauses,\n orderBy('lastSeenAtMs', 'desc'),\n limit(windowSize + 1),\n ),\n (snapshot) => {\n const rows = snapshot.docs.map((entry) => ({\n ...(entry.data() as DocumentData),\n $id: entry.id,\n leadId: entry.id,\n }))\n setAnswer({\n rows: rows.slice(0, windowSize),\n truncated: rows.length > windowSize,\n })\n },\n (error) => {\n console.error(error)\n setAnswer('error')\n },\n )\n }, [firestore, orgId, scopeKey, windowSize])\n\n return useMemo(() => {\n if (answer === 'error') {\n return { data: [], status: 'error' as const, truncated: false }\n }\n if (!answer) return { data: [], status: 'loading' as const, truncated: false }\n return {\n data: answer.rows,\n status: 'success' as const,\n truncated: answer.truncated,\n }\n }, [answer])\n}\n\nexport default useOrgLeads\n"],"names":["useFirestore","collection","limit","onSnapshot","orderBy","query","where","useEffect","useMemo","useState","useOrgLeads","options","orgId","visibleTo","windowSize","firestore","scopeKey","join","answer","setAnswer","undefined","tokens","split","length","rows","truncated","clauses","snapshot","docs","map","entry","data","$id","id","leadId","slice","error","console","status"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,YAAY,QAAQ,iCAAgC;AAC7D,SACEC,UAAU,EACVC,KAAK,EACLC,UAAU,EACVC,OAAO,EACPC,KAAK,EACLC,KAAK,QAGA,qBAAoB;AAC3B,SAASC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AA0BpD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,YAAYC,OAK3B;IACC,MAAM,EAAEC,KAAK,EAAEC,SAAS,EAAEC,UAAU,EAAE,GAAGH;IACzC,MAAMI,YAAYf;IAClB,MAAMgB,WAAWH,YAAYA,UAAUI,IAAI,CAAC,QAAQ;IACpD,MAAM,CAACC,QAAQC,UAAU,GAAGV,SAE1B;IAEFF,UAAU;QACRY,UAAU;QACV,IAAI,CAACP,OAAO,OAAOQ;QACnB,0EAA0E;QAC1E,iEAAiE;QACjE,MAAMC,SAASL,aAAa,OAAO,OAAOA,WAAWA,SAASM,KAAK,CAAC,QAAQ,EAAE;QAC9E,IAAID,UAAU,CAACA,OAAOE,MAAM,EAAE;YAC5BJ,UAAU;gBAAEK,MAAM,EAAE;gBAAEC,WAAW;YAAM;YACvC,OAAOL;QACT;QACA,MAAMM,UAA6BL,SAC/B;YAACf,MAAM,aAAa,sBAAsBe;SAAQ,GAClD,EAAE;QACN,OAAOlB,WACLE,MACEJ,WAAWc,WAAW,QAAQH,OAAO,aAClCc,SACHtB,QAAQ,gBAAgB,SACxBF,MAAMY,aAAa,KAErB,CAACa;YACC,MAAMH,OAAOG,SAASC,IAAI,CAACC,GAAG,CAAC,CAACC,QAAW,aACrCA,MAAMC,IAAI;oBACdC,KAAKF,MAAMG,EAAE;oBACbC,QAAQJ,MAAMG,EAAE;;YAElBd,UAAU;gBACRK,MAAMA,KAAKW,KAAK,CAAC,GAAGrB;gBACpBW,WAAWD,KAAKD,MAAM,GAAGT;YAC3B;QACF,GACA,CAACsB;YACCC,QAAQD,KAAK,CAACA;YACdjB,UAAU;QACZ;IAEJ,GAAG;QAACJ;QAAWH;QAAOI;QAAUF;KAAW;IAE3C,OAAON,QAAQ;QACb,IAAIU,WAAW,SAAS;YACtB,OAAO;gBAAEa,MAAM,EAAE;gBAAEO,QAAQ;gBAAkBb,WAAW;YAAM;QAChE;QACA,IAAI,CAACP,QAAQ,OAAO;YAAEa,MAAM,EAAE;YAAEO,QAAQ;YAAoBb,WAAW;QAAM;QAC7E,OAAO;YACLM,MAAMb,OAAOM,IAAI;YACjBc,QAAQ;YACRb,WAAWP,OAAOO,SAAS;QAC7B;IACF,GAAG;QAACP;KAAO;AACb;AAEA,eAAeR,YAAW"}
@@ -0,0 +1,82 @@
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 { CrmActivity, CrmActivityLink, ScopeToken } from '@aglyn/aglyn';
18
+ /**
19
+ * WHAT A FILING WRITES ON THE RECORD'S TIMELINE (AGL-3274).
20
+ *
21
+ * A lead or a contact filed under a campaign — from its own page, the bulk
22
+ * bar, the New lead drawer, or by conversion carrying a lead's campaigns
23
+ * onto the contact it became — showed nothing of it in Activity, so the
24
+ * timeline read "email sent" with no line for the act that put the person
25
+ * in the campaign in the first place. Every door now files one entry per
26
+ * campaign changed, and this module is the one place their shape lives:
27
+ * the client hook and the server helper both build the document here, so
28
+ * an entry filed from the page and one filed by the conversion read alike.
29
+ *
30
+ * ## A `note`, not a kind of its own
31
+ *
32
+ * The timeline's kinds are what a PERSON logs — call, email, meeting, note
33
+ * — and a filing is bookkeeping beside them, the way a sequence's "Enrolled
34
+ * in" entry is. A `filing` kind would need its own label, glyph, report
35
+ * column and a place in the log dialog where a member could log one by
36
+ * hand, which is the one thing a filing must never be. So it is a note that
37
+ * names its author (the member, or the conversion), carries the CRM's own
38
+ * id as `sourcePluginId` the way a sequence's entries carry Outreach's, and
39
+ * names the campaign twice: by name in the body, by id in `campaignId`.
40
+ */
41
+ /** The kind every filing entry is filed as. */
42
+ export declare const CAMPAIGN_FILING_ACTIVITY_KIND: "note";
43
+ /** What the entry says was done: put in the campaign, or taken out of it. */
44
+ export type CampaignFilingAction = 'filed' | 'removed';
45
+ /** A campaign as a filing entry names it: the container's id and its name at the time. */
46
+ export interface CampaignFilingRef {
47
+ id: string;
48
+ name: string;
49
+ }
50
+ /**
51
+ * The author a conversion's carry signs its entries with: nobody in the
52
+ * room typed it, and "A team member" — the timeline's word for an
53
+ * anonymous entry — would credit a person with what the conversion did.
54
+ */
55
+ export declare const CAMPAIGN_FILING_CARRY_BY_NAME = "Lead conversion";
56
+ /** The one line of the entry, as the timeline shows it. */
57
+ export declare function campaignFilingBody(action: CampaignFilingAction, campaignName: string): string;
58
+ /**
59
+ * The membership a save changed, as the entries it owes: one `filed` per
60
+ * id the save added, one `removed` per id it dropped. Order-insensitive,
61
+ * like `campaignMembershipUnchanged`; a reorder owes nothing.
62
+ */
63
+ export declare function campaignFilingChanges(before: readonly string[], after: readonly string[]): {
64
+ added: string[];
65
+ removed: string[];
66
+ };
67
+ export interface CampaignFilingActivityInput {
68
+ action: CampaignFilingAction;
69
+ campaign: CampaignFilingRef;
70
+ link: CrmActivityLink;
71
+ /** The site the entry carries as provenance — the lead's site, the contact's viewing site. */
72
+ hostId: string;
73
+ visibleTo: readonly ScopeToken[];
74
+ /** When it happened, epoch ms. */
75
+ atMs: number;
76
+ /** The member who did it, or `''` for the conversion. */
77
+ byUid: string;
78
+ /** The member's name as it read, or the carry's fixed author; absent rather than blank. */
79
+ byName?: string | null;
80
+ }
81
+ /** The document a filing entry is stored as, minus the timestamps the writer stamps. */
82
+ export declare function buildCampaignFilingActivity(input: CampaignFilingActivityInput): CrmActivity;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
18
+ /**
19
+ * WHAT A FILING WRITES ON THE RECORD'S TIMELINE (AGL-3274).
20
+ *
21
+ * A lead or a contact filed under a campaign — from its own page, the bulk
22
+ * bar, the New lead drawer, or by conversion carrying a lead's campaigns
23
+ * onto the contact it became — showed nothing of it in Activity, so the
24
+ * timeline read "email sent" with no line for the act that put the person
25
+ * in the campaign in the first place. Every door now files one entry per
26
+ * campaign changed, and this module is the one place their shape lives:
27
+ * the client hook and the server helper both build the document here, so
28
+ * an entry filed from the page and one filed by the conversion read alike.
29
+ *
30
+ * ## A `note`, not a kind of its own
31
+ *
32
+ * The timeline's kinds are what a PERSON logs — call, email, meeting, note
33
+ * — and a filing is bookkeeping beside them, the way a sequence's "Enrolled
34
+ * in" entry is. A `filing` kind would need its own label, glyph, report
35
+ * column and a place in the log dialog where a member could log one by
36
+ * hand, which is the one thing a filing must never be. So it is a note that
37
+ * names its author (the member, or the conversion), carries the CRM's own
38
+ * id as `sourcePluginId` the way a sequence's entries carry Outreach's, and
39
+ * names the campaign twice: by name in the body, by id in `campaignId`.
40
+ */ /** The kind every filing entry is filed as. */ export const CAMPAIGN_FILING_ACTIVITY_KIND = 'note';
41
+ /**
42
+ * The author a conversion's carry signs its entries with: nobody in the
43
+ * room typed it, and "A team member" — the timeline's word for an
44
+ * anonymous entry — would credit a person with what the conversion did.
45
+ */ export const CAMPAIGN_FILING_CARRY_BY_NAME = 'Lead conversion';
46
+ /** The one line of the entry, as the timeline shows it. */ export function campaignFilingBody(action, campaignName) {
47
+ const name = campaignName.trim() || 'a campaign';
48
+ return action === 'filed' ? `Filed under ${name}` : `Removed from ${name}`;
49
+ }
50
+ /**
51
+ * The membership a save changed, as the entries it owes: one `filed` per
52
+ * id the save added, one `removed` per id it dropped. Order-insensitive,
53
+ * like `campaignMembershipUnchanged`; a reorder owes nothing.
54
+ */ export function campaignFilingChanges(before, after) {
55
+ return {
56
+ added: after.filter((id)=>!before.includes(id)),
57
+ removed: before.filter((id)=>!after.includes(id))
58
+ };
59
+ }
60
+ /** The document a filing entry is stored as, minus the timestamps the writer stamps. */ export function buildCampaignFilingActivity(input) {
61
+ var _input_byName;
62
+ const byName = String((_input_byName = input.byName) != null ? _input_byName : '').trim();
63
+ return _extends({
64
+ kind: CAMPAIGN_FILING_ACTIVITY_KIND,
65
+ body: campaignFilingBody(input.action, input.campaign.name),
66
+ atMs: input.atMs,
67
+ byUid: input.byUid
68
+ }, byName ? {
69
+ byName
70
+ } : {}, input.link.contactId ? {
71
+ contactId: input.link.contactId
72
+ } : {}, input.link.companyId ? {
73
+ companyId: input.link.companyId
74
+ } : {}, input.link.dealId ? {
75
+ dealId: input.link.dealId
76
+ } : {}, input.link.leadId ? {
77
+ leadId: input.link.leadId
78
+ } : {}, {
79
+ hostId: input.hostId,
80
+ visibleTo: [
81
+ ...input.visibleTo
82
+ ],
83
+ sourcePluginId: BUNDLE_ID,
84
+ campaignId: input.campaign.id
85
+ });
86
+ }
87
+
88
+ //# sourceMappingURL=campaign-filing-activity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/campaign-filing-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { CrmActivity, CrmActivityLink, ScopeToken } from '@aglyn/aglyn'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * WHAT A FILING WRITES ON THE RECORD'S TIMELINE (AGL-3274).\n *\n * A lead or a contact filed under a campaign — from its own page, the bulk\n * bar, the New lead drawer, or by conversion carrying a lead's campaigns\n * onto the contact it became — showed nothing of it in Activity, so the\n * timeline read \"email sent\" with no line for the act that put the person\n * in the campaign in the first place. Every door now files one entry per\n * campaign changed, and this module is the one place their shape lives:\n * the client hook and the server helper both build the document here, so\n * an entry filed from the page and one filed by the conversion read alike.\n *\n * ## A `note`, not a kind of its own\n *\n * The timeline's kinds are what a PERSON logs — call, email, meeting, note\n * — and a filing is bookkeeping beside them, the way a sequence's \"Enrolled\n * in\" entry is. A `filing` kind would need its own label, glyph, report\n * column and a place in the log dialog where a member could log one by\n * hand, which is the one thing a filing must never be. So it is a note that\n * names its author (the member, or the conversion), carries the CRM's own\n * id as `sourcePluginId` the way a sequence's entries carry Outreach's, and\n * names the campaign twice: by name in the body, by id in `campaignId`.\n */\n\n/** The kind every filing entry is filed as. */\nexport const CAMPAIGN_FILING_ACTIVITY_KIND = 'note' as const\n\n/** What the entry says was done: put in the campaign, or taken out of it. */\nexport type CampaignFilingAction = 'filed' | 'removed'\n\n/** A campaign as a filing entry names it: the container's id and its name at the time. */\nexport interface CampaignFilingRef {\n id: string\n name: string\n}\n\n/**\n * The author a conversion's carry signs its entries with: nobody in the\n * room typed it, and \"A team member\" — the timeline's word for an\n * anonymous entry — would credit a person with what the conversion did.\n */\nexport const CAMPAIGN_FILING_CARRY_BY_NAME = 'Lead conversion'\n\n/** The one line of the entry, as the timeline shows it. */\nexport function campaignFilingBody(action: CampaignFilingAction, campaignName: string): string {\n const name = campaignName.trim() || 'a campaign'\n return action === 'filed' ? `Filed under ${name}` : `Removed from ${name}`\n}\n\n/**\n * The membership a save changed, as the entries it owes: one `filed` per\n * id the save added, one `removed` per id it dropped. Order-insensitive,\n * like `campaignMembershipUnchanged`; a reorder owes nothing.\n */\nexport function campaignFilingChanges(\n before: readonly string[],\n after: readonly string[],\n): { added: string[]; removed: string[] } {\n return {\n added: after.filter((id) => !before.includes(id)),\n removed: before.filter((id) => !after.includes(id)),\n }\n}\n\nexport interface CampaignFilingActivityInput {\n action: CampaignFilingAction\n campaign: CampaignFilingRef\n link: CrmActivityLink\n /** The site the entry carries as provenance — the lead's site, the contact's viewing site. */\n hostId: string\n visibleTo: readonly ScopeToken[]\n /** When it happened, epoch ms. */\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n /** The member's name as it read, or the carry's fixed author; absent rather than blank. */\n byName?: string | null\n}\n\n/** The document a filing entry is stored as, minus the timestamps the writer stamps. */\nexport function buildCampaignFilingActivity(input: CampaignFilingActivityInput): CrmActivity {\n const byName = String(input.byName ?? '').trim()\n return {\n kind: CAMPAIGN_FILING_ACTIVITY_KIND,\n body: campaignFilingBody(input.action, input.campaign.name),\n atMs: input.atMs,\n byUid: input.byUid,\n ...(byName ? { byName } : {}),\n ...(input.link.contactId ? { contactId: input.link.contactId } : {}),\n ...(input.link.companyId ? { companyId: input.link.companyId } : {}),\n ...(input.link.dealId ? { dealId: input.link.dealId } : {}),\n ...(input.link.leadId ? { leadId: input.link.leadId } : {}),\n hostId: input.hostId,\n visibleTo: [...input.visibleTo],\n sourcePluginId: BUNDLE_ID,\n campaignId: input.campaign.id,\n }\n}\n"],"names":["BUNDLE_ID","CAMPAIGN_FILING_ACTIVITY_KIND","CAMPAIGN_FILING_CARRY_BY_NAME","campaignFilingBody","action","campaignName","name","trim","campaignFilingChanges","before","after","added","filter","id","includes","removed","buildCampaignFilingActivity","input","byName","String","kind","body","campaign","atMs","byUid","link","contactId","companyId","dealId","leadId","hostId","visibleTo","sourcePluginId","campaignId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,6CAA6C,GAC7C,OAAO,MAAMC,gCAAgC,OAAe;AAW5D;;;;CAIC,GACD,OAAO,MAAMC,gCAAgC,kBAAiB;AAE9D,yDAAyD,GACzD,OAAO,SAASC,mBAAmBC,MAA4B,EAAEC,YAAoB;IACnF,MAAMC,OAAOD,aAAaE,IAAI,MAAM;IACpC,OAAOH,WAAW,UAAU,CAAC,YAAY,EAAEE,MAAM,GAAG,CAAC,aAAa,EAAEA,MAAM;AAC5E;AAEA;;;;CAIC,GACD,OAAO,SAASE,sBACdC,MAAyB,EACzBC,KAAwB;IAExB,OAAO;QACLC,OAAOD,MAAME,MAAM,CAAC,CAACC,KAAO,CAACJ,OAAOK,QAAQ,CAACD;QAC7CE,SAASN,OAAOG,MAAM,CAAC,CAACC,KAAO,CAACH,MAAMI,QAAQ,CAACD;IACjD;AACF;AAiBA,sFAAsF,GACtF,OAAO,SAASG,4BAA4BC,KAAkC;QACtDA;IAAtB,MAAMC,SAASC,QAAOF,gBAAAA,MAAMC,MAAM,YAAZD,gBAAgB,IAAIV,IAAI;IAC9C,OAAO;QACLa,MAAMnB;QACNoB,MAAMlB,mBAAmBc,MAAMb,MAAM,EAAEa,MAAMK,QAAQ,CAAChB,IAAI;QAC1DiB,MAAMN,MAAMM,IAAI;QAChBC,OAAOP,MAAMO,KAAK;OACdN,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvBD,MAAMQ,IAAI,CAACC,SAAS,GAAG;QAAEA,WAAWT,MAAMQ,IAAI,CAACC,SAAS;IAAC,IAAI,CAAC,GAC9DT,MAAMQ,IAAI,CAACE,SAAS,GAAG;QAAEA,WAAWV,MAAMQ,IAAI,CAACE,SAAS;IAAC,IAAI,CAAC,GAC9DV,MAAMQ,IAAI,CAACG,MAAM,GAAG;QAAEA,QAAQX,MAAMQ,IAAI,CAACG,MAAM;IAAC,IAAI,CAAC,GACrDX,MAAMQ,IAAI,CAACI,MAAM,GAAG;QAAEA,QAAQZ,MAAMQ,IAAI,CAACI,MAAM;IAAC,IAAI,CAAC;QACzDC,QAAQb,MAAMa,MAAM;QACpBC,WAAW;eAAId,MAAMc,SAAS;SAAC;QAC/BC,gBAAgBhC;QAChBiC,YAAYhB,MAAMK,QAAQ,CAACT,EAAE;;AAEjC"}
@@ -54,13 +54,16 @@ export declare function crmRoutes(basePath: string): {
54
54
  */
55
55
  contactByEmail: (email: string) => string;
56
56
  /**
57
- * A lead's page and, at the ORGANIZATION level (AGL-2630), the site
58
- * it lives under, as a segment before the id: a lead's id is a person
59
- * key, the same on every site that met the person, and `hosts/{hostId}
60
- * /leads` is host-scoped by path, so an address that spans sites has to
61
- * name one. Under a site the address is the id alone, as it always was.
57
+ * A lead's page: the id alone, at both levels (AGL-3275).
58
+ *
59
+ * This carried the SITE as a segment before the id at the organization
60
+ * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`
61
+ * was host-scoped by path, and so an address that two sites had both met
62
+ * was two documents with the same id — which the id alone could not tell
63
+ * apart. One org collection makes the person key unambiguous again, and a
64
+ * lead addresses like every other CRM record.
62
65
  */
63
- lead: (id: string, hostId?: string | null) => string;
66
+ lead: (id: string) => string;
64
67
  company: (id: string) => string;
65
68
  deal: (id: string) => string;
66
69
  /**
@@ -57,12 +57,15 @@ import { CRM_VIEW_PARAM } from "./crm-view-param.js";
57
57
  [CONTACTS_LIST_EMAIL_PARAM]: email
58
58
  }).toString()}`,
59
59
  /**
60
- * A lead's page and, at the ORGANIZATION level (AGL-2630), the site
61
- * it lives under, as a segment before the id: a lead's id is a person
62
- * key, the same on every site that met the person, and `hosts/{hostId}
63
- * /leads` is host-scoped by path, so an address that spans sites has to
64
- * name one. Under a site the address is the id alone, as it always was.
65
- */ lead: (id, hostId)=>hostId ? `${section('leads')}/${encodeURIComponent(hostId)}/${encodeURIComponent(id)}` : `${section('leads')}/${encodeURIComponent(id)}`,
60
+ * A lead's page: the id alone, at both levels (AGL-3275).
61
+ *
62
+ * This carried the SITE as a segment before the id at the organization
63
+ * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`
64
+ * was host-scoped by path, and so an address that two sites had both met
65
+ * was two documents with the same id which the id alone could not tell
66
+ * apart. One org collection makes the person key unambiguous again, and a
67
+ * lead addresses like every other CRM record.
68
+ */ lead: (id)=>`${section('leads')}/${encodeURIComponent(id)}`,
66
69
  company: (id)=>`${section('companies')}/${encodeURIComponent(id)}`,
67
70
  deal: (id)=>`${section('deals')}/${encodeURIComponent(id)}`,
68
71
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-routes.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 { CRM_CONTACTS_EMAIL_PARAM, type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport type { CrmConsoleSectionId } from '../components/crm-console-sections'\nimport { CRM_VIEW_PARAM } from './crm-view-param'\n\n/**\n * Every address inside the Contacts surface, built from the one string the\n * shell hands the page (AGL-2595).\n *\n * The hub owns three kinds of deeper route — `people/{contactId}`,\n * `companies/{companyId}`, `deals/{dealId}` — and eight surfaces link into\n * them: a deal names its contact and company, a task names all three, a\n * report drills into a deal, an activity names whoever it happened with.\n * Eight surfaces each spelling `${basePath}/people/${id}` is eight places\n * for one of them to spell it `contacts/`, and a link that 404s inside the\n * hub is indistinguishable from a record that was deleted.\n *\n * The id is URL-encoded because Firestore ids are opaque: the console mints\n * them, but an import or an API caller may not, and a slash in an id would\n * otherwise read as a further segment.\n */\nexport function crmRoutes(basePath: string) {\n const section = (id: CrmConsoleSectionId) => `${basePath}/${id}`\n return {\n section,\n contact: (id: string) => `${section('contacts')}/${encodeURIComponent(id)}`,\n /**\n * The Contacts list narrowed to the people one form captured (AGL-2612):\n * source `form`, and the `formIds` filter on the form's id. The form's\n * own page links here; the list reads the two keys back through\n * `contactsListSeed`, which is the other half of this address.\n */\n contactsByForm: (formId: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_SOURCE_PARAM]: 'form',\n [CONTACTS_LIST_FORM_PARAM]: formId,\n }).toString()}`,\n /**\n * The Contacts list asked to OPEN the one person with this address\n * (AGL-2612). The list is the lookup: a contact's id is minted at\n * capture and nothing outside the CRM holds it, so a surface that has\n * only an email — an Inbox submission row — links here, the list\n * filters on the address (a whole-collection query, under the scope\n * the viewer may read) and moves straight on to the record when exactly\n * one matches. No match leaves the filtered list on screen, which is\n * the honest answer for a submission whose contact the band dropped.\n */\n contactByEmail: (email: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_EMAIL_PARAM]: email,\n }).toString()}`,\n /**\n * A lead's page and, at the ORGANIZATION level (AGL-2630), the site\n * it lives under, as a segment before the id: a lead's id is a person\n * key, the same on every site that met the person, and `hosts/{hostId}\n * /leads` is host-scoped by path, so an address that spans sites has to\n * name one. Under a site the address is the id alone, as it always was.\n */\n lead: (id: string, hostId?: string | null) =>\n hostId\n ? `${section('leads')}/${encodeURIComponent(hostId)}/${encodeURIComponent(id)}`\n : `${section('leads')}/${encodeURIComponent(id)}`,\n company: (id: string) => `${section('companies')}/${encodeURIComponent(id)}`,\n deal: (id: string) => `${section('deals')}/${encodeURIComponent(id)}`,\n /**\n * A section opened on one of its saved views (AGL-2617). The list reads\n * the key back through `crmViewIdFromParams`; the same key composes\n * with the Contacts seeds above, which is why it is a query key and not\n * a path segment.\n */\n sectionView: (id: CrmConsoleSectionId, viewId: string) =>\n `${section(id)}?${new URLSearchParams({\n [CRM_VIEW_PARAM]: viewId,\n }).toString()}`,\n }\n}\n\n/**\n * The query keys the Contacts list reads on arrival — written by the two\n * builders above and parsed by `contactsListSeed`, named once so neither\n * side can misspell the other.\n */\nexport const CONTACTS_LIST_SOURCE_PARAM = 'source'\nexport const CONTACTS_LIST_FORM_PARAM = 'formId'\n/**\n * The email key is the SHARED constant (AGL-2622): the console app — which\n * the module boundaries keep from importing this plugin — builds the same\n * address through `crmContactByEmailHref`, and `crm-routes.spec.ts` pins\n * `contactByEmail` against it so the plugin and the app cannot spell the\n * key two ways.\n */\nexport const CONTACTS_LIST_EMAIL_PARAM = CRM_CONTACTS_EMAIL_PARAM\n\nexport type CrmRoutes = ReturnType<typeof crmRoutes>\n\n/**\n * What the hub hands a record page: the shell's context, the record's id and\n * the surface's own path so the page can link back to its list.\n *\n * `basePath` is required here where the shell's prop is optional, because a\n * record page is only ever reached THROUGH the hub, which has already refused\n * to render without one.\n */\nexport type CrmDetailPageProps = Pick<\n ConsolePluginPageProps,\n 'hostId' | 'org' | 'permissions' | 'releaseFlag' | 'hostRole'\n> & {\n id: string\n basePath: string\n}\n"],"names":["CRM_CONTACTS_EMAIL_PARAM","CRM_VIEW_PARAM","crmRoutes","basePath","section","id","contact","encodeURIComponent","contactsByForm","formId","URLSearchParams","CONTACTS_LIST_SOURCE_PARAM","CONTACTS_LIST_FORM_PARAM","toString","contactByEmail","email","CONTACTS_LIST_EMAIL_PARAM","lead","hostId","company","deal","sectionView","viewId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAqC,eAAc;AAEpF,SAASC,cAAc,QAAQ,sBAAkB;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,UAAUC,QAAgB;IACxC,MAAMC,UAAU,CAACC,KAA4B,GAAGF,SAAS,CAAC,EAAEE,IAAI;IAChE,OAAO;QACLD;QACAE,SAAS,CAACD,KAAe,GAAGD,QAAQ,YAAY,CAAC,EAAEG,mBAAmBF,KAAK;QAC3E;;;;;KAKC,GACDG,gBAAgB,CAACC,SACf,GAAGL,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACC,2BAA2B,EAAE;gBAC9B,CAACC,yBAAyB,EAAEH;YAC9B,GAAGI,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDC,gBAAgB,CAACC,QACf,GAAGX,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACM,0BAA0B,EAAED;YAC/B,GAAGF,QAAQ,IAAI;QACjB;;;;;;KAMC,GACDI,MAAM,CAACZ,IAAYa,SACjBA,SACI,GAAGd,QAAQ,SAAS,CAAC,EAAEG,mBAAmBW,QAAQ,CAAC,EAAEX,mBAAmBF,KAAK,GAC7E,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrDc,SAAS,CAACd,KAAe,GAAGD,QAAQ,aAAa,CAAC,EAAEG,mBAAmBF,KAAK;QAC5Ee,MAAM,CAACf,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrE;;;;;KAKC,GACDgB,aAAa,CAAChB,IAAyBiB,SACrC,GAAGlB,QAAQC,IAAI,CAAC,EAAE,IAAIK,gBAAgB;gBACpC,CAACT,eAAe,EAAEqB;YACpB,GAAGT,QAAQ,IAAI;IACnB;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMF,6BAA6B,SAAQ;AAClD,OAAO,MAAMC,2BAA2B,SAAQ;AAChD;;;;;;CAMC,GACD,OAAO,MAAMI,4BAA4BhB,yBAAwB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-routes.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 { CRM_CONTACTS_EMAIL_PARAM, type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport type { CrmConsoleSectionId } from '../components/crm-console-sections'\nimport { CRM_VIEW_PARAM } from './crm-view-param'\n\n/**\n * Every address inside the Contacts surface, built from the one string the\n * shell hands the page (AGL-2595).\n *\n * The hub owns three kinds of deeper route — `people/{contactId}`,\n * `companies/{companyId}`, `deals/{dealId}` — and eight surfaces link into\n * them: a deal names its contact and company, a task names all three, a\n * report drills into a deal, an activity names whoever it happened with.\n * Eight surfaces each spelling `${basePath}/people/${id}` is eight places\n * for one of them to spell it `contacts/`, and a link that 404s inside the\n * hub is indistinguishable from a record that was deleted.\n *\n * The id is URL-encoded because Firestore ids are opaque: the console mints\n * them, but an import or an API caller may not, and a slash in an id would\n * otherwise read as a further segment.\n */\nexport function crmRoutes(basePath: string) {\n const section = (id: CrmConsoleSectionId) => `${basePath}/${id}`\n return {\n section,\n contact: (id: string) => `${section('contacts')}/${encodeURIComponent(id)}`,\n /**\n * The Contacts list narrowed to the people one form captured (AGL-2612):\n * source `form`, and the `formIds` filter on the form's id. The form's\n * own page links here; the list reads the two keys back through\n * `contactsListSeed`, which is the other half of this address.\n */\n contactsByForm: (formId: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_SOURCE_PARAM]: 'form',\n [CONTACTS_LIST_FORM_PARAM]: formId,\n }).toString()}`,\n /**\n * The Contacts list asked to OPEN the one person with this address\n * (AGL-2612). The list is the lookup: a contact's id is minted at\n * capture and nothing outside the CRM holds it, so a surface that has\n * only an email — an Inbox submission row — links here, the list\n * filters on the address (a whole-collection query, under the scope\n * the viewer may read) and moves straight on to the record when exactly\n * one matches. No match leaves the filtered list on screen, which is\n * the honest answer for a submission whose contact the band dropped.\n */\n contactByEmail: (email: string) =>\n `${section('contacts')}?${new URLSearchParams({\n [CONTACTS_LIST_EMAIL_PARAM]: email,\n }).toString()}`,\n /**\n * A lead's page: the id alone, at both levels (AGL-3275).\n *\n * This carried the SITE as a segment before the id at the organization\n * level, and had to: a lead's id is a person key, `hosts/{hostId}/leads`\n * was host-scoped by path, and so an address that two sites had both met\n * was two documents with the same id — which the id alone could not tell\n * apart. One org collection makes the person key unambiguous again, and a\n * lead addresses like every other CRM record.\n */\n lead: (id: string) => `${section('leads')}/${encodeURIComponent(id)}`,\n company: (id: string) => `${section('companies')}/${encodeURIComponent(id)}`,\n deal: (id: string) => `${section('deals')}/${encodeURIComponent(id)}`,\n /**\n * A section opened on one of its saved views (AGL-2617). The list reads\n * the key back through `crmViewIdFromParams`; the same key composes\n * with the Contacts seeds above, which is why it is a query key and not\n * a path segment.\n */\n sectionView: (id: CrmConsoleSectionId, viewId: string) =>\n `${section(id)}?${new URLSearchParams({\n [CRM_VIEW_PARAM]: viewId,\n }).toString()}`,\n }\n}\n\n/**\n * The query keys the Contacts list reads on arrival — written by the two\n * builders above and parsed by `contactsListSeed`, named once so neither\n * side can misspell the other.\n */\nexport const CONTACTS_LIST_SOURCE_PARAM = 'source'\nexport const CONTACTS_LIST_FORM_PARAM = 'formId'\n/**\n * The email key is the SHARED constant (AGL-2622): the console app — which\n * the module boundaries keep from importing this plugin — builds the same\n * address through `crmContactByEmailHref`, and `crm-routes.spec.ts` pins\n * `contactByEmail` against it so the plugin and the app cannot spell the\n * key two ways.\n */\nexport const CONTACTS_LIST_EMAIL_PARAM = CRM_CONTACTS_EMAIL_PARAM\n\nexport type CrmRoutes = ReturnType<typeof crmRoutes>\n\n/**\n * What the hub hands a record page: the shell's context, the record's id and\n * the surface's own path so the page can link back to its list.\n *\n * `basePath` is required here where the shell's prop is optional, because a\n * record page is only ever reached THROUGH the hub, which has already refused\n * to render without one.\n */\nexport type CrmDetailPageProps = Pick<\n ConsolePluginPageProps,\n 'hostId' | 'org' | 'permissions' | 'releaseFlag' | 'hostRole'\n> & {\n id: string\n basePath: string\n}\n"],"names":["CRM_CONTACTS_EMAIL_PARAM","CRM_VIEW_PARAM","crmRoutes","basePath","section","id","contact","encodeURIComponent","contactsByForm","formId","URLSearchParams","CONTACTS_LIST_SOURCE_PARAM","CONTACTS_LIST_FORM_PARAM","toString","contactByEmail","email","CONTACTS_LIST_EMAIL_PARAM","lead","company","deal","sectionView","viewId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAqC,eAAc;AAEpF,SAASC,cAAc,QAAQ,sBAAkB;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,UAAUC,QAAgB;IACxC,MAAMC,UAAU,CAACC,KAA4B,GAAGF,SAAS,CAAC,EAAEE,IAAI;IAChE,OAAO;QACLD;QACAE,SAAS,CAACD,KAAe,GAAGD,QAAQ,YAAY,CAAC,EAAEG,mBAAmBF,KAAK;QAC3E;;;;;KAKC,GACDG,gBAAgB,CAACC,SACf,GAAGL,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACC,2BAA2B,EAAE;gBAC9B,CAACC,yBAAyB,EAAEH;YAC9B,GAAGI,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDC,gBAAgB,CAACC,QACf,GAAGX,QAAQ,YAAY,CAAC,EAAE,IAAIM,gBAAgB;gBAC5C,CAACM,0BAA0B,EAAED;YAC/B,GAAGF,QAAQ,IAAI;QACjB;;;;;;;;;KASC,GACDI,MAAM,CAACZ,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrEa,SAAS,CAACb,KAAe,GAAGD,QAAQ,aAAa,CAAC,EAAEG,mBAAmBF,KAAK;QAC5Ec,MAAM,CAACd,KAAe,GAAGD,QAAQ,SAAS,CAAC,EAAEG,mBAAmBF,KAAK;QACrE;;;;;KAKC,GACDe,aAAa,CAACf,IAAyBgB,SACrC,GAAGjB,QAAQC,IAAI,CAAC,EAAE,IAAIK,gBAAgB;gBACpC,CAACT,eAAe,EAAEoB;YACpB,GAAGR,QAAQ,IAAI;IACnB;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMF,6BAA6B,SAAQ;AAClD,OAAO,MAAMC,2BAA2B,SAAQ;AAChD;;;;;;CAMC,GACD,OAAO,MAAMI,4BAA4BhB,yBAAwB"}