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

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 (40) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +14 -4
  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/fields-section.d.ts +2 -1
  7. package/src/lib/components/fields-section.js +6 -3
  8. package/src/lib/components/fields-section.js.map +1 -1
  9. package/src/lib/components/lead-campaigns-card.d.ts +59 -0
  10. package/src/lib/components/lead-campaigns-card.js +160 -0
  11. package/src/lib/components/lead-campaigns-card.js.map +1 -0
  12. package/src/lib/components/lead-detail-page.js +53 -3
  13. package/src/lib/components/lead-detail-page.js.map +1 -1
  14. package/src/lib/components/lead-history-card.js +5 -0
  15. package/src/lib/components/lead-history-card.js.map +1 -1
  16. package/src/lib/components/lead-properties-card.d.ts +12 -0
  17. package/src/lib/components/lead-properties-card.js +63 -5
  18. package/src/lib/components/lead-properties-card.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.d.ts +6 -0
  20. package/src/lib/components/leads-bulk-bar.js +34 -3
  21. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  22. package/src/lib/components/leads-section.js +16 -2
  23. package/src/lib/components/leads-section.js.map +1 -1
  24. package/src/lib/components/new-lead-drawer.d.ts +14 -1
  25. package/src/lib/components/new-lead-drawer.js +41 -3
  26. package/src/lib/components/new-lead-drawer.js.map +1 -1
  27. package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
  28. package/src/lib/hooks/use-campaign-filing-log.js +91 -0
  29. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
  30. package/src/lib/model/campaign-filing-activity.d.ts +82 -0
  31. package/src/lib/model/campaign-filing-activity.js +88 -0
  32. package/src/lib/model/campaign-filing-activity.js.map +1 -0
  33. package/src/lib/server/campaign-filing-activity.d.ts +79 -0
  34. package/src/lib/server/campaign-filing-activity.js +93 -0
  35. package/src/lib/server/campaign-filing-activity.js.map +1 -0
  36. package/src/lib/server/lead-campaign-carry.js +25 -0
  37. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  38. package/src/lib/server/lead-create.d.ts +15 -4
  39. package/src/lib/server/lead-create.js +68 -10
  40. package/src/lib/server/lead-create.js.map +1 -1
