@aglyn/plugins-crm 1.0.0-beta.296 → 1.1.0

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 (161) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/activity-link-fields.d.ts +34 -0
  3. package/src/lib/components/activity-link-fields.js +141 -0
  4. package/src/lib/components/activity-link-fields.js.map +1 -0
  5. package/src/lib/components/activity-list.d.ts +8 -0
  6. package/src/lib/components/activity-list.js +4 -2
  7. package/src/lib/components/activity-list.js.map +1 -1
  8. package/src/lib/components/activity-queries.d.ts +7 -2
  9. package/src/lib/components/activity-queries.js +7 -3
  10. package/src/lib/components/activity-queries.js.map +1 -1
  11. package/src/lib/components/company-contact-roles-card.d.ts +42 -0
  12. package/src/lib/components/company-contact-roles-card.js +135 -0
  13. package/src/lib/components/company-contact-roles-card.js.map +1 -0
  14. package/src/lib/components/company-detail-page.js +12 -1
  15. package/src/lib/components/company-detail-page.js.map +1 -1
  16. package/src/lib/components/company-edit-drawer.js +9 -6
  17. package/src/lib/components/company-edit-drawer.js.map +1 -1
  18. package/src/lib/components/company-picker.js +4 -2
  19. package/src/lib/components/company-picker.js.map +1 -1
  20. package/src/lib/components/contact-associations-card.js +15 -1
  21. package/src/lib/components/contact-associations-card.js.map +1 -1
  22. package/src/lib/components/contact-detail-page.js +13 -14
  23. package/src/lib/components/contact-detail-page.js.map +1 -1
  24. package/src/lib/components/contact-list-columns.js +5 -4
  25. package/src/lib/components/contact-list-columns.js.map +1 -1
  26. package/src/lib/components/contact-properties-card.js +41 -12
  27. package/src/lib/components/contact-properties-card.js.map +1 -1
  28. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  29. package/src/lib/components/contact-roles-editor.js +270 -0
  30. package/src/lib/components/contact-roles-editor.js.map +1 -0
  31. package/src/lib/components/contacts-section.js +6 -4
  32. package/src/lib/components/contacts-section.js.map +1 -1
  33. package/src/lib/components/crm-console-sections.js +1 -1
  34. package/src/lib/components/crm-console-sections.js.map +1 -1
  35. package/src/lib/components/crm-record-picker.d.ts +19 -0
  36. package/src/lib/components/crm-record-picker.js +40 -11
  37. package/src/lib/components/crm-record-picker.js.map +1 -1
  38. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  39. package/src/lib/components/crm-related-to-picker.js +135 -0
  40. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  41. package/src/lib/components/crm-site-picker.d.ts +13 -1
  42. package/src/lib/components/crm-site-picker.js +15 -6
  43. package/src/lib/components/crm-site-picker.js.map +1 -1
  44. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  45. package/src/lib/components/crm-transfer-buttons.js +9 -6
  46. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  47. package/src/lib/components/deal-contact-roles-card.js +21 -239
  48. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  49. package/src/lib/components/deal-detail-page.js +2 -1
  50. package/src/lib/components/deal-detail-page.js.map +1 -1
  51. package/src/lib/components/deal-edit-drawer.js +11 -7
  52. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  53. package/src/lib/components/deals-section.js +5 -4
  54. package/src/lib/components/deals-section.js.map +1 -1
  55. package/src/lib/components/lead-campaigns-card.js +26 -14
  56. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  57. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  58. package/src/lib/components/log-activity-dialog.js +96 -32
  59. package/src/lib/components/log-activity-dialog.js.map +1 -1
  60. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  61. package/src/lib/components/new-contact-drawer.js +27 -15
  62. package/src/lib/components/new-contact-drawer.js.map +1 -1
  63. package/src/lib/components/picklist-values-card.js +7 -4
  64. package/src/lib/components/picklist-values-card.js.map +1 -1
  65. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  66. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  67. package/src/lib/components/record-activity-card.d.ts +11 -2
  68. package/src/lib/components/record-activity-card.js +90 -5
  69. package/src/lib/components/record-activity-card.js.map +1 -1
  70. package/src/lib/components/settings-section.d.ts +4 -2
  71. package/src/lib/components/settings-section.js +7 -5
  72. package/src/lib/components/settings-section.js.map +1 -1
  73. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  74. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  75. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  76. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  77. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  78. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  79. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  80. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  81. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  82. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  83. package/src/lib/hooks/use-crm-scope.js +12 -5
  84. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  85. package/src/lib/model/contact-holder.d.ts +12 -0
  86. package/src/lib/model/contact-holder.js +20 -1
  87. package/src/lib/model/contact-holder.js.map +1 -1
  88. package/src/lib/model/contact-record.d.ts +5 -0
  89. package/src/lib/model/contact-record.js +7 -1
  90. package/src/lib/model/contact-record.js.map +1 -1
  91. package/src/lib/model/contact-update.d.ts +6 -0
  92. package/src/lib/model/contact-update.js +1 -0
  93. package/src/lib/model/contact-update.js.map +1 -1
  94. package/src/lib/model/crm-activity-link.d.ts +117 -0
  95. package/src/lib/model/crm-activity-link.js +113 -0
  96. package/src/lib/model/crm-activity-link.js.map +1 -0
  97. package/src/lib/model/crm-api.d.ts +4 -1
  98. package/src/lib/model/crm-api.js +9 -5
  99. package/src/lib/model/crm-api.js.map +1 -1
  100. package/src/lib/model/deal-board-model.js +5 -3
  101. package/src/lib/model/deal-board-model.js.map +1 -1
  102. package/src/lib/model/deal-contact-roles.d.ts +15 -1
  103. package/src/lib/model/deal-contact-roles.js +17 -3
  104. package/src/lib/model/deal-contact-roles.js.map +1 -1
  105. package/src/lib/model/settle-stage-names.d.ts +25 -0
  106. package/src/lib/model/settle-stage-names.js +29 -0
  107. package/src/lib/model/settle-stage-names.js.map +1 -0
  108. package/src/lib/native-nav.js +1 -1
  109. package/src/lib/native-nav.js.map +1 -1
  110. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  111. package/src/lib/server/add-contact-by-hand.js +212 -0
  112. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  113. package/src/lib/server/api-v1/contacts.js +61 -23
  114. package/src/lib/server/api-v1/contacts.js.map +1 -1
  115. package/src/lib/server/api-v1/crm-activities.js +13 -3
  116. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  117. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  118. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  119. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  120. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  121. package/src/lib/server/api-v1/crm-shared.js +23 -11
  122. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  123. package/src/lib/server/api-v1/openapi.js +3 -3
  124. package/src/lib/server/api-v1/openapi.js.map +1 -1
  125. package/src/lib/server/automation-steps.js +11 -9
  126. package/src/lib/server/automation-steps.js.map +1 -1
  127. package/src/lib/server/campaign-filing-activity.js +7 -2
  128. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  129. package/src/lib/server/contact-update.d.ts +4 -0
  130. package/src/lib/server/contact-update.js +71 -10
  131. package/src/lib/server/contact-update.js.map +1 -1
  132. package/src/lib/server/convert-host-lead.js +13 -2
  133. package/src/lib/server/convert-host-lead.js.map +1 -1
  134. package/src/lib/server/convert-open-lead.js +8 -1
  135. package/src/lib/server/convert-open-lead.js.map +1 -1
  136. package/src/lib/server/crm-activity-company.d.ts +25 -0
  137. package/src/lib/server/crm-activity-company.js +34 -0
  138. package/src/lib/server/crm-activity-company.js.map +1 -0
  139. package/src/lib/server/crm-inbound-email.js +21 -15
  140. package/src/lib/server/crm-inbound-email.js.map +1 -1
  141. package/src/lib/server/crm-sharing.js +9 -2
  142. package/src/lib/server/crm-sharing.js.map +1 -1
  143. package/src/lib/server/email-send.js +10 -8
  144. package/src/lib/server/email-send.js.map +1 -1
  145. package/src/lib/server/merge-contacts.js +6 -2
  146. package/src/lib/server/merge-contacts.js.map +1 -1
  147. package/src/lib/server/org-caller.d.ts +8 -0
  148. package/src/lib/server/org-caller.js +10 -0
  149. package/src/lib/server/org-caller.js.map +1 -1
  150. package/src/lib/server/record-timeline.d.ts +3 -1
  151. package/src/lib/server/record-timeline.js +11 -3
  152. package/src/lib/server/record-timeline.js.map +1 -1
  153. package/src/lib/server/task-reminders-route.d.ts +7 -5
  154. package/src/lib/server/task-reminders-route.js +82 -15
  155. package/src/lib/server/task-reminders-route.js.map +1 -1
  156. package/src/lib/server.js +152 -43
  157. package/src/lib/server.js.map +1 -1
  158. package/src/lib/transfer/contacts.js +83 -48
  159. package/src/lib/transfer/contacts.js.map +1 -1
  160. package/src/lib/transfer/fields.js +1 -2
  161. package/src/lib/transfer/fields.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordDeliveryRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaggedDeliveryRequest,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n CRM_EMAIL_ACTIVITY_TAG,\n CRM_EMAIL_ORG_TAG,\n crmScopeTokens,\n crmTaskLabelsForNew,\n crmTaskListFields,\n isCrmActivityKind,\n isCrmTaskKind,\n readContactFacet,\n visibleToHost,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n findContactByEmail,\n firebaseAdmin,\n readLeadForHost,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { buildCrmCapturedEmailActivity, crmCapturedEmailKey } from '../model/crm-inbound'\nimport { readCrmTaskPicklists } from './read-picklist'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\nimport { crmTaskReminderAfterEdit } from '../model/crm-task-reminders'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport {\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n crmEmailDeliveryStateForEvent,\n recordCrmEmailDelivery,\n} from './crm-email-activity'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written;\n * 6. a bounce or a complaint on a filed email (AGL-3245) advances that\n * email's own delivery state — the row the send was filed under, found\n * by its `Message-ID` — exactly as the campaign webhook advances one,\n * so the timeline reads Sent, then Bounced, with what the server said.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n *\n * ## A record found rather than named\n *\n * A caller that holds no id of the CRM's — a booking made through a link a\n * rep dropped from a record, or one taken off the widget cold (AGL-2660) —\n * hands back the record the link carried, or the booker's address, and the\n * entry is filed where the CRM finds it: the record the reference names\n * when this site may see it, otherwise the contact the address names\n * through the org's address index, narrowed to the site. A deal carries its\n * contact and company onto the entry; a contact its company, from the\n * site's own facet. A task with no assignee of the caller's goes to whoever\n * holds that record.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** What a reference handed back by a caller may name: the records a booking link is dropped from. */\nconst FOUND_RECORD_KINDS: ReadonlySet<string> = new Set(['contact', 'lead', 'deal'])\n/**\n * A record id as the CRM mints them: Firestore's for a contact and a deal, a\n * person key for a lead. A reference arrives on a public request, so one\n * that could not be an id is passed over rather than read.\n */\nconst RECORD_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/** The record an entry lands on, and who holds it. */\ninterface FoundRecord {\n link: CrmActivityLink\n holderUid?: string\n}\n\n/** A contact's link and holder, from this site's facet on it. */\nfunction contactRecord(\n contact: FirebaseFirestore.DocumentSnapshot,\n groupId: string,\n): FoundRecord {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: { contactId: contact.id, ...(facet.companyId ? { companyId: facet.companyId } : {}) },\n ...(facet.ownerUid ? { holderUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * The record a caller's reference names, then the contact its address does,\n * as `hostId` may see them; `null` when neither finds one. See the module\n * comment.\n */\nasync function findRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: PluginRecordEntryContext['link'],\n): Promise<FoundRecord | null> {\n const contacts = orgRef.collection('contacts')\n const kind = String(link?.record?.kind ?? '')\n const id = String(link?.record?.id ?? '')\n if (FOUND_RECORD_KINDS.has(kind) && RECORD_ID_PATTERN.test(id)) {\n if (kind === 'contact') {\n const contact = await contacts.doc(id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n return contactRecord(contact, groupId)\n }\n } else if (kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: { dealId: deal.id, ...(contactId ? { contactId } : {}), ...(companyId ? { companyId } : {}) },\n ...(ownerUid ? { holderUid: ownerUid } : {}),\n }\n }\n } else {\n // A lead carries its own `visibleTo` (AGL-3275), and the site has to be\n // allowed to see it, or one brand's booking would name another's lead.\n const lead = await readLeadForHost(hostId, id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return { link: { leadId: lead.id }, ...(ownerUid ? { holderUid: ownerUid } : {}) }\n }\n }\n }\n const email = String(link?.email ?? '').trim()\n if (!email) return null\n const hit = await findContactByEmail(contacts, email, { hostId })\n return hit ? contactRecord(hit, groupId) : null\n}\n\n/** Who holds a record a caller named by id: its contact's site facet, or its lead. */\nasync function holderOf(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: CrmActivityLink,\n): Promise<string | undefined> {\n if (link.contactId) {\n const contact = await orgRef.collection('contacts').doc(link.contactId).get()\n return contact.exists ? contactRecord(contact, groupId).holderUid : undefined\n }\n if (link.leadId) {\n const lead = await readLeadForHost(hostId, link.leadId)\n return String(lead?.get('ownerUid') ?? '') || undefined\n }\n return undefined\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /**\n * The org, its plan, the link and the scope an entry is stamped with — and,\n * when `wantHolder`, who holds the record it lands on.\n */\n async function admit(\n context: PluginRecordEntryContext,\n wantHolder = false,\n ): Promise<\n | PluginRecordWrite\n | {\n firestore: Firestore\n orgRef: FirebaseFirestore.DocumentReference\n link: CrmActivityLink\n visibleTo: string[]\n holderUid?: string\n }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const named = linkFields(context.link)\n const isNamed = Boolean(named.contactId || named.companyId || named.leadId)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!isNamed && !context.link?.record && !String(context.link?.email ?? '').trim()) {\n return refuse(400, 'An entry is filed on a contact, a company or a lead, or names how to find one.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n const group = consentGroupForHost(org, hostId)\n let link = named\n let holderUid: string | undefined\n if (isNamed) {\n if (wantHolder) holderUid = await holderOf(orgRef, hostId, group.groupId, named)\n } else {\n const found = await findRecord(orgRef, hostId, group.groupId, context.link)\n if (!found) return refuse(404, 'Nothing this site keeps matches the record or the address.')\n link = found.link\n holderUid = found.holderUid\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, group),\n ...(holderUid ? { holderUid } : {}),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request, request.assigneeUid === null)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n // `null` is \"whoever holds the record\", which admission read.\n const assigneeUid =\n request.assigneeUid === null\n ? (admitted.holderUid ?? '')\n : String(request.assigneeUid ?? '').trim()\n // The org's labels for the meanings it is filed with (AGL-3517).\n const labels = crmTaskLabelsForNew(await readCrmTaskPicklists(firestore, orgRef.id), {\n kind: request.kind,\n priority: 'normal',\n })\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n ...labels,\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const created = await createOnce(ref, { ...task, ...crmTaskListFields(task) })\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n\n async recordEmailDelivery(request: PluginRecordDeliveryRequest): Promise<PluginRecordWrite> {\n const orgId = String(request.orgId ?? '').trim()\n const key = crmCapturedEmailKey(request.messageId, null)\n if (!orgId || !key) return refuse(400, 'A delivery names its organization and the email’s Message-ID.')\n if (request.state !== 'bounced' && request.state !== 'complained') {\n return refuse(400, `\"${String(request.state)}\" isn't a delivery failure.`)\n }\n const firestore = deps.firestore()\n const ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n const outcome = await recordCrmEmailDelivery(firestore, {\n orgId,\n activityId: ref.id,\n state: request.state,\n atMs: request.atMs,\n detail: request.detail ?? null,\n })\n if (outcome === 'missing') return refuse(404, 'No email by that Message-ID is filed here.')\n if (outcome === 'failed') return refuse(409, 'The delivery state could not be written.')\n return { ok: true, id: ref.id, created: outcome === 'advanced' }\n },\n\n /*\n * A delivery event for a message the CRM tagged (AGL-2615): an email sent\n * from a record, or an automation's email to its contact, carries the org\n * and the activity it was filed as. The state comes from the log's own\n * vocabulary; both ids are path components, so both are checked before\n * either names a document. Tags that name no activity of the CRM's are\n * somebody else's message. The write is forward-only and never throws.\n */\n async recordTaggedDelivery(request: PluginRecordTaggedDeliveryRequest): Promise<void> {\n const state = crmEmailDeliveryStateForEvent(request.event)\n const activityId = request.tags?.[CRM_EMAIL_ACTIVITY_TAG]\n const orgId = request.tags?.[CRM_EMAIL_ORG_TAG]\n if (!state || !isDocumentId(activityId) || !isDocumentId(orgId)) return\n await recordCrmEmailDelivery(deps.firestore(), {\n orgId,\n activityId,\n state,\n atMs: Number.isFinite(request.atMs) ? request.atMs : Date.now(),\n })\n },\n\n // An automation's email to the person its event is about (AGL-2615):\n // see `automation-steps.ts`, loaded with the first one.\n async prepareEmail(request) {\n const { prepareCrmRecordEmail } = await import('./automation-steps')\n return prepareCrmRecordEmail(request)\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","CRM_EMAIL_ACTIVITY_TAG","CRM_EMAIL_ORG_TAG","crmScopeTokens","crmTaskLabelsForNew","crmTaskListFields","isCrmActivityKind","isCrmTaskKind","readContactFacet","visibleToHost","countCrmActivitiesForRecord","findContactByEmail","firebaseAdmin","readLeadForHost","isDocumentId","createHash","FieldValue","BUNDLE_ID","buildCrmCapturedEmailActivity","crmCapturedEmailKey","readCrmTaskPicklists","CRM_SUITE_FEATURE","crmTaskReminderAfterEdit","recomputeCrmNextTaskAt","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","crmEmailDeliveryStateForEvent","recordCrmEmailDelivery","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","leadId","FOUND_RECORD_KINDS","Set","RECORD_ID_PATTERN","contactRecord","contact","groupId","facet","data","id","ownerUid","holderUid","findRecord","orgRef","hostId","contacts","collection","kind","record","has","test","doc","get","exists","deal","deals","dealId","lead","email","trim","hit","holderOf","undefined","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","wantHolder","snapshot","orgId","named","isNamed","Boolean","firestore","org","group","found","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","admitted","activity","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","assigneeUid","tasks","notes","labels","priority","task","remindAtMs","previous","createdByUid","catch","console","recordEmailDelivery","state","outcome","activityId","detail","recordTaggedDelivery","event","tags","Date","now","prepareEmail","prepareCrmRecordEmail","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordDeliveryRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaggedDeliveryRequest,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n CRM_EMAIL_ACTIVITY_TAG,\n CRM_EMAIL_ORG_TAG,\n crmScopeTokens,\n crmTaskLabelsForNew,\n crmTaskListFields,\n isCrmActivityKind,\n isCrmTaskKind,\n readContactFacet,\n visibleToHost,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n findContactByEmail,\n firebaseAdmin,\n readLeadForHost,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { buildCrmCapturedEmailActivity, crmCapturedEmailKey } from '../model/crm-inbound'\nimport { readCrmTaskPicklists } from './read-picklist'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\nimport { crmTaskReminderAfterEdit } from '../model/crm-task-reminders'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport { crmActivityLinkWithCompany } from './crm-activity-company'\nimport {\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n crmEmailDeliveryStateForEvent,\n recordCrmEmailDelivery,\n} from './crm-email-activity'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written;\n * 6. a bounce or a complaint on a filed email (AGL-3245) advances that\n * email's own delivery state — the row the send was filed under, found\n * by its `Message-ID` — exactly as the campaign webhook advances one,\n * so the timeline reads Sent, then Bounced, with what the server said;\n * 7. an activity rolls up to its deal's company, else its contact's\n * (AGL-4123), as every writer's does.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n *\n * ## A record found rather than named\n *\n * A caller that holds no id of the CRM's — a booking made through a link a\n * rep dropped from a record, or one taken off the widget cold (AGL-2660) —\n * hands back the record the link carried, or the booker's address, and the\n * entry is filed where the CRM finds it: the record the reference names\n * when this site may see it, otherwise the contact the address names\n * through the org's address index, narrowed to the site. A deal carries its\n * contact and company onto the entry; a contact its company, from the\n * site's own facet. A task with no assignee of the caller's goes to whoever\n * holds that record.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** What a reference handed back by a caller may name: the records a booking link is dropped from. */\nconst FOUND_RECORD_KINDS: ReadonlySet<string> = new Set(['contact', 'lead', 'deal'])\n/**\n * A record id as the CRM mints them: Firestore's for a contact and a deal, a\n * person key for a lead. A reference arrives on a public request, so one\n * that could not be an id is passed over rather than read.\n */\nconst RECORD_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/** The record an entry lands on, and who holds it. */\ninterface FoundRecord {\n link: CrmActivityLink\n holderUid?: string\n}\n\n/** A contact's link and holder, from this site's facet on it. */\nfunction contactRecord(\n contact: FirebaseFirestore.DocumentSnapshot,\n groupId: string,\n): FoundRecord {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: { contactId: contact.id, ...(facet.companyId ? { companyId: facet.companyId } : {}) },\n ...(facet.ownerUid ? { holderUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * The record a caller's reference names, then the contact its address does,\n * as `hostId` may see them; `null` when neither finds one. See the module\n * comment.\n */\nasync function findRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: PluginRecordEntryContext['link'],\n): Promise<FoundRecord | null> {\n const contacts = orgRef.collection('contacts')\n const kind = String(link?.record?.kind ?? '')\n const id = String(link?.record?.id ?? '')\n if (FOUND_RECORD_KINDS.has(kind) && RECORD_ID_PATTERN.test(id)) {\n if (kind === 'contact') {\n const contact = await contacts.doc(id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n return contactRecord(contact, groupId)\n }\n } else if (kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: { dealId: deal.id, ...(contactId ? { contactId } : {}), ...(companyId ? { companyId } : {}) },\n ...(ownerUid ? { holderUid: ownerUid } : {}),\n }\n }\n } else {\n // A lead carries its own `visibleTo` (AGL-3275), and the site has to be\n // allowed to see it, or one brand's booking would name another's lead.\n const lead = await readLeadForHost(hostId, id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return { link: { leadId: lead.id }, ...(ownerUid ? { holderUid: ownerUid } : {}) }\n }\n }\n }\n const email = String(link?.email ?? '').trim()\n if (!email) return null\n const hit = await findContactByEmail(contacts, email, { hostId })\n return hit ? contactRecord(hit, groupId) : null\n}\n\n/** Who holds a record a caller named by id: its contact's site facet, or its lead. */\nasync function holderOf(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: CrmActivityLink,\n): Promise<string | undefined> {\n if (link.contactId) {\n const contact = await orgRef.collection('contacts').doc(link.contactId).get()\n return contact.exists ? contactRecord(contact, groupId).holderUid : undefined\n }\n if (link.leadId) {\n const lead = await readLeadForHost(hostId, link.leadId)\n return String(lead?.get('ownerUid') ?? '') || undefined\n }\n return undefined\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /**\n * The org, its plan, the link and the scope an entry is stamped with — and,\n * when `wantHolder`, who holds the record it lands on.\n */\n async function admit(\n context: PluginRecordEntryContext,\n wantHolder = false,\n ): Promise<\n | PluginRecordWrite\n | {\n firestore: Firestore\n orgRef: FirebaseFirestore.DocumentReference\n link: CrmActivityLink\n visibleTo: string[]\n groupId: string\n holderUid?: string\n }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const named = linkFields(context.link)\n const isNamed = Boolean(named.contactId || named.companyId || named.leadId)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!isNamed && !context.link?.record && !String(context.link?.email ?? '').trim()) {\n return refuse(400, 'An entry is filed on a contact, a company or a lead, or names how to find one.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n const group = consentGroupForHost(org, hostId)\n let link = named\n let holderUid: string | undefined\n if (isNamed) {\n if (wantHolder) holderUid = await holderOf(orgRef, hostId, group.groupId, named)\n } else {\n const found = await findRecord(orgRef, hostId, group.groupId, context.link)\n if (!found) return refuse(404, 'Nothing this site keeps matches the record or the address.')\n link = found.link\n holderUid = found.holderUid\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, group),\n groupId: group.groupId,\n ...(holderUid ? { holderUid } : {}),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo, groupId } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n // After the refusals, so an entry answered off one read costs no others.\n const { companyId } = await crmActivityLinkWithCompany(orgRef, link, { groupId })\n const stamped = {\n ...activity,\n ...(companyId ? { companyId } : {}),\n sourcePluginId: request.sourcePluginId,\n }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request, request.assigneeUid === null)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n // `null` is \"whoever holds the record\", which admission read.\n const assigneeUid =\n request.assigneeUid === null\n ? (admitted.holderUid ?? '')\n : String(request.assigneeUid ?? '').trim()\n // The org's labels for the meanings it is filed with (AGL-3517).\n const labels = crmTaskLabelsForNew(await readCrmTaskPicklists(firestore, orgRef.id), {\n kind: request.kind,\n priority: 'normal',\n })\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n ...labels,\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const created = await createOnce(ref, { ...task, ...crmTaskListFields(task) })\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n\n async recordEmailDelivery(request: PluginRecordDeliveryRequest): Promise<PluginRecordWrite> {\n const orgId = String(request.orgId ?? '').trim()\n const key = crmCapturedEmailKey(request.messageId, null)\n if (!orgId || !key) return refuse(400, 'A delivery names its organization and the email’s Message-ID.')\n if (request.state !== 'bounced' && request.state !== 'complained') {\n return refuse(400, `\"${String(request.state)}\" isn't a delivery failure.`)\n }\n const firestore = deps.firestore()\n const ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n const outcome = await recordCrmEmailDelivery(firestore, {\n orgId,\n activityId: ref.id,\n state: request.state,\n atMs: request.atMs,\n detail: request.detail ?? null,\n })\n if (outcome === 'missing') return refuse(404, 'No email by that Message-ID is filed here.')\n if (outcome === 'failed') return refuse(409, 'The delivery state could not be written.')\n return { ok: true, id: ref.id, created: outcome === 'advanced' }\n },\n\n /*\n * A delivery event for a message the CRM tagged (AGL-2615): an email sent\n * from a record, or an automation's email to its contact, carries the org\n * and the activity it was filed as. The state comes from the log's own\n * vocabulary; both ids are path components, so both are checked before\n * either names a document. Tags that name no activity of the CRM's are\n * somebody else's message. The write is forward-only and never throws.\n */\n async recordTaggedDelivery(request: PluginRecordTaggedDeliveryRequest): Promise<void> {\n const state = crmEmailDeliveryStateForEvent(request.event)\n const activityId = request.tags?.[CRM_EMAIL_ACTIVITY_TAG]\n const orgId = request.tags?.[CRM_EMAIL_ORG_TAG]\n if (!state || !isDocumentId(activityId) || !isDocumentId(orgId)) return\n await recordCrmEmailDelivery(deps.firestore(), {\n orgId,\n activityId,\n state,\n atMs: Number.isFinite(request.atMs) ? request.atMs : Date.now(),\n })\n },\n\n // An automation's email to the person its event is about (AGL-2615):\n // see `automation-steps.ts`, loaded with the first one.\n async prepareEmail(request) {\n const { prepareCrmRecordEmail } = await import('./automation-steps')\n return prepareCrmRecordEmail(request)\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","CRM_EMAIL_ACTIVITY_TAG","CRM_EMAIL_ORG_TAG","crmScopeTokens","crmTaskLabelsForNew","crmTaskListFields","isCrmActivityKind","isCrmTaskKind","readContactFacet","visibleToHost","countCrmActivitiesForRecord","findContactByEmail","firebaseAdmin","readLeadForHost","isDocumentId","createHash","FieldValue","BUNDLE_ID","buildCrmCapturedEmailActivity","crmCapturedEmailKey","readCrmTaskPicklists","CRM_SUITE_FEATURE","crmTaskReminderAfterEdit","recomputeCrmNextTaskAt","crmActivityLinkWithCompany","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","crmEmailDeliveryStateForEvent","recordCrmEmailDelivery","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","leadId","FOUND_RECORD_KINDS","Set","RECORD_ID_PATTERN","contactRecord","contact","groupId","facet","data","id","ownerUid","holderUid","findRecord","orgRef","hostId","contacts","collection","kind","record","has","test","doc","get","exists","deal","deals","dealId","lead","email","trim","hit","holderOf","undefined","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","wantHolder","snapshot","orgId","named","isNamed","Boolean","firestore","org","group","found","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","admitted","activity","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","assigneeUid","tasks","notes","labels","priority","task","remindAtMs","previous","createdByUid","catch","console","recordEmailDelivery","state","outcome","activityId","detail","recordTaggedDelivery","event","tags","Date","now","prepareEmail","prepareCrmRecordEmail","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
@@ -19,8 +19,9 @@
19
19
  *
20
20
  * The digest beside this route says once a morning what is late and what
21
21
  * is due today; nothing said "the 3:00 call is at 3:00". Every hour this
22
- * reads, per org, the open tasks whose `remindAtMs` has come and tells
23
- * each one's assignee ONCE — a console notification per task
22
+ * finds the orgs with an open task whose `remindAtMs` has come
23
+ * (`orgIdsWithDueReminders`), reads those orgs' tasks and tells each one's
24
+ * assignee ONCE — a console notification per task
24
25
  * (`content.taskReminder`, opening the record the task is for) and one
25
26
  * email per member per run listing everything of theirs that fell due.
26
27
  * The words and the rule live in `crm-task-reminders.ts`; this file is the
@@ -48,8 +49,8 @@
48
49
  * not come back every hour. A reminder is left unstamped only when the run
49
50
  * never reached a verdict: the send-rate governor refused, or the org
50
51
  * threw. Orgs are swept in id order behind a cursor, as the digest sweeps
51
- * them, so the workflow's `done:false` loop carries a platform of any
52
- * size across invocations.
52
+ * them, so the workflow's `done:false` loop carries any number of them
53
+ * across invocations.
53
54
  *
54
55
  * The task query reads the NEWEST reminders that have come due, not the
55
56
  * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a
@@ -70,7 +71,8 @@ export declare const CRM_TASK_REMINDERS_JOB_ID = "crm-task-reminders";
70
71
  * More than the digest's twenty-five: an org here costs one task query and,
71
72
  * only when something fell due, the roster and the sends. The common hour
72
73
  * finds nothing due in most orgs, and a chunk that reads a hundred empty
73
- * queries finishes well inside `maxDuration = 60`.
74
+ * queries finishes well inside `maxDuration = 60`, which is what the whole
75
+ * platform costs on the night `orgIdsWithDueReminders` cannot answer.
74
76
  */
75
77
  export declare const CRM_TASK_REMINDERS_ORG_CHUNK = 100;
76
78
  /**
@@ -49,8 +49,9 @@ import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCr
49
49
  *
50
50
  * The digest beside this route says once a morning what is late and what
51
51
  * is due today; nothing said "the 3:00 call is at 3:00". Every hour this
52
- * reads, per org, the open tasks whose `remindAtMs` has come and tells
53
- * each one's assignee ONCE — a console notification per task
52
+ * finds the orgs with an open task whose `remindAtMs` has come
53
+ * (`orgIdsWithDueReminders`), reads those orgs' tasks and tells each one's
54
+ * assignee ONCE — a console notification per task
54
55
  * (`content.taskReminder`, opening the record the task is for) and one
55
56
  * email per member per run listing everything of theirs that fell due.
56
57
  * The words and the rule live in `crm-task-reminders.ts`; this file is the
@@ -78,8 +79,8 @@ import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCr
78
79
  * not come back every hour. A reminder is left unstamped only when the run
79
80
  * never reached a verdict: the send-rate governor refused, or the org
80
81
  * threw. Orgs are swept in id order behind a cursor, as the digest sweeps
81
- * them, so the workflow's `done:false` loop carries a platform of any
82
- * size across invocations.
82
+ * them, so the workflow's `done:false` loop carries any number of them
83
+ * across invocations.
83
84
  *
84
85
  * The task query reads the NEWEST reminders that have come due, not the
85
86
  * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a
@@ -98,13 +99,55 @@ import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailParts, composeCr
98
99
  * More than the digest's twenty-five: an org here costs one task query and,
99
100
  * only when something fell due, the roster and the sends. The common hour
100
101
  * finds nothing due in most orgs, and a chunk that reads a hundred empty
101
- * queries finishes well inside `maxDuration = 60`.
102
+ * queries finishes well inside `maxDuration = 60`, which is what the whole
103
+ * platform costs on the night `orgIdsWithDueReminders` cannot answer.
102
104
  */ export const CRM_TASK_REMINDERS_ORG_CHUNK = 100;
105
+ /** Tasks per page of the platform-wide due query. */ const DUE_TASK_PAGE = 500;
103
106
  /**
104
107
  * The most members per org whose address is chased through the auth pools
105
108
  * when their member document carries none — the digest's bound, for the
106
109
  * digest's reason: work, not audience.
107
110
  */ export const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5;
111
+ /**
112
+ * THE ORGS WITH A REMINDER OWED, found from the tasks (AGL-4121).
113
+ *
114
+ * The sweep used to walk the orgs to find them: every org document read, and
115
+ * every entitled org's tasks asked, every hour, to learn that nothing had come
116
+ * due in almost all of them. Asked of the collection group, an hour with
117
+ * nothing due is one query and no org at all.
118
+ *
119
+ * Every page is read, not the newest, and that is what keeps this honest.
120
+ * `CRM_TASK_REMINDER_CEILING` is safe per org because the newest five hundred
121
+ * of ONE org begin with what fell due since the last run; across the platform
122
+ * one busy org's handled reminders could bury a quiet org's first one. This
123
+ * pays once for the same open tasks the org queries read, projected to what
124
+ * the rule needs, and `remindOrg` then decides from its own window exactly as
125
+ * it always has. An org found here with nothing inside that window reports
126
+ * `nothing-due`, as it did.
127
+ *
128
+ * Needs the COLLECTION_GROUP `(status, remindAtMs desc)` index, and the
129
+ * `crmTasks` entry for it is deployed after the code that first asks: see the
130
+ * caller for what happens until then.
131
+ */ async function orgIdsWithDueReminders(firestore, nowMs) {
132
+ const orgIds = new Set();
133
+ let after;
134
+ for(;;){
135
+ let query = firestore.collectionGroup(CRM_COLLECTIONS.tasks).where('status', '==', 'open').where('remindAtMs', '<=', nowMs).orderBy('remindAtMs', 'desc').select('status', 'remindAtMs', 'reminderSentAtMs').limit(DUE_TASK_PAGE);
136
+ if (after) query = query.startAfter(after);
137
+ const page = await query.get();
138
+ for (const doc of page.docs){
139
+ const org = doc.ref.parent.parent;
140
+ if ((org == null ? void 0 : org.parent.id) !== 'orgs') continue;
141
+ if (crmTaskReminderDue(doc.data(), nowMs)) {
142
+ orgIds.add(org.id);
143
+ }
144
+ }
145
+ if (page.docs.length < DUE_TASK_PAGE) return [
146
+ ...orgIds
147
+ ].sort();
148
+ after = page.docs[page.docs.length - 1];
149
+ }
150
+ }
108
151
  async function remindOrg(ctx, orgDoc) {
109
152
  var _orgDoc_data, _org_slug, _org_name;
110
153
  const { firestore, nowMs, timeZone, dryRun } = ctx;
@@ -415,8 +458,6 @@ export async function crmTaskRemindersRoute(request) {
415
458
  // nothing due still ran.
416
459
  if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID);
417
460
  try {
418
- var _ref;
419
- var _orgDocs_;
420
461
  const firestore = firebaseAdmin.app().firestore();
421
462
  const nowMs = Date.now();
422
463
  const ctx = {
@@ -428,17 +469,43 @@ export async function crmTaskRemindersRoute(request) {
428
469
  origin: consoleOrigin()
429
470
  };
430
471
  // The resumable sweep, in the shape the digest reads orgs (AGL-2220):
431
- // ordered by id, `limit + 1` to learn whether there is more, the cursor
432
- // validated as an org id before it becomes a reference.
472
+ // ordered by id, one more than a chunk to learn whether there is more, the
473
+ // cursor validated as an org id before it becomes a reference.
433
474
  const requestedLimit = Number(body == null ? void 0 : body.limit);
434
475
  const pageSize = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK) : CRM_TASK_REMINDERS_ORG_CHUNK;
435
476
  const rawCursor = body == null ? void 0 : body.cursor;
436
477
  const cursor = typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/') ? rawCursor : null;
437
- let orgQuery = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
438
- if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor));
439
- const page = await orgQuery.get();
440
- const hasMore = page.docs.length > pageSize;
441
- const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
478
+ /*
479
+ * Only the orgs with something owed. A due query that fails is not an hour
480
+ * with nothing due, so it never answers for one: it falls back to walking
481
+ * every org, which needs no collection-group index and is what makes this
482
+ * safe to ship ahead of the index it uses. Composite indexes deploy in the
483
+ * promotion's tail, and a reminder must not wait on the order of that tail.
484
+ */ const withDue = await orgIdsWithDueReminders(firestore, nowMs).catch((error)=>{
485
+ console.warn('[crm] task reminders: the due query failed, sweeping every org', error);
486
+ return null;
487
+ });
488
+ let orgDocs;
489
+ let hasMore;
490
+ /** Where the next chunk starts, whichever way this one was chosen. */ let lastOrgId;
491
+ if (withDue) {
492
+ var _chunk_;
493
+ const ahead = cursor ? withDue.filter((orgId)=>orgId > cursor) : withDue;
494
+ const chunk = ahead.slice(0, pageSize);
495
+ hasMore = ahead.length > pageSize;
496
+ lastOrgId = (_chunk_ = chunk[chunk.length - 1]) != null ? _chunk_ : null;
497
+ // A task can outlive its org by a moment; there is nobody to tell.
498
+ orgDocs = chunk.length ? (await firestore.getAll(...chunk.map((orgId)=>firestore.collection('orgs').doc(orgId)))).filter((doc)=>doc.exists) : [];
499
+ } else {
500
+ var _ref;
501
+ var _orgDocs_;
502
+ let orgQuery = firestore.collection('orgs').orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(pageSize + 1);
503
+ if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor));
504
+ const page = await orgQuery.get();
505
+ hasMore = page.docs.length > pageSize;
506
+ orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs;
507
+ lastOrgId = (_ref = (_orgDocs_ = orgDocs[orgDocs.length - 1]) == null ? void 0 : _orgDocs_.id) != null ? _ref : null;
508
+ }
442
509
  const orgs = {};
443
510
  const failures = {};
444
511
  let due = 0;
@@ -481,7 +548,7 @@ export async function crmTaskRemindersRoute(request) {
481
548
  } : {}, {
482
549
  // A deferred run reports `done: true` — see the module comment —
483
550
  // so the workflow's loop does not re-POST into a full window.
484
- nextCursor: deferred || !hasMore ? null : (_ref = (_orgDocs_ = orgDocs[orgDocs.length - 1]) == null ? void 0 : _orgDocs_.id) != null ? _ref : null,
551
+ nextCursor: deferred || !hasMore ? null : lastOrgId,
485
552
  done: deferred || !hasMore
486
553
  }), // 207: this chunk finished and some org in it needs a person, which is
487
554
  // what fails the workflow (its one alerting channel).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-reminders-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n crmDigestEntitled,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as the digest beside this route.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { digestTimeZone } from './daily-digest-route'\nimport {\n composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailParts,\n composeCrmTaskReminderEmailText,\n crmTaskReminderMergeValues,\n composeCrmTaskReminderSubject,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n} from '../model/crm-task-reminders'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.\n *\n * The digest beside this route says once a morning what is late and what\n * is due today; nothing said \"the 3:00 call is at 3:00\". Every hour this\n * reads, per org, the open tasks whose `remindAtMs` has come and tells\n * each one's assignee ONCE — a console notification per task\n * (`content.taskReminder`, opening the record the task is for) and one\n * email per member per run listing everything of theirs that fell due.\n * The words and the rule live in `crm-task-reminders.ts`; this file is the\n * reads, the sends and the sweep, in the digest's shape wherever the two\n * do the same thing.\n *\n * ## Who is told, and who is not\n *\n * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate\n * choice in the drawer, as the digest reads it. Only a member who may open\n * the CRM (`data.manage`, resolved through their custom role), only in an\n * org whose plan carries the suite and whose `release_crm` flag is\n * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan\n * that gets no digest gets no reminders either. The recipient's\n * operational-category mute (`notificationPrefs.content`) governs the WHOLE\n * reminder, mail included — unlike the digest, which has a switch of its\n * own for the schedule it keeps; a reminder is one message about one task.\n *\n * ## Once, and resumable\n *\n * `reminderSentAtMs` on the task is the idempotence: stamped in one batch\n * with the member's sends, so a rerun over the same hour reads the mark and\n * sends nothing, and stamped on every reminder the run HANDLED — sent, or\n * found nobody to send it to — so a reminder nobody can be told about does\n * not come back every hour. A reminder is left unstamped only when the run\n * never reached a verdict: the send-rate governor refused, or the org\n * threw. Orgs are swept in id order behind a cursor, as the digest sweeps\n * them, so the workflow's `done:false` loop carries a platform of any\n * size across invocations.\n *\n * The task query reads the NEWEST reminders that have come due, not the\n * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a\n * bounded page safe against a window that only ever grows.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron\n * fan-out on the shared sending domain, and the platform reminding a\n * member is not the org's sending. A refusal STOPS the run and reports\n * `done: true`; the refused member was never stamped, and the next hour's\n * run finds their reminders at the top of the window again.\n */\n\n/** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */\nexport const CRM_TASK_REMINDERS_JOB_ID = 'crm-task-reminders'\n\n/**\n * Orgs per invocation.\n *\n * More than the digest's twenty-five: an org here costs one task query and,\n * only when something fell due, the roster and the sends. The common hour\n * finds nothing due in most orgs, and a chunk that reads a hundred empty\n * queries finishes well inside `maxDuration = 60`.\n */\nexport const CRM_TASK_REMINDERS_ORG_CHUNK = 100\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none — the digest's bound, for the\n * digest's reason: work, not audience.\n */\nexport const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n tasks: number\n skipped?: string\n notified?: number\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n /** Reminders that had come due and were not yet handled. */\n due: number\n /** Reminders this run stamped as handled. */\n handled: number\n /** Console notifications sent. */\n notified: number\n /** Emails sent — one per member per run. */\n emailed: number\n members?: Record<string, MemberReport>\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/** A due reminder with the document it came from, so the stamp has a target. */\ninterface DueTask extends CrmReminderTask {\n ref: FirebaseFirestore.DocumentReference\n}\n\nasync function remindOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { due: 0, handled: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n /*\n * Every open task whose reminder has come, newest first, on\n * `(status, remindAtMs desc)`. The \"not yet handled\" half is a fact about\n * a field's absence, which a query cannot select on, so it is asked of\n * the page in memory — and the page being the NEWEST five hundred is what\n * keeps the handled ones from crowding out the next one due.\n */\n const page = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('remindAtMs', '<=', nowMs)\n .orderBy('remindAtMs', 'desc')\n .limit(CRM_TASK_REMINDER_CEILING)\n .get()\n const due: DueTask[] = []\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!crmTaskReminderDue(data as Parameters<typeof crmTaskReminderDue>[0], nowMs)) continue\n due.push({\n ref: doc.ref,\n id: doc.id,\n title: String(data['title'] ?? ''),\n kind: (data['kind'] as CrmReminderTask['kind']) ?? 'todo',\n dueAtMs: typeof data['dueAtMs'] === 'number' ? (data['dueAtMs'] as number) : null,\n remindAtMs: Number(data['remindAtMs']),\n assigneeUid: String(data['assigneeUid'] ?? ''),\n hostId: String(data['hostId'] ?? ''),\n contactId: (data['contactId'] as string) || undefined,\n companyId: (data['companyId'] as string) || undefined,\n dealId: (data['dealId'] as string) || undefined,\n })\n }\n const tasksTruncated = page.docs.length >= CRM_TASK_REMINDER_CEILING\n if (!due.length) {\n return { ...quiet, skipped: 'nothing-due', ...(tasksTruncated ? { tasksTruncated } : {}) }\n }\n\n const report: OrgReport = { due: due.length, handled: 0, notified: 0, emailed: 0, members: {} }\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n\n /**\n * Stamp a set of reminders as handled, in one batch, unless this is a\n * plan. Everything a member's verdict covers lands together, which is\n * what makes a rerun read the same answer for all of them.\n */\n const settle = async (tasks: DueTask[]): Promise<void> => {\n report.handled += tasks.length\n if (dryRun) return\n const batch = firestore.batch()\n for (const task of tasks) batch.update(task.ref, { reminderSentAtMs: nowMs })\n await batch.commit()\n }\n\n const byAssignee = new Map<string, DueTask[]>()\n const unassigned: DueTask[] = []\n for (const task of due) {\n if (!task.assigneeUid) {\n unassigned.push(task)\n continue\n }\n const mine = byAssignee.get(task.assigneeUid) ?? []\n mine.push(task)\n byAssignee.set(task.assigneeUid, mine)\n }\n // A reminder for nobody is handled by saying so: it would otherwise sit\n // at the top of the window every hour for as long as the task is open.\n if (unassigned.length) {\n rows[''] = { tasks: unassigned.length, skipped: 'unassigned' }\n await settle(unassigned)\n }\n if (!byAssignee.size) return report\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...byAssignee.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n // The sites the due tasks name, for the absolute links the mail carries.\n const hostIds = [...new Set(due.map((task) => task.hostId).filter(Boolean))]\n const hostDocs = hostIds.length\n ? await firestore.getAll(...hostIds.map((hostId) => firestore.collection('hosts').doc(hostId)))\n : []\n const hostById = new Map(hostDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const orgHub = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm` : null\n const hubUrl = (hostId: string): string | null => {\n if (!hostId) return orgHub\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The record the task is for, on its own hub — the notification's link\n // past the `/{hostId}/crm` or `/org/crm` prefix the console rewrites.\n const taskUrl = (task: CrmReminderTask): string => {\n const hub = hubUrl(task.hostId)\n if (!hub) return ctx.origin\n const link = crmTaskReminderLink(task.hostId || null, task)\n return `${hub}${link.slice(link.indexOf('/crm') + '/crm'.length)}`\n }\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let reminderEmail: Promise<LoadedSystemEmail | null> | undefined\n let poolLookups = 0\n\n for (const uid of uids) {\n const tasks = byAssignee.get(uid) as DueTask[]\n const row: MemberReport = { tasks: tasks.length }\n rows[uid] = row\n const member = memberById.get(uid)\n if (!member) {\n row.skipped = 'not-a-member'\n await settle(tasks)\n continue\n }\n if (!(await memberHasOrgPermission(orgId, member as AglynOrgMember, 'data.manage'))) {\n row.skipped = 'no-data-manage'\n await settle(tasks)\n continue\n }\n const userDoc = userById.get(uid)\n if (notificationMuted(userDoc?.get('notificationPrefs'), 'content.taskReminder')) {\n row.skipped = 'muted'\n await settle(tasks)\n continue\n }\n if (dryRun) {\n row.notified = tasks.length\n row.emailed = true\n await settle(tasks)\n continue\n }\n\n /*\n * THE EMAIL FIRST, for the digest's reason: a refusal from the\n * send-rate governor stops the run with nothing else done for this\n * member — no notification to double next hour, no mark to skip them\n * on — so the order here is the idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_TASK_REMINDERS_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const reminderInput = {\n tasks,\n timeZone,\n productName: branding.productName,\n workspaceName,\n taskUrl,\n settingsUrl,\n supportLine,\n }\n const subject = composeCrmTaskReminderSubject(tasks)\n // The `crm-task-reminder` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n reminderEmail ??= loadSystemEmail('crm-task-reminder').catch(() => null)\n const loaded = await reminderEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'reminder.subject': subject,\n 'reminder.body': composeCrmTaskReminderEmailText({ ...reminderInput, omitClosing: true }),\n ...crmTaskReminderMergeValues(composeCrmTaskReminderEmailParts(reminderInput)),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmTaskReminderEmailText(reminderInput),\n }),\n fromName: branding.fromName,\n context: 'crm-task-reminder',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n // One notification per task: each opens the record it is for.\n for (const task of tasks) {\n await notifyUsers([uid], {\n type: 'content.taskReminder',\n title: 'Task reminder',\n body: composeCrmTaskReminderBody(task, timeZone, workspaceName),\n link: crmTaskReminderLink(task.hostId || null, task),\n orgId,\n ...(task.hostId ? { hostId: task.hostId } : {}),\n })\n }\n await settle(tasks)\n row.notified = tasks.length\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n report.notified += tasks.length\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmTaskRemindersRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'Task reminders are not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955): an hour with\n // nothing due still ran.\n if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape the digest reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK)\n : CRM_TASK_REMINDERS_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let due = 0\n let handled = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await remindOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n due += report.due\n handled += report.handled\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] task reminders failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n due,\n handled,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] task reminders run failed', error)\n return Response.json({ error: 'Task reminders run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","crmDigestEntitled","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","digestTimeZone","composeCrmTaskReminderBody","composeCrmTaskReminderEmailParts","composeCrmTaskReminderEmailText","crmTaskReminderMergeValues","composeCrmTaskReminderSubject","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","recordCronBeat","jobId","app","firestore","CRM_TASK_REMINDERS_JOB_ID","CRM_TASK_REMINDERS_ORG_CHUNK","CRM_TASK_REMINDERS_MAX_UID_LOOKUPS","remindOrg","ctx","orgDoc","org","nowMs","timeZone","dryRun","orgId","id","data","quiet","due","handled","notified","emailed","features","skipped","flagOn","flagValues","page","ref","collection","tasks","where","orderBy","limit","get","doc","docs","push","title","String","kind","dueAtMs","remindAtMs","Number","assigneeUid","hostId","contactId","undefined","companyId","dealId","tasksTruncated","length","report","members","rows","settle","batch","task","update","reminderSentAtMs","commit","byAssignee","Map","unassigned","mine","set","size","listed","filter","member","orgSuspended","reachable","Set","map","$id","has","memberById","uids","keys","sort","userDocs","getAll","uid","userById","hostIds","Boolean","hostDocs","hostById","branding","orgSlug","workspaceName","trim","orgHub","origin","ORG_HOME","hubUrl","subdomain","HOST_DASHBOARD","host","taskUrl","hub","link","slice","indexOf","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","reminderEmail","poolLookups","row","userDoc","emailReason","address","email","toLowerCase","includes","pooled","catch","record","recipients","reminderInput","productName","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","crmTaskRemindersRoute","request","method","headers","rawHeaders","query","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","cursor","orgQuery","FieldPath","documentId","startAfter","hasMore","orgs","failures","console","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-reminders-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildRoute,\n CRM_COLLECTIONS,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n crmDigestEntitled,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as the digest beside this route.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { digestTimeZone } from './daily-digest-route'\nimport {\n composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailParts,\n composeCrmTaskReminderEmailText,\n crmTaskReminderMergeValues,\n composeCrmTaskReminderSubject,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n} from '../model/crm-task-reminders'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.\n *\n * The digest beside this route says once a morning what is late and what\n * is due today; nothing said \"the 3:00 call is at 3:00\". Every hour this\n * finds the orgs with an open task whose `remindAtMs` has come\n * (`orgIdsWithDueReminders`), reads those orgs' tasks and tells each one's\n * assignee ONCE — a console notification per task\n * (`content.taskReminder`, opening the record the task is for) and one\n * email per member per run listing everything of theirs that fell due.\n * The words and the rule live in `crm-task-reminders.ts`; this file is the\n * reads, the sends and the sweep, in the digest's shape wherever the two\n * do the same thing.\n *\n * ## Who is told, and who is not\n *\n * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate\n * choice in the drawer, as the digest reads it. Only a member who may open\n * the CRM (`data.manage`, resolved through their custom role), only in an\n * org whose plan carries the suite and whose `release_crm` flag is\n * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan\n * that gets no digest gets no reminders either. The recipient's\n * operational-category mute (`notificationPrefs.content`) governs the WHOLE\n * reminder, mail included — unlike the digest, which has a switch of its\n * own for the schedule it keeps; a reminder is one message about one task.\n *\n * ## Once, and resumable\n *\n * `reminderSentAtMs` on the task is the idempotence: stamped in one batch\n * with the member's sends, so a rerun over the same hour reads the mark and\n * sends nothing, and stamped on every reminder the run HANDLED — sent, or\n * found nobody to send it to — so a reminder nobody can be told about does\n * not come back every hour. A reminder is left unstamped only when the run\n * never reached a verdict: the send-rate governor refused, or the org\n * threw. Orgs are swept in id order behind a cursor, as the digest sweeps\n * them, so the workflow's `done:false` loop carries any number of them\n * across invocations.\n *\n * The task query reads the NEWEST reminders that have come due, not the\n * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a\n * bounded page safe against a window that only ever grows.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron\n * fan-out on the shared sending domain, and the platform reminding a\n * member is not the org's sending. A refusal STOPS the run and reports\n * `done: true`; the refused member was never stamped, and the next hour's\n * run finds their reminders at the top of the window again.\n */\n\n/** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */\nexport const CRM_TASK_REMINDERS_JOB_ID = 'crm-task-reminders'\n\n/**\n * Orgs per invocation.\n *\n * More than the digest's twenty-five: an org here costs one task query and,\n * only when something fell due, the roster and the sends. The common hour\n * finds nothing due in most orgs, and a chunk that reads a hundred empty\n * queries finishes well inside `maxDuration = 60`, which is what the whole\n * platform costs on the night `orgIdsWithDueReminders` cannot answer.\n */\nexport const CRM_TASK_REMINDERS_ORG_CHUNK = 100\n\n/** Tasks per page of the platform-wide due query. */\nconst DUE_TASK_PAGE = 500\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none — the digest's bound, for the\n * digest's reason: work, not audience.\n */\nexport const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.DocumentSnapshot\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n tasks: number\n skipped?: string\n notified?: number\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n /** Reminders that had come due and were not yet handled. */\n due: number\n /** Reminders this run stamped as handled. */\n handled: number\n /** Console notifications sent. */\n notified: number\n /** Emails sent — one per member per run. */\n emailed: number\n members?: Record<string, MemberReport>\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/** A due reminder with the document it came from, so the stamp has a target. */\ninterface DueTask extends CrmReminderTask {\n ref: FirebaseFirestore.DocumentReference\n}\n\n/**\n * THE ORGS WITH A REMINDER OWED, found from the tasks (AGL-4121).\n *\n * The sweep used to walk the orgs to find them: every org document read, and\n * every entitled org's tasks asked, every hour, to learn that nothing had come\n * due in almost all of them. Asked of the collection group, an hour with\n * nothing due is one query and no org at all.\n *\n * Every page is read, not the newest, and that is what keeps this honest.\n * `CRM_TASK_REMINDER_CEILING` is safe per org because the newest five hundred\n * of ONE org begin with what fell due since the last run; across the platform\n * one busy org's handled reminders could bury a quiet org's first one. This\n * pays once for the same open tasks the org queries read, projected to what\n * the rule needs, and `remindOrg` then decides from its own window exactly as\n * it always has. An org found here with nothing inside that window reports\n * `nothing-due`, as it did.\n *\n * Needs the COLLECTION_GROUP `(status, remindAtMs desc)` index, and the\n * `crmTasks` entry for it is deployed after the code that first asks: see the\n * caller for what happens until then.\n */\nasync function orgIdsWithDueReminders(firestore: Firestore, nowMs: number): Promise<string[]> {\n const orgIds = new Set<string>()\n let after: FirebaseFirestore.QueryDocumentSnapshot | undefined\n for (;;) {\n let query = firestore\n .collectionGroup(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('remindAtMs', '<=', nowMs)\n .orderBy('remindAtMs', 'desc')\n .select('status', 'remindAtMs', 'reminderSentAtMs')\n .limit(DUE_TASK_PAGE)\n if (after) query = query.startAfter(after)\n const page = await query.get()\n for (const doc of page.docs) {\n const org = doc.ref.parent.parent\n if (org?.parent.id !== 'orgs') continue\n if (crmTaskReminderDue(doc.data() as Parameters<typeof crmTaskReminderDue>[0], nowMs)) {\n orgIds.add(org.id)\n }\n }\n if (page.docs.length < DUE_TASK_PAGE) return [...orgIds].sort()\n after = page.docs[page.docs.length - 1]\n }\n}\n\nasync function remindOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { due: 0, handled: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n /*\n * Every open task whose reminder has come, newest first, on\n * `(status, remindAtMs desc)`. The \"not yet handled\" half is a fact about\n * a field's absence, which a query cannot select on, so it is asked of\n * the page in memory — and the page being the NEWEST five hundred is what\n * keeps the handled ones from crowding out the next one due.\n */\n const page = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('remindAtMs', '<=', nowMs)\n .orderBy('remindAtMs', 'desc')\n .limit(CRM_TASK_REMINDER_CEILING)\n .get()\n const due: DueTask[] = []\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!crmTaskReminderDue(data as Parameters<typeof crmTaskReminderDue>[0], nowMs)) continue\n due.push({\n ref: doc.ref,\n id: doc.id,\n title: String(data['title'] ?? ''),\n kind: (data['kind'] as CrmReminderTask['kind']) ?? 'todo',\n dueAtMs: typeof data['dueAtMs'] === 'number' ? (data['dueAtMs'] as number) : null,\n remindAtMs: Number(data['remindAtMs']),\n assigneeUid: String(data['assigneeUid'] ?? ''),\n hostId: String(data['hostId'] ?? ''),\n contactId: (data['contactId'] as string) || undefined,\n companyId: (data['companyId'] as string) || undefined,\n dealId: (data['dealId'] as string) || undefined,\n })\n }\n const tasksTruncated = page.docs.length >= CRM_TASK_REMINDER_CEILING\n if (!due.length) {\n return { ...quiet, skipped: 'nothing-due', ...(tasksTruncated ? { tasksTruncated } : {}) }\n }\n\n const report: OrgReport = { due: due.length, handled: 0, notified: 0, emailed: 0, members: {} }\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n\n /**\n * Stamp a set of reminders as handled, in one batch, unless this is a\n * plan. Everything a member's verdict covers lands together, which is\n * what makes a rerun read the same answer for all of them.\n */\n const settle = async (tasks: DueTask[]): Promise<void> => {\n report.handled += tasks.length\n if (dryRun) return\n const batch = firestore.batch()\n for (const task of tasks) batch.update(task.ref, { reminderSentAtMs: nowMs })\n await batch.commit()\n }\n\n const byAssignee = new Map<string, DueTask[]>()\n const unassigned: DueTask[] = []\n for (const task of due) {\n if (!task.assigneeUid) {\n unassigned.push(task)\n continue\n }\n const mine = byAssignee.get(task.assigneeUid) ?? []\n mine.push(task)\n byAssignee.set(task.assigneeUid, mine)\n }\n // A reminder for nobody is handled by saying so: it would otherwise sit\n // at the top of the window every hour for as long as the task is open.\n if (unassigned.length) {\n rows[''] = { tasks: unassigned.length, skipped: 'unassigned' }\n await settle(unassigned)\n }\n if (!byAssignee.size) return report\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...byAssignee.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n // The sites the due tasks name, for the absolute links the mail carries.\n const hostIds = [...new Set(due.map((task) => task.hostId).filter(Boolean))]\n const hostDocs = hostIds.length\n ? await firestore.getAll(...hostIds.map((hostId) => firestore.collection('hosts').doc(hostId)))\n : []\n const hostById = new Map(hostDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const orgHub = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm` : null\n const hubUrl = (hostId: string): string | null => {\n if (!hostId) return orgHub\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The record the task is for, on its own hub — the notification's link\n // past the `/{hostId}/crm` or `/org/crm` prefix the console rewrites.\n const taskUrl = (task: CrmReminderTask): string => {\n const hub = hubUrl(task.hostId)\n if (!hub) return ctx.origin\n const link = crmTaskReminderLink(task.hostId || null, task)\n return `${hub}${link.slice(link.indexOf('/crm') + '/crm'.length)}`\n }\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let reminderEmail: Promise<LoadedSystemEmail | null> | undefined\n let poolLookups = 0\n\n for (const uid of uids) {\n const tasks = byAssignee.get(uid) as DueTask[]\n const row: MemberReport = { tasks: tasks.length }\n rows[uid] = row\n const member = memberById.get(uid)\n if (!member) {\n row.skipped = 'not-a-member'\n await settle(tasks)\n continue\n }\n if (!(await memberHasOrgPermission(orgId, member as AglynOrgMember, 'data.manage'))) {\n row.skipped = 'no-data-manage'\n await settle(tasks)\n continue\n }\n const userDoc = userById.get(uid)\n if (notificationMuted(userDoc?.get('notificationPrefs'), 'content.taskReminder')) {\n row.skipped = 'muted'\n await settle(tasks)\n continue\n }\n if (dryRun) {\n row.notified = tasks.length\n row.emailed = true\n await settle(tasks)\n continue\n }\n\n /*\n * THE EMAIL FIRST, for the digest's reason: a refusal from the\n * send-rate governor stops the run with nothing else done for this\n * member — no notification to double next hour, no mark to skip them\n * on — so the order here is the idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_TASK_REMINDERS_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const reminderInput = {\n tasks,\n timeZone,\n productName: branding.productName,\n workspaceName,\n taskUrl,\n settingsUrl,\n supportLine,\n }\n const subject = composeCrmTaskReminderSubject(tasks)\n // The `crm-task-reminder` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n reminderEmail ??= loadSystemEmail('crm-task-reminder').catch(() => null)\n const loaded = await reminderEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'reminder.subject': subject,\n 'reminder.body': composeCrmTaskReminderEmailText({ ...reminderInput, omitClosing: true }),\n ...crmTaskReminderMergeValues(composeCrmTaskReminderEmailParts(reminderInput)),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmTaskReminderEmailText(reminderInput),\n }),\n fromName: branding.fromName,\n context: 'crm-task-reminder',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n // One notification per task: each opens the record it is for.\n for (const task of tasks) {\n await notifyUsers([uid], {\n type: 'content.taskReminder',\n title: 'Task reminder',\n body: composeCrmTaskReminderBody(task, timeZone, workspaceName),\n link: crmTaskReminderLink(task.hostId || null, task),\n orgId,\n ...(task.hostId ? { hostId: task.hostId } : {}),\n })\n }\n await settle(tasks)\n row.notified = tasks.length\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n report.notified += tasks.length\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmTaskRemindersRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'Task reminders are not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955): an hour with\n // nothing due still ran.\n if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape the digest reads orgs (AGL-2220):\n // ordered by id, one more than a chunk to learn whether there is more, the\n // cursor validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK)\n : CRM_TASK_REMINDERS_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n /*\n * Only the orgs with something owed. A due query that fails is not an hour\n * with nothing due, so it never answers for one: it falls back to walking\n * every org, which needs no collection-group index and is what makes this\n * safe to ship ahead of the index it uses. Composite indexes deploy in the\n * promotion's tail, and a reminder must not wait on the order of that tail.\n */\n const withDue = await orgIdsWithDueReminders(firestore, nowMs).catch((error: unknown) => {\n console.warn('[crm] task reminders: the due query failed, sweeping every org', error)\n return null\n })\n let orgDocs: Snapshot[]\n let hasMore: boolean\n /** Where the next chunk starts, whichever way this one was chosen. */\n let lastOrgId: string | null\n if (withDue) {\n const ahead = cursor ? withDue.filter((orgId) => orgId > cursor) : withDue\n const chunk = ahead.slice(0, pageSize)\n hasMore = ahead.length > pageSize\n lastOrgId = chunk[chunk.length - 1] ?? null\n // A task can outlive its org by a moment; there is nobody to tell.\n orgDocs = chunk.length\n ? (await firestore.getAll(...chunk.map((orgId) => firestore.collection('orgs').doc(orgId)))).filter(\n (doc) => doc.exists,\n )\n : []\n } else {\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n hasMore = page.docs.length > pageSize\n orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n lastOrgId = orgDocs[orgDocs.length - 1]?.id ?? null\n }\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let due = 0\n let handled = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await remindOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n due += report.due\n handled += report.handled\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] task reminders failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n due,\n handled,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : lastOrgId,\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] task reminders run failed', error)\n return Response.json({ error: 'Task reminders run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","crmDigestEntitled","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","digestTimeZone","composeCrmTaskReminderBody","composeCrmTaskReminderEmailParts","composeCrmTaskReminderEmailText","crmTaskReminderMergeValues","composeCrmTaskReminderSubject","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","recordCronBeat","jobId","app","firestore","CRM_TASK_REMINDERS_JOB_ID","CRM_TASK_REMINDERS_ORG_CHUNK","DUE_TASK_PAGE","CRM_TASK_REMINDERS_MAX_UID_LOOKUPS","orgIdsWithDueReminders","nowMs","orgIds","Set","after","query","collectionGroup","tasks","where","orderBy","select","limit","startAfter","page","get","doc","docs","org","ref","parent","id","data","add","length","sort","remindOrg","ctx","orgDoc","timeZone","dryRun","orgId","quiet","due","handled","notified","emailed","features","skipped","flagOn","flagValues","collection","push","title","String","kind","dueAtMs","remindAtMs","Number","assigneeUid","hostId","contactId","undefined","companyId","dealId","tasksTruncated","report","members","rows","settle","batch","task","update","reminderSentAtMs","commit","byAssignee","Map","unassigned","mine","set","size","listed","filter","member","orgSuspended","reachable","map","$id","has","memberById","uids","keys","userDocs","getAll","uid","userById","hostIds","Boolean","hostDocs","hostById","branding","orgSlug","workspaceName","trim","orgHub","origin","ORG_HOME","hubUrl","subdomain","HOST_DASHBOARD","host","taskUrl","hub","link","slice","indexOf","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","reminderEmail","poolLookups","row","userDoc","emailReason","address","email","toLowerCase","includes","pooled","catch","record","recipients","reminderInput","productName","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","crmTaskRemindersRoute","request","method","headers","rawHeaders","Response","json","error","status","process","env","CRON_SECRET","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","cursor","withDue","console","warn","orgDocs","hasMore","lastOrgId","chunk","ahead","exists","orgQuery","FieldPath","documentId","orgs","failures","message","failed","Object","swept","nextCursor","done"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAGEA,UAAU,EACVC,eAAe,EACfC,qBAAqB,EACrBC,iBAAiB,EACjBC,4BAA4B,EAC5BC,oBAAoB,EACpBC,sBAAsB,EACtBC,oBAAoB,EACpBC,sBAAsB,EACtBC,KAAK,QACA,sBAAqB;AAC5B,SACEC,iBAAiB,QACZ,yBAAqB;AAC5B,SAASC,oBAAoB,EAAEC,SAAS,QAAQ,2BAA0B;AAC1E,SAASC,2BAA2B,QAAQ,+CAA8C;AAC1F,SACEC,wBAAwB,EACxBC,aAAa,EACbC,0BAA0B,EAC1BC,cAAc,EACdC,sBAAsB,EACtBC,kBAAkB,EAClBC,WAAW,QACN,2BAA0B;AACjC,gEAAgE;AAChE,wEAAwE;AACxE,wEAAwE;AACxE,yDAAyD;AACzD,SAASC,sBAAsB,QAAQ,oDAAmD;AAC1F,SACEC,eAAe,EACfC,uBAAuB,EACvBC,gBAAgB,EAChBC,kBAAkB,QAEb,sDAAqD;AAC5D,SAASC,gBAAgB,EAAEC,YAAY,QAAQ,4CAA2C;AAC1F,SAASC,aAAa,QAAQ,uCAAsC;AACpE,SAASC,gBAAgB,QAAQ,4CAA2C;AAC5E,SAASC,yBAAyBC,aAAa,QAAQ,wCAAuC;AAC9F,SAASC,cAAc,QAAQ,0BAAsB;AACrD,SACEC,0BAA0B,EAC1BC,gCAAgC,EAChCC,+BAA+B,EAC/BC,0BAA0B,EAC1BC,6BAA6B,EAC7BC,yBAAyB,EAEzBC,kBAAkB,EAClBC,mBAAmB,QACd,iCAA6B;AAEpC;;;;;CAKC,GACD,eAAeC,eAAeC,KAAa;IACzC,IAAI;QACF,MAAMd,cAAcb,cAAc4B,GAAG,GAAGC,SAAS,IAAIF;IACvD,EAAE,eAAM;IACN,sEAAsE;IACxE;AACF;AAEA,uKAAuK;AAEvK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,wEAAwE,GACxE,OAAO,MAAMG,4BAA4B,qBAAoB;AAE7D;;;;;;;;CAQC,GACD,OAAO,MAAMC,+BAA+B,IAAG;AAE/C,mDAAmD,GACnD,MAAMC,gBAAgB;AAEtB;;;;CAIC,GACD,OAAO,MAAMC,qCAAqC,EAAC;AA6CnD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,eAAeC,uBAAuBL,SAAoB,EAAEM,KAAa;IACvE,MAAMC,SAAS,IAAIC;IACnB,IAAIC;IACJ,OAAS;QACP,IAAIC,QAAQV,UACTW,eAAe,CAACtD,gBAAgBuD,KAAK,EACrCC,KAAK,CAAC,UAAU,MAAM,QACtBA,KAAK,CAAC,cAAc,MAAMP,OAC1BQ,OAAO,CAAC,cAAc,QACtBC,MAAM,CAAC,UAAU,cAAc,oBAC/BC,KAAK,CAACb;QACT,IAAIM,OAAOC,QAAQA,MAAMO,UAAU,CAACR;QACpC,MAAMS,OAAO,MAAMR,MAAMS,GAAG;QAC5B,KAAK,MAAMC,OAAOF,KAAKG,IAAI,CAAE;YAC3B,MAAMC,MAAMF,IAAIG,GAAG,CAACC,MAAM,CAACA,MAAM;YACjC,IAAIF,CAAAA,uBAAAA,IAAKE,MAAM,CAACC,EAAE,MAAK,QAAQ;YAC/B,IAAI9B,mBAAmByB,IAAIM,IAAI,IAAgDpB,QAAQ;gBACrFC,OAAOoB,GAAG,CAACL,IAAIG,EAAE;YACnB;QACF;QACA,IAAIP,KAAKG,IAAI,CAACO,MAAM,GAAGzB,eAAe,OAAO;eAAII;SAAO,CAACsB,IAAI;QAC7DpB,QAAQS,KAAKG,IAAI,CAACH,KAAKG,IAAI,CAACO,MAAM,GAAG,EAAE;IACzC;AACF;AAEA,eAAeE,UAAUC,GAAiB,EAAEC,MAAgB;QAG7CA,cAkHUV,WACMA;IArH7B,MAAM,EAAEtB,SAAS,EAAEM,KAAK,EAAE2B,QAAQ,EAAEC,MAAM,EAAE,GAAGH;IAC/C,MAAMI,QAAQH,OAAOP,EAAE;IACvB,MAAMH,OAAOU,eAAAA,OAAON,IAAI,cAAXM,eAAiB,CAAC;IAC/B,MAAMI,QAAmB;QAAEC,KAAK;QAAGC,SAAS;QAAGC,UAAU;QAAGC,SAAS;IAAE;IAEvE,IAAI,CAAC1E,kBAAkBF,uBAAuB0D,KAAKmB,QAAQ,GAAG;QAC5D,OAAO,aAAKL;YAAOM,SAAS;;IAC9B;IACA,MAAMC,SAASrF,sBACb,eACAyE,IAAIa,UAAU,CAAC,cAAc,EAC7BT,OACA3E,6BAA6B8D,GAAG,CAAC,eAAe,GAChD3D,qBAAqB2D;IAEvB,IAAI,CAACqB,QAAQ,OAAO,aAAKP;QAAOM,SAAS;;IAEzC;;;;;;GAMC,GACD,MAAMxB,OAAO,MAAMc,OAAOT,GAAG,CAC1BsB,UAAU,CAACxF,gBAAgBuD,KAAK,EAChCC,KAAK,CAAC,UAAU,MAAM,QACtBA,KAAK,CAAC,cAAc,MAAMP,OAC1BQ,OAAO,CAAC,cAAc,QACtBE,KAAK,CAACtB,2BACNyB,GAAG;IACN,MAAMkB,MAAiB,EAAE;IACzB,KAAK,MAAMjB,OAAOF,KAAKG,IAAI,CAAE;YAMXK,aACPA,YAGaA,mBACLA;QAVjB,MAAMA,OAAON,IAAIM,IAAI;QACrB,IAAI,CAAC/B,mBAAmB+B,MAAkDpB,QAAQ;QAClF+B,IAAIS,IAAI,CAAC;YACPvB,KAAKH,IAAIG,GAAG;YACZE,IAAIL,IAAIK,EAAE;YACVsB,OAAOC,QAAOtB,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;YAC/BuB,IAAI,GAAGvB,aAAAA,IAAI,CAAC,OAAO,YAAZA,aAA4C;YACnDwB,SAAS,OAAOxB,IAAI,CAAC,UAAU,KAAK,WAAYA,IAAI,CAAC,UAAU,GAAc;YAC7EyB,YAAYC,OAAO1B,IAAI,CAAC,aAAa;YACrC2B,aAAaL,QAAOtB,oBAAAA,IAAI,CAAC,cAAc,YAAnBA,oBAAuB;YAC3C4B,QAAQN,QAAOtB,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB;YACjC6B,WAAW,AAAC7B,IAAI,CAAC,YAAY,IAAe8B;YAC5CC,WAAW,AAAC/B,IAAI,CAAC,YAAY,IAAe8B;YAC5CE,QAAQ,AAAChC,IAAI,CAAC,SAAS,IAAe8B;QACxC;IACF;IACA,MAAMG,iBAAiBzC,KAAKG,IAAI,CAACO,MAAM,IAAIlC;IAC3C,IAAI,CAAC2C,IAAIT,MAAM,EAAE;QACf,OAAO,aAAKQ;YAAOM,SAAS;WAAmBiB,iBAAiB;YAAEA;QAAe,IAAI,CAAC;IACxF;IAEA,MAAMC,SAAoB;QAAEvB,KAAKA,IAAIT,MAAM;QAAEU,SAAS;QAAGC,UAAU;QAAGC,SAAS;QAAGqB,SAAS,CAAC;IAAE;IAC9F,IAAIF,gBAAgBC,OAAOD,cAAc,GAAG;IAC5C,MAAMG,OAAOF,OAAOC,OAAO;IAE3B;;;;GAIC,GACD,MAAME,SAAS,OAAOnD;QACpBgD,OAAOtB,OAAO,IAAI1B,MAAMgB,MAAM;QAC9B,IAAIM,QAAQ;QACZ,MAAM8B,QAAQhE,UAAUgE,KAAK;QAC7B,KAAK,MAAMC,QAAQrD,MAAOoD,MAAME,MAAM,CAACD,KAAK1C,GAAG,EAAE;YAAE4C,kBAAkB7D;QAAM;QAC3E,MAAM0D,MAAMI,MAAM;IACpB;IAEA,MAAMC,aAAa,IAAIC;IACvB,MAAMC,aAAwB,EAAE;IAChC,KAAK,MAAMN,QAAQ5B,IAAK;YAKTgC;QAJb,IAAI,CAACJ,KAAKZ,WAAW,EAAE;YACrBkB,WAAWzB,IAAI,CAACmB;YAChB;QACF;QACA,MAAMO,QAAOH,kBAAAA,WAAWlD,GAAG,CAAC8C,KAAKZ,WAAW,aAA/BgB,kBAAoC,EAAE;QACnDG,KAAK1B,IAAI,CAACmB;QACVI,WAAWI,GAAG,CAACR,KAAKZ,WAAW,EAAEmB;IACnC;IACA,wEAAwE;IACxE,uEAAuE;IACvE,IAAID,WAAW3C,MAAM,EAAE;QACrBkC,IAAI,CAAC,GAAG,GAAG;YAAElD,OAAO2D,WAAW3C,MAAM;YAAEc,SAAS;QAAa;QAC7D,MAAMqB,OAAOQ;IACf;IACA,IAAI,CAACF,WAAWK,IAAI,EAAE,OAAOd;IAE7B,MAAMe,SAAS,AAAC,CAAA,MAAMtG,eAAe8D,MAAK,EAAGyC,MAAM,CACjD,CAACC,SAAWA,OAAOC,YAAY,KAAK;IAEtC,yEAAyE;IACzE,gBAAgB;IAChB,MAAMC,YAAY,IAAIvE,IACpB,MAAMvC,4BAA4B0G,OAAOK,GAAG,CAAC,CAACH,SAAWA,OAAOI,GAAG;IAErE,MAAMpB,UAAUc,OAAOC,MAAM,CAAC,CAACC,SAAWE,UAAUG,GAAG,CAACL,OAAOI,GAAG;IAClE,MAAME,aAAa,IAAIb,IAAIT,QAAQmB,GAAG,CAAC,CAACH,SAAW;YAACA,OAAOI,GAAG;YAAEJ;SAAO;IACvE,qEAAqE;IACrE,uCAAuC;IACvC,MAAMO,OAAO;WAAIf,WAAWgB,IAAI;KAAG,CAACxD,IAAI;IACxC,MAAMyD,WAAW,MAAMtF,UAAUuF,MAAM,IAClCH,KAAKJ,GAAG,CAAC,CAACQ,MAAQxF,UAAU6C,UAAU,CAAC,SAASzB,GAAG,CAACoE;IAEzD,MAAMC,WAAW,IAAInB,IAAIgB,SAASN,GAAG,CAAC,CAAC5D,MAAQ;YAACA,IAAIK,EAAE;YAAEL;SAAI;IAE5D,yEAAyE;IACzE,MAAMsE,UAAU;WAAI,IAAIlF,IAAI6B,IAAI2C,GAAG,CAAC,CAACf,OAASA,KAAKX,MAAM,EAAEsB,MAAM,CAACe;KAAU;IAC5E,MAAMC,WAAWF,QAAQ9D,MAAM,GAC3B,MAAM5B,UAAUuF,MAAM,IAAIG,QAAQV,GAAG,CAAC,CAAC1B,SAAWtD,UAAU6C,UAAU,CAAC,SAASzB,GAAG,CAACkC,YACpF,EAAE;IACN,MAAMuC,WAAW,IAAIvB,IAAIsB,SAASZ,GAAG,CAAC,CAAC5D,MAAQ;YAACA,IAAIK,EAAE;YAAEL;SAAI;IAE5D,MAAM0E,WAAWpI,uBAAuB4D;IACxC,MAAMyE,UAAU/C,QAAO1B,YAAAA,GAAG,CAAC,OAAO,YAAXA,YAAe;IACtC,MAAM0E,gBAAgBhD,QAAO1B,YAAAA,GAAG,CAAC,OAAO,YAAXA,YAAe,IAAI2E,IAAI,MAAMF;IAC1D,MAAMG,SAASH,UAAU,GAAGhE,IAAIoE,MAAM,GAAG/I,WAAWS,MAAMuI,QAAQ,EAAE;QAAEL;IAAQ,GAAG,IAAI,CAAC,GAAG;IACzF,MAAMM,SAAS,CAAC/C;;YAEWuC;QADzB,IAAI,CAACvC,QAAQ,OAAO4C;QACpB,MAAMI,YAAYtD,gBAAO6C,gBAAAA,SAAS1E,GAAG,CAACmC,4BAAbuC,cAAsB1E,GAAG,CAAC,+BAAgB;QACnE,IAAI,CAAC4E,WAAW,CAACO,WAAW,OAAO;QACnC,OAAO,GAAGvE,IAAIoE,MAAM,GAAG/I,WAAWS,MAAM0I,cAAc,EAAE;YAAER;YAASS,MAAMF;QAAU,GAAG,IAAI,CAAC;IAC7F;IACA,uEAAuE;IACvE,sEAAsE;IACtE,MAAMG,UAAU,CAACxC;QACf,MAAMyC,MAAML,OAAOpC,KAAKX,MAAM;QAC9B,IAAI,CAACoD,KAAK,OAAO3E,IAAIoE,MAAM;QAC3B,MAAMQ,OAAO/G,oBAAoBqE,KAAKX,MAAM,IAAI,MAAMW;QACtD,OAAO,GAAGyC,MAAMC,KAAKC,KAAK,CAACD,KAAKE,OAAO,CAAC,UAAU,OAAOjF,MAAM,GAAG;IACpE;IACA,MAAMkF,cAAc,GAAG/E,IAAIoE,MAAM,GAAG/I,WAAWS,MAAMkJ,4BAA4B,GAAG;IACpF,MAAMC,cAAc/H,iBAAiB6G;IACrC,MAAMmB,QAAQrI,iBAAiB0C;IAC/B,IAAI4F;IACJ,IAAIC,cAAc;IAElB,KAAK,MAAM3B,OAAOJ,KAAM;YAoCDP;QAnCrB,MAAMjE,QAAQyD,WAAWlD,GAAG,CAACqE;QAC7B,MAAM4B,MAAoB;YAAExG,OAAOA,MAAMgB,MAAM;QAAC;QAChDkC,IAAI,CAAC0B,IAAI,GAAG4B;QACZ,MAAMvC,SAASM,WAAWhE,GAAG,CAACqE;QAC9B,IAAI,CAACX,QAAQ;YACXuC,IAAI1E,OAAO,GAAG;YACd,MAAMqB,OAAOnD;YACb;QACF;QACA,IAAI,CAAE,MAAMtC,uBAAuB6D,OAAO0C,QAA0B,gBAAiB;YACnFuC,IAAI1E,OAAO,GAAG;YACd,MAAMqB,OAAOnD;YACb;QACF;QACA,MAAMyG,UAAU5B,SAAStE,GAAG,CAACqE;QAC7B,IAAIjI,kBAAkB8J,2BAAAA,QAASlG,GAAG,CAAC,sBAAsB,yBAAyB;YAChFiG,IAAI1E,OAAO,GAAG;YACd,MAAMqB,OAAOnD;YACb;QACF;QACA,IAAIsB,QAAQ;YACVkF,IAAI7E,QAAQ,GAAG3B,MAAMgB,MAAM;YAC3BwF,IAAI5E,OAAO,GAAG;YACd,MAAMuB,OAAOnD;YACb;QACF;QAEA;;;;;KAKC,GACD,IAAI4B,UAAU;QACd,IAAI8E;QACJ,IAAIC,UAAUvE,QAAO6B,gBAAAA,OAAO2C,KAAK,YAAZ3C,gBAAgB,IAAIoB,IAAI,GAAGwB,WAAW;QAC3D,IAAI,CAACF,QAAQG,QAAQ,CAAC,QAAQP,cAAc/G,oCAAoC;;gBAG7DuH;YAFjBR,eAAe;YACf,MAAMQ,SAAS,MAAMzJ,yBAAyBsH,KAAKoC,KAAK,CAAC,IAAM;YAC/DL,UAAUvE,eAAO2E,2BAAAA,iBAAAA,OAAQE,MAAM,qBAAdF,eAAgBH,KAAK,mBAAI,IAAIvB,IAAI,GAAGwB,WAAW;QAClE;QACA,MAAMK,aAAaP,QAAQG,QAAQ,CAAC,OAChC,MAAMjJ,uBAAuB;YAAC8I;SAAQ,EAAEvH,aACxC,EAAE;QACN,IAAI,CAAC8H,WAAWlG,MAAM,EAAE;YACtB0F,cAAcC,QAAQG,QAAQ,CAAC,OAAO,eAAe;QACvD,OAAO;YACL,MAAMK,gBAAgB;gBACpBnH;gBACAqB;gBACA+F,aAAalC,SAASkC,WAAW;gBACjChC;gBACAS;gBACAK;gBACAE;YACF;YACA,MAAMiB,UAAUxI,8BAA8BmB;YAC9C,uEAAuE;YACvE,6CAA6C;YAC7CsG,wBAAAA,gBAAAA,gBAAkBxI,gBAAgB,qBAAqBkJ,KAAK,CAAC,IAAM;YACnE,MAAMM,SAAS,MAAMhB;YACrB,MAAMiB,WAAWD,SACbvJ,wBACEuJ,QACA,aACKjB,MAAMmB,KAAK;gBACd,oBAAoBH;gBACpB,iBAAiB1I,gCAAgC,aAAKwI;oBAAeM,aAAa;;eAC/E7I,2BAA2BF,iCAAiCyI;gBAC/DjB;gBAEFG,MAAMqB,OAAO,IAEf;YACJ,MAAMC,SAAS,MAAMvK,UAAU;gBAC7BwK,IAAIV;eACDjJ,mBAAmBsJ,UAAU;gBAC9BF;gBACAQ,MAAMlJ,gCAAgCwI;YACxC;gBACAW,UAAU5C,SAAS4C,QAAQ;gBAC3BC,SAAS;gBACTC,UAAU;;YAEZ,IAAIL,OAAOM,IAAI,EAAE;gBACfrG,UAAU;gBACV,MAAMjE,qBAAqBqJ,KAAK,CAAC,IAAMpE;YACzC,OAAO,IAAIzF,qBAAqBwK,YAAY,MAAM;gBAChDnB,IAAI0B,QAAQ,GAAG;gBACflF,OAAOkF,QAAQ,GAAG;gBAClB,OAAOlF;YACT,OAAO;oBACS;gBAAd0D,eAAc,iBAAA,AAACiB,OAA+BQ,MAAM,YAAtC,iBAA0C;YAC1D;QACF;QAEA,8DAA8D;QAC9D,KAAK,MAAM9E,QAAQrD,MAAO;YACxB,MAAMpC,YAAY;gBAACgH;aAAI,EAAE;gBACvBwD,MAAM;gBACNjG,OAAO;gBACPkG,MAAM5J,2BAA2B4E,MAAMhC,UAAU+D;gBACjDW,MAAM/G,oBAAoBqE,KAAKX,MAAM,IAAI,MAAMW;gBAC/C9B;eACI8B,KAAKX,MAAM,GAAG;gBAAEA,QAAQW,KAAKX,MAAM;YAAC,IAAI,CAAC;QAEjD;QACA,MAAMS,OAAOnD;QACbwG,IAAI7E,QAAQ,GAAG3B,MAAMgB,MAAM;QAC3BwF,IAAI5E,OAAO,GAAGA;QACd,IAAI8E,aAAaF,IAAIE,WAAW,GAAGA;QACnC1D,OAAOrB,QAAQ,IAAI3B,MAAMgB,MAAM;QAC/B,IAAIY,SAASoB,OAAOpB,OAAO,IAAI;IACjC;IACA,OAAOoB;AACT;AAEA,OAAO,eAAesF,sBAAsBC,OAAgB;IAC1D,MAAM,EAAEC,MAAM,EAAEH,IAAI,EAAEI,SAASC,UAAU,EAAE5I,KAAK,EAAE,GAAG,MAAMjD,qBAAqB0L;IAChF,MAAME,UAAUC;IAChB,IAAIF,WAAW,UAAUA,WAAW,OAAO;QACzC,OAAOG,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAqB,GAAG;YAAEC,QAAQ;QAAI;IACtE;IACA,IAAI,CAACC,QAAQC,GAAG,CAACC,WAAW,EAAE;QAC5B,OAAON,SAASC,IAAI,CAClB;YAAEC,OAAO;QAAmD,GAC5D;YAAEC,QAAQ;QAAI;IAElB;IACA,IAAI,CAAC5K,iBAAiBuK,UAAU;QAC9B,OAAOE,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAkB,GAAG;YAAEC,QAAQ;QAAI;IACnE;IACA,uEAAuE;IACvE,oEAAoE;IACpE,iDAAiD;IACjD,MAAMxH,SAASnD,aAAa;QAAEqK;QAAQH;QAAMvI;IAAM;IAClD,sEAAsE;IACtE,yBAAyB;IACzB,IAAI0I,WAAW,QAAQ,MAAMvJ,eAAeI;IAE5C,IAAI;QACF,MAAMD,YAAY7B,cAAc4B,GAAG,GAAGC,SAAS;QAC/C,MAAMM,QAAQwJ,KAAKC,GAAG;QACtB,MAAMhI,MAAoB;YACxB/B;YACAM;YACA2B,UAAU7C;YACV8C;YACAU,YAAY,MAAMxE;YAClB+H,QAAQhH;QACV;QAEA,sEAAsE;QACtE,2EAA2E;QAC3E,+DAA+D;QAC/D,MAAM6K,iBAAiB5G,OAAQ6F,wBAAD,AAACA,KAA0CjI,KAAK;QAC9E,MAAMiJ,WACJ7G,OAAO8G,QAAQ,CAACF,mBAAmBA,iBAAiB,IAChDG,KAAKC,GAAG,CAACD,KAAKE,KAAK,CAACL,iBAAiB9J,gCACrCA;QACN,MAAMoK,YAAarB,wBAAD,AAACA,KAA2CsB,MAAM;QACpE,MAAMA,SACJ,OAAOD,cAAc,YAAYA,UAAU1I,MAAM,GAAG,KAAK,CAAC0I,UAAU5C,QAAQ,CAAC,OACzE4C,YACA;QACN;;;;;;KAMC,GACD,MAAME,UAAU,MAAMnK,uBAAuBL,WAAWM,OAAOsH,KAAK,CAAC,CAAC6B;YACpEgB,QAAQC,IAAI,CAAC,kEAAkEjB;YAC/E,OAAO;QACT;QACA,IAAIkB;QACJ,IAAIC;QACJ,oEAAoE,GACpE,IAAIC;QACJ,IAAIL,SAAS;gBAICM;YAHZ,MAAMC,QAAQR,SAASC,QAAQ5F,MAAM,CAAC,CAACzC,QAAUA,QAAQoI,UAAUC;YACnE,MAAMM,QAAQC,MAAMnE,KAAK,CAAC,GAAGqD;YAC7BW,UAAUG,MAAMnJ,MAAM,GAAGqI;YACzBY,aAAYC,UAAAA,KAAK,CAACA,MAAMlJ,MAAM,GAAG,EAAE,YAAvBkJ,UAA2B;YACvC,mEAAmE;YACnEH,UAAUG,MAAMlJ,MAAM,GAClB,AAAC,CAAA,MAAM5B,UAAUuF,MAAM,IAAIuF,MAAM9F,GAAG,CAAC,CAAC7C,QAAUnC,UAAU6C,UAAU,CAAC,QAAQzB,GAAG,CAACe,QAAO,EAAGyC,MAAM,CAC/F,CAACxD,MAAQA,IAAI4J,MAAM,IAErB,EAAE;QACR,OAAO;;gBASOL;YARZ,IAAIM,WAAWjL,UACZ6C,UAAU,CAAC,QACX/B,OAAO,CAAC3C,cAAc6B,SAAS,CAACkL,SAAS,CAACC,UAAU,IACpDnK,KAAK,CAACiJ,WAAW;YACpB,IAAIM,QAAQU,WAAWA,SAAShK,UAAU,CAACjB,UAAU6C,UAAU,CAAC,QAAQzB,GAAG,CAACmJ;YAC5E,MAAMrJ,OAAO,MAAM+J,SAAS9J,GAAG;YAC/ByJ,UAAU1J,KAAKG,IAAI,CAACO,MAAM,GAAGqI;YAC7BU,UAAUC,UAAU1J,KAAKG,IAAI,CAACuF,KAAK,CAAC,GAAGqD,YAAY/I,KAAKG,IAAI;YAC5DwJ,qBAAYF,YAAAA,OAAO,CAACA,QAAQ/I,MAAM,GAAG,EAAE,qBAA3B+I,UAA6BlJ,EAAE,mBAAI;QACjD;QAEA,MAAM2J,OAAkC,CAAC;QACzC,MAAMC,WAAmC,CAAC;QAC1C,IAAIhJ,MAAM;QACV,IAAIC,UAAU;QACd,IAAIC,WAAW;QACf,IAAIC,UAAU;QACd,IAAIsG,WAAW;QACf,KAAK,MAAM9G,UAAU2I,QAAS;YAC5B,IAAI;gBACF,MAAM/G,SAAS,MAAM9B,UAAUC,KAAKC;gBACpCoJ,IAAI,CAACpJ,OAAOP,EAAE,CAAC,GAAGmC;gBAClBvB,OAAOuB,OAAOvB,GAAG;gBACjBC,WAAWsB,OAAOtB,OAAO;gBACzBC,YAAYqB,OAAOrB,QAAQ;gBAC3BC,WAAWoB,OAAOpB,OAAO;gBACzB,IAAIoB,OAAOkF,QAAQ,EAAE;oBACnBA,WAAW;oBACX;gBACF;YACF,EAAE,OAAOW,OAAO;;gBACdgB,QAAQhB,KAAK,CAAC,uCAAuCzH,OAAOP,EAAE,EAAEgI;gBAChE4B,QAAQ,CAACrJ,OAAOP,EAAE,CAAC,YAAIgI,yBAAD,AAACA,MAAiB6B,OAAO,oBAAI;YACrD;QACF;QACA,MAAMC,SAASC,OAAOnG,IAAI,CAACgG;QAC3B,OAAO9B,SAASC,IAAI,CAClB;YACEvH,UAAUF,IAAIE,QAAQ;YACtBC;YACAuJ,OAAOd,QAAQ/I,MAAM;YACrBS;YACAC;YACAC;YACAC;YACA4I;WACItC,WAAW;YAAEA,UAAU;QAAK,IAAI,CAAC,GACjCyC,OAAO3J,MAAM,GAAG;YAAEyJ;QAAS,IAAI,CAAC;YACpC,iEAAiE;YACjE,8DAA8D;YAC9DK,YAAY5C,YAAY,CAAC8B,UAAU,OAAOC;YAC1Cc,MAAM7C,YAAY,CAAC8B;YAErB,uEAAuE;QACvE,sDAAsD;QACtD;YAAElB,QAAQ6B,OAAO3J,MAAM,GAAG,MAAM;QAAI;IAExC,EAAE,OAAO6H,OAAO;QACdgB,QAAQhB,KAAK,CAAC,mCAAmCA;QACjD,OAAOF,SAASC,IAAI,CAAC;YAAEC,OAAO;QAA4B,GAAG;YAAEC,QAAQ;QAAI;IAC7E;AACF"}