@aglyn/aglyn 1.0.0-beta.201 → 1.0.0-beta.203

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 (47) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/activity-presenter.d.ts +29 -3
  3. package/src/lib/app-utils/activity-presenter.js +32 -4
  4. package/src/lib/app-utils/activity-presenter.js.map +1 -1
  5. package/src/lib/app-utils/api-plugins.d.ts +20 -0
  6. package/src/lib/app-utils/api-plugins.js +9 -0
  7. package/src/lib/app-utils/api-plugins.js.map +1 -1
  8. package/src/lib/app-utils/card-payment-velocity.d.ts +57 -0
  9. package/src/lib/app-utils/card-payment-velocity.js +90 -0
  10. package/src/lib/app-utils/card-payment-velocity.js.map +1 -0
  11. package/src/lib/app-utils/crm-digest.d.ts +6 -0
  12. package/src/lib/app-utils/crm-digest.js +1 -0
  13. package/src/lib/app-utils/crm-digest.js.map +1 -1
  14. package/src/lib/app-utils/crm-task-reminders.d.ts +6 -0
  15. package/src/lib/app-utils/crm-task-reminders.js +1 -0
  16. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  17. package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
  18. package/src/lib/app-utils/docs-help.generated.js +2 -0
  19. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  20. package/src/lib/app-utils/docs-index.generated.js +8 -1
  21. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  22. package/src/lib/app-utils/marketplace-listing-visibility.d.ts +1 -0
  23. package/src/lib/app-utils/marketplace-listing-visibility.js +11 -5
  24. package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
  25. package/src/lib/app-utils/notifications.d.ts +35 -0
  26. package/src/lib/app-utils/notifications.js +57 -1
  27. package/src/lib/app-utils/notifications.js.map +1 -1
  28. package/src/lib/app-utils/plan-entitlements.js +23 -0
  29. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  30. package/src/lib/app-utils/platform-brand.d.ts +8 -2
  31. package/src/lib/app-utils/platform-brand.js +9 -3
  32. package/src/lib/app-utils/platform-brand.js.map +1 -1
  33. package/src/lib/app-utils/plugin-bundle-checks.d.ts +2 -1
  34. package/src/lib/app-utils/plugin-bundle-checks.js +36 -1
  35. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  36. package/src/lib/app-utils/site-return-url.d.ts +55 -0
  37. package/src/lib/app-utils/site-return-url.js +129 -0
  38. package/src/lib/app-utils/site-return-url.js.map +1 -0
  39. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
  40. package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
  41. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  42. package/src/lib/plugin-manager/plugin-org-lockdown.d.ts +77 -0
  43. package/src/lib/plugin-manager/plugin-org-lockdown.js +88 -0
  44. package/src/lib/plugin-manager/plugin-org-lockdown.js.map +1 -0
  45. package/src/lib/plugin-manager/plugin-recurring-charges.d.ts +39 -0
  46. package/src/lib/plugin-manager/plugin-recurring-charges.js +49 -0
  47. package/src/lib/plugin-manager/plugin-recurring-charges.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-digest.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\n/**\n * The daily CRM digest (AGL-2619): what one member is owed this morning,\n * computed from the tasks and leads a scheduled route has read.\n *\n * \"Overdue\" and \"due today\" are read off the clock at read time by design\n * (AGL-2599) — nothing stamps them — which is why nothing ever told anybody.\n * The digest is the one reader that runs on a schedule: once a day it\n * counts, per org and per member, the open tasks whose due date has passed\n * or falls today and the leads nobody has worked, and says so once, in the\n * console and by mail. Every judgment here is pure so the route that sends\n * and the spec that proves it read the same arithmetic.\n *\n * ## Whose day\n *\n * A task's \"today\" on screen is the READER's calendar day, from their own\n * clock. A server has no reader, so the digest reads the day in ONE zone —\n * {@link CRM_DIGEST_DEFAULT_TIME_ZONE}, the zone the schedule was chosen for\n * (08:00 there) — taken as a parameter so an org-level setting can supply\n * its own the day one exists. The idempotence key is the UTC day\n * ({@link utcDayKey}), which is a fact about the run and not about anybody's\n * morning.\n */\n\nimport {\n startOfNextZonedDay,\n startOfZonedDay,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport { type CrmLeadFields, type CrmTask, crmLeadStatus } from './crm'\n\n/**\n * The zone the digest's \"today\" is read in when nothing else names one.\n *\n * The schedule fires at 13:00 UTC, which is 08:00 here, so the window this\n * zone draws is the morning the mail arrives in for the people the product\n * is built with. A member elsewhere reads a window that is at most a few\n * hours off their own, which is what a morning summary can tolerate and a\n * \"due at 9:00\" reminder could not — that reminder is the assignment\n * notification's job, not this one's.\n */\nexport const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago'\n\n/** How long a `new` lead may sit untouched before the digest names it. */\nexport const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000\n\n/**\n * The most due tasks one org's sweep reads.\n *\n * One query per org, ordered soonest-due first, so an org with more than this\n * many overdue-or-today tasks digests the oldest five hundred and reports the\n * window as full rather than reading without bound. Five hundred is an org\n * whose problem is not that nobody reminded them.\n */\nexport const CRM_DIGEST_TASK_CEILING = 500\n\n/**\n * The most old leads one site's sweep reads.\n *\n * The same shape the Leads section uses, for the same reason it gives: a\n * lead the capture door wrote carries no `status` field, and Firestore\n * cannot select on a field's absence, so \"unworked\" is a filter over a\n * loaded window rather than a query. The window is the newest old leads by\n * first sighting, which is where the ones still worth a call are.\n */\nexport const CRM_DIGEST_LEAD_WINDOW = 200\n\n/** How many items one section of the email lists before \"and N more\". */\nexport const CRM_DIGEST_LIST_MAX = 10\n\n/*==========================================\n * CALENDAR DAYS IN A NAMED ZONE\n *\n * The zone math lives in `@aglyn/shared-util-timestamp/zoned-time`, where\n * booking slots and sending windows read it as well; the digest route and\n * its spec call it by the names below.\n *=========================================*/\n\n/** Midnight at the start of the calendar day `ms` falls in, in `timeZone`. */\nexport function startOfDayInZone(ms: number, timeZone: string): number {\n return startOfZonedDay(ms, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, for the reason the tasks\n * section gives: a day a clock change falls in is 23 or 25 hours long.\n */\nexport function startOfNextDayInZone(ms: number, timeZone: string): number {\n return startOfNextZonedDay(ms, timeZone)\n}\n\n/** `YYYY-MM-DD` of the UTC day `ms` falls in — the digest's idempotence key. */\nexport function utcDayKey(ms: number): string {\n return new Date(ms).toISOString().slice(0, 10)\n}\n\n/** The half-open window `[start of today, start of tomorrow)` in a zone. */\nexport interface CrmDigestWindow {\n startMs: number\n endMs: number\n}\n\nexport function crmDigestWindow(nowMs: number, timeZone: string): CrmDigestWindow {\n return {\n startMs: startOfDayInZone(nowMs, timeZone),\n endMs: startOfNextDayInZone(nowMs, timeZone),\n }\n}\n\n/*==========================================\n * WHAT A DIGEST IS MADE OF\n *=========================================*/\n\n/** A due task as the digest carries it: the fields a line and a link need. */\nexport interface CrmDigestTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number\n assigneeUid: string\n /** The site the task was created on — where its link opens. */\n hostId: string\n}\n\n/** An unworked lead as the digest carries it. */\nexport interface CrmDigestLead {\n id: string\n hostId: string\n email: string\n name?: string\n ownerUid?: string\n firstSeenAtMs: number\n}\n\nexport interface CrmDigestCounts {\n overdue: number\n today: number\n leads: number\n}\n\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: CrmDigestLead[]\n}\n\n/**\n * Where a due task stands against the digest's window. Only tasks due\n * BEFORE the window's end are read at all, so `later` names a task the\n * query should not have returned and the bucketing refuses to count.\n */\nexport function crmDigestTaskState(\n dueAtMs: number,\n window: CrmDigestWindow,\n): 'overdue' | 'today' | 'later' {\n if (dueAtMs < window.startMs) return 'overdue'\n if (dueAtMs < window.endMs) return 'today'\n return 'later'\n}\n\n/**\n * Whether a lead is one the digest should name: still `new` — which an\n * absent status reads as, because the capture door stamps none — and first\n * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.\n */\nexport function isUnworkedLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n if (crmLeadStatus(lead) !== 'new') return false\n const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= nowMs - ageMs\n}\n\n/**\n * Whether a lead is THIS member's to work: theirs by owner, or nobody's on\n * a site they reach. An owned lead is one person's; an unowned one is on\n * everybody's list until somebody takes it, which is what the digest is for.\n */\nexport function leadIsForMember(\n lead: Pick<CrmDigestLead, 'ownerUid' | 'hostId'>,\n member: { uid: string; reachesHost: (hostId: string) => boolean },\n): boolean {\n if (lead.ownerUid) return lead.ownerUid === member.uid\n return member.reachesHost(lead.hostId)\n}\n\n/**\n * Whether the org's plan carries the CRM (AGL-2611's `features.crm`).\n *\n * Read by key rather than through the typed flag map so this module does not\n * depend on the key existing: the flag lands with the CRM's gate, and a\n * plan that has not declared it is a plan that does not include it — a\n * digest about a surface the plan cannot open would be a reminder to pay.\n */\nexport function crmDigestEntitled(\n features: Record<string, boolean | undefined> | null | undefined,\n): boolean {\n return features?.['crm'] === true\n}\n\n/**\n * Every member's digest, from what the org's sweep read.\n *\n * A task goes to its assignee and to nobody else — an unassigned task is a\n * deliberate choice in the drawer, and an assignee no longer on the roster\n * is a task nobody owes. A lead goes to its owner, or to every member who\n * reaches the site when it has none. Members with nothing owed are simply\n * absent from the result, which is what makes \"per member with open work\"\n * cost nothing for everyone else.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, members, window } = input\n const digests = new Map<string, CrmMemberDigest>()\n const digestFor = (uid: string): CrmMemberDigest => {\n let digest = digests.get(uid)\n if (!digest) {\n digest = { overdue: [], today: [], leads: [] }\n digests.set(uid, digest)\n }\n return digest\n }\n const roster = new Map(members.map((member) => [member.uid, member]))\n for (const task of tasks) {\n if (!task.assigneeUid || !roster.has(task.assigneeUid)) continue\n const state = crmDigestTaskState(task.dueAtMs, window)\n if (state === 'later') continue\n digestFor(task.assigneeUid)[state].push(task)\n }\n for (const lead of leads) {\n for (const member of members) {\n if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead)\n }\n }\n for (const digest of digests.values()) {\n digest.overdue.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.today.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.leads.sort((a, b) => a.firstSeenAtMs - b.firstSeenAtMs)\n }\n return digests\n}\n\nexport function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts {\n return {\n overdue: digest.overdue.length,\n today: digest.today.length,\n leads: digest.leads.length,\n }\n}\n\nconst plural = (count: number, noun: string): string =>\n `${count} ${noun}${count === 1 ? '' : 's'}`\n\n/**\n * The one line the notification is: \"3 tasks due today, 2 overdue, 4\n * unworked leads\". Segments that are zero are left out rather than read as\n * \"0 overdue\" — the line is what is owed, and nothing owed is no segment.\n */\nexport function composeCrmDigestSummary(counts: CrmDigestCounts): string {\n const segments: string[] = []\n if (counts.today > 0) segments.push(`${plural(counts.today, 'task')} due today`)\n if (counts.overdue > 0) {\n segments.push(\n counts.today > 0 ? `${counts.overdue} overdue` : `${plural(counts.overdue, 'task')} overdue`,\n )\n }\n if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`)\n return segments.join(', ')\n}\n\nexport function composeCrmDigestSubject(counts: CrmDigestCounts): string {\n return `Your CRM today: ${composeCrmDigestSummary(counts)}`\n}\n\n/** What the digest email needs from the route besides the digest itself. */\nexport interface CrmDigestEmailInput {\n digest: CrmMemberDigest\n nowMs: number\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /** The Tasks section, on the site the member's first task belongs to. */\n tasksUrl: string\n /** The Leads section of a site, for the sites that have unworked leads. */\n leadsUrl: (hostId: string) => string\n /** Account settings → Notifications, where the digest can be switched off. */\n settingsUrl: string\n /** A site's name for a lead line; the id when the site is unknown. */\n hostName: (hostId: string) => string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n}\n\nfunction whenInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\nfunction dateInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleDateString('en-US', {\n timeZone,\n month: 'short',\n day: 'numeric',\n })\n}\n\nfunction section(heading: string, lines: string[]): string[] {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [\n `${heading} (${lines.length})`,\n ...shown.map((line) => `- ${line}`),\n ...(rest > 0 ? [` and ${rest} more`] : []),\n '',\n ]\n}\n\n/**\n * The plain-text email. Text rather than a designed template because the\n * body is a list, and `sendEmail` synthesizes the HTML part that makes its\n * links live. Overdue first — it is the section a reader acts on — then\n * today, then the leads, each capped and counted, and the links last.\n */\nexport function composeCrmDigestEmailText(input: CrmDigestEmailInput): string {\n const { digest, nowMs, timeZone, productName } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const lines: string[] = [\n `Here is your ${productName} CRM for ${day}: ${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n const taskLine = (task: CrmDigestTask) =>\n `${task.title} · ${whenInZone(task.dueAtMs, timeZone)}`\n if (digest.overdue.length) {\n lines.push(...section('Overdue', digest.overdue.map(taskLine)))\n }\n if (digest.today.length) {\n lines.push(...section('Due today', digest.today.map(taskLine)))\n }\n if (digest.leads.length) {\n lines.push(\n ...section(\n 'Unworked leads',\n digest.leads.map((lead) => {\n const who = lead.name ? `${lead.name} <${lead.email}>` : lead.email\n return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(\n lead.firstSeenAtMs,\n timeZone,\n )}`\n }),\n ),\n )\n }\n if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`)\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n for (const hostId of leadHosts) {\n lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`)\n }\n lines.push(\n '',\n 'You get this each morning because the Daily CRM digest is on in your ' +\n `notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["startOfNextZonedDay","startOfZonedDay","crmLeadStatus","CRM_DIGEST_DEFAULT_TIME_ZONE","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","timeZone","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","plural","count","noun","composeCrmDigestSummary","counts","segments","join","composeCrmDigestSubject","whenInZone","toLocaleString","weekday","month","day","hour","minute","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","who","name","email","hostName","tasksUrl","leadHosts","Set","leadsUrl","settingsUrl","supportLine"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-digest.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\n/**\n * The daily CRM digest (AGL-2619): what one member is owed this morning,\n * computed from the tasks and leads a scheduled route has read.\n *\n * \"Overdue\" and \"due today\" are read off the clock at read time by design\n * (AGL-2599) — nothing stamps them — which is why nothing ever told anybody.\n * The digest is the one reader that runs on a schedule: once a day it\n * counts, per org and per member, the open tasks whose due date has passed\n * or falls today and the leads nobody has worked, and says so once, in the\n * console and by mail. Every judgment here is pure so the route that sends\n * and the spec that proves it read the same arithmetic.\n *\n * ## Whose day\n *\n * A task's \"today\" on screen is the READER's calendar day, from their own\n * clock. A server has no reader, so the digest reads the day in ONE zone —\n * {@link CRM_DIGEST_DEFAULT_TIME_ZONE}, the zone the schedule was chosen for\n * (08:00 there) — taken as a parameter so an org-level setting can supply\n * its own the day one exists. The idempotence key is the UTC day\n * ({@link utcDayKey}), which is a fact about the run and not about anybody's\n * morning.\n */\n\nimport {\n startOfNextZonedDay,\n startOfZonedDay,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport { type CrmLeadFields, type CrmTask, crmLeadStatus } from './crm'\n\n/**\n * The zone the digest's \"today\" is read in when nothing else names one.\n *\n * The schedule fires at 13:00 UTC, which is 08:00 here, so the window this\n * zone draws is the morning the mail arrives in for the people the product\n * is built with. A member elsewhere reads a window that is at most a few\n * hours off their own, which is what a morning summary can tolerate and a\n * \"due at 9:00\" reminder could not — that reminder is the assignment\n * notification's job, not this one's.\n */\nexport const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago'\n\n/** How long a `new` lead may sit untouched before the digest names it. */\nexport const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000\n\n/**\n * The most due tasks one org's sweep reads.\n *\n * One query per org, ordered soonest-due first, so an org with more than this\n * many overdue-or-today tasks digests the oldest five hundred and reports the\n * window as full rather than reading without bound. Five hundred is an org\n * whose problem is not that nobody reminded them.\n */\nexport const CRM_DIGEST_TASK_CEILING = 500\n\n/**\n * The most old leads one site's sweep reads.\n *\n * The same shape the Leads section uses, for the same reason it gives: a\n * lead the capture door wrote carries no `status` field, and Firestore\n * cannot select on a field's absence, so \"unworked\" is a filter over a\n * loaded window rather than a query. The window is the newest old leads by\n * first sighting, which is where the ones still worth a call are.\n */\nexport const CRM_DIGEST_LEAD_WINDOW = 200\n\n/** How many items one section of the email lists before \"and N more\". */\nexport const CRM_DIGEST_LIST_MAX = 10\n\n/*==========================================\n * CALENDAR DAYS IN A NAMED ZONE\n *\n * The zone math lives in `@aglyn/shared-util-timestamp/zoned-time`, where\n * booking slots and sending windows read it as well; the digest route and\n * its spec call it by the names below.\n *=========================================*/\n\n/** Midnight at the start of the calendar day `ms` falls in, in `timeZone`. */\nexport function startOfDayInZone(ms: number, timeZone: string): number {\n return startOfZonedDay(ms, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, for the reason the tasks\n * section gives: a day a clock change falls in is 23 or 25 hours long.\n */\nexport function startOfNextDayInZone(ms: number, timeZone: string): number {\n return startOfNextZonedDay(ms, timeZone)\n}\n\n/** `YYYY-MM-DD` of the UTC day `ms` falls in — the digest's idempotence key. */\nexport function utcDayKey(ms: number): string {\n return new Date(ms).toISOString().slice(0, 10)\n}\n\n/** The half-open window `[start of today, start of tomorrow)` in a zone. */\nexport interface CrmDigestWindow {\n startMs: number\n endMs: number\n}\n\nexport function crmDigestWindow(nowMs: number, timeZone: string): CrmDigestWindow {\n return {\n startMs: startOfDayInZone(nowMs, timeZone),\n endMs: startOfNextDayInZone(nowMs, timeZone),\n }\n}\n\n/*==========================================\n * WHAT A DIGEST IS MADE OF\n *=========================================*/\n\n/** A due task as the digest carries it: the fields a line and a link need. */\nexport interface CrmDigestTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number\n assigneeUid: string\n /** The site the task was created on — where its link opens. */\n hostId: string\n}\n\n/** An unworked lead as the digest carries it. */\nexport interface CrmDigestLead {\n id: string\n hostId: string\n email: string\n name?: string\n ownerUid?: string\n firstSeenAtMs: number\n}\n\nexport interface CrmDigestCounts {\n overdue: number\n today: number\n leads: number\n}\n\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: CrmDigestLead[]\n}\n\n/**\n * Where a due task stands against the digest's window. Only tasks due\n * BEFORE the window's end are read at all, so `later` names a task the\n * query should not have returned and the bucketing refuses to count.\n */\nexport function crmDigestTaskState(\n dueAtMs: number,\n window: CrmDigestWindow,\n): 'overdue' | 'today' | 'later' {\n if (dueAtMs < window.startMs) return 'overdue'\n if (dueAtMs < window.endMs) return 'today'\n return 'later'\n}\n\n/**\n * Whether a lead is one the digest should name: still `new` — which an\n * absent status reads as, because the capture door stamps none — and first\n * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.\n */\nexport function isUnworkedLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n if (crmLeadStatus(lead) !== 'new') return false\n const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= nowMs - ageMs\n}\n\n/**\n * Whether a lead is THIS member's to work: theirs by owner, or nobody's on\n * a site they reach. An owned lead is one person's; an unowned one is on\n * everybody's list until somebody takes it, which is what the digest is for.\n */\nexport function leadIsForMember(\n lead: Pick<CrmDigestLead, 'ownerUid' | 'hostId'>,\n member: { uid: string; reachesHost: (hostId: string) => boolean },\n): boolean {\n if (lead.ownerUid) return lead.ownerUid === member.uid\n return member.reachesHost(lead.hostId)\n}\n\n/**\n * Whether the org's plan carries the CRM (AGL-2611's `features.crm`).\n *\n * Read by key rather than through the typed flag map so this module does not\n * depend on the key existing: the flag lands with the CRM's gate, and a\n * plan that has not declared it is a plan that does not include it — a\n * digest about a surface the plan cannot open would be a reminder to pay.\n */\nexport function crmDigestEntitled(\n features: Record<string, boolean | undefined> | null | undefined,\n): boolean {\n return features?.['crm'] === true\n}\n\n/**\n * Every member's digest, from what the org's sweep read.\n *\n * A task goes to its assignee and to nobody else — an unassigned task is a\n * deliberate choice in the drawer, and an assignee no longer on the roster\n * is a task nobody owes. A lead goes to its owner, or to every member who\n * reaches the site when it has none. Members with nothing owed are simply\n * absent from the result, which is what makes \"per member with open work\"\n * cost nothing for everyone else.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, members, window } = input\n const digests = new Map<string, CrmMemberDigest>()\n const digestFor = (uid: string): CrmMemberDigest => {\n let digest = digests.get(uid)\n if (!digest) {\n digest = { overdue: [], today: [], leads: [] }\n digests.set(uid, digest)\n }\n return digest\n }\n const roster = new Map(members.map((member) => [member.uid, member]))\n for (const task of tasks) {\n if (!task.assigneeUid || !roster.has(task.assigneeUid)) continue\n const state = crmDigestTaskState(task.dueAtMs, window)\n if (state === 'later') continue\n digestFor(task.assigneeUid)[state].push(task)\n }\n for (const lead of leads) {\n for (const member of members) {\n if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead)\n }\n }\n for (const digest of digests.values()) {\n digest.overdue.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.today.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.leads.sort((a, b) => a.firstSeenAtMs - b.firstSeenAtMs)\n }\n return digests\n}\n\nexport function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts {\n return {\n overdue: digest.overdue.length,\n today: digest.today.length,\n leads: digest.leads.length,\n }\n}\n\nconst plural = (count: number, noun: string): string =>\n `${count} ${noun}${count === 1 ? '' : 's'}`\n\n/**\n * The one line the notification is: \"3 tasks due today, 2 overdue, 4\n * unworked leads\". Segments that are zero are left out rather than read as\n * \"0 overdue\" — the line is what is owed, and nothing owed is no segment.\n */\nexport function composeCrmDigestSummary(counts: CrmDigestCounts): string {\n const segments: string[] = []\n if (counts.today > 0) segments.push(`${plural(counts.today, 'task')} due today`)\n if (counts.overdue > 0) {\n segments.push(\n counts.today > 0 ? `${counts.overdue} overdue` : `${plural(counts.overdue, 'task')} overdue`,\n )\n }\n if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`)\n return segments.join(', ')\n}\n\nexport function composeCrmDigestSubject(counts: CrmDigestCounts): string {\n return `Your CRM today: ${composeCrmDigestSummary(counts)}`\n}\n\n/** What the digest email needs from the route besides the digest itself. */\nexport interface CrmDigestEmailInput {\n digest: CrmMemberDigest\n nowMs: number\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /** The Tasks section, on the site the member's first task belongs to. */\n tasksUrl: string\n /** The Leads section of a site, for the sites that have unworked leads. */\n leadsUrl: (hostId: string) => string\n /** Account settings → Notifications, where the digest can be switched off. */\n settingsUrl: string\n /** A site's name for a lead line; the id when the site is unknown. */\n hostName: (hostId: string) => string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\nfunction whenInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\nfunction dateInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleDateString('en-US', {\n timeZone,\n month: 'short',\n day: 'numeric',\n })\n}\n\nfunction section(heading: string, lines: string[]): string[] {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [\n `${heading} (${lines.length})`,\n ...shown.map((line) => `- ${line}`),\n ...(rest > 0 ? [` and ${rest} more`] : []),\n '',\n ]\n}\n\n/**\n * The plain-text email. Text rather than a designed template because the\n * body is a list, and `sendEmail` synthesizes the HTML part that makes its\n * links live. Overdue first — it is the section a reader acts on — then\n * today, then the leads, each capped and counted, and the links last.\n */\nexport function composeCrmDigestEmailText(input: CrmDigestEmailInput): string {\n const { digest, nowMs, timeZone, productName } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const lines: string[] = [\n `Here is your ${productName} CRM for ${day}: ${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n const taskLine = (task: CrmDigestTask) =>\n `${task.title} · ${whenInZone(task.dueAtMs, timeZone)}`\n if (digest.overdue.length) {\n lines.push(...section('Overdue', digest.overdue.map(taskLine)))\n }\n if (digest.today.length) {\n lines.push(...section('Due today', digest.today.map(taskLine)))\n }\n if (digest.leads.length) {\n lines.push(\n ...section(\n 'Unworked leads',\n digest.leads.map((lead) => {\n const who = lead.name ? `${lead.name} <${lead.email}>` : lead.email\n return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(\n lead.firstSeenAtMs,\n timeZone,\n )}`\n }),\n ),\n )\n }\n if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`)\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n for (const hostId of leadHosts) {\n lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get this each morning because the Daily CRM digest is on in your ' +\n `notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["startOfNextZonedDay","startOfZonedDay","crmLeadStatus","CRM_DIGEST_DEFAULT_TIME_ZONE","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","timeZone","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","plural","count","noun","composeCrmDigestSummary","counts","segments","join","composeCrmDigestSubject","whenInZone","toLocaleString","weekday","month","day","hour","minute","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","who","name","email","hostName","tasksUrl","leadHosts","Set","leadsUrl","omitClosing","trimEnd","settingsUrl","supportLine"],"mappings":"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"}
@@ -119,6 +119,12 @@ export interface CrmTaskReminderEmailInput {
119
119
  settingsUrl: string;
120
120
  /** The brand's support line, already prefixed, or empty. */
121
121
  supportLine?: string;
122
+ /**
123
+ * Leave out the closing paragraph and the support line (AGL-3367), for the
124
+ * copy that goes inside a system email: its footer says why the mail came
125
+ * and where to get help, and its body links the settings page.
126
+ */
127
+ omitClosing?: boolean;
122
128
  }
123
129
  /**
124
130
  * The plain-text email. Text rather than a designed template for the
@@ -132,6 +132,7 @@
132
132
  lines.push(`- ${composeCrmTaskReminderBody(task, timeZone)}`);
133
133
  lines.push(` ${input.taskUrl(task)}`);
134
134
  }
135
+ if (input.omitClosing) return lines.join('\n').trimEnd();
135
136
  lines.push('', 'You get task reminders because the Forms & bookings category is on in ' + `your notification settings: ${input.settingsUrl}`);
136
137
  return lines.join('\n') + ((_input_supportLine = input.supportLine) != null ? _input_supportLine : '');
137
138
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-task-reminders.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\n/**\n * A task's reminder at its own time (AGL-2659).\n *\n * The daily digest says once a morning what is late and what is due today;\n * nothing said \"the 3:00 call is at 3:00\". A task now carries `remindAtMs`\n * — the due time unless a person moved it, `null` for none — and an hourly\n * route reads every open task whose reminder has come due and tells the\n * assignee once, in the console and by mail. Every judgment here is pure so\n * the three writers of the field (the task routes, the REST resource, the\n * drawer) and the route that reads it agree on what a reminder is.\n *\n * ## Which reminder a save leaves\n *\n * A caller that SAYS what the reminder is gets what it said. A caller that\n * says nothing gets the rule: a new task's reminder is its due time, and an\n * edit that moves the due date moves a reminder that still sat on the old\n * due time — the reminder was never a choice of its own, so it follows —\n * while a reminder somebody set to a time of their own stays where they put\n * it. The one thing an edit never does is invent a reminder for a task that\n * has none.\n */\n\nimport type { CrmTask } from './crm'\n\n/**\n * The most due reminders one org's sweep reads per run.\n *\n * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent\n * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the\n * range \"reminders up to now\" holds every reminder an open task ever had,\n * and a window of the OLDEST five hundred would fill with ones already sent\n * and never reach a new one. The newest five hundred always begin with what\n * fell due since the last run; the sent ones sit behind them.\n */\nexport const CRM_TASK_REMINDER_CEILING = 500\n\n/** The two fields the rule reads off a stored task, or off nothing on create. */\nexport interface CrmTaskReminderState {\n dueAtMs: number | null\n remindAtMs: number | null\n}\n\n/**\n * The reminder a save leaves on the task — see the module comment.\n *\n * `remindAtMs` is what the caller SAID: a time, `null` for \"no reminder\",\n * `undefined` for nothing said. `previous` is the stored task, or `null`\n * when the task is being created. A stored task from before reminders\n * existed carries no `remindAtMs` at all; it reads as `null` here — no\n * reminder — rather than as one that follows the due date, because a task\n * nobody ever set a reminder on should not start reminding the day its\n * title is corrected.\n */\nexport function crmTaskReminderAfterEdit(input: {\n dueAtMs: number | null\n remindAtMs?: number | null\n previous: CrmTaskReminderState | null\n}): number | null {\n const { dueAtMs, remindAtMs, previous } = input\n if (remindAtMs !== undefined) return remindAtMs\n if (!previous) return dueAtMs\n const followed =\n typeof previous.remindAtMs === 'number' && previous.remindAtMs === previous.dueAtMs\n if (followed && dueAtMs !== previous.dueAtMs) return dueAtMs\n return previous.remindAtMs ?? null\n}\n\n/** Whether a task's reminder is still owed: set, and not yet handled. */\nexport function crmTaskReminderPending(\n task: Pick<CrmTask, 'remindAtMs' | 'reminderSentAtMs'>,\n): boolean {\n return typeof task.remindAtMs === 'number' && task.reminderSentAtMs === undefined\n}\n\n/**\n * Whether the runner owes this task a reminder now: open, pending, and the\n * reminder's time has come. The query reads the first and third off the\n * index; the second is a fact about a field's absence, which a query cannot\n * select on, so the runner asks it here over the page it read.\n */\nexport function crmTaskReminderDue(\n task: Pick<CrmTask, 'status' | 'remindAtMs' | 'reminderSentAtMs'>,\n nowMs: number,\n): boolean {\n return (\n task.status === 'open' &&\n crmTaskReminderPending(task) &&\n (task.remindAtMs as number) <= nowMs\n )\n}\n\n/** A due reminder as the runner carries it: the fields a line and a link need. */\nexport interface CrmReminderTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number | null\n remindAtMs: number\n assigneeUid: string\n /** The site the task was created on, or `''` for the organization's own. */\n hostId: string\n}\n\n/**\n * The console path a reminder opens: the record the task is for, in the\n * hub, or the tasks list when it is for nobody in particular — the same\n * page the assignment notification opens, in the `/{hostDocId}/rest` shape\n * every host notification uses and the `/org/rest` shape the console\n * rewrites onto the organization's own hub for a task with no site.\n */\nexport function crmTaskReminderLink(\n hostId: string | null,\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (task.contactId) return `${base}/contacts/${encodeURIComponent(task.contactId)}`\n if (task.dealId) return `${base}/deals/${encodeURIComponent(task.dealId)}`\n if (task.companyId) return `${base}/companies/${encodeURIComponent(task.companyId)}`\n return `${base}/tasks`\n}\n\n/** `Wed, Sep 9, 3:00 PM` in a named zone — the words a reminder says a time with. */\nexport function crmTaskReminderWhen(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\n/**\n * The one line the console notification is: the title and when the task is\n * due. A reminder set on a task with no due date says only the title —\n * the person chose the time; the task itself has none.\n */\nexport function composeCrmTaskReminderBody(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n): string {\n return typeof task.dueAtMs === 'number'\n ? `${task.title} · due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}`\n : task.title\n}\n\n/**\n * One mail per member per run, so five tasks due at nine o'clock are one\n * message and not five. The subject names the task when there is one.\n */\nexport function composeCrmTaskReminderSubject(\n tasks: ReadonlyArray<Pick<CrmReminderTask, 'title'>>,\n): string {\n if (tasks.length === 1) return `Reminder: ${tasks[0].title}`\n return `Reminder: ${tasks.length} tasks are due`\n}\n\n/** What the reminder email needs from the route besides the tasks. */\nexport interface CrmTaskReminderEmailInput {\n tasks: readonly CrmReminderTask[]\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /** The console page a task opens on, absolute. */\n taskUrl: (task: CrmReminderTask) => string\n /** Account settings → Notifications, where the category can be muted. */\n settingsUrl: string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n}\n\n/**\n * The plain-text email. Text rather than a designed template for the\n * reason the digest gives: the body is a list, and `sendEmail` synthesizes\n * the HTML part that makes its links live. Soonest due first, each task on\n * its own line with its link under it, and the way out last.\n */\nexport function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string {\n const { tasks, timeZone, productName } = input\n const ordered = [...tasks].sort(\n (a, b) => (a.dueAtMs ?? a.remindAtMs) - (b.dueAtMs ?? b.remindAtMs),\n )\n const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName}: this task is due.`\n : `A reminder from ${productName}: ${ordered.length} tasks are due.`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${composeCrmTaskReminderBody(task, timeZone)}`)\n lines.push(` ${input.taskUrl(task)}`)\n }\n lines.push(\n '',\n 'You get task reminders because the Forms & bookings category is on in ' +\n `your notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["CRM_TASK_REMINDER_CEILING","crmTaskReminderAfterEdit","input","previous","dueAtMs","remindAtMs","undefined","followed","crmTaskReminderPending","task","reminderSentAtMs","crmTaskReminderDue","nowMs","status","crmTaskReminderLink","hostId","base","contactId","encodeURIComponent","dealId","companyId","crmTaskReminderWhen","ms","timeZone","Date","toLocaleString","weekday","month","day","hour","minute","composeCrmTaskReminderBody","title","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","lines","push","taskUrl","settingsUrl","join","supportLine"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-task-reminders.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\n/**\n * A task's reminder at its own time (AGL-2659).\n *\n * The daily digest says once a morning what is late and what is due today;\n * nothing said \"the 3:00 call is at 3:00\". A task now carries `remindAtMs`\n * — the due time unless a person moved it, `null` for none — and an hourly\n * route reads every open task whose reminder has come due and tells the\n * assignee once, in the console and by mail. Every judgment here is pure so\n * the three writers of the field (the task routes, the REST resource, the\n * drawer) and the route that reads it agree on what a reminder is.\n *\n * ## Which reminder a save leaves\n *\n * A caller that SAYS what the reminder is gets what it said. A caller that\n * says nothing gets the rule: a new task's reminder is its due time, and an\n * edit that moves the due date moves a reminder that still sat on the old\n * due time — the reminder was never a choice of its own, so it follows —\n * while a reminder somebody set to a time of their own stays where they put\n * it. The one thing an edit never does is invent a reminder for a task that\n * has none.\n */\n\nimport type { CrmTask } from './crm'\n\n/**\n * The most due reminders one org's sweep reads per run.\n *\n * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent\n * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the\n * range \"reminders up to now\" holds every reminder an open task ever had,\n * and a window of the OLDEST five hundred would fill with ones already sent\n * and never reach a new one. The newest five hundred always begin with what\n * fell due since the last run; the sent ones sit behind them.\n */\nexport const CRM_TASK_REMINDER_CEILING = 500\n\n/** The two fields the rule reads off a stored task, or off nothing on create. */\nexport interface CrmTaskReminderState {\n dueAtMs: number | null\n remindAtMs: number | null\n}\n\n/**\n * The reminder a save leaves on the task — see the module comment.\n *\n * `remindAtMs` is what the caller SAID: a time, `null` for \"no reminder\",\n * `undefined` for nothing said. `previous` is the stored task, or `null`\n * when the task is being created. A stored task from before reminders\n * existed carries no `remindAtMs` at all; it reads as `null` here — no\n * reminder — rather than as one that follows the due date, because a task\n * nobody ever set a reminder on should not start reminding the day its\n * title is corrected.\n */\nexport function crmTaskReminderAfterEdit(input: {\n dueAtMs: number | null\n remindAtMs?: number | null\n previous: CrmTaskReminderState | null\n}): number | null {\n const { dueAtMs, remindAtMs, previous } = input\n if (remindAtMs !== undefined) return remindAtMs\n if (!previous) return dueAtMs\n const followed =\n typeof previous.remindAtMs === 'number' && previous.remindAtMs === previous.dueAtMs\n if (followed && dueAtMs !== previous.dueAtMs) return dueAtMs\n return previous.remindAtMs ?? null\n}\n\n/** Whether a task's reminder is still owed: set, and not yet handled. */\nexport function crmTaskReminderPending(\n task: Pick<CrmTask, 'remindAtMs' | 'reminderSentAtMs'>,\n): boolean {\n return typeof task.remindAtMs === 'number' && task.reminderSentAtMs === undefined\n}\n\n/**\n * Whether the runner owes this task a reminder now: open, pending, and the\n * reminder's time has come. The query reads the first and third off the\n * index; the second is a fact about a field's absence, which a query cannot\n * select on, so the runner asks it here over the page it read.\n */\nexport function crmTaskReminderDue(\n task: Pick<CrmTask, 'status' | 'remindAtMs' | 'reminderSentAtMs'>,\n nowMs: number,\n): boolean {\n return (\n task.status === 'open' &&\n crmTaskReminderPending(task) &&\n (task.remindAtMs as number) <= nowMs\n )\n}\n\n/** A due reminder as the runner carries it: the fields a line and a link need. */\nexport interface CrmReminderTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number | null\n remindAtMs: number\n assigneeUid: string\n /** The site the task was created on, or `''` for the organization's own. */\n hostId: string\n}\n\n/**\n * The console path a reminder opens: the record the task is for, in the\n * hub, or the tasks list when it is for nobody in particular — the same\n * page the assignment notification opens, in the `/{hostDocId}/rest` shape\n * every host notification uses and the `/org/rest` shape the console\n * rewrites onto the organization's own hub for a task with no site.\n */\nexport function crmTaskReminderLink(\n hostId: string | null,\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (task.contactId) return `${base}/contacts/${encodeURIComponent(task.contactId)}`\n if (task.dealId) return `${base}/deals/${encodeURIComponent(task.dealId)}`\n if (task.companyId) return `${base}/companies/${encodeURIComponent(task.companyId)}`\n return `${base}/tasks`\n}\n\n/** `Wed, Sep 9, 3:00 PM` in a named zone — the words a reminder says a time with. */\nexport function crmTaskReminderWhen(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\n/**\n * The one line the console notification is: the title and when the task is\n * due. A reminder set on a task with no due date says only the title —\n * the person chose the time; the task itself has none.\n */\nexport function composeCrmTaskReminderBody(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n): string {\n return typeof task.dueAtMs === 'number'\n ? `${task.title} · due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}`\n : task.title\n}\n\n/**\n * One mail per member per run, so five tasks due at nine o'clock are one\n * message and not five. The subject names the task when there is one.\n */\nexport function composeCrmTaskReminderSubject(\n tasks: ReadonlyArray<Pick<CrmReminderTask, 'title'>>,\n): string {\n if (tasks.length === 1) return `Reminder: ${tasks[0].title}`\n return `Reminder: ${tasks.length} tasks are due`\n}\n\n/** What the reminder email needs from the route besides the tasks. */\nexport interface CrmTaskReminderEmailInput {\n tasks: readonly CrmReminderTask[]\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /** The console page a task opens on, absolute. */\n taskUrl: (task: CrmReminderTask) => string\n /** Account settings → Notifications, where the category can be muted. */\n settingsUrl: string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/**\n * The plain-text email. Text rather than a designed template for the\n * reason the digest gives: the body is a list, and `sendEmail` synthesizes\n * the HTML part that makes its links live. Soonest due first, each task on\n * its own line with its link under it, and the way out last.\n */\nexport function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string {\n const { tasks, timeZone, productName } = input\n const ordered = [...tasks].sort(\n (a, b) => (a.dueAtMs ?? a.remindAtMs) - (b.dueAtMs ?? b.remindAtMs),\n )\n const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName}: this task is due.`\n : `A reminder from ${productName}: ${ordered.length} tasks are due.`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${composeCrmTaskReminderBody(task, timeZone)}`)\n lines.push(` ${input.taskUrl(task)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get task reminders because the Forms & bookings category is on in ' +\n `your notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["CRM_TASK_REMINDER_CEILING","crmTaskReminderAfterEdit","input","previous","dueAtMs","remindAtMs","undefined","followed","crmTaskReminderPending","task","reminderSentAtMs","crmTaskReminderDue","nowMs","status","crmTaskReminderLink","hostId","base","contactId","encodeURIComponent","dealId","companyId","crmTaskReminderWhen","ms","timeZone","Date","toLocaleString","weekday","month","day","hour","minute","composeCrmTaskReminderBody","title","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","lines","push","taskUrl","omitClosing","join","trimEnd","settingsUrl","supportLine"],"mappings":"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"}
@@ -276,7 +276,7 @@ export declare const PLUGIN_DOCS: {
276
276
  };
277
277
  export type PluginDocsKey = keyof typeof PLUGIN_DOCS;
278
278
  export declare const PLUGIN_DOCS_ANCHORS: {
279
- readonly abuseReports: readonly ["#where-reports-come-from", "#held-outbound-email", "#what-is-screened", "#tiers", "#deciding-a-held-row", "#names-and-domains", "#stripe-fraud-signals", "#seller-fraud-pattern", "#triage-by-severity", "#csam", "#which-lever", "#statuses", "#disclosure", "#dmca", "#counter-notices", "#counter-notice-clock", "#counter-notice-steps", "#repeat-infringers", "#repeat-infringer-threshold", "#known-gaps", "#related"];
279
+ readonly abuseReports: readonly ["#where-reports-come-from", "#held-outbound-email", "#what-is-screened", "#tiers", "#deciding-a-held-row", "#names-and-domains", "#stripe-fraud-signals", "#seller-fraud-pattern", "#card-testing-velocity", "#marketplace", "#triage-by-severity", "#csam", "#which-lever", "#statuses", "#disclosure", "#dmca", "#counter-notices", "#counter-notice-clock", "#counter-notice-steps", "#repeat-infringers", "#repeat-infringer-threshold", "#known-gaps", "#related"];
280
280
  readonly actionsBuilder: readonly ["#create-an-action", "#recipes", "#describe-it", "#triggers", "#crm-events", "#only-run-when-a-field-matches", "#chain-multiple-conditions-andor", "#steps", "#crm-steps", "#step-conditions", "#sequences", "#run-history", "#what-is-and-isnt-recorded", "#interactions-from-the-besigner", "#when-to-use-which", "#related"];
281
281
  readonly aglynAssist: readonly ["#what-it-can-do", "#aglyn-ai", "#answers-for-beginners-and-developers", "#offers-to-open-a-page", "#edits-in-the-besigner", "#where-an-answer-came-from", "#answers-straight-from-the-documentation", "#message-limits", "#feedback", "#privacy"];
282
282
  readonly aiAutomations: readonly ["#draft", "#explain", "#why-a-run-failed", "#what-is-sent", "#who-can-use-it", "#related"];
@@ -281,6 +281,8 @@ export const PLUGIN_DOCS_ANCHORS = {
281
281
  '#names-and-domains',
282
282
  '#stripe-fraud-signals',
283
283
  '#seller-fraud-pattern',
284
+ '#card-testing-velocity',
285
+ '#marketplace',
284
286
  '#triage-by-severity',
285
287
  '#csam',
286
288
  '#which-lever',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/docs-help.generated.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// GENERATED FILE — do not edit. Regenerate with:\n// node tools/scripts/generate-docs-help.mjs\n// Source of truth: apps/docs/docs frontmatter + headings (AGL-602).\n\n// The first-party plugin consoles live in `libs/plugins/*` and cannot import\n// the console's constants, so they carry their own generated subset of the\n// docs help registry — the topics their cards actually link to, and no others.\n\nexport interface PluginDocsTopic {\n /** Docs-site path, e.g. `/commerce-and-bookings/commerce/overview`. */\n path: string\n /** Docs page title. */\n title: string\n /** Verbatim docs frontmatter description — the tooltip excerpt. */\n excerpt: string\n}\n\nexport const PLUGIN_DOCS = {\n abuseReports: {\n path: '/staff-console/abuse-reports',\n title: 'Abuse reports',\n excerpt: 'The public abuse-report queue — where outside reports land, how to triage by severity, which lever answers which report, and the CSAM and DMCA paths that are not takedown buttons.',\n },\n actionsBuilder: {\n path: '/marketing-and-automation/workflows-and-actions/actions-builder',\n title: 'Actions builder',\n excerpt: 'Map a single event to a single action without building a full workflow.',\n },\n aglynAssist: {\n path: '/getting-started/aglyn-assist',\n title: 'Aglyn Assist',\n excerpt: 'Ask the built-in AI helper how to do anything in Aglyn — it answers from these docs and links you straight to the right page.',\n },\n aiAutomations: {\n path: '/ai/automations-with-ai',\n title: 'Explain automations with AI',\n excerpt: 'Aglyn AI can explain an automation you already have and tell you why one of its runs failed. Drafting an automation from a description is not available yet.',\n },\n aiCrm: {\n path: '/ai/crm-by-ai',\n title: 'The AI CRM built into Aglyn',\n excerpt: 'An AI CRM for small business: a short summary and a suggested next step on a contact, company, deal or lead, a one-to-one email drafted into the composer, and an import\\'s columns matched to fields.',\n },\n aiExperiments: {\n path: '/ai/ab-tests-with-ai',\n title: 'A/B tests by AI: write variants, read the result',\n excerpt: 'Have Aglyn AI write two to four variants for a screen, section or email experiment, and put a finished test into plain language — with the verdict decided from the counts before the model is asked anything.',\n },\n aiMonitoring: {\n path: '/staff-console/ai-monitoring',\n title: 'AI monitoring',\n excerpt: 'How staff watch one organization\\'s AI usage — the add-on, credit pool, overage, refusals, jobs, tokens and cache hit rate, top spenders and margin — and where those figures appear across the staff console.',\n },\n aiProducts: {\n path: '/ai/products-with-ai',\n title: 'Product copy and catalogs with AI',\n excerpt: 'An AI product description generator for your catalog: write copy for one product or a whole import, propose a first catalog, and propose categories and discounts — all as proposals you review.',\n },\n aiSeo: {\n path: '/building-sites/seo/seo-by-ai',\n title: 'AI SEO for your website',\n excerpt: 'AI SEO for websites, on the pages you already have: have AI write a page or product\\'s search listing, audit every published page with a proposed fix for each finding, and draft your structured data and /llms.txt.',\n },\n aiThemes: {\n path: '/ai/theme-assist',\n title: 'Change your site\\'s theme with AI',\n excerpt: 'Describe a change to your site\\'s theme and get a proposal for the theme editor\\'s own controls, previewed before and after, that you save in the editor. Rolling out.',\n },\n assistSignals: {\n path: '/staff-console/assist-signals',\n title: 'Assist Signal',\n excerpt: 'The docs-gap and cost board behind Assist — how the ranking is ordered, why ungrounded questions are counted separately, and what the cache-read rate says about margin.',\n },\n billing: {\n path: '/workspace-and-billing/billing-and-plans/overview',\n title: 'Billing & Plans',\n excerpt: 'How Aglyn\\'s tiers, entitlements, quotas, usage meters, and seat add-ons work.',\n },\n bindings: {\n path: '/building-sites/bindings/overview',\n title: 'Bindings, Variables & Functions',\n excerpt: 'Live values in your content — typed variables, no-code functions, and rename-safe id tokens.',\n },\n bookings: {\n path: '/commerce-and-bookings/bookings/overview',\n title: 'Bookings & Scheduling',\n excerpt: 'Offer services with availability, let visitors book, take payment, and send reminders.',\n },\n buildAWorkflow: {\n path: '/marketing-and-automation/workflows-and-actions/build-a-workflow',\n title: 'Build a workflow',\n excerpt: 'Create a multi-step workflow that runs when a site event fires.',\n },\n catalog: {\n path: '/commerce-and-bookings/commerce/catalog',\n title: 'Product catalog',\n excerpt: 'Products with options and variants, categories, tags, and manual or smart collections.',\n },\n commerce: {\n path: '/commerce-and-bookings/commerce/overview',\n title: 'Commerce',\n excerpt: 'Sell physical, digital, and service products with a full catalog, orders pipeline, shipping, taxes, and your own Stripe account.',\n },\n commerceEndToEnd: {\n path: '/guides/commerce-end-to-end',\n title: 'Commerce end to end',\n excerpt: 'Create products with billing modes, design the storefront with Commerce blocks, take Stripe checkout for one-time and subscription sales, and run orders from the console.',\n },\n companies: {\n path: '/content-and-data/crm/companies',\n title: 'Companies',\n excerpt: 'Group your contacts under the businesses they belong to — one record per company, with its domain, owner, address and the people who work there.',\n },\n consoleTour: {\n path: '/getting-started/console-tour',\n title: 'The console tour',\n excerpt: 'Where things live in the Aglyn console app bar and navigation.',\n },\n contactActivities: {\n path: '/content-and-data/crm/activities',\n title: 'Activities & the timeline',\n excerpt: 'Log calls, emails, meetings and notes against a contact, a company, a deal or a lead, email one person from their record, and read it all in one timeline beside everything captured and every campaign sent.',\n },\n contactFields: {\n path: '/content-and-data/crm/custom-fields',\n title: 'Custom fields',\n excerpt: 'Define your own properties on contacts, companies, deals and leads — text, number, date, choice, checkbox or link — show them on every record and its list, and save form answers straight into a contact\\'s.',\n },\n contactRecord: {\n path: '/content-and-data/crm/contact-record',\n title: 'The contact record',\n excerpt: 'Add a contact by hand, keep a profile on them — phone, company, title, address, owner and lifecycle stage — open their own page in the CRM, and merge two records that turn out to be one person.',\n },\n contacts: {\n path: '/content-and-data/crm/overview',\n title: 'CRM',\n excerpt: 'One place for the people who interact with your sites — contacts captured from forms, members, orders and bookings, with leads, companies, a deals pipeline, tasks, a timeline, reports and custom fields.',\n },\n crmEmailTemplates: {\n path: '/content-and-data/crm/email-templates',\n title: 'Email templates',\n excerpt: 'Keep the letters your team sends from a record — templates that fill in the subject and the message, snippets that drop in a paragraph, and merge fields that fill in the person\\'s name, their company, the deal and you.',\n },\n crmLeads: {\n path: '/content-and-data/crm/leads',\n title: 'Leads',\n excerpt: 'Work the people your site has captured — a status, an owner and notes on every lead — and convert one into a contact, a company and a deal.',\n },\n crmReports: {\n path: '/content-and-data/crm/reports',\n title: 'Reports',\n excerpt: 'New contacts, where they came from and which sources convert, the lead funnel, the open pipeline and its forecast, won and lost, who logged what, and the task load — counted on the server, every table exportable as CSV.',\n },\n crmSettings: {\n path: '/content-and-data/crm/settings',\n title: 'CRM settings',\n excerpt: 'What the CRM does on its own for every site — whether a company is created from a contact\\'s work email domain, who a new contact is assigned to, the address that files replies on a record, and each site\\'s recipes.',\n },\n crmTasks: {\n path: '/content-and-data/crm/tasks',\n title: 'Tasks & follow-ups',\n excerpt: 'Calls, emails, meetings and to-dos with a due date, an assignee and a link to the record they are for — overdue and today read off the clock, a snooze, a reminder at its own time, and a morning digest.',\n },\n crmViews: {\n path: '/content-and-data/crm/views',\n title: 'Saved views',\n excerpt: 'Keep a CRM list\\'s filters, columns and sort under a name, open it from the views menu or a link, share it with the team, and use a contacts view as an email audience.',\n },\n datasets: {\n path: '/content-and-data/datasets/overview',\n title: 'Datasets & Dynamic Content',\n excerpt: 'Model structured content with typed fields and relations, then bind it into repeatable components.',\n },\n deals: {\n path: '/content-and-data/crm/deals',\n title: 'Deals pipeline',\n excerpt: 'Every open deal by stage — with an amount, an owner and an expected close — as a board you drag across or a table you page through, and the won and lost history behind it.',\n },\n designedEmails: {\n path: '/marketing-and-automation/email-campaigns/designed-emails',\n title: 'Designed emails',\n excerpt: 'Build campaign emails in the besigner with email-safe blocks and merge tokens — no separate editor.',\n },\n emailCampaigns: {\n path: '/marketing-and-automation/email-campaigns/overview',\n title: 'Email Campaigns',\n excerpt: 'Campaigns to audiences built from your contacts. Campaign email starts at Pro and needs a sending domain of the site\\'s own; receipts and account email send on every plan.',\n },\n events: {\n path: '/content-and-data/events/overview',\n title: 'Events Calendar',\n excerpt: 'Keep a schedule of events in the console and publish the ones you choose to any screen, with search-engine event markup.',\n },\n forms: {\n path: '/content-and-data/forms/overview',\n title: 'Forms & Lead Capture',\n excerpt: 'Add forms to your site, collect submissions in an inbox, and write them into datasets.',\n },\n installYourFirstPlugin: {\n path: '/guides/install-your-first-plugin',\n title: 'Install your first marketplace item',\n excerpt: 'A click-by-click walkthrough of the Marketplace — find something, choose which sites get it, install it, and turn it off again.',\n },\n inviteTeammates: {\n path: '/workspace-and-billing/teams-and-roles/invite-teammates',\n title: 'Invite teammates',\n excerpt: 'Add people to your site and understand how team members act within your organization.',\n },\n manifestAndEnvs: {\n path: '/developers/plugins/reference/manifest-and-envs',\n title: 'Manifests, trust lifecycle & environment',\n excerpt: 'The plugin manifest schema, the marketplace listing/version documents, the trust state machine, and every PLUGIN_* environment variable.',\n },\n marketingOverlays: {\n path: '/marketing-and-automation/marketing-overlays/overview',\n title: 'Marketing Overlays',\n excerpt: 'Site-wide announcement bars and promotional popups with triggers, scheduling, and email capture.',\n },\n membersOnly: {\n path: '/workspace-and-billing/teams-and-roles/members-only',\n title: 'Members-only areas',\n excerpt: 'Let visitors sign up as members and gate screens so only members can view them.',\n },\n orgAutomations: {\n path: '/marketing-and-automation/workflows-and-actions/org-automations',\n title: 'Org automations',\n excerpt: 'Write an automation once for the whole organization and run it on the sites you choose, with a pause on each site.',\n },\n plugins: {\n path: '/developers/plugins/overview',\n title: 'Plugins & Marketplace',\n excerpt: 'Extend Aglyn with sandboxed plugins — install from the marketplace, configure them, and publish your own.',\n },\n pos: {\n path: '/commerce-and-bookings/commerce/pos-and-reservations',\n title: 'POS & reservations',\n excerpt: 'Sell in person from the console register and take date-range reservations with deposits.',\n },\n publishAPlugin: {\n path: '/developers/plugins/publish-a-plugin',\n title: 'Publish a plugin',\n excerpt: 'Ship your own plugin to the marketplace with version pinning.',\n },\n publisherHandbook: {\n path: '/developers/plugins/publishing/publisher-handbook',\n title: 'Publisher handbook',\n excerpt: 'Publishing to the Aglyn marketplace — from profile setup through listing authoring, review, updates, and getting paid.',\n },\n redirects: {\n path: '/building-sites/redirects/overview',\n title: 'Redirects',\n excerpt: 'Manage URL redirects with validation, loop detection, and hit metrics.',\n },\n sandboxSecurity: {\n path: '/developers/plugins/reference/sandbox-security',\n title: 'Sandbox security model',\n excerpt: 'How sandboxed marketplace plugins are isolated — separate origin, per-manifest CSP, pinned artifacts — and what that means when you write one.',\n },\n sequences: {\n path: '/content-and-data/crm/sequences',\n title: 'Sequences',\n excerpt: 'One-to-one email sequences a rep sends to a person from their own connected mailbox, kept with the CRM. Rolling out.',\n },\n webhooks: {\n path: '/marketing-and-automation/workflows-and-actions/webhooks',\n title: 'Webhooks',\n excerpt: 'Connect Aglyn to other systems with outbound and inbound webhooks.',\n },\n} as const satisfies Record<string, PluginDocsTopic>\n\nexport type PluginDocsKey = keyof typeof PLUGIN_DOCS\n\nexport const PLUGIN_DOCS_ANCHORS = {\n abuseReports: ['#where-reports-come-from', '#held-outbound-email', '#what-is-screened', '#tiers', '#deciding-a-held-row', '#names-and-domains', '#stripe-fraud-signals', '#seller-fraud-pattern', '#triage-by-severity', '#csam', '#which-lever', '#statuses', '#disclosure', '#dmca', '#counter-notices', '#counter-notice-clock', '#counter-notice-steps', '#repeat-infringers', '#repeat-infringer-threshold', '#known-gaps', '#related'],\n actionsBuilder: ['#create-an-action', '#recipes', '#describe-it', '#triggers', '#crm-events', '#only-run-when-a-field-matches', '#chain-multiple-conditions-andor', '#steps', '#crm-steps', '#step-conditions', '#sequences', '#run-history', '#what-is-and-isnt-recorded', '#interactions-from-the-besigner', '#when-to-use-which', '#related'],\n aglynAssist: ['#what-it-can-do', '#aglyn-ai', '#answers-for-beginners-and-developers', '#offers-to-open-a-page', '#edits-in-the-besigner', '#where-an-answer-came-from', '#answers-straight-from-the-documentation', '#message-limits', '#feedback', '#privacy'],\n aiAutomations: ['#draft', '#explain', '#why-a-run-failed', '#what-is-sent', '#who-can-use-it', '#related'],\n aiCrm: ['#summarize-a-record', '#summaries-are-reused-until-the-record-changes', '#draft-an-email', '#match-columns', '#what-is-sent', '#who-can-use-it', '#related'],\n aiExperiments: ['#it-proposes-you-write', '#write-variants', '#putting-them-in', '#what-it-will-not-write', '#read-a-result', '#the-verdict', '#the-words', '#undecided', '#what-is-sent', '#who-can-use-it', '#related'],\n aiMonitoring: ['#the-ai-card', '#where-else', '#one-account', '#the-spend-leaderboard', '#alerts', '#related'],\n aiProducts: ['#write-a-products-copy', '#write-copy-for-many-products', '#when-you-import-products', '#propose-a-first-catalog', '#propose-categories-and-discounts', '#what-the-copy-never-says', '#what-is-sent-to-the-ai-provider', '#who-can-use-it', '#related'],\n aiSeo: ['#write-a-pages-listing', '#write-a-products-listing', '#audit-the-whole-site', '#target-keywords', '#apply-all-as-drafts', '#structured-data-and-llmstxt', '#related'],\n aiThemes: ['#change-what-you-describe-or-design-a-new-theme', '#match-your-brand', '#review-the-proposal', '#save-it-or-dont', '#who-can-use-it', '#related'],\n assistSignals: ['#the-workflow-this-board-exists-for', '#fleet', '#the-cache-read-rate-and-what-a-bad-number-looks-like', '#where-the-money-goes', '#tokens-by-kind', '#docs-gaps', '#questions-the-docs-could-not-answer', '#what-people-actually-asked', '#what-assist-costs-by-workspace', '#reading-the-sample-honestly', '#related'],\n billing: ['#tiers--entitlements', '#enterprise', '#single-sign-on-and-enforcement', '#usage-meters', '#who-is-generating-what', '#ai-allotments', '#storage-overage', '#if-you-would-rather-uploads-stopped', '#assist-overage', '#stop-ai-assist-at-the-included-band', '#ai-overage-ceiling', '#free-ai-credits', '#ai-credit-alerts', '#usage-budget', '#seats', '#crm-records', '#the-crm-suite', '#one-to-one-email', '#organization-data', '#api-access', '#payments', '#outstanding', '#plan-total', '#billing-email', '#payment-methods', '#billing-address', '#tax-ids', '#sales-tax', '#platform-fees', '#related'],\n bindings: ['#binding-tokens', '#rename-safe-id-tokens', '#insert-a-variable', '#token-pills', '#in-the-canvas-text-editor', '#site-details', '#typed-variables', '#no-code-functions', '#parameters-a-visitor-can-answer', '#a-calculator-you-lay-out', '#where-used--safety', '#workflows', '#related'],\n bookings: ['#set-up-bookings', '#taking-bookings', '#reminders', '#payments-and-fees', '#service-tax', '#manage', '#booking-from-the-crm', '#canceling-and-refunding', '#related'],\n buildAWorkflow: ['#1-open-the-workflows-page', '#2-choose-a-trigger', '#3-add-steps', '#waiting', '#4-save-and-test', '#duplicate-a-workflow', '#tips', '#related'],\n catalog: ['#products-options-and-variants', '#billing-modes-and-subscriptions', '#ai', '#categories-and-tags', '#collections', '#slugs', '#merchant-center-feed', '#related'],\n commerce: ['#products-hub', '#inventory', '#reserved-stock', '#stock-movements', '#gift-cards', '#recovery-and-alerts', '#orders', '#orders-screen', '#order-statuses', '#order-money-tiles', '#a-lost-dispute', '#shipping--taxes', '#lodging-tax-on-reservations', '#storefront-sales-tax', '#destination-coverage', '#dropshipping', '#related'],\n commerceEndToEnd: ['#1-connect-payments', '#2-create-products', '#3-design-the-storefront', '#catalog-search-filters-and-sort', '#category-pages', '#the-product-page-template', '#4-what-checkout-does', '#paying-without-leaving-your-site', '#5-run-orders-from-the-console', '#6-subscriptions--the-stripe-portal', '#related'],\n companies: ['#the-companies-list', '#create-a-company', '#a-companys-page', '#contacts-at-a-company', '#linked-on-capture', '#import-from-csv', '#export-csv', '#deleting-a-company', '#who-can-see-a-company', '#files', '#related'],\n consoleTour: ['#the-app-bar', '#in-context-help', '#filter-and-search', '#primary-navigation', '#editing-vs-managing', '#the-sites-list', '#the-status-pill', '#how-the-pill-is-decided', '#your-site-allowance', '#a-sites-dashboard', '#next', '#workspace-settings--notifications', '#the-notifications-feed', '#notification-settings', '#one-kind-at-a-time', '#workspace-and-site-overrides', '#daily-digests', '#alerts-on-this-device'],\n contactActivities: ['#four-kinds-of-history', '#reading-the-timeline', '#campaign-email', '#logging-an-activity', '#meeting-from-a-booking', '#click-to-call', '#sending-an-email', '#delivery-states', '#captured-email', '#where-an-activity-is-visible', '#the-recent-activity-feed', '#related'],\n contactFields: ['#define-a-field', '#fields-per-record', '#where-values-show', '#save-a-form-field', '#lead-source-values', '#over-the-api', '#retire-restore-delete', '#recompute-next-activity', '#related'],\n contactRecord: ['#adding-a-contact-by-hand', '#the-record-page', '#deleting-and-erasing', '#what-each-site-keeps-to-itself', '#when-sites-join-or-leave-a-group', '#merging-two-records', '#likely-duplicates', '#owner', '#last-engaged', '#lifecycle-stages', '#where-the-persons-lead-is', '#finding-a-contact', '#files', '#related'],\n contacts: ['#whats-in-the-crm-area', '#unified-ingestion', '#what-each-plan-includes', '#the-contacts-page', '#import-from-csv', '#segments', '#everywhere-the-crm-shows-up', '#capture-replies', '#at-the-organization-level', '#who-can-open-the-crm', '#one-sender-one-crm', '#related'],\n crmEmailTemplates: ['#templates-and-snippets', '#merge-fields', '#saving', '#managing-templates', '#duplicate-a-template', '#shared-or-personal', '#over-the-rest-api', '#related'],\n crmLeads: ['#what-makes-a-lead', '#what-a-lead-holds', '#adding-a-lead-by-hand', '#the-leads-list', '#filter-the-leads', '#working-a-lead-from-the-row', '#several-leads-at-once', '#export-csv', '#import-from-csv', '#who-owns-a-lead', '#a-leads-page', '#email-state', '#converting-a-lead', '#unqualifying-a-lead', '#erasing-the-person', '#who-can-do-this', '#related'],\n crmReports: ['#choosing-a-period', '#contacts', '#sources-and-lifecycle', '#conversion-by-source', '#lead-funnel', '#pipeline', '#forecast-by-close-month', '#won-and-lost', '#won-and-lost-by-owner', '#activity-by-teammate', '#tasks', '#exporting-a-table', '#crm-at-a-glance', '#how-the-numbers-are-counted', '#related'],\n crmSettings: ['#companies', '#create-companies-from-work-email-domains', '#default-owner', '#assignment-rules', '#round-robin', '#email-templates', '#email-capture', '#your-sending-addresses', '#recipes', '#related'],\n crmTasks: ['#the-tasks-page', '#the-calendar-view', '#snoozing-a-task', '#selecting-exporting-and-acting-on-many', '#import-from-csv', '#creating-a-task', '#assigning-a-task-to-someone-else', '#completing-and-reopening', '#organization-tasks', '#tasks-on-a-contact-company-or-deal', '#reminders', '#turning-reminders-off', '#next-activity', '#the-daily-digest', '#turning-it-off', '#the-dashboard-card', '#who-can-do-what', '#related'],\n crmViews: ['#the-views-control', '#a-view-is-a-link', '#filters', '#filters-on-the-contacts-list', '#filters-on-the-other-lists', '#columns-and-sort', '#segments-and-views', '#who-sees-what', '#related'],\n datasets: ['#model-builder', '#typed-documents', '#filter-records', '#relations', '#query-layer', '#repeatable-components', '#who-a-dataset-is-shared-with', '#import--export', '#related'],\n deals: ['#pipelines', '#stages', '#the-board-and-the-table', '#import-from-csv', '#creating-a-deal', '#line-items', '#moving-winning-and-losing', '#a-won-deal-makes-its-contact-a-customer', '#a-deals-page', '#files', '#related'],\n designedEmails: ['#create-a-template', '#find-a-template', '#duplicate-a-template', '#styling-email-blocks', '#merge-tokens', '#send-it', '#the-plain-text-version', '#start-from-a-brief-instead'],\n emailCampaigns: ['#send-a-campaign', '#campaigns-belong-to-the-organization', '#organization-emails-page', '#campaigns-group-emails', '#filter-the-lists', '#what-belongs-to-a-campaign', '#who-the-email-comes-from', '#sending-domains', '#account-email-always-sends', '#marketing-needs-a-domain', '#two-ways-to-get-a-domain', '#a-domain-we-set-up-is-a-request', '#domain-states', '#senders', '#send-a-test', '#preview-the-email', '#monthly-send-cap', '#personalize-with-merge-tags', '#recipient-count', '#who-a-campaign-is-allowed-to-reach', '#schedule-a-send', '#held-for-review', '#duplicate-an-email', '#email-lists', '#manual-lists', '#list-members', '#add-to-a-list', '#import-a-list', '#remove-from-a-list', '#lists-built-from-a-rule', '#experiments', '#experiments-across-sites', '#opens--clicks', '#the-campaign-report', '#per-contact-engagement', '#which-links-were-clicked', '#revenue-from-a-campaign', '#conversions', '#compliance', '#list-unsubscribe', '#topics', '#preference-page', '#consent-groups', '#consent-group-create', '#consent-group-join', '#consent-group-leave', '#consent-group-rename', '#consent-group-progress', '#frequency-opt-down', '#double-opt-in', '#consent-group-confirmation', '#marketing-mail', '#frequency-cap', '#suppressions', '#add-a-suppression', '#platform-suppressions', '#related'],\n events: ['#manage-events', '#show-events-on-a-screen', '#search-engines', '#related'],\n forms: ['#reading-submissions-from-code', '#build-a-form', '#monthly-allowance-per-plan', '#spam-and-abuse-protection', '#the-per-site-monthly-ceiling', '#field-types', '#labels-and-placeholders', '#example-a-quick-survey', '#after-submit', '#example-grow-an-email-list-from-a-signup-form', '#consent-group-disclosure', '#where-submissions-go', '#the-inbox', '#filter-the-inbox', '#who-a-submission-is-from', '#where-this-one-went', '#replying-to-a-submission', '#every-sites-inbox-at-once', '#one-forms-own-page', '#find-a-form', '#duplicate-a-form', '#switch-forms-off-for-one-site', '#related'],\n installYourFirstPlugin: ['#before-you-start', '#step-1-open', '#step-2-browse', '#step-3-reviews', '#step-4-targeting', '#step-5-install', '#step-6-use', '#step-7-off', '#what-to-do-next', '#related'],\n inviteTeammates: ['#invite-someone', '#pending-invites', '#who-gets-told', '#accepting-an-invite', '#an-invitation-never-changes-who-owns-the-workspace', '#how-team-members-act', '#you-are-a-site-collaborators-support-channel', '#help-a-teammate-who-is-locked-out', '#why-you-cant-always-set-a-password', '#activity-log', '#ai-actions', '#ai-usage', '#ai-allotment', '#tips', '#related'],\n manifestAndEnvs: ['#plugin-manifest-published-with-every-version', '#contributes--where-the-plugin-loads', '#config--settings-without-writing-a-settings-screen', '#listing--version-documents', '#review--trust-lifecycle', '#environment-variables', '#pluginsconfigjson-first-party-contributors'],\n marketingOverlays: ['#announcement-bar', '#promotional-popups', '#frequency', '#popup-v2', '#multiple-overlays-scheduling--page-targeting', '#variables-in-copy', '#engagement-stats', '#across-your-sites', '#related'],\n membersOnly: ['#let-visitors-sign-up', '#sign-in-sign-up-and-recovery-pages', '#forgotten-passwords', '#gate-a-screen', '#manage-your-members', '#suspend-or-reactivate-a-member', '#tips', '#related'],\n orgAutomations: ['#what-an-org-automation-is', '#create-one', '#triggers', '#steps', '#pause-it-on-one-site', '#waiting-switching-off-and-deleting', '#every-sites-own-automations', '#related'],\n plugins: ['#install--upgrade', '#browse-card', '#whats-included', '#what-the-badges-on-a-listing-mean', '#how-plugins-run', '#when-one-plugin-depends-on-another', '#a-dependency-that-is-off-for-one-site', '#configure', '#configure-site', '#publish-your-own', '#related'],\n pos: ['#registers', '#the-register', '#platform-fees-at-the-register', '#selling-past-the-count', '#when-something-disconnects', '#reservations', '#related'],\n publishAPlugin: ['#the-publish-pipeline', '#private-plugins', '#paid-listings', '#your-publisher-profile', '#tips', '#related'],\n publisherHandbook: ['#before-your-first-publish', '#the-publisher-agreement', '#where-to-publish-from', '#what-installing-each-type-does', '#rules-an-email-starter-has-to-meet', '#publishing-a-version', '#before-you-publish', '#review-what-happens-after-you-publish', '#the-two-badges-and-what-each-one-promises', '#asking-to-be-verified', '#testing-a-version-before-it-is-approved', '#watching-your-own-submission', '#disabled-versions', '#private-plugins', '#authoring-your-listing', '#what-your-listing-can-say-about-aglyn', '#versioning--updates', '#shipping-a-new-version', '#how-installs-work-the-buyer-side', '#getting-paid', '#low-prices-and-processing'],\n redirects: ['#manage-redirects', '#sending-visitors-to-another-site', '#metrics', '#match-modes-v2', '#related'],\n sandboxSecurity: ['#a-separate-origin', '#per-manifest-network-policy', '#when-you-cant-declare-the-origin', '#pinned-immutable-artifacts', '#what-this-means-when-you-build', '#related'],\n sequences: ['#what-it-is-for', '#sent-from-your-own-mailbox', '#where-it-lives', '#connect-a-mailbox', '#send-as', '#daily-cap', '#mailbox-status', '#auto-pause', '#mailbox-actions', '#link-domains', '#compliance-settings', '#allowed-countries', '#do-not-contact-domains', '#sequences', '#build-a-sequence', '#send-a-test', '#sequence-status', '#enroll', '#mail-gateways', '#cold-contacts', '#enrollments', '#person-history', '#curate', '#sending', '#what-stops-a-sequence', '#unsubscribe', '#related'],\n webhooks: ['#outbound-webhooks', '#inbound-webhooks', '#tips', '#related'],\n} as const satisfies Partial<Record<PluginDocsKey, readonly `#${string}`[]>>\n\ntype PluginAnchorMap = typeof PLUGIN_DOCS_ANCHORS\n\n/** Valid heading anchors for a plugin docs page (`never` when none). */\nexport type PluginDocsAnchor<K extends PluginDocsKey> =\n K extends keyof PluginAnchorMap ? PluginAnchorMap[K][number] : never\n"],"names":["PLUGIN_DOCS","abuseReports","path","title","excerpt","actionsBuilder","aglynAssist","aiAutomations","aiCrm","aiExperiments","aiMonitoring","aiProducts","aiSeo","aiThemes","assistSignals","billing","bindings","bookings","buildAWorkflow","catalog","commerce","commerceEndToEnd","companies","consoleTour","contactActivities","contactFields","contactRecord","contacts","crmEmailTemplates","crmLeads","crmReports","crmSettings","crmTasks","crmViews","datasets","deals","designedEmails","emailCampaigns","events","forms","installYourFirstPlugin","inviteTeammates","manifestAndEnvs","marketingOverlays","membersOnly","orgAutomations","plugins","pos","publishAPlugin","publisherHandbook","redirects","sandboxSecurity","sequences","webhooks","PLUGIN_DOCS_ANCHORS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,iDAAiD;AACjD,8CAA8C;AAC9C,oEAAoE;AAEpE,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAW/E,OAAO,MAAMA,cAAc;IACzBC,cAAc;QACZC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAC,gBAAgB;QACdH,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAE,aAAa;QACXJ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAG,eAAe;QACbL,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAI,OAAO;QACLN,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAK,eAAe;QACbP,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAM,cAAc;QACZR,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAO,YAAY;QACVT,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAQ,OAAO;QACLV,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAS,UAAU;QACRX,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAU,eAAe;QACbZ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAW,SAAS;QACPb,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAY,UAAU;QACRd,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAa,UAAU;QACRf,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAc,gBAAgB;QACdhB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAe,SAAS;QACPjB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgB,UAAU;QACRlB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiB,kBAAkB;QAChBnB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkB,WAAW;QACTpB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmB,aAAa;QACXrB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoB,mBAAmB;QACjBtB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqB,eAAe;QACbvB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsB,eAAe;QACbxB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuB,UAAU;QACRzB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwB,mBAAmB;QACjB1B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyB,UAAU;QACR3B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0B,YAAY;QACV5B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2B,aAAa;QACX7B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4B,UAAU;QACR9B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA6B,UAAU;QACR/B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA8B,UAAU;QACRhC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA+B,OAAO;QACLjC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgC,gBAAgB;QACdlC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiC,gBAAgB;QACdnC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkC,QAAQ;QACNpC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmC,OAAO;QACLrC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoC,wBAAwB;QACtBtC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqC,iBAAiB;QACfvC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsC,iBAAiB;QACfxC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuC,mBAAmB;QACjBzC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwC,aAAa;QACX1C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyC,gBAAgB;QACd3C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0C,SAAS;QACP5C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2C,KAAK;QACH7C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4C,gBAAgB;QACd9C,MAAM;QACNC,O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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/docs-help.generated.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// GENERATED FILE — do not edit. Regenerate with:\n// node tools/scripts/generate-docs-help.mjs\n// Source of truth: apps/docs/docs frontmatter + headings (AGL-602).\n\n// The first-party plugin consoles live in `libs/plugins/*` and cannot import\n// the console's constants, so they carry their own generated subset of the\n// docs help registry — the topics their cards actually link to, and no others.\n\nexport interface PluginDocsTopic {\n /** Docs-site path, e.g. `/commerce-and-bookings/commerce/overview`. */\n path: string\n /** Docs page title. */\n title: string\n /** Verbatim docs frontmatter description — the tooltip excerpt. */\n excerpt: string\n}\n\nexport const PLUGIN_DOCS = {\n abuseReports: {\n path: '/staff-console/abuse-reports',\n title: 'Abuse reports',\n excerpt: 'The public abuse-report queue — where outside reports land, how to triage by severity, which lever answers which report, and the CSAM and DMCA paths that are not takedown buttons.',\n },\n actionsBuilder: {\n path: '/marketing-and-automation/workflows-and-actions/actions-builder',\n title: 'Actions builder',\n excerpt: 'Map a single event to a single action without building a full workflow.',\n },\n aglynAssist: {\n path: '/getting-started/aglyn-assist',\n title: 'Aglyn Assist',\n excerpt: 'Ask the built-in AI helper how to do anything in Aglyn — it answers from these docs and links you straight to the right page.',\n },\n aiAutomations: {\n path: '/ai/automations-with-ai',\n title: 'Explain automations with AI',\n excerpt: 'Aglyn AI can explain an automation you already have and tell you why one of its runs failed. Drafting an automation from a description is not available yet.',\n },\n aiCrm: {\n path: '/ai/crm-by-ai',\n title: 'The AI CRM built into Aglyn',\n excerpt: 'An AI CRM for small business: a short summary and a suggested next step on a contact, company, deal or lead, a one-to-one email drafted into the composer, and an import\\'s columns matched to fields.',\n },\n aiExperiments: {\n path: '/ai/ab-tests-with-ai',\n title: 'A/B tests by AI: write variants, read the result',\n excerpt: 'Have Aglyn AI write two to four variants for a screen, section or email experiment, and put a finished test into plain language — with the verdict decided from the counts before the model is asked anything.',\n },\n aiMonitoring: {\n path: '/staff-console/ai-monitoring',\n title: 'AI monitoring',\n excerpt: 'How staff watch one organization\\'s AI usage — the add-on, credit pool, overage, refusals, jobs, tokens and cache hit rate, top spenders and margin — and where those figures appear across the staff console.',\n },\n aiProducts: {\n path: '/ai/products-with-ai',\n title: 'Product copy and catalogs with AI',\n excerpt: 'An AI product description generator for your catalog: write copy for one product or a whole import, propose a first catalog, and propose categories and discounts — all as proposals you review.',\n },\n aiSeo: {\n path: '/building-sites/seo/seo-by-ai',\n title: 'AI SEO for your website',\n excerpt: 'AI SEO for websites, on the pages you already have: have AI write a page or product\\'s search listing, audit every published page with a proposed fix for each finding, and draft your structured data and /llms.txt.',\n },\n aiThemes: {\n path: '/ai/theme-assist',\n title: 'Change your site\\'s theme with AI',\n excerpt: 'Describe a change to your site\\'s theme and get a proposal for the theme editor\\'s own controls, previewed before and after, that you save in the editor. Rolling out.',\n },\n assistSignals: {\n path: '/staff-console/assist-signals',\n title: 'Assist Signal',\n excerpt: 'The docs-gap and cost board behind Assist — how the ranking is ordered, why ungrounded questions are counted separately, and what the cache-read rate says about margin.',\n },\n billing: {\n path: '/workspace-and-billing/billing-and-plans/overview',\n title: 'Billing & Plans',\n excerpt: 'How Aglyn\\'s tiers, entitlements, quotas, usage meters, and seat add-ons work.',\n },\n bindings: {\n path: '/building-sites/bindings/overview',\n title: 'Bindings, Variables & Functions',\n excerpt: 'Live values in your content — typed variables, no-code functions, and rename-safe id tokens.',\n },\n bookings: {\n path: '/commerce-and-bookings/bookings/overview',\n title: 'Bookings & Scheduling',\n excerpt: 'Offer services with availability, let visitors book, take payment, and send reminders.',\n },\n buildAWorkflow: {\n path: '/marketing-and-automation/workflows-and-actions/build-a-workflow',\n title: 'Build a workflow',\n excerpt: 'Create a multi-step workflow that runs when a site event fires.',\n },\n catalog: {\n path: '/commerce-and-bookings/commerce/catalog',\n title: 'Product catalog',\n excerpt: 'Products with options and variants, categories, tags, and manual or smart collections.',\n },\n commerce: {\n path: '/commerce-and-bookings/commerce/overview',\n title: 'Commerce',\n excerpt: 'Sell physical, digital, and service products with a full catalog, orders pipeline, shipping, taxes, and your own Stripe account.',\n },\n commerceEndToEnd: {\n path: '/guides/commerce-end-to-end',\n title: 'Commerce end to end',\n excerpt: 'Create products with billing modes, design the storefront with Commerce blocks, take Stripe checkout for one-time and subscription sales, and run orders from the console.',\n },\n companies: {\n path: '/content-and-data/crm/companies',\n title: 'Companies',\n excerpt: 'Group your contacts under the businesses they belong to — one record per company, with its domain, owner, address and the people who work there.',\n },\n consoleTour: {\n path: '/getting-started/console-tour',\n title: 'The console tour',\n excerpt: 'Where things live in the Aglyn console app bar and navigation.',\n },\n contactActivities: {\n path: '/content-and-data/crm/activities',\n title: 'Activities & the timeline',\n excerpt: 'Log calls, emails, meetings and notes against a contact, a company, a deal or a lead, email one person from their record, and read it all in one timeline beside everything captured and every campaign sent.',\n },\n contactFields: {\n path: '/content-and-data/crm/custom-fields',\n title: 'Custom fields',\n excerpt: 'Define your own properties on contacts, companies, deals and leads — text, number, date, choice, checkbox or link — show them on every record and its list, and save form answers straight into a contact\\'s.',\n },\n contactRecord: {\n path: '/content-and-data/crm/contact-record',\n title: 'The contact record',\n excerpt: 'Add a contact by hand, keep a profile on them — phone, company, title, address, owner and lifecycle stage — open their own page in the CRM, and merge two records that turn out to be one person.',\n },\n contacts: {\n path: '/content-and-data/crm/overview',\n title: 'CRM',\n excerpt: 'One place for the people who interact with your sites — contacts captured from forms, members, orders and bookings, with leads, companies, a deals pipeline, tasks, a timeline, reports and custom fields.',\n },\n crmEmailTemplates: {\n path: '/content-and-data/crm/email-templates',\n title: 'Email templates',\n excerpt: 'Keep the letters your team sends from a record — templates that fill in the subject and the message, snippets that drop in a paragraph, and merge fields that fill in the person\\'s name, their company, the deal and you.',\n },\n crmLeads: {\n path: '/content-and-data/crm/leads',\n title: 'Leads',\n excerpt: 'Work the people your site has captured — a status, an owner and notes on every lead — and convert one into a contact, a company and a deal.',\n },\n crmReports: {\n path: '/content-and-data/crm/reports',\n title: 'Reports',\n excerpt: 'New contacts, where they came from and which sources convert, the lead funnel, the open pipeline and its forecast, won and lost, who logged what, and the task load — counted on the server, every table exportable as CSV.',\n },\n crmSettings: {\n path: '/content-and-data/crm/settings',\n title: 'CRM settings',\n excerpt: 'What the CRM does on its own for every site — whether a company is created from a contact\\'s work email domain, who a new contact is assigned to, the address that files replies on a record, and each site\\'s recipes.',\n },\n crmTasks: {\n path: '/content-and-data/crm/tasks',\n title: 'Tasks & follow-ups',\n excerpt: 'Calls, emails, meetings and to-dos with a due date, an assignee and a link to the record they are for — overdue and today read off the clock, a snooze, a reminder at its own time, and a morning digest.',\n },\n crmViews: {\n path: '/content-and-data/crm/views',\n title: 'Saved views',\n excerpt: 'Keep a CRM list\\'s filters, columns and sort under a name, open it from the views menu or a link, share it with the team, and use a contacts view as an email audience.',\n },\n datasets: {\n path: '/content-and-data/datasets/overview',\n title: 'Datasets & Dynamic Content',\n excerpt: 'Model structured content with typed fields and relations, then bind it into repeatable components.',\n },\n deals: {\n path: '/content-and-data/crm/deals',\n title: 'Deals pipeline',\n excerpt: 'Every open deal by stage — with an amount, an owner and an expected close — as a board you drag across or a table you page through, and the won and lost history behind it.',\n },\n designedEmails: {\n path: '/marketing-and-automation/email-campaigns/designed-emails',\n title: 'Designed emails',\n excerpt: 'Build campaign emails in the besigner with email-safe blocks and merge tokens — no separate editor.',\n },\n emailCampaigns: {\n path: '/marketing-and-automation/email-campaigns/overview',\n title: 'Email Campaigns',\n excerpt: 'Campaigns to audiences built from your contacts. Campaign email starts at Pro and needs a sending domain of the site\\'s own; receipts and account email send on every plan.',\n },\n events: {\n path: '/content-and-data/events/overview',\n title: 'Events Calendar',\n excerpt: 'Keep a schedule of events in the console and publish the ones you choose to any screen, with search-engine event markup.',\n },\n forms: {\n path: '/content-and-data/forms/overview',\n title: 'Forms & Lead Capture',\n excerpt: 'Add forms to your site, collect submissions in an inbox, and write them into datasets.',\n },\n installYourFirstPlugin: {\n path: '/guides/install-your-first-plugin',\n title: 'Install your first marketplace item',\n excerpt: 'A click-by-click walkthrough of the Marketplace — find something, choose which sites get it, install it, and turn it off again.',\n },\n inviteTeammates: {\n path: '/workspace-and-billing/teams-and-roles/invite-teammates',\n title: 'Invite teammates',\n excerpt: 'Add people to your site and understand how team members act within your organization.',\n },\n manifestAndEnvs: {\n path: '/developers/plugins/reference/manifest-and-envs',\n title: 'Manifests, trust lifecycle & environment',\n excerpt: 'The plugin manifest schema, the marketplace listing/version documents, the trust state machine, and every PLUGIN_* environment variable.',\n },\n marketingOverlays: {\n path: '/marketing-and-automation/marketing-overlays/overview',\n title: 'Marketing Overlays',\n excerpt: 'Site-wide announcement bars and promotional popups with triggers, scheduling, and email capture.',\n },\n membersOnly: {\n path: '/workspace-and-billing/teams-and-roles/members-only',\n title: 'Members-only areas',\n excerpt: 'Let visitors sign up as members and gate screens so only members can view them.',\n },\n orgAutomations: {\n path: '/marketing-and-automation/workflows-and-actions/org-automations',\n title: 'Org automations',\n excerpt: 'Write an automation once for the whole organization and run it on the sites you choose, with a pause on each site.',\n },\n plugins: {\n path: '/developers/plugins/overview',\n title: 'Plugins & Marketplace',\n excerpt: 'Extend Aglyn with sandboxed plugins — install from the marketplace, configure them, and publish your own.',\n },\n pos: {\n path: '/commerce-and-bookings/commerce/pos-and-reservations',\n title: 'POS & reservations',\n excerpt: 'Sell in person from the console register and take date-range reservations with deposits.',\n },\n publishAPlugin: {\n path: '/developers/plugins/publish-a-plugin',\n title: 'Publish a plugin',\n excerpt: 'Ship your own plugin to the marketplace with version pinning.',\n },\n publisherHandbook: {\n path: '/developers/plugins/publishing/publisher-handbook',\n title: 'Publisher handbook',\n excerpt: 'Publishing to the Aglyn marketplace — from profile setup through listing authoring, review, updates, and getting paid.',\n },\n redirects: {\n path: '/building-sites/redirects/overview',\n title: 'Redirects',\n excerpt: 'Manage URL redirects with validation, loop detection, and hit metrics.',\n },\n sandboxSecurity: {\n path: '/developers/plugins/reference/sandbox-security',\n title: 'Sandbox security model',\n excerpt: 'How sandboxed marketplace plugins are isolated — separate origin, per-manifest CSP, pinned artifacts — and what that means when you write one.',\n },\n sequences: {\n path: '/content-and-data/crm/sequences',\n title: 'Sequences',\n excerpt: 'One-to-one email sequences a rep sends to a person from their own connected mailbox, kept with the CRM. Rolling out.',\n },\n webhooks: {\n path: '/marketing-and-automation/workflows-and-actions/webhooks',\n title: 'Webhooks',\n excerpt: 'Connect Aglyn to other systems with outbound and inbound webhooks.',\n },\n} as const satisfies Record<string, PluginDocsTopic>\n\nexport type PluginDocsKey = keyof typeof PLUGIN_DOCS\n\nexport const PLUGIN_DOCS_ANCHORS = {\n abuseReports: ['#where-reports-come-from', '#held-outbound-email', '#what-is-screened', '#tiers', '#deciding-a-held-row', '#names-and-domains', '#stripe-fraud-signals', '#seller-fraud-pattern', '#card-testing-velocity', '#marketplace', '#triage-by-severity', '#csam', '#which-lever', '#statuses', '#disclosure', '#dmca', '#counter-notices', '#counter-notice-clock', '#counter-notice-steps', '#repeat-infringers', '#repeat-infringer-threshold', '#known-gaps', '#related'],\n actionsBuilder: ['#create-an-action', '#recipes', '#describe-it', '#triggers', '#crm-events', '#only-run-when-a-field-matches', '#chain-multiple-conditions-andor', '#steps', '#crm-steps', '#step-conditions', '#sequences', '#run-history', '#what-is-and-isnt-recorded', '#interactions-from-the-besigner', '#when-to-use-which', '#related'],\n aglynAssist: ['#what-it-can-do', '#aglyn-ai', '#answers-for-beginners-and-developers', '#offers-to-open-a-page', '#edits-in-the-besigner', '#where-an-answer-came-from', '#answers-straight-from-the-documentation', '#message-limits', '#feedback', '#privacy'],\n aiAutomations: ['#draft', '#explain', '#why-a-run-failed', '#what-is-sent', '#who-can-use-it', '#related'],\n aiCrm: ['#summarize-a-record', '#summaries-are-reused-until-the-record-changes', '#draft-an-email', '#match-columns', '#what-is-sent', '#who-can-use-it', '#related'],\n aiExperiments: ['#it-proposes-you-write', '#write-variants', '#putting-them-in', '#what-it-will-not-write', '#read-a-result', '#the-verdict', '#the-words', '#undecided', '#what-is-sent', '#who-can-use-it', '#related'],\n aiMonitoring: ['#the-ai-card', '#where-else', '#one-account', '#the-spend-leaderboard', '#alerts', '#related'],\n aiProducts: ['#write-a-products-copy', '#write-copy-for-many-products', '#when-you-import-products', '#propose-a-first-catalog', '#propose-categories-and-discounts', '#what-the-copy-never-says', '#what-is-sent-to-the-ai-provider', '#who-can-use-it', '#related'],\n aiSeo: ['#write-a-pages-listing', '#write-a-products-listing', '#audit-the-whole-site', '#target-keywords', '#apply-all-as-drafts', '#structured-data-and-llmstxt', '#related'],\n aiThemes: ['#change-what-you-describe-or-design-a-new-theme', '#match-your-brand', '#review-the-proposal', '#save-it-or-dont', '#who-can-use-it', '#related'],\n assistSignals: ['#the-workflow-this-board-exists-for', '#fleet', '#the-cache-read-rate-and-what-a-bad-number-looks-like', '#where-the-money-goes', '#tokens-by-kind', '#docs-gaps', '#questions-the-docs-could-not-answer', '#what-people-actually-asked', '#what-assist-costs-by-workspace', '#reading-the-sample-honestly', '#related'],\n billing: ['#tiers--entitlements', '#enterprise', '#single-sign-on-and-enforcement', '#usage-meters', '#who-is-generating-what', '#ai-allotments', '#storage-overage', '#if-you-would-rather-uploads-stopped', '#assist-overage', '#stop-ai-assist-at-the-included-band', '#ai-overage-ceiling', '#free-ai-credits', '#ai-credit-alerts', '#usage-budget', '#seats', '#crm-records', '#the-crm-suite', '#one-to-one-email', '#organization-data', '#api-access', '#payments', '#outstanding', '#plan-total', '#billing-email', '#payment-methods', '#billing-address', '#tax-ids', '#sales-tax', '#platform-fees', '#related'],\n bindings: ['#binding-tokens', '#rename-safe-id-tokens', '#insert-a-variable', '#token-pills', '#in-the-canvas-text-editor', '#site-details', '#typed-variables', '#no-code-functions', '#parameters-a-visitor-can-answer', '#a-calculator-you-lay-out', '#where-used--safety', '#workflows', '#related'],\n bookings: ['#set-up-bookings', '#taking-bookings', '#reminders', '#payments-and-fees', '#service-tax', '#manage', '#booking-from-the-crm', '#canceling-and-refunding', '#related'],\n buildAWorkflow: ['#1-open-the-workflows-page', '#2-choose-a-trigger', '#3-add-steps', '#waiting', '#4-save-and-test', '#duplicate-a-workflow', '#tips', '#related'],\n catalog: ['#products-options-and-variants', '#billing-modes-and-subscriptions', '#ai', '#categories-and-tags', '#collections', '#slugs', '#merchant-center-feed', '#related'],\n commerce: ['#products-hub', '#inventory', '#reserved-stock', '#stock-movements', '#gift-cards', '#recovery-and-alerts', '#orders', '#orders-screen', '#order-statuses', '#order-money-tiles', '#a-lost-dispute', '#shipping--taxes', '#lodging-tax-on-reservations', '#storefront-sales-tax', '#destination-coverage', '#dropshipping', '#related'],\n commerceEndToEnd: ['#1-connect-payments', '#2-create-products', '#3-design-the-storefront', '#catalog-search-filters-and-sort', '#category-pages', '#the-product-page-template', '#4-what-checkout-does', '#paying-without-leaving-your-site', '#5-run-orders-from-the-console', '#6-subscriptions--the-stripe-portal', '#related'],\n companies: ['#the-companies-list', '#create-a-company', '#a-companys-page', '#contacts-at-a-company', '#linked-on-capture', '#import-from-csv', '#export-csv', '#deleting-a-company', '#who-can-see-a-company', '#files', '#related'],\n consoleTour: ['#the-app-bar', '#in-context-help', '#filter-and-search', '#primary-navigation', '#editing-vs-managing', '#the-sites-list', '#the-status-pill', '#how-the-pill-is-decided', '#your-site-allowance', '#a-sites-dashboard', '#next', '#workspace-settings--notifications', '#the-notifications-feed', '#notification-settings', '#one-kind-at-a-time', '#workspace-and-site-overrides', '#daily-digests', '#alerts-on-this-device'],\n contactActivities: ['#four-kinds-of-history', '#reading-the-timeline', '#campaign-email', '#logging-an-activity', '#meeting-from-a-booking', '#click-to-call', '#sending-an-email', '#delivery-states', '#captured-email', '#where-an-activity-is-visible', '#the-recent-activity-feed', '#related'],\n contactFields: ['#define-a-field', '#fields-per-record', '#where-values-show', '#save-a-form-field', '#lead-source-values', '#over-the-api', '#retire-restore-delete', '#recompute-next-activity', '#related'],\n contactRecord: ['#adding-a-contact-by-hand', '#the-record-page', '#deleting-and-erasing', '#what-each-site-keeps-to-itself', '#when-sites-join-or-leave-a-group', '#merging-two-records', '#likely-duplicates', '#owner', '#last-engaged', '#lifecycle-stages', '#where-the-persons-lead-is', '#finding-a-contact', '#files', '#related'],\n contacts: ['#whats-in-the-crm-area', '#unified-ingestion', '#what-each-plan-includes', '#the-contacts-page', '#import-from-csv', '#segments', '#everywhere-the-crm-shows-up', '#capture-replies', '#at-the-organization-level', '#who-can-open-the-crm', '#one-sender-one-crm', '#related'],\n crmEmailTemplates: ['#templates-and-snippets', '#merge-fields', '#saving', '#managing-templates', '#duplicate-a-template', '#shared-or-personal', '#over-the-rest-api', '#related'],\n crmLeads: ['#what-makes-a-lead', '#what-a-lead-holds', '#adding-a-lead-by-hand', '#the-leads-list', '#filter-the-leads', '#working-a-lead-from-the-row', '#several-leads-at-once', '#export-csv', '#import-from-csv', '#who-owns-a-lead', '#a-leads-page', '#email-state', '#converting-a-lead', '#unqualifying-a-lead', '#erasing-the-person', '#who-can-do-this', '#related'],\n crmReports: ['#choosing-a-period', '#contacts', '#sources-and-lifecycle', '#conversion-by-source', '#lead-funnel', '#pipeline', '#forecast-by-close-month', '#won-and-lost', '#won-and-lost-by-owner', '#activity-by-teammate', '#tasks', '#exporting-a-table', '#crm-at-a-glance', '#how-the-numbers-are-counted', '#related'],\n crmSettings: ['#companies', '#create-companies-from-work-email-domains', '#default-owner', '#assignment-rules', '#round-robin', '#email-templates', '#email-capture', '#your-sending-addresses', '#recipes', '#related'],\n crmTasks: ['#the-tasks-page', '#the-calendar-view', '#snoozing-a-task', '#selecting-exporting-and-acting-on-many', '#import-from-csv', '#creating-a-task', '#assigning-a-task-to-someone-else', '#completing-and-reopening', '#organization-tasks', '#tasks-on-a-contact-company-or-deal', '#reminders', '#turning-reminders-off', '#next-activity', '#the-daily-digest', '#turning-it-off', '#the-dashboard-card', '#who-can-do-what', '#related'],\n crmViews: ['#the-views-control', '#a-view-is-a-link', '#filters', '#filters-on-the-contacts-list', '#filters-on-the-other-lists', '#columns-and-sort', '#segments-and-views', '#who-sees-what', '#related'],\n datasets: ['#model-builder', '#typed-documents', '#filter-records', '#relations', '#query-layer', '#repeatable-components', '#who-a-dataset-is-shared-with', '#import--export', '#related'],\n deals: ['#pipelines', '#stages', '#the-board-and-the-table', '#import-from-csv', '#creating-a-deal', '#line-items', '#moving-winning-and-losing', '#a-won-deal-makes-its-contact-a-customer', '#a-deals-page', '#files', '#related'],\n designedEmails: ['#create-a-template', '#find-a-template', '#duplicate-a-template', '#styling-email-blocks', '#merge-tokens', '#send-it', '#the-plain-text-version', '#start-from-a-brief-instead'],\n emailCampaigns: ['#send-a-campaign', '#campaigns-belong-to-the-organization', '#organization-emails-page', '#campaigns-group-emails', '#filter-the-lists', '#what-belongs-to-a-campaign', '#who-the-email-comes-from', '#sending-domains', '#account-email-always-sends', '#marketing-needs-a-domain', '#two-ways-to-get-a-domain', '#a-domain-we-set-up-is-a-request', '#domain-states', '#senders', '#send-a-test', '#preview-the-email', '#monthly-send-cap', '#personalize-with-merge-tags', '#recipient-count', '#who-a-campaign-is-allowed-to-reach', '#schedule-a-send', '#held-for-review', '#duplicate-an-email', '#email-lists', '#manual-lists', '#list-members', '#add-to-a-list', '#import-a-list', '#remove-from-a-list', '#lists-built-from-a-rule', '#experiments', '#experiments-across-sites', '#opens--clicks', '#the-campaign-report', '#per-contact-engagement', '#which-links-were-clicked', '#revenue-from-a-campaign', '#conversions', '#compliance', '#list-unsubscribe', '#topics', '#preference-page', '#consent-groups', '#consent-group-create', '#consent-group-join', '#consent-group-leave', '#consent-group-rename', '#consent-group-progress', '#frequency-opt-down', '#double-opt-in', '#consent-group-confirmation', '#marketing-mail', '#frequency-cap', '#suppressions', '#add-a-suppression', '#platform-suppressions', '#related'],\n events: ['#manage-events', '#show-events-on-a-screen', '#search-engines', '#related'],\n forms: ['#reading-submissions-from-code', '#build-a-form', '#monthly-allowance-per-plan', '#spam-and-abuse-protection', '#the-per-site-monthly-ceiling', '#field-types', '#labels-and-placeholders', '#example-a-quick-survey', '#after-submit', '#example-grow-an-email-list-from-a-signup-form', '#consent-group-disclosure', '#where-submissions-go', '#the-inbox', '#filter-the-inbox', '#who-a-submission-is-from', '#where-this-one-went', '#replying-to-a-submission', '#every-sites-inbox-at-once', '#one-forms-own-page', '#find-a-form', '#duplicate-a-form', '#switch-forms-off-for-one-site', '#related'],\n installYourFirstPlugin: ['#before-you-start', '#step-1-open', '#step-2-browse', '#step-3-reviews', '#step-4-targeting', '#step-5-install', '#step-6-use', '#step-7-off', '#what-to-do-next', '#related'],\n inviteTeammates: ['#invite-someone', '#pending-invites', '#who-gets-told', '#accepting-an-invite', '#an-invitation-never-changes-who-owns-the-workspace', '#how-team-members-act', '#you-are-a-site-collaborators-support-channel', '#help-a-teammate-who-is-locked-out', '#why-you-cant-always-set-a-password', '#activity-log', '#ai-actions', '#ai-usage', '#ai-allotment', '#tips', '#related'],\n manifestAndEnvs: ['#plugin-manifest-published-with-every-version', '#contributes--where-the-plugin-loads', '#config--settings-without-writing-a-settings-screen', '#listing--version-documents', '#review--trust-lifecycle', '#environment-variables', '#pluginsconfigjson-first-party-contributors'],\n marketingOverlays: ['#announcement-bar', '#promotional-popups', '#frequency', '#popup-v2', '#multiple-overlays-scheduling--page-targeting', '#variables-in-copy', '#engagement-stats', '#across-your-sites', '#related'],\n membersOnly: ['#let-visitors-sign-up', '#sign-in-sign-up-and-recovery-pages', '#forgotten-passwords', '#gate-a-screen', '#manage-your-members', '#suspend-or-reactivate-a-member', '#tips', '#related'],\n orgAutomations: ['#what-an-org-automation-is', '#create-one', '#triggers', '#steps', '#pause-it-on-one-site', '#waiting-switching-off-and-deleting', '#every-sites-own-automations', '#related'],\n plugins: ['#install--upgrade', '#browse-card', '#whats-included', '#what-the-badges-on-a-listing-mean', '#how-plugins-run', '#when-one-plugin-depends-on-another', '#a-dependency-that-is-off-for-one-site', '#configure', '#configure-site', '#publish-your-own', '#related'],\n pos: ['#registers', '#the-register', '#platform-fees-at-the-register', '#selling-past-the-count', '#when-something-disconnects', '#reservations', '#related'],\n publishAPlugin: ['#the-publish-pipeline', '#private-plugins', '#paid-listings', '#your-publisher-profile', '#tips', '#related'],\n publisherHandbook: ['#before-your-first-publish', '#the-publisher-agreement', '#where-to-publish-from', '#what-installing-each-type-does', '#rules-an-email-starter-has-to-meet', '#publishing-a-version', '#before-you-publish', '#review-what-happens-after-you-publish', '#the-two-badges-and-what-each-one-promises', '#asking-to-be-verified', '#testing-a-version-before-it-is-approved', '#watching-your-own-submission', '#disabled-versions', '#private-plugins', '#authoring-your-listing', '#what-your-listing-can-say-about-aglyn', '#versioning--updates', '#shipping-a-new-version', '#how-installs-work-the-buyer-side', '#getting-paid', '#low-prices-and-processing'],\n redirects: ['#manage-redirects', '#sending-visitors-to-another-site', '#metrics', '#match-modes-v2', '#related'],\n sandboxSecurity: ['#a-separate-origin', '#per-manifest-network-policy', '#when-you-cant-declare-the-origin', '#pinned-immutable-artifacts', '#what-this-means-when-you-build', '#related'],\n sequences: ['#what-it-is-for', '#sent-from-your-own-mailbox', '#where-it-lives', '#connect-a-mailbox', '#send-as', '#daily-cap', '#mailbox-status', '#auto-pause', '#mailbox-actions', '#link-domains', '#compliance-settings', '#allowed-countries', '#do-not-contact-domains', '#sequences', '#build-a-sequence', '#send-a-test', '#sequence-status', '#enroll', '#mail-gateways', '#cold-contacts', '#enrollments', '#person-history', '#curate', '#sending', '#what-stops-a-sequence', '#unsubscribe', '#related'],\n webhooks: ['#outbound-webhooks', '#inbound-webhooks', '#tips', '#related'],\n} as const satisfies Partial<Record<PluginDocsKey, readonly `#${string}`[]>>\n\ntype PluginAnchorMap = typeof PLUGIN_DOCS_ANCHORS\n\n/** Valid heading anchors for a plugin docs page (`never` when none). */\nexport type PluginDocsAnchor<K extends PluginDocsKey> =\n K extends keyof PluginAnchorMap ? PluginAnchorMap[K][number] : never\n"],"names":["PLUGIN_DOCS","abuseReports","path","title","excerpt","actionsBuilder","aglynAssist","aiAutomations","aiCrm","aiExperiments","aiMonitoring","aiProducts","aiSeo","aiThemes","assistSignals","billing","bindings","bookings","buildAWorkflow","catalog","commerce","commerceEndToEnd","companies","consoleTour","contactActivities","contactFields","contactRecord","contacts","crmEmailTemplates","crmLeads","crmReports","crmSettings","crmTasks","crmViews","datasets","deals","designedEmails","emailCampaigns","events","forms","installYourFirstPlugin","inviteTeammates","manifestAndEnvs","marketingOverlays","membersOnly","orgAutomations","plugins","pos","publishAPlugin","publisherHandbook","redirects","sandboxSecurity","sequences","webhooks","PLUGIN_DOCS_ANCHORS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,iDAAiD;AACjD,8CAA8C;AAC9C,oEAAoE;AAEpE,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAW/E,OAAO,MAAMA,cAAc;IACzBC,cAAc;QACZC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAC,gBAAgB;QACdH,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAE,aAAa;QACXJ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAG,eAAe;QACbL,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAI,OAAO;QACLN,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAK,eAAe;QACbP,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAM,cAAc;QACZR,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAO,YAAY;QACVT,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAQ,OAAO;QACLV,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAS,UAAU;QACRX,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAU,eAAe;QACbZ,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAW,SAAS;QACPb,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAY,UAAU;QACRd,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAa,UAAU;QACRf,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAc,gBAAgB;QACdhB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAe,SAAS;QACPjB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgB,UAAU;QACRlB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiB,kBAAkB;QAChBnB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkB,WAAW;QACTpB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmB,aAAa;QACXrB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoB,mBAAmB;QACjBtB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqB,eAAe;QACbvB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsB,eAAe;QACbxB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuB,UAAU;QACRzB,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwB,mBAAmB;QACjB1B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyB,UAAU;QACR3B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0B,YAAY;QACV5B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2B,aAAa;QACX7B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4B,UAAU;QACR9B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA6B,UAAU;QACR/B,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA8B,UAAU;QACRhC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA+B,OAAO;QACLjC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAgC,gBAAgB;QACdlC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAiC,gBAAgB;QACdnC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAkC,QAAQ;QACNpC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAmC,OAAO;QACLrC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAoC,wBAAwB;QACtBtC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAqC,iBAAiB;QACfvC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAsC,iBAAiB;QACfxC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAuC,mBAAmB;QACjBzC,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAwC,aAAa;QACX1C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACAyC,gBAAgB;QACd3C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA0C,SAAS;QACP5C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA2C,KAAK;QACH7C,MAAM;QACNC,OAAO;QACPC,SAAS;IACX;IACA4C,gBAAgB;QACd9C,MAAM;QACNC,O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"}
@@ -5702,6 +5702,13 @@ export const ASSIST_DOCS_INDEX = [
5702
5702
  anchor: '#the-tenants-tax-rule--plugin-tax-profile-server',
5703
5703
  text: 'More than one plugin takes money, and a merchant has one tax profile. The plugin that keeps it answers what a flat rate adds to a charge and which regime a settled payment was taxed under; any other plugin that charges asks here instead of importing the owner\'s model.\n| API | Semantics |\n| registerPluginTaxProfile(profile, { pluginId? }) | A slot: a workspace has one tax profile, so a second plugin\'s is refused and the incumbent keeps serving. |\n| pluginTaxProfile() | The rule — and it throws when no plugin registered one. |\n| flatTax(rate, chargeCents, fallbackLabel) | { taxCents, label, pct }, exclusive and rounded to the cent. rate is the merchant\'s stored setting passed as read; an absent, zero, negative or out-of-range one answers all-zero and never throws. |\n| taxModeOf(settledPayment, manualTaxCents?) | The regime as the owner records it. manualTaxCents is tax the caller added as a line of its own, which the processor reports as none. |\n| pluginTaxProfileOwner() | The owner\'s plugin id, or null — for a caller that only wants to know who it is. |\nNo profile is a refusal, never a zero. Every other seam here answers null for "nobody home", and this one must not: a caller that read null as "no tax" would charge an untaxed total and record it as untaxed, and the merchant would owe the difference. A refused sale is seen the same day. It cannot happen in a working build — both apps load every plugin\'s server entry before a plugin handler, a cron or the billing webhook runs — and tax-profile-is-registered.spec.ts in each app runs the real registrars and holds the real rule, because a plugin\'s own spec may not import the owner.'
5704
5704
  },
5705
+ {
5706
+ path: '/developers/plugins/reference/plugin-manager-api',
5707
+ title: 'Plugin-manager API reference',
5708
+ heading: 'Recurring charges — `plugin-recurring-charges` (`/server`)',
5709
+ anchor: '#recurring-charges--plugin-recurring-charges-server',
5710
+ text: 'A security lockdown pauses the subscriptions a locked site sells to its own customers. If your plugin sells subscriptions, declare them so a lock can find them:\n| API | Semantics |\n| registerRecurringChargeSource(source, { pluginId? }) | Joins the core.recurring-charge-sources contract (multiple). Register it on the consoleApi surface, where the lockdown route runs. |\n| listRecurringChargeSources() | Every declared source with its plugin id. The lockdown asks each one; a source that throws is reported as a lookup error and the others still run. |\nAnswer from your own records, never by searching the payment provider. The lockdown pauses what you return with pausecollection[behavior]=void, records it, and the lift resumes exactly those. You never pause, resume or cancel anything yourself.'
5711
+ },
5705
5712
  {
5706
5713
  path: '/developers/plugins/reference/plugin-manager-api',
5707
5714
  title: 'Plugin-manager API reference',
@@ -8402,7 +8409,7 @@ export const ASSIST_DOCS_INDEX = [
8402
8409
  title: 'Invite teammates',
8403
8410
  heading: 'Activity log',
8404
8411
  anchor: '#activity-log',
8405
- text: 'The organization\'s Team page shows a Recent Activity log: what happened at organization level — renames, workspace URL changes, ownership transfers, members added/removed or re-roled, and invites sent, revoked, or accepted — together with the changes made on each of the organization\'s sites, newest first, each with who did it, where and when.\nVisible to members whose role carries the Activity & audit log permission. Owners and admins have it by default; viewers do not, and neither does a custom role until you grant it. Revoking it does not merely hide the card — the feed is served by an API that checks the permission, so a member without it cannot read the log by any route.\nEntries name the thing that changed and link straight to it, so "Saved the screen — Home" takes you to that screen. Entries recorded before this shipped show a plain description instead of a link.\nThe log filters through its table\'s toolbar, across the whole log rather than the page on screen:\n- Filters narrows it by Action — is one, or is any of several, picked from the actions the log records as named entries, such as the AI entries below — by Who (is one of the organization\'s members), by Where (is the organization itself or one of its sites), and by When (is, is after, is on or after, is before or is on or before a day). They add up, and every one is answered by the log\'s own query.\n- Search finds an entry by the start of a word of the address of the person who made the change (or the API key\'s name, for an entry an integration wrote) and of the name of what changed — ada, example.com or home — across the whole log. It matches one word at a time: type two and the log says it searched the first.\nEvery filter and the search are on the log\'s own query. Anything that query cannot take is not applied,'
8412
+ text: 'The organization\'s Team page shows a Recent Activity log: what happened at organization level — renames, workspace URL changes, ownership transfers, members added/removed or re-roled, and invites sent, revoked, or accepted — together with the changes made on each of the organization\'s sites, newest first, each with who did it, where and when.\nVisible to members whose role carries the Activity & audit log permission. Owners and admins have it by default; viewers do not, and neither does a custom role until you grant it. Revoking it does not merely hide the card — the feed is served by an API that checks the permission, so a member without it cannot read the log by any route.\nWho is always someone you can narrow down:\n- a person, by the address they had when they acted — or, for the few entries that recorded only their account (subscription changes made before the log carried the address), the address that account has now;\n- an integration, as API key and the name you gave the key;\n- a workflow run, as Workflow and the workflow\'s name;\n- Aglyn, for what the platform did on its own — a subscription Stripe ended after failed payments, a cancellation made by Aglyn staff, inbound CRM mail, a background AI job.\nEntries name the thing that changed and link straight to it, so "Saved the screen — Home" takes you to that screen. Entries recorded before this shipped show a plain description instead of a link.\nThe log filters through its table\'s toolbar, across the whole log rather than the page on screen:\n- Filters narrows it by Action — is one, or is any of several, picked from the actions the log records as named entries, such as the AI entries below — by Who (is one of the organization\'s members), by Where (is the organization itself or one of its sites), and by When (is, is aft'
8406
8413
  },
8407
8414
  {
8408
8415
  path: '/workspace-and-billing/teams-and-roles/invite-teammates',