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

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 (78) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/activity-list.d.ts +36 -5
  3. package/src/lib/components/activity-list.js +159 -137
  4. package/src/lib/components/activity-list.js.map +1 -1
  5. package/src/lib/components/activity-timeline.d.ts +84 -0
  6. package/src/lib/components/activity-timeline.js +224 -0
  7. package/src/lib/components/activity-timeline.js.map +1 -0
  8. package/src/lib/components/company-properties-card.js +53 -36
  9. package/src/lib/components/company-properties-card.js.map +1 -1
  10. package/src/lib/components/contact-associations-card.d.ts +3 -2
  11. package/src/lib/components/contact-associations-card.js +12 -12
  12. package/src/lib/components/contact-associations-card.js.map +1 -1
  13. package/src/lib/components/contact-detail-page.d.ts +4 -3
  14. package/src/lib/components/contact-detail-page.js +4 -10
  15. package/src/lib/components/contact-detail-page.js.map +1 -1
  16. package/src/lib/components/contact-properties-card.d.ts +21 -4
  17. package/src/lib/components/contact-properties-card.js +129 -29
  18. package/src/lib/components/contact-properties-card.js.map +1 -1
  19. package/src/lib/components/contact-timeline-card.d.ts +12 -6
  20. package/src/lib/components/contact-timeline-card.js +132 -174
  21. package/src/lib/components/contact-timeline-card.js.map +1 -1
  22. package/src/lib/components/contacts-section.js +197 -198
  23. package/src/lib/components/contacts-section.js.map +1 -1
  24. package/src/lib/components/crm-email-state-chip.js +4 -1
  25. package/src/lib/components/crm-email-state-chip.js.map +1 -1
  26. package/src/lib/components/deal-products-card.js +15 -13
  27. package/src/lib/components/deal-products-card.js.map +1 -1
  28. package/src/lib/components/deal-properties-card.js +7 -0
  29. package/src/lib/components/deal-properties-card.js.map +1 -1
  30. package/src/lib/components/deal-stage-card.js +31 -29
  31. package/src/lib/components/deal-stage-card.js.map +1 -1
  32. package/src/lib/components/deals-section.js +9 -14
  33. package/src/lib/components/deals-section.js.map +1 -1
  34. package/src/lib/components/fields-section.d.ts +4 -0
  35. package/src/lib/components/fields-section.js +368 -154
  36. package/src/lib/components/fields-section.js.map +1 -1
  37. package/src/lib/components/form-contact-fields-card.js +24 -20
  38. package/src/lib/components/form-contact-fields-card.js.map +1 -1
  39. package/src/lib/components/lead-campaigns-card.d.ts +1 -1
  40. package/src/lib/components/lead-campaigns-card.js +32 -35
  41. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  42. package/src/lib/components/lead-properties-card.d.ts +14 -6
  43. package/src/lib/components/lead-properties-card.js +317 -313
  44. package/src/lib/components/lead-properties-card.js.map +1 -1
  45. package/src/lib/components/lead-surfaces-note.js +13 -2
  46. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  47. package/src/lib/components/linked-deals-card.js +12 -10
  48. package/src/lib/components/linked-deals-card.js.map +1 -1
  49. package/src/lib/components/recent-activity-feed.d.ts +12 -8
  50. package/src/lib/components/recent-activity-feed.js +54 -29
  51. package/src/lib/components/recent-activity-feed.js.map +1 -1
  52. package/src/lib/components/record-activity-card.d.ts +4 -2
  53. package/src/lib/components/record-activity-card.js +29 -11
  54. package/src/lib/components/record-activity-card.js.map +1 -1
  55. package/src/lib/components/reports/activity-card.js +18 -12
  56. package/src/lib/components/reports/activity-card.js.map +1 -1
  57. package/src/lib/components/reports/lead-funnel-card.js +47 -37
  58. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  59. package/src/lib/components/reports/pipeline-card.js +22 -16
  60. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  61. package/src/lib/components/reports/report-export.d.ts +4 -11
  62. package/src/lib/components/reports/report-export.js +16 -33
  63. package/src/lib/components/reports/report-export.js.map +1 -1
  64. package/src/lib/components/reports/source-conversion-card.js +18 -12
  65. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  66. package/src/lib/components/reports/tasks-card.js +35 -25
  67. package/src/lib/components/reports/tasks-card.js.map +1 -1
  68. package/src/lib/components/tasks-section.js +23 -23
  69. package/src/lib/components/tasks-section.js.map +1 -1
  70. package/src/lib/constants/field-list-query.d.ts +23 -0
  71. package/src/lib/constants/field-list-query.js +80 -0
  72. package/src/lib/constants/field-list-query.js.map +1 -0
  73. package/src/lib/model/lead-filters.d.ts +1 -1
  74. package/src/lib/server/capture-contact.js +7 -5
  75. package/src/lib/server/capture-contact.js.map +1 -1
  76. package/src/lib/components/contact-custom-fields-card.d.ts +0 -39
  77. package/src/lib/components/contact-custom-fields-card.js +0 -204
  78. package/src/lib/components/contact-custom-fields-card.js.map +0 -1