@@ -0,0 +1,91 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { CRM_COLLECTIONS, consentGroupForHost, createResourceUid, crmScopeTokens } from "@aglyn/aglyn";
19
+ import { useFirestore, useUser, useUserName } from "@aglyn/tenant-feature-instance";
20
+ import { collection, doc, setDoc } from "firebase/firestore";
21
+ import { useCallback } from "react";
22
+ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.js";
23
+ /**
24
+ * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the
25
+ * console.
26
+ *
27
+ * The lead's Campaigns card and the Leads list's bulk bar write the
28
+ * membership client-direct — a one-field update the rules already admit —
29
+ * so the entry that records it is written the same way, into the org's
30
+ * activity collection the log dialog writes, with the scope a record made
31
+ * on this site carries (`crmScopeTokens`), the signed-in member as author,
32
+ * and the CRM's own id as the filing plugin.
33
+ *
34
+ * Bookkeeping beside the act: the membership has landed by the time this
35
+ * runs, so a write that fails is logged and the filing stands — the card
36
+ * shows the campaigns from the document, not from the timeline.
37
+ */ export function useCampaignFilingLog(input) {
38
+ const { orgId, hostId, org } = input;
39
+ const firestore = useFirestore();
40
+ const { data: user } = useUser();
41
+ const authorName = useUserName();
42
+ const uid = user == null ? void 0 : user.uid;
43
+ return useCallback(async (link, change)=>{
44
+ var _change_filed, _change_removed;
45
+ if (!orgId || !hostId || !uid) return;
46
+ const entries = [
47
+ ...((_change_filed = change.filed) != null ? _change_filed : []).map((campaign)=>({
48
+ action: 'filed',
49
+ campaign
50
+ })),
51
+ ...((_change_removed = change.removed) != null ? _change_removed : []).map((campaign)=>({
52
+ action: 'removed',
53
+ campaign
54
+ }))
55
+ ];
56
+ if (!entries.length) return;
57
+ const visibleTo = crmScopeTokens(org, consentGroupForHost(org, hostId));
58
+ const atMs = Date.now();
59
+ const activities = collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.activities);
60
+ for (const entry of entries){
61
+ try {
62
+ // Named by the platform's own id, as every console resource is.
63
+ await setDoc(doc(activities, createResourceUid()), _extends({}, buildCampaignFilingActivity({
64
+ action: entry.action,
65
+ campaign: entry.campaign,
66
+ link,
67
+ hostId,
68
+ visibleTo,
69
+ atMs,
70
+ byUid: uid,
71
+ byName: authorName || null
72
+ }), {
73
+ createdAt: new Date(),
74
+ updatedAt: new Date()
75
+ }));
76
+ } catch (error) {
77
+ console.error('[crm] the filing could not be written on the timeline', error);
78
+ }
79
+ }
80
+ }, [
81
+ firestore,
82
+ orgId,
83
+ hostId,
84
+ org,
85
+ uid,
86
+ authorName
87
+ ]);
88
+ }
89
+ export default useCampaignFilingLog;
90
+
91
+ //# sourceMappingURL=use-campaign-filing-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-campaign-filing-log.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 {\n CRM_COLLECTIONS,\n consentGroupForHost,\n createResourceUid,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn'\nimport { useFirestore, useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport { collection, doc, setDoc } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/** What one surface's save changed, by campaign, with the names it showed. */\nexport interface CampaignFilingChange {\n filed?: readonly CampaignFilingRef[]\n removed?: readonly CampaignFilingRef[]\n}\n\n/**\n * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the\n * console.\n *\n * The lead's Campaigns card and the Leads list's bulk bar write the\n * membership client-direct — a one-field update the rules already admit —\n * so the entry that records it is written the same way, into the org's\n * activity collection the log dialog writes, with the scope a record made\n * on this site carries (`crmScopeTokens`), the signed-in member as author,\n * and the CRM's own id as the filing plugin.\n *\n * Bookkeeping beside the act: the membership has landed by the time this\n * runs, so a write that fails is logged and the filing stands — the card\n * shows the campaigns from the document, not from the timeline.\n */\nexport function useCampaignFilingLog(input: {\n orgId: string | null | undefined\n /** The site the entry carries as provenance: the lead's, or the contact's viewing site. */\n hostId: string | null | undefined\n org: Record<string, unknown> | null | undefined\n}) {\n const { orgId, hostId, org } = input\n const firestore = useFirestore()\n const { data: user } = useUser()\n const authorName = useUserName()\n const uid = user?.uid\n\n return useCallback(\n async (link: CrmActivityLink, change: CampaignFilingChange): Promise<void> => {\n if (!orgId || !hostId || !uid) return\n const entries = [\n ...(change.filed ?? []).map((campaign) => ({ action: 'filed' as const, campaign })),\n ...(change.removed ?? []).map((campaign) => ({ action: 'removed' as const, campaign })),\n ]\n if (!entries.length) return\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, hostId))\n const atMs = Date.now()\n const activities = collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.activities)\n for (const entry of entries) {\n try {\n // Named by the platform's own id, as every console resource is.\n await setDoc(doc(activities, createResourceUid()), {\n ...buildCampaignFilingActivity({\n action: entry.action,\n campaign: entry.campaign,\n link,\n hostId,\n visibleTo,\n atMs,\n byUid: uid,\n byName: authorName || null,\n }),\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', error)\n }\n }\n },\n [firestore, orgId, hostId, org, uid, authorName],\n )\n}\n\nexport default useCampaignFilingLog\n"],"names":["CRM_COLLECTIONS","consentGroupForHost","createResourceUid","crmScopeTokens","useFirestore","useUser","useUserName","collection","doc","setDoc","useCallback","buildCampaignFilingActivity","useCampaignFilingLog","input","orgId","hostId","org","firestore","data","user","authorName","uid","link","change","entries","filed","map","campaign","action","removed","length","visibleTo","atMs","Date","now","activities","entry","byUid","byName","createdAt","updatedAt","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,eAAe,EACfC,mBAAmB,EACnBC,iBAAiB,EACjBC,cAAc,QAET,eAAc;AACrB,SAASC,YAAY,EAAEC,OAAO,EAAEC,WAAW,QAAQ,iCAAgC;AACnF,SAASC,UAAU,EAAEC,GAAG,EAAEC,MAAM,QAAQ,qBAAoB;AAC5D,SAASC,WAAW,QAAQ,QAAO;AACnC,SACEC,2BAA2B,QAEtB,uCAAmC;AAQ1C;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,qBAAqBC,KAKpC;IACC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,GAAG,EAAE,GAAGH;IAC/B,MAAMI,YAAYb;IAClB,MAAM,EAAEc,MAAMC,IAAI,EAAE,GAAGd;IACvB,MAAMe,aAAad;IACnB,MAAMe,MAAMF,wBAAAA,KAAME,GAAG;IAErB,OAAOX,YACL,OAAOY,MAAuBC;YAGtBA,eACAA;QAHN,IAAI,CAACT,SAAS,CAACC,UAAU,CAACM,KAAK;QAC/B,MAAMG,UAAU;eACX,EAACD,gBAAAA,OAAOE,KAAK,YAAZF,gBAAgB,EAAE,EAAEG,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,QAAQ;oBAAkBD;gBAAS,CAAA;eAC7E,EAACJ,kBAAAA,OAAOM,OAAO,YAAdN,kBAAkB,EAAE,EAAEG,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,QAAQ;oBAAoBD;gBAAS,CAAA;SACrF;QACD,IAAI,CAACH,QAAQM,MAAM,EAAE;QACrB,MAAMC,YAAY5B,eAAea,KAAKf,oBAAoBe,KAAKD;QAC/D,MAAMiB,OAAOC,KAAKC,GAAG;QACrB,MAAMC,aAAa5B,WAAWU,WAAW,QAAQH,OAAOd,gBAAgBmC,UAAU;QAClF,KAAK,MAAMC,SAASZ,QAAS;YAC3B,IAAI;gBACF,gEAAgE;gBAChE,MAAMf,OAAOD,IAAI2B,YAAYjC,sBAAsB,aAC9CS,4BAA4B;oBAC7BiB,QAAQQ,MAAMR,MAAM;oBACpBD,UAAUS,MAAMT,QAAQ;oBACxBL;oBACAP;oBACAgB;oBACAC;oBACAK,OAAOhB;oBACPiB,QAAQlB,cAAc;gBACxB;oBACAmB,WAAW,IAAIN;oBACfO,WAAW,IAAIP;;YAEnB,EAAE,OAAOQ,OAAO;gBACdC,QAAQD,KAAK,CAAC,yDAAyDA;YACzE;QACF;IACF,GACA;QAACxB;QAAWH;QAAOC;QAAQC;QAAKK;QAAKD;KAAW;AAEpD;AAEA,eAAeR,qBAAoB"}
@@ -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"}
@@ -0,0 +1,79 @@
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 CrmActivityLink } from '@aglyn/aglyn/app-utils/crm';
18
+ import { type CampaignFilingAction, type CampaignFilingRef } from '../model/campaign-filing-activity';
19
+ /**
20
+ * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.
21
+ *
22
+ * The two doors that file a lead under a campaign on the server — the
23
+ * `leads-create` route behind the New lead drawer, and the conversion's
24
+ * carry of a lead's campaigns onto the contact — write the entry through
25
+ * this, with the scope a record made on the site carries and the CRM's
26
+ * own id as the filing plugin, exactly as the console's hook writes one.
27
+ *
28
+ * ## Once, when the caller says so
29
+ *
30
+ * A member's act is an act each time, and the route's entries are named
31
+ * by `createResourceUid()`, the id every console resource carries. The
32
+ * conversion carry runs from a seam every converting
33
+ * door reaches, and a door that runs twice must not file twice — so a
34
+ * caller that hands in a `dedupeKey` gets a keyed `create()`, and the
35
+ * second run finds its own entry and writes nothing. The key is hashed
36
+ * under the CRM's id the way the timeline seam hashes a plugin's, so no
37
+ * caller's raw key becomes a document id.
38
+ *
39
+ * ## Never throws
40
+ *
41
+ * The membership has landed by the time this runs; an entry that could
42
+ * not be filed is logged, and the record's page shows the campaigns from
43
+ * the document either way. A record at its activity ceiling is refused
44
+ * before anything is written, in the same breath.
45
+ */
46
+ type Firestore = FirebaseFirestore.Firestore;
47
+ export interface CampaignFilingActivitiesInput {
48
+ orgId: string;
49
+ /**
50
+ * The organization document, when the caller already holds it — the
51
+ * scope tokens read `defaultResourceScope` and the consent groups off
52
+ * it. Read here otherwise.
53
+ */
54
+ org?: Record<string, unknown> | null;
55
+ /** The site the entry carries as provenance. */
56
+ hostId: string;
57
+ link: CrmActivityLink;
58
+ action: CampaignFilingAction;
59
+ campaigns: readonly CampaignFilingRef[];
60
+ atMs: number;
61
+ /** The member who did it, or `''` for the conversion. */
62
+ byUid: string;
63
+ byName?: string | null;
64
+ /**
65
+ * A key that files each campaign's entry once across runs — the carry's
66
+ * — or nothing for a member's act, which is new each time.
67
+ */
68
+ dedupeKey?: string | null;
69
+ }
70
+ /** The document id a keyed entry files under: the CRM's namespace, never a raw key. */
71
+ export declare function campaignFilingActivityId(key: string): string;
72
+ /**
73
+ * The names of a site's campaigns, by id, for the entries that name them.
74
+ * A container that is gone answers its id, so the entry still says which.
75
+ */
76
+ export declare function siteCampaignRefs(firestore: Firestore, hostId: string, campaignIds: readonly string[]): Promise<CampaignFilingRef[]>;
77
+ /** Files one entry per campaign; answers how many were written. */
78
+ export declare function fileCampaignFilingActivities(firestore: Firestore, input: CampaignFilingActivitiesInput): Promise<number>;
79
+ export {};
@@ -0,0 +1,93 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { consentGroupForHost } from "@aglyn/aglyn/app-utils/consent-groups";
18
+ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
19
+ import { CRM_COLLECTIONS, crmActivityLogHasRoom, crmScopeTokens } from "@aglyn/aglyn/app-utils/crm";
20
+ import { countCrmActivitiesForRecord } from "@aglyn/tenant-data-admin/server/crm-records";
21
+ import { createHash } from "crypto";
22
+ import { FieldValue } from "firebase-admin/firestore";
23
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
24
+ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.js";
25
+ /** The document id a keyed entry files under: the CRM's namespace, never a raw key. */ export function campaignFilingActivityId(key) {
26
+ return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`;
27
+ }
28
+ /** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */ const ALREADY_EXISTS = 6;
29
+ /**
30
+ * The names of a site's campaigns, by id, for the entries that name them.
31
+ * A container that is gone answers its id, so the entry still says which.
32
+ */ export async function siteCampaignRefs(firestore, hostId, campaignIds) {
33
+ if (!campaignIds.length) return [];
34
+ const containers = firestore.collection('hosts').doc(hostId).collection('emailCampaigns');
35
+ const found = await firestore.getAll(...campaignIds.map((id)=>containers.doc(id)));
36
+ return campaignIds.map((id, index)=>{
37
+ var _ref;
38
+ var _found_index;
39
+ return {
40
+ id,
41
+ name: String((_ref = (_found_index = found[index]) == null ? void 0 : _found_index.get('name')) != null ? _ref : '').trim() || id
42
+ };
43
+ });
44
+ }
45
+ /** Files one entry per campaign; answers how many were written. */ export async function fileCampaignFilingActivities(firestore, input) {
46
+ if (!input.campaigns.length) return 0;
47
+ try {
48
+ var _ref, _input_org;
49
+ const orgRef = firestore.collection('orgs').doc(input.orgId);
50
+ const org = (_ref = (_input_org = input.org) != null ? _input_org : (await orgRef.get()).data()) != null ? _ref : null;
51
+ const visibleTo = crmScopeTokens(org, consentGroupForHost(org, input.hostId));
52
+ if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {
53
+ console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link);
54
+ return 0;
55
+ }
56
+ const activities = orgRef.collection(CRM_COLLECTIONS.activities);
57
+ let written = 0;
58
+ for (const campaign of input.campaigns){
59
+ var _input_byName;
60
+ const entry = _extends({}, buildCampaignFilingActivity({
61
+ action: input.action,
62
+ campaign,
63
+ link: input.link,
64
+ hostId: input.hostId,
65
+ visibleTo,
66
+ atMs: input.atMs,
67
+ byUid: input.byUid,
68
+ byName: (_input_byName = input.byName) != null ? _input_byName : null
69
+ }), {
70
+ createdAt: FieldValue.serverTimestamp(),
71
+ updatedAt: FieldValue.serverTimestamp()
72
+ });
73
+ if (input.dedupeKey) {
74
+ try {
75
+ await activities.doc(campaignFilingActivityId(`${input.dedupeKey}:${campaign.id}`)).create(entry);
76
+ written += 1;
77
+ } catch (error) {
78
+ if ((error == null ? void 0 : error.code) !== ALREADY_EXISTS) throw error;
79
+ }
80
+ } else {
81
+ // A member's act, new each time, named by the platform's own id.
82
+ await activities.doc(createResourceUid()).create(entry);
83
+ written += 1;
84
+ }
85
+ }
86
+ return written;
87
+ } catch (error) {
88
+ console.error('[crm] the filing could not be written on the timeline', input.link, error);
89
+ return 0;
90
+ }
91
+ }
92
+
93
+ //# sourceMappingURL=campaign-filing-activity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of a site's campaigns, by id, for the entries that name them.\n * A container that is gone answers its id, so the entry still says which.\n */\nexport async function siteCampaignRefs(\n firestore: Firestore,\n hostId: string,\n campaignIds: readonly string[],\n): Promise<CampaignFilingRef[]> {\n if (!campaignIds.length) return []\n const containers = firestore.collection('hosts').doc(hostId).collection('emailCampaigns')\n const found = await firestore.getAll(...campaignIds.map((id) => containers.doc(id)))\n return campaignIds.map((id, index) => ({\n id,\n name: String(found[index]?.get('name') ?? '').trim() || id,\n }))\n}\n\n/** Files one entry per campaign; answers how many were written. */\nexport async function fileCampaignFilingActivities(\n firestore: Firestore,\n input: CampaignFilingActivitiesInput,\n): Promise<number> {\n if (!input.campaigns.length) return 0\n try {\n const orgRef = firestore.collection('orgs').doc(input.orgId)\n const org =\n input.org ?? ((await orgRef.get()).data() as Record<string, unknown> | undefined) ?? null\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, input.hostId))\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {\n console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link)\n return 0\n }\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n let written = 0\n for (const campaign of input.campaigns) {\n const entry = {\n ...buildCampaignFilingActivity({\n action: input.action,\n campaign,\n link: input.link,\n hostId: input.hostId,\n visibleTo,\n atMs: input.atMs,\n byUid: input.byUid,\n byName: input.byName ?? null,\n }),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (input.dedupeKey) {\n try {\n await activities.doc(campaignFilingActivityId(`${input.dedupeKey}:${campaign.id}`)).create(entry)\n written += 1\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n }\n } else {\n // A member's act, new each time, named by the platform's own id.\n await activities.doc(createResourceUid()).create(entry)\n written += 1\n }\n }\n return written\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', input.link, error)\n return 0\n }\n}\n"],"names":["consentGroupForHost","createResourceUid","CRM_COLLECTIONS","crmActivityLogHasRoom","crmScopeTokens","countCrmActivitiesForRecord","createHash","FieldValue","BUNDLE_ID","buildCampaignFilingActivity","campaignFilingActivityId","key","update","digest","slice","ALREADY_EXISTS","siteCampaignRefs","firestore","hostId","campaignIds","length","containers","collection","doc","found","getAll","map","id","index","name","String","get","trim","fileCampaignFilingActivities","input","campaigns","orgRef","orgId","org","data","visibleTo","link","console","warn","activities","written","campaign","entry","action","atMs","byUid","byName","createdAt","serverTimestamp","updatedAt","dedupeKey","create","error","code"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,mBAAmB,QAAQ,wCAAuC;AAC3E,SAASC,iBAAiB,QAAQ,6CAA4C;AAC9E,SACEC,eAAe,EACfC,qBAAqB,EACrBC,cAAc,QAET,6BAA4B;AACnC,SAASC,2BAA2B,QAAQ,8CAA6C;AACzF,SAASC,UAAU,QAAQ,SAAQ;AACnC,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,2BAA2B,QAGtB,uCAAmC;AAwD1C,qFAAqF,GACrF,OAAO,SAASC,yBAAyBC,GAAW;IAClD,OAAO,CAAC,IAAI,EAAEL,WAAW,UAAUM,MAAM,CAAC,GAAGJ,UAAU,CAAC,EAAEG,KAAK,EAAEE,MAAM,CAAC,OAAOC,KAAK,CAAC,GAAG,KAAK;AAC/F;AAEA,qFAAqF,GACrF,MAAMC,iBAAiB;AAEvB;;;CAGC,GACD,OAAO,eAAeC,iBACpBC,SAAoB,EACpBC,MAAc,EACdC,WAA8B;IAE9B,IAAI,CAACA,YAAYC,MAAM,EAAE,OAAO,EAAE;IAClC,MAAMC,aAAaJ,UAAUK,UAAU,CAAC,SAASC,GAAG,CAACL,QAAQI,UAAU,CAAC;IACxE,MAAME,QAAQ,MAAMP,UAAUQ,MAAM,IAAIN,YAAYO,GAAG,CAAC,CAACC,KAAON,WAAWE,GAAG,CAACI;IAC/E,OAAOR,YAAYO,GAAG,CAAC,CAACC,IAAIC;;YAEbJ;eAFwB;YACrCG;YACAE,MAAMC,gBAAON,eAAAA,KAAK,CAACI,MAAM,qBAAZJ,aAAcO,GAAG,CAAC,0BAAW,IAAIC,IAAI,MAAML;QAC1D;;AACF;AAEA,iEAAiE,GACjE,OAAO,eAAeM,6BACpBhB,SAAoB,EACpBiB,KAAoC;IAEpC,IAAI,CAACA,MAAMC,SAAS,CAACf,MAAM,EAAE,OAAO;IACpC,IAAI;YAGAc,MAAAA;QAFF,MAAME,SAASnB,UAAUK,UAAU,CAAC,QAAQC,GAAG,CAACW,MAAMG,KAAK;QAC3D,MAAMC,OACJJ,QAAAA,aAAAA,MAAMI,GAAG,YAATJ,aAAc,AAAC,CAAA,MAAME,OAAOL,GAAG,EAAC,EAAGQ,IAAI,cAAvCL,OAAqF;QACvF,MAAMM,YAAYpC,eAAekC,KAAKtC,oBAAoBsC,KAAKJ,MAAMhB,MAAM;QAC3E,IAAI,CAACf,sBAAsB,MAAME,4BAA4B+B,QAAQF,MAAMO,IAAI,IAAI;YACjFC,QAAQC,IAAI,CAAC,8EAA8ET,MAAMO,IAAI;YACrG,OAAO;QACT;QACA,MAAMG,aAAaR,OAAOd,UAAU,CAACpB,gBAAgB0C,UAAU;QAC/D,IAAIC,UAAU;QACd,KAAK,MAAMC,YAAYZ,MAAMC,SAAS,CAAE;gBAU1BD;YATZ,MAAMa,QAAQ,aACTtC,4BAA4B;gBAC7BuC,QAAQd,MAAMc,MAAM;gBACpBF;gBACAL,MAAMP,MAAMO,IAAI;gBAChBvB,QAAQgB,MAAMhB,MAAM;gBACpBsB;gBACAS,MAAMf,MAAMe,IAAI;gBAChBC,OAAOhB,MAAMgB,KAAK;gBAClBC,MAAM,GAAEjB,gBAAAA,MAAMiB,MAAM,YAAZjB,gBAAgB;YAC1B;gBACAkB,WAAW7C,WAAW8C,eAAe;gBACrCC,WAAW/C,WAAW8C,eAAe;;YAEvC,IAAInB,MAAMqB,SAAS,EAAE;gBACnB,IAAI;oBACF,MAAMX,WAAWrB,GAAG,CAACb,yBAAyB,GAAGwB,MAAMqB,SAAS,CAAC,CAAC,EAAET,SAASnB,EAAE,EAAE,GAAG6B,MAAM,CAACT;oBAC3FF,WAAW;gBACb,EAAE,OAAOY,OAAO;oBACd,IAAI,CAACA,yBAAD,AAACA,MAA8BC,IAAI,MAAK3C,gBAAgB,MAAM0C;gBACpE;YACF,OAAO;gBACL,iEAAiE;gBACjE,MAAMb,WAAWrB,GAAG,CAACtB,qBAAqBuD,MAAM,CAACT;gBACjDF,WAAW;YACb;QACF;QACA,OAAOA;IACT,EAAE,OAAOY,OAAO;QACdf,QAAQe,KAAK,CAAC,yDAAyDvB,MAAMO,IAAI,EAAEgB;QACnF,OAAO;IACT;AACF"}
@@ -17,6 +17,8 @@
17
17
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
18
  import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
19
19
  import { FieldValue } from "firebase-admin/firestore";
20
+ import { CAMPAIGN_FILING_CARRY_BY_NAME } from "../model/campaign-filing-activity.js";
21
+ import { fileCampaignFilingActivities, siteCampaignRefs } from "./campaign-filing-activity.js";
20
22
  /**
21
23
  * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).
22
24
  *
@@ -48,6 +50,29 @@ import { FieldValue } from "firebase-admin/firestore";
48
50
  [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(...campaignIds),
49
51
  updatedAt: FieldValue.serverTimestamp()
50
52
  });
53
+ /*
54
+ * The carry on the contact's Activity (AGL-3274): one "Filed under"
55
+ * per campaign, by the conversion rather than a member, keyed once per
56
+ * lead and contact so a door that converts twice files once. After the
57
+ * membership has landed, and never failing the carry that is done.
58
+ */ await fileCampaignFilingActivities(firestore, {
59
+ orgId: request.orgId,
60
+ hostId: request.hostId,
61
+ link: {
62
+ contactId: request.contactId
63
+ },
64
+ action: 'filed',
65
+ // Named where the containers answer; by id where a read fails, so a
66
+ // name lookup can never unmake a carry that has landed.
67
+ campaigns: await siteCampaignRefs(firestore, request.hostId, campaignIds).catch(()=>campaignIds.map((id)=>({
68
+ id,
69
+ name: id
70
+ }))),
71
+ atMs: Date.now(),
72
+ byUid: '',
73
+ byName: CAMPAIGN_FILING_CARRY_BY_NAME,
74
+ dedupeKey: `carry:${request.leadId}:${request.contactId}`
75
+ });
51
76
  return {
52
77
  campaigns: campaignIds.length
53
78
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.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 contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await firestore\n .collection('hosts')\n .doc(request.hostId)\n .collection('leads')\n .doc(request.leadId)\n .get()\n const campaignIds = readCampaignIds(\n lead.exists ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","FieldValue","carryLeadCampaignsToContact","firestore","request","lead","collection","doc","hostId","leadId","get","campaignIds","exists","data","length","campaigns","group","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMF,UAChBG,UAAU,CAAC,SACXC,GAAG,CAACH,QAAQI,MAAM,EAClBF,UAAU,CAAC,SACXC,GAAG,CAACH,QAAQK,MAAM,EAClBC,GAAG;QACN,MAAMC,cAAcb,gBAClBO,KAAKO,MAAM,GAAIP,KAAKQ,IAAI,KAAiC;QAE3D,IAAI,CAACF,YAAYG,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBI,QAAQI,MAAM;QACtD,MAAML,UACHG,UAAU,CAAC,QACXC,GAAG,CAACH,QAAQa,KAAK,EACjBX,UAAU,CAAC,YACXC,GAAG,CAACH,QAAQc,SAAS,EACrBC,MAAM,CAAC;YACN,CAACtB,yBAAyBmB,MAAMI,OAAO,EAAE,EAAEnB,WAAWoB,UAAU,IAC3DV;YAELW,WAAWrB,WAAWsB,eAAe;QACvC;QACF,OAAO;YAAER,WAAWJ,YAAYG,MAAM;QAAC;IACzC,EAAE,OAAOU,OAAO;QACdC,QAAQD,KAAK,CACX,kEACApB,QAAQI,MAAM,EACdJ,QAAQK,MAAM,EACde;QAEF,OAAO;YAAET,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASW,+BACdtB,OAGC;IAED,OAAOF,4BAA4BH,cAAc4B,GAAG,GAAGxB,SAAS,IAAIC;AACtE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.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 contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, siteCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await firestore\n .collection('hosts')\n .doc(request.hostId)\n .collection('leads')\n .doc(request.leadId)\n .get()\n const campaignIds = readCampaignIds(\n lead.exists ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await siteCampaignRefs(firestore, request.hostId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","siteCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","collection","doc","hostId","leadId","get","campaignIds","exists","data","length","campaigns","group","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,gBAAgB,QAAQ,gCAA4B;AAE3F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMF,UAChBG,UAAU,CAAC,SACXC,GAAG,CAACH,QAAQI,MAAM,EAClBF,UAAU,CAAC,SACXC,GAAG,CAACH,QAAQK,MAAM,EAClBC,GAAG;QACN,MAAMC,cAAchB,gBAClBU,KAAKO,MAAM,GAAIP,KAAKQ,IAAI,KAAiC;QAE3D,IAAI,CAACF,YAAYG,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMnB,oBAAoBO,QAAQI,MAAM;QACtD,MAAML,UACHG,UAAU,CAAC,QACXC,GAAG,CAACH,QAAQa,KAAK,EACjBX,UAAU,CAAC,YACXC,GAAG,CAACH,QAAQc,SAAS,EACrBC,MAAM,CAAC;YACN,CAACzB,yBAAyBsB,MAAMI,OAAO,EAAE,EAAEtB,WAAWuB,UAAU,IAC3DV;YAELW,WAAWxB,WAAWyB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMvB,6BAA6BG,WAAW;YAC5Cc,OAAOb,QAAQa,KAAK;YACpBT,QAAQJ,QAAQI,MAAM;YACtBgB,MAAM;gBAAEN,WAAWd,QAAQc,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDV,WAAW,MAAMd,iBAAiBE,WAAWC,QAAQI,MAAM,EAAEG,aAAae,KAAK,CAAC,IAC9Ef,YAAYgB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQnC;YACRoC,WAAW,CAAC,MAAM,EAAE/B,QAAQK,MAAM,CAAC,CAAC,EAAEL,QAAQc,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEH,WAAWJ,YAAYG,MAAM;QAAC;IACzC,EAAE,OAAOsB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACAhC,QAAQI,MAAM,EACdJ,QAAQK,MAAM,EACd2B;QAEF,OAAO;YAAErB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASuB,+BACdlC,OAGC;IAED,OAAOF,4BAA4BN,cAAc2C,GAAG,GAAGpC,SAAS,IAAIC;AACtE"}
@@ -45,7 +45,8 @@
45
45
  * because a lead entered by hand is suite work: the capture doors that fill
46
46
  * a Free workspace's leads do not come through here.
47
47
  */
48
- import { type CrmLeadProfilePatch, type CrmLeadStatus, type PluginApiHandler } from '@aglyn/aglyn/server';
48
+ import { type CrmCustomValue, type CrmLeadProfilePatch, type CrmLeadStatus, type PluginApiHandler } from '@aglyn/aglyn/server';
49
+ import type { CampaignFilingRef } from '../model/campaign-filing-activity';
49
50
  /** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */
50
51
  export declare const LEAD_MANUAL_SOURCE = "manual";
51
52
  /** What the New lead drawer posts. */
@@ -65,17 +66,27 @@ export interface LeadCreateRequest {
65
66
  notes?: string;
66
67
  /** The site's campaigns to file the lead under (AGL-3254), by container id. */
67
68
  campaignIds?: string[];
69
+ /**
70
+ * The org's custom LEAD fields (AGL-3272), keyed by each definition's
71
+ * `key`. Judged against the definitions whose `object` is `lead`, so a
72
+ * contact field of the same name cannot be written through this door.
73
+ */
74
+ custom?: Record<string, CrmCustomValue>;
68
75
  }
69
76
  /** The sentence a campaign that is not the site's is refused with, under the field. */
70
77
  export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked isn't a campaign of this site any more. Pick it again.";
71
78
  /**
72
- * The campaign ids a request may file a lead under: the site's own live
79
+ * The campaigns a request may file a lead under: the site's own live
73
80
  * containers, and nothing else. A request can claim any id, and a lead
74
81
  * filed under another site's campaign — or one the console soft-deleted —
75
- * would sit on a page nobody at this site can open.
82
+ * would sit on a page nobody at this site can open. Answered with the
83
+ * names as they stand, for the timeline entries the filing writes
84
+ * (AGL-3274).
76
85
  *
77
- * @returns the clean ids, or `null` when one of them is not the site's.
86
+ * @returns the clean ids with their names, or `null` when one is not the site's.
78
87
  */
88
+ export declare function siteCampaigns(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<CampaignFilingRef[] | null>;
89
+ /** The ids alone — see {@link siteCampaigns}. */
79
90
  export declare function siteCampaignIds(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<string[] | null>;
80
91
  /** What the route answers on success. */
81
92
  export interface LeadCreateResponse {