@@ -15,10 +15,11 @@
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { formLeadSource } from "@aglyn/aglyn/app-utils/forms";
18
19
  import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
19
20
  import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
20
21
  import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
21
- import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
22
+ import { addHostLeadOutcome, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
22
23
  import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
23
24
  /**
24
25
  * The CRM answering the platform's contact-capture contract (AGL-3080).
@@ -142,8 +143,8 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
142
143
  // this only needs to know whether the person is new, which decides the event.
143
144
  const created = !await readLeadForHost(request.hostId, key);
144
145
  const { formId } = entryPointOf(request.detail);
145
- const source = request.interaction.source === 'form' && formId ? `form:${formId}` : request.interaction.source;
146
- const stored = await addHostLead(_extends({
146
+ const source = request.interaction.source === 'form' && formId ? formLeadSource(formId) : request.interaction.source;
147
+ const outcome = await addHostLeadOutcome(_extends({
147
148
  hostRef,
148
149
  hostId: request.hostId,
149
150
  lead: _extends({
@@ -158,7 +159,7 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
158
159
  }, campaignTouchOf(request.detail).campaignTouch ? {
159
160
  touch: campaignTouchOf(request.detail).campaignTouch
160
161
  } : {}));
161
- if (!stored) {
162
+ if (!outcome.stored) {
162
163
  return {
163
164
  ok: false,
164
165
  reason: 'band',
@@ -178,7 +179,8 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
178
179
  ok: true,
179
180
  record: 'lead',
180
181
  leadId: key,
181
- created
182
+ created,
183
+ sourceAdded: outcome.sourceAdded
182
184
  };
183
185
  }
184
186
  /** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","stored","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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 {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
@@ -1,39 +0,0 @@
1
- import { type ConsolePluginPageProps } from '@aglyn/aglyn';
2
- export interface ContactCustomFieldsCardProps extends Pick<ConsolePluginPageProps, 'hostId' | 'org'> {
3
- contactId: string;
4
- /** The CRM surface's own path, for the link to the Fields section. */
5
- basePath?: string;
6
- /**
7
- * The contact document, facets and all, when the page already holds it.
8
- *
9
- * Omit it and the card reads the document once itself — the shape the
10
- * record stub mounts it in. A page that passes it pays no second read,
11
- * and owns the seed discipline for it: the card's own guard covers only
12
- * the read the card made.
13
- */
14
- contact?: Record<string, unknown> | null;
15
- }
16
- /**
17
- * The custom fields on one contact, as THIS holder's values (AGL-2601).
18
- *
19
- * Every active definition draws the control its type calls for, seeded
20
- * from the viewing group's facet — `facets.{group}.custom.{key}` — and
21
- * never from another holder's, because a value is one business's knowledge
22
- * of a person.
23
- *
24
- * Save sends ONLY the keys that changed to `crm/contact-update` (AGL-2804):
25
- * the values are the holder's facet, which the server writes, judging each
26
- * against its definition and writing it at its own dotted path — a nested
27
- * `custom` object would replace the map and take every key this card did
28
- * not touch out with it — and refusing a plan without the CRM. A
29
- * refusal is shown in the route's own words, with the draft kept.
30
- *
31
- * A cleared control sends `null`, the explicit "cleared" the model keeps the
32
- * key present with, rather than deleting the field: a `where` on the key can
33
- * still find the contact, and an export still shows the column.
34
- */
35
- export declare function ContactCustomFieldsCard(props: ContactCustomFieldsCardProps): import("react").JSX.Element;
36
- export declare namespace ContactCustomFieldsCard {
37
- var displayName: string;
38
- }
39
- export default ContactCustomFieldsCard;
@@ -1,204 +0,0 @@
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 { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
- import * as Aglyn from "@aglyn/aglyn";
20
- import { pluginDocsHelp } from "@aglyn/aglyn";
21
- import { AppLink, CardDisplay } from "@aglyn/shared-ui-jsx";
22
- import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.component";
23
- import { useSnackbar } from "@aglyn/shared-ui-snackstack";
24
- import { useFirestore, useFirestoreDoc, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
25
- import { Button, Stack, Typography } from "@mui/material";
26
- import { doc } from "firebase/firestore";
27
- import { useCallback, useMemo, useState } from "react";
28
- import { useContactFieldDefinitions } from "../hooks/use-contact-field-definitions.js";
29
- import { useContactUpdate } from "../hooks/use-contact-update.js";
30
- import { useCrmScope } from "../hooks/use-crm-scope.js";
31
- import { contactPrimaryGroup } from "../model/contact-record.js";
32
- import { crmCustomDraftChanges, crmCustomDraftMissingRequired, crmCustomDraftValue } from "../model/crm-custom-draft.js";
33
- import { crmRoutes } from "../model/crm-routes.js";
34
- import { CrmCustomFieldControl } from "./crm-custom-field-control.js";
35
- /**
36
- * The custom fields on one contact, as THIS holder's values (AGL-2601).
37
- *
38
- * Every active definition draws the control its type calls for, seeded
39
- * from the viewing group's facet — `facets.{group}.custom.{key}` — and
40
- * never from another holder's, because a value is one business's knowledge
41
- * of a person.
42
- *
43
- * Save sends ONLY the keys that changed to `crm/contact-update` (AGL-2804):
44
- * the values are the holder's facet, which the server writes, judging each
45
- * against its definition and writing it at its own dotted path — a nested
46
- * `custom` object would replace the map and take every key this card did
47
- * not touch out with it — and refusing a plan without the CRM. A
48
- * refusal is shown in the route's own words, with the draft kept.
49
- *
50
- * A cleared control sends `null`, the explicit "cleared" the model keeps the
51
- * key present with, rather than deleting the field: a `where` on the key can
52
- * still find the contact, and an export still shows the column.
53
- */ export function ContactCustomFieldsCard(props) {
54
- var _ref, _ownRead_data;
55
- const { hostId, org, contactId, contact, basePath } = props;
56
- const firestore = useFirestore();
57
- const { enqueueSnackbar } = useSnackbar();
58
- const contactUpdate = useContactUpdate(hostId != null ? hostId : null);
59
- const { scope, consentGroup: viewingGroup } = useCrmScope({
60
- hostId,
61
- org
62
- });
63
- const orgId = (_ref = scope == null ? void 0 : scope[1]) != null ? _ref : null;
64
- const { active, ready } = useContactFieldDefinitions(orgId);
65
- // The record, read here only when the page did not hand one over.
66
- const ownRead = useFirestoreDoc(()=>contact === undefined && scope ? doc(firestore, scope[0], scope[1], 'contacts', contactId) : null, [
67
- firestore,
68
- scope,
69
- contactId,
70
- contact === undefined
71
- ]);
72
- const record = contact === undefined ? (_ownRead_data = ownRead.data) != null ? _ownRead_data : null : contact;
73
- // The facet the values live on: the viewing group's under a site, the
74
- // person's own primary holder's at the organization level (AGL-2630).
75
- const consentGroup = useMemo(()=>viewingGroup != null ? viewingGroup : contactPrimaryGroup(record, org), [
76
- viewingGroup,
77
- record,
78
- org
79
- ]);
80
- const stored = useMemo(()=>{
81
- var _Aglyn_readContactFacet_custom;
82
- return (_Aglyn_readContactFacet_custom = Aglyn.readContactFacet(record, consentGroup.groupId).custom) != null ? _Aglyn_readContactFacet_custom : {};
83
- }, [
84
- record,
85
- consentGroup.groupId
86
- ]);
87
- /** Only the keys the reader touched; everything else reads from `stored` — see `crm-custom-draft`. */ const [draft, setDraft] = useState({});
88
- const [saving, setSaving] = useState(false);
89
- const setValue = useCallback((key, value)=>{
90
- setDraft((current)=>_extends({}, current, {
91
- [key]: value
92
- }));
93
- }, []);
94
- /** The keys whose draft differs from what is stored — what Save sends. */ const changed = useMemo(()=>crmCustomDraftChanges(stored, draft), [
95
- draft,
96
- stored
97
- ]);
98
- const clearingRequired = crmCustomDraftMissingRequired(active, stored, draft, 'edit').length > 0;
99
- const handleSave = useCallback(async ()=>{
100
- if (!scope || !changed.length || clearingRequired) return;
101
- setSaving(true);
102
- try {
103
- const verdict = await writeGuardedBySeed({
104
- subject: 'contact',
105
- unreadable: contact === undefined && ownRead.status === 'error',
106
- fromCache: contact === undefined && ownRead.fromCache
107
- }, ()=>contactUpdate.updateOne(contactId, {
108
- custom: Object.fromEntries(changed)
109
- }));
110
- if (!verdict.ok) {
111
- return void enqueueSnackbar(verdict.message, {
112
- variant: 'warning',
113
- persist: false
114
- });
115
- }
116
- setDraft({});
117
- enqueueSnackbar('Contact saved', {
118
- variant: 'success',
119
- persist: false
120
- });
121
- } catch (error) {
122
- console.error(error);
123
- enqueueSnackbar(error instanceof Error && error.message ? error.message : 'An error has occurred', {
124
- variant: 'error',
125
- allowDuplicate: true
126
- });
127
- } finally{
128
- setSaving(false);
129
- }
130
- }, [
131
- scope,
132
- changed,
133
- clearingRequired,
134
- contact,
135
- ownRead.status,
136
- ownRead.fromCache,
137
- contactUpdate,
138
- contactId,
139
- enqueueSnackbar
140
- ]);
141
- return /*#__PURE__*/ _jsx(CardDisplay, {
142
- header: 'Custom fields',
143
- help: pluginDocsHelp('contactFields', {
144
- anchor: '#where-values-show'
145
- }),
146
- contentGutterX: true,
147
- contentGutterY: true,
148
- children: /*#__PURE__*/ _jsx(Stack, {
149
- spacing: 2,
150
- children: !ready ? null : active.length === 0 ? /*#__PURE__*/ _jsx(EmptyStateComponent, {
151
- compact: true,
152
- label: 'No custom fields yet',
153
- description: 'A field defined under Fields is kept on every contact and shows here.',
154
- action: basePath ? /*#__PURE__*/ _jsx(Button, _extends({
155
- component: AppLink
156
- }, {
157
- componentVariant: 'naked',
158
- nativeButton: false
159
- }, {
160
- href: crmRoutes(basePath).section('fields'),
161
- size: "small",
162
- variant: "contained",
163
- color: "primary",
164
- children: 'New field'
165
- })) : undefined
166
- }) : /*#__PURE__*/ _jsxs(_Fragment, {
167
- children: [
168
- active.map((definition)=>/*#__PURE__*/ _jsx(CrmCustomFieldControl, {
169
- definition: definition,
170
- value: crmCustomDraftValue(stored, draft, definition.key),
171
- onChange: (value)=>setValue(definition.key, value)
172
- }, definition.$id)),
173
- clearingRequired ? /*#__PURE__*/ _jsx(Typography, {
174
- variant: "caption",
175
- color: "error",
176
- children: 'A required field cannot be left empty.'
177
- }) : null,
178
- /*#__PURE__*/ _jsxs(Stack, {
179
- direction: "row",
180
- spacing: 1,
181
- children: [
182
- /*#__PURE__*/ _jsx(Button, {
183
- variant: "contained",
184
- color: "primary",
185
- disabled: !changed.length || clearingRequired || saving || !scope,
186
- onClick: handleSave,
187
- children: 'Save'
188
- }),
189
- changed.length ? /*#__PURE__*/ _jsx(Button, {
190
- onClick: ()=>setDraft({}),
191
- disabled: saving,
192
- children: 'Discard'
193
- }) : null
194
- ]
195
- })
196
- ]
197
- })
198
- })
199
- });
200
- }
201
- ContactCustomFieldsCard.displayName = 'ContactCustomFieldsCard';
202
- export default ContactCustomFieldsCard;
203
-
204
- //# sourceMappingURL=contact-custom-fields-card.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-custom-fields-card.tsx"],"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 * as Aglyn from '@aglyn/aglyn'\nimport { type ConsolePluginPageProps, type ContactCustomValue, pluginDocsHelp } from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n useFirestore,\n useFirestoreDoc,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { Button, Stack, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { useContactUpdate } from '../hooks/use-contact-update'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { contactPrimaryGroup } from '../model/contact-record'\nimport {\n type CrmCustomDraft,\n crmCustomDraftChanges,\n crmCustomDraftMissingRequired,\n crmCustomDraftValue,\n} from '../model/crm-custom-draft'\nimport { crmRoutes } from '../model/crm-routes'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\n\nexport interface ContactCustomFieldsCardProps\n extends Pick<ConsolePluginPageProps, 'hostId' | 'org'> {\n contactId: string\n /** The CRM surface's own path, for the link to the Fields section. */\n basePath?: string\n /**\n * The contact document, facets and all, when the page already holds it.\n *\n * Omit it and the card reads the document once itself — the shape the\n * record stub mounts it in. A page that passes it pays no second read,\n * and owns the seed discipline for it: the card's own guard covers only\n * the read the card made.\n */\n contact?: Record<string, unknown> | null\n}\n\n/**\n * The custom fields on one contact, as THIS holder's values (AGL-2601).\n *\n * Every active definition draws the control its type calls for, seeded\n * from the viewing group's facet — `facets.{group}.custom.{key}` — and\n * never from another holder's, because a value is one business's knowledge\n * of a person.\n *\n * Save sends ONLY the keys that changed to `crm/contact-update` (AGL-2804):\n * the values are the holder's facet, which the server writes, judging each\n * against its definition and writing it at its own dotted path — a nested\n * `custom` object would replace the map and take every key this card did\n * not touch out with it — and refusing a plan without the CRM. A\n * refusal is shown in the route's own words, with the draft kept.\n *\n * A cleared control sends `null`, the explicit \"cleared\" the model keeps the\n * key present with, rather than deleting the field: a `where` on the key can\n * still find the contact, and an export still shows the column.\n */\nexport function ContactCustomFieldsCard(props: ContactCustomFieldsCardProps) {\n const { hostId, org, contactId, contact, basePath } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const contactUpdate = useContactUpdate(hostId ?? null)\n const { scope, consentGroup: viewingGroup } = useCrmScope({ hostId, org })\n const orgId = scope?.[1] ?? null\n const { active, ready } = useContactFieldDefinitions(orgId)\n\n // The record, read here only when the page did not hand one over.\n const ownRead = useFirestoreDoc<Record<string, unknown>>(\n () =>\n contact === undefined && scope\n ? doc(firestore, scope[0], scope[1], 'contacts', contactId)\n : null,\n [firestore, scope, contactId, contact === undefined],\n )\n const record = contact === undefined ? (ownRead.data ?? null) : contact\n // The facet the values live on: the viewing group's under a site, the\n // person's own primary holder's at the organization level (AGL-2630).\n const consentGroup = useMemo(\n () => viewingGroup ?? contactPrimaryGroup(record, org as Record<string, unknown>),\n [viewingGroup, record, org],\n )\n const stored = useMemo(\n () => Aglyn.readContactFacet(record, consentGroup.groupId).custom ?? {},\n [record, consentGroup.groupId],\n )\n\n /** Only the keys the reader touched; everything else reads from `stored` — see `crm-custom-draft`. */\n const [draft, setDraft] = useState<CrmCustomDraft>({})\n const [saving, setSaving] = useState(false)\n const setValue = useCallback((key: string, value: ContactCustomValue) => {\n setDraft((current) => ({ ...current, [key]: value }))\n }, [])\n\n /** The keys whose draft differs from what is stored — what Save sends. */\n const changed = useMemo(() => crmCustomDraftChanges(stored, draft), [draft, stored])\n const clearingRequired =\n crmCustomDraftMissingRequired(active, stored, draft, 'edit').length > 0\n\n const handleSave = useCallback(async () => {\n if (!scope || !changed.length || clearingRequired) return\n setSaving(true)\n try {\n const verdict = await writeGuardedBySeed(\n {\n subject: 'contact',\n unreadable: contact === undefined && ownRead.status === 'error',\n fromCache: contact === undefined && ownRead.fromCache,\n },\n () => contactUpdate.updateOne(contactId, { custom: Object.fromEntries(changed) }),\n )\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, { variant: 'warning', persist: false })\n }\n setDraft({})\n enqueueSnackbar('Contact saved', { variant: 'success', persist: false })\n } catch (error) {\n console.error(error)\n enqueueSnackbar(\n error instanceof Error && error.message ? error.message : 'An error has occurred',\n { variant: 'error', allowDuplicate: true },\n )\n } finally {\n setSaving(false)\n }\n }, [\n scope,\n changed,\n clearingRequired,\n contact,\n ownRead.status,\n ownRead.fromCache,\n contactUpdate,\n contactId,\n enqueueSnackbar,\n ])\n\n return (\n <CardDisplay\n header={'Custom fields'}\n help={pluginDocsHelp('contactFields', { anchor: '#where-values-show' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {!ready ? null : active.length === 0 ? (\n <EmptyStateComponent\n compact\n label={'No custom fields yet'}\n description={'A field defined under Fields is kept on every contact and shows here.'}\n action={\n basePath ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={crmRoutes(basePath).section('fields')}\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n >\n {'New field'}\n </Button>\n ) : undefined\n }\n />\n ) : (\n <>\n {active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={crmCustomDraftValue(stored, draft, definition.key)}\n onChange={(value) => setValue(definition.key, value)}\n />\n ))}\n {clearingRequired ? (\n <Typography variant=\"caption\" color=\"error\">\n {'A required field cannot be left empty.'}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!changed.length || clearingRequired || saving || !scope}\n onClick={handleSave}\n >\n {'Save'}\n </Button>\n {changed.length ? (\n <Button onClick={() => setDraft({})} disabled={saving}>\n {'Discard'}\n </Button>\n ) : null}\n </Stack>\n </>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nContactCustomFieldsCard.displayName = 'ContactCustomFieldsCard'\n\nexport default ContactCustomFieldsCard\n"],"names":["Aglyn","pluginDocsHelp","AppLink","CardDisplay","EmptyStateComponent","useSnackbar","useFirestore","useFirestoreDoc","writeGuardedBySeed","Button","Stack","Typography","doc","useCallback","useMemo","useState","useContactFieldDefinitions","useContactUpdate","useCrmScope","contactPrimaryGroup","crmCustomDraftChanges","crmCustomDraftMissingRequired","crmCustomDraftValue","crmRoutes","CrmCustomFieldControl","ContactCustomFieldsCard","props","ownRead","hostId","org","contactId","contact","basePath","firestore","enqueueSnackbar","contactUpdate","scope","consentGroup","viewingGroup","orgId","active","ready","undefined","record","data","stored","readContactFacet","groupId","custom","draft","setDraft","saving","setSaving","setValue","key","value","current","changed","clearingRequired","length","handleSave","verdict","subject","unreadable","status","fromCache","updateOne","Object","fromEntries","ok","message","variant","persist","error","console","Error","allowDuplicate","header","help","anchor","contentGutterX","contentGutterY","spacing","compact","label","description","action","component","componentVariant","nativeButton","href","section","size","color","map","definition","onChange","$id","direction","disabled","onClick","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,YAAYA,WAAW,eAAc;AACrC,SAA+DC,cAAc,QAAQ,eAAc;AACnG,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,OAAOC,yBAAyB,wDAAuD;AACvF,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SACEC,YAAY,EACZC,eAAe,EACfC,kBAAkB,QACb,iCAAgC;AACvC,SAASC,MAAM,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACzD,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,0BAA0B,QAAQ,4CAAwC;AACnF,SAASC,gBAAgB,QAAQ,iCAA6B;AAC9D,SAASC,WAAW,QAAQ,4BAAwB;AACpD,SAASC,mBAAmB,QAAQ,6BAAyB;AAC7D,SAEEC,qBAAqB,EACrBC,6BAA6B,EAC7BC,mBAAmB,QACd,+BAA2B;AAClC,SAASC,SAAS,QAAQ,yBAAqB;AAC/C,SAASC,qBAAqB,QAAQ,gCAA4B;AAkBlE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,wBAAwBC,KAAmC;cAiBjCC;IAhBxC,MAAM,EAAEC,MAAM,EAAEC,GAAG,EAAEC,SAAS,EAAEC,OAAO,EAAEC,QAAQ,EAAE,GAAGN;IACtD,MAAMO,YAAY3B;IAClB,MAAM,EAAE4B,eAAe,EAAE,GAAG7B;IAC5B,MAAM8B,gBAAgBlB,iBAAiBW,iBAAAA,SAAU;IACjD,MAAM,EAAEQ,KAAK,EAAEC,cAAcC,YAAY,EAAE,GAAGpB,YAAY;QAAEU;QAAQC;IAAI;IACxE,MAAMU,gBAAQH,yBAAAA,KAAO,CAAC,EAAE,mBAAI;IAC5B,MAAM,EAAEI,MAAM,EAAEC,KAAK,EAAE,GAAGzB,2BAA2BuB;IAErD,kEAAkE;IAClE,MAAMZ,UAAUpB,gBACd,IACEwB,YAAYW,aAAaN,QACrBxB,IAAIqB,WAAWG,KAAK,CAAC,EAAE,EAAEA,KAAK,CAAC,EAAE,EAAE,YAAYN,aAC/C,MACN;QAACG;QAAWG;QAAON;QAAWC,YAAYW;KAAU;IAEtD,MAAMC,SAASZ,YAAYW,aAAaf,gBAAAA,QAAQiB,IAAI,YAAZjB,gBAAgB,OAAQI;IAChE,sEAAsE;IACtE,sEAAsE;IACtE,MAAMM,eAAevB,QACnB,IAAMwB,uBAAAA,eAAgBnB,oBAAoBwB,QAAQd,MAClD;QAACS;QAAcK;QAAQd;KAAI;IAE7B,MAAMgB,SAAS/B,QACb;YAAMd;gBAAAA,iCAAAA,MAAM8C,gBAAgB,CAACH,QAAQN,aAAaU,OAAO,EAAEC,MAAM,YAA3DhD,iCAA+D,CAAC;OACtE;QAAC2C;QAAQN,aAAaU,OAAO;KAAC;IAGhC,oGAAoG,GACpG,MAAM,CAACE,OAAOC,SAAS,GAAGnC,SAAyB,CAAC;IACpD,MAAM,CAACoC,QAAQC,UAAU,GAAGrC,SAAS;IACrC,MAAMsC,WAAWxC,YAAY,CAACyC,KAAaC;QACzCL,SAAS,CAACM,UAAa,aAAKA;gBAAS,CAACF,IAAI,EAAEC;;IAC9C,GAAG,EAAE;IAEL,wEAAwE,GACxE,MAAME,UAAU3C,QAAQ,IAAMM,sBAAsByB,QAAQI,QAAQ;QAACA;QAAOJ;KAAO;IACnF,MAAMa,mBACJrC,8BAA8BmB,QAAQK,QAAQI,OAAO,QAAQU,MAAM,GAAG;IAExE,MAAMC,aAAa/C,YAAY;QAC7B,IAAI,CAACuB,SAAS,CAACqB,QAAQE,MAAM,IAAID,kBAAkB;QACnDN,UAAU;QACV,IAAI;YACF,MAAMS,UAAU,MAAMrD,mBACpB;gBACEsD,SAAS;gBACTC,YAAYhC,YAAYW,aAAaf,QAAQqC,MAAM,KAAK;gBACxDC,WAAWlC,YAAYW,aAAaf,QAAQsC,SAAS;YACvD,GACA,IAAM9B,cAAc+B,SAAS,CAACpC,WAAW;oBAAEkB,QAAQmB,OAAOC,WAAW,CAACX;gBAAS;YAEjF,IAAI,CAACI,QAAQQ,EAAE,EAAE;gBACf,OAAO,KAAKnC,gBAAgB2B,QAAQS,OAAO,EAAE;oBAAEC,SAAS;oBAAWC,SAAS;gBAAM;YACpF;YACAtB,SAAS,CAAC;YACVhB,gBAAgB,iBAAiB;gBAAEqC,SAAS;gBAAWC,SAAS;YAAM;QACxE,EAAE,OAAOC,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdvC,gBACEuC,iBAAiBE,SAASF,MAAMH,OAAO,GAAGG,MAAMH,OAAO,GAAG,yBAC1D;gBAAEC,SAAS;gBAASK,gBAAgB;YAAK;QAE7C,SAAU;YACRxB,UAAU;QACZ;IACF,GAAG;QACDhB;QACAqB;QACAC;QACA3B;QACAJ,QAAQqC,MAAM;QACdrC,QAAQsC,SAAS;QACjB9B;QACAL;QACAI;KACD;IAED,qBACE,KAAC/B;QACC0E,QAAQ;QACRC,MAAM7E,eAAe,iBAAiB;YAAE8E,QAAQ;QAAqB;QACrEC,cAAc;QACdC,cAAc;kBAEd,cAAA,KAACvE;YAAMwE,SAAS;sBACb,CAACzC,QAAQ,OAAOD,OAAOmB,MAAM,KAAK,kBACjC,KAACvD;gBACC+E,OAAO;gBACPC,OAAO;gBACPC,aAAa;gBACbC,QACEtD,yBACE,KAACvB;oBACC8E,WAAWrF;mBACN;oBAAEsF,kBAAkB;oBAASC,cAAc;gBAAM;oBACtDC,MAAMnE,UAAUS,UAAU2D,OAAO,CAAC;oBAClCC,MAAK;oBACLrB,SAAQ;oBACRsB,OAAM;8BAEL;sBAEDnD;+BAIR;;oBACGF,OAAOsD,GAAG,CAAC,CAACC,2BACX,KAACvE;4BAECuE,YAAYA;4BACZxC,OAAOjC,oBAAoBuB,QAAQI,OAAO8C,WAAWzC,GAAG;4BACxD0C,UAAU,CAACzC,QAAUF,SAAS0C,WAAWzC,GAAG,EAAEC;2BAHzCwC,WAAWE,GAAG;oBAMtBvC,iCACC,KAAC/C;wBAAW4D,SAAQ;wBAAUsB,OAAM;kCACjC;yBAED;kCACJ,MAACnF;wBAAMwF,WAAU;wBAAMhB,SAAS;;0CAC9B,KAACzE;gCACC8D,SAAQ;gCACRsB,OAAM;gCACNM,UAAU,CAAC1C,QAAQE,MAAM,IAAID,oBAAoBP,UAAU,CAACf;gCAC5DgE,SAASxC;0CAER;;4BAEFH,QAAQE,MAAM,iBACb,KAAClD;gCAAO2F,SAAS,IAAMlD,SAAS,CAAC;gCAAIiD,UAAUhD;0CAC5C;iCAED;;;;;;;AAOlB;AACA1B,wBAAwB4E,WAAW,GAAG;AAEtC,eAAe5E,wBAAuB"}