@aglyn/aglyn 1.0.0-beta.161 → 1.0.0-beta.163

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 (39) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/campaign-membership.d.ts +12 -2
  3. package/src/lib/app-utils/campaign-membership.js +13 -2
  4. package/src/lib/app-utils/campaign-membership.js.map +1 -1
  5. package/src/lib/app-utils/collection-entry-date.d.ts +25 -17
  6. package/src/lib/app-utils/collection-entry-date.js +55 -20
  7. package/src/lib/app-utils/collection-entry-date.js.map +1 -1
  8. package/src/lib/app-utils/console-routes.d.ts +2 -0
  9. package/src/lib/app-utils/console-routes.js +22 -0
  10. package/src/lib/app-utils/console-routes.js.map +1 -1
  11. package/src/lib/app-utils/contacts.d.ts +8 -0
  12. package/src/lib/app-utils/contacts.js.map +1 -1
  13. package/src/lib/app-utils/crm.d.ts +21 -0
  14. package/src/lib/app-utils/crm.js.map +1 -1
  15. package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
  16. package/src/lib/app-utils/docs-help.generated.js +2 -0
  17. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  18. package/src/lib/app-utils/docs-index.generated.js +9 -9
  19. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  20. package/src/lib/app-utils/email-state.d.ts +57 -0
  21. package/src/lib/app-utils/email-state.js +136 -0
  22. package/src/lib/app-utils/email-state.js.map +1 -0
  23. package/src/lib/app-utils/notifications.d.ts +58 -0
  24. package/src/lib/app-utils/notifications.js +62 -8
  25. package/src/lib/app-utils/notifications.js.map +1 -1
  26. package/src/lib/app-utils/server.d.ts +1 -0
  27. package/src/lib/app-utils/server.js +3 -0
  28. package/src/lib/app-utils/server.js.map +1 -1
  29. package/src/lib/foundation/definitions/platform.types.d.ts +16 -0
  30. package/src/lib/foundation/definitions/platform.types.js +1 -0
  31. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  32. package/src/lib/plugin-manager/plugin-membership-detach.d.ts +55 -0
  33. package/src/lib/plugin-manager/plugin-membership-detach.js +102 -0
  34. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -0
  35. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +85 -0
  36. package/src/lib/plugin-manager/plugin-record-email-state.js +53 -0
  37. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -0
  38. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +22 -0
  39. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/console-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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// URL scheme (AGL-621): the org is a first-class path segment `[orgSlug]`\n// so the URL — not client-side precedence — is the source of truth for the\n// active workspace. Host routes nest under `/[orgSlug]/hosts/[host]`; the\n// org area (settings, team, billing, media, data, plugins, support,\n// marketplace) lives directly under `/[orgSlug]`. User-level `manage/*`,\n// staff `admin/*`, and `auth` routes are NOT org-scoped. Hosts stay keyed\n// by doc id here; AGL-622 swaps `[hostId]` for the subdomain slug.\nexport enum Route {\n ADMIN_ORGS = '/admin/orgs',\n ADMIN_ORG_DETAIL = '/admin/orgs/[orgId]',\n ADMIN_ORG_HOST_DETAIL = '/admin/orgs/[orgId]/host/[hostId]',\n ADMIN_OVERVIEW = '/admin/overview',\n // Staff coupon management (AGL-1105): create discount coupons + promotion\n // codes and read the live net-margin rating before committing to one.\n ADMIN_COUPONS = '/admin/coupons',\n // The panic button (AGL-1501): platform/org/host/user lockdown controls.\n ADMIN_LOCKDOWN = '/admin/lockdown',\n // The narrow lever beside it (AGL-1687): disable or release ONE uploaded\n // file. Its own page rather than a panel on Lockdown because the input is\n // an asset, not a scope — the two forms share no field but the reason.\n ADMIN_MEDIA_QUARANTINE = '/admin/media-quarantine',\n // The INPUT to the two levers above (AGL-1964). Lockdown and quarantine are\n // both things an operator does after learning there is a problem; until\n // this page there was no way for anyone outside the company to say there\n // was one. Reports arrive from the unauthenticated form the tenant runtime\n // serves at /api/report-abuse on every origin.\n ADMIN_ABUSE_REPORTS = '/admin/abuse-reports',\n // The same input, one collection over (AGL-2310). The marketplace's own\n // report button wrote `marketplaceReports` from the day it shipped and\n // nothing read it — every report filed, acknowledged, and unreachable. This\n // is that queue, deliberately shaped like the abuse one rather than as a\n // second vocabulary for the same act.\n // The probes, on a screen (AGL-1900). /api/health/{backups,rate-limits,\n // signups} and /api/admin/email-health each answered a bad-day question to\n // a curl and to nothing else; this is where an operator reads them.\n ADMIN_HEALTH = '/admin/health',\n // The maintenance jobs, on a screen (AGL-1949). audit-archive,\n // reap-plugin-artifacts and reverify-plugin-versions accepted the shared\n // cron secret and nothing else, so the only way to preview or run one was a\n // shell holding the production secret. Separate from Health because Health\n // reads and this acts.\n ADMIN_MAINTENANCE = '/admin/maintenance',\n // The Texas sales tax return (AGL-1900). The figures AGL-1811 computes had\n // only a curl to reach them; filing happens on a fixed quarterly calendar\n // from 2026-09-01, so the return needs a URL a person can bookmark.\n ADMIN_REVENUE = '/admin/revenue',\n // Realised band utilization and the margin that follows from it. Beside\n // Revenue because the two are the halves of one question: that page reports\n // what came in, this one what serving it cost.\n ADMIN_MARGIN_UTILIZATION = '/admin/margin-utilization',\n ADMIN_TAX_RETURN = '/admin/tax-return',\n ADMIN_AUDIT = '/admin/audit',\n ADMIN_USERS = '/admin/users',\n ADMIN_USER_DETAIL = '/admin/users/[uid]',\n ADMIN_FLAGS = '/admin/flags',\n // Platform-wide settings that are not a release flag (AGL-2486). The\n // free-workspace ceiling was the first of them and it opened the\n // Organizations LIST, above the table — a global lever wedged into the\n // screen for browsing individual orgs. Settings that describe the platform\n // rather than one org belong on their own tab, beside the flags.\n ADMIN_SETTINGS = '/admin/settings',\n // One submission or listed plugin in full (AGL-959): the queue index is\n // for scanning, this is where a reviewer reads the manifest, weighs the\n // verifier findings and acts.\n // A page a plugin adds to the staff area (AGL-2939), rendered by the\n // console's generic staff route. The console's own staff routes win the\n // segments they use.\n ADMIN_STAFF_PAGE = '/admin/[staffPage]',\n // Staff support-ticket queue (AGL-849): the operator side of the\n // subscriber `MANAGE_SUPPORT_TICKETS` page — every org's tickets in one place.\n ADMIN_SUPPORT = '/admin/support',\n // Do-not-contact list (AGL-1592): the intake and the queue for Privacy\n // Policy v4 §11's marketing call/text opt-out, and for \"delete the phone\n // number you hold for me\".\n ADMIN_CONTACT_SUPPRESSIONS = '/admin/contact-suppressions',\n ADMIN_EMAILS = '/admin/emails',\n // The one besigner route with no host and no org in it (AGL-749). A system\n // email belongs to the platform, not to a workspace, so it is staff-scoped\n // like the rest of `/admin/*` rather than nested under `[orgSlug]`.\n ADMIN_EMAIL_BESIGNER = '/admin/emails/[templateKey]/versions/[versionId]/besigner',\n ORG_HOME = '/[orgSlug]',\n ORG_MEDIA = '/[orgSlug]/media',\n ORG_DATA = '/[orgSlug]/data',\n /*\n * The organization's address book.\n *\n * Org-scoped rather than a section of a site, because it answers the one\n * question a site page cannot: which of the organization's sites know a\n * given person. A contact document is shared by every site in the org — one\n * human who touched two of them is one row — and until this route there was\n * no surface anywhere that showed the deduped person, so the dedupe the\n * shared address book exists for was invisible and the billing unit\n * (unique people per org) had nothing standing behind it.\n *\n * It shows identity, which sites hold the person, and consent per site. The\n * per-site CRM — notes, tags, timeline, order history — stays on the site's\n * own Contacts surface, because those are the holder's business records and\n * this is the one route designed to cross between holders.\n *\n * A LEGACY ADDRESS since AGL-2630: the page behind it redirects, permanently,\n * to the org-level CRM hub's contacts section. It stays declared so a link\n * kept from before — a bookmark, a docs page, an email — still resolves,\n * and so the redirect has a constant to be built from rather than a string.\n */\n ORG_CONTACTS = '/[orgSlug]/contacts',\n /*\n * The organization-level CRM hub (AGL-2630): the same sections the site hub\n * has — `/[orgSlug]/crm/<section>[/<recordId>]` — mounted with an ORG scope,\n * for an org-wide member who works every site's people at once. A bare\n * `/crm` lands on the first section the reader may open, as the site hub's\n * does. What this route adds over the site hub is the cross-site fact the\n * old address book carried — which sites know a person — and what it takes\n * from it is the site every create must name, since a record is always\n * captured BY a site.\n */\n ORG_CRM = '/[orgSlug]/crm',\n /*\n * The generic ORGANIZATION-level plugin route (AGL-2974): every surface an\n * extension declares in `orgNavItems` is served here, at\n * `/[orgSlug]/<href>[/<section>…]`, by\n * `app/(app)/[orgSlug]/[...pluginSlug]/page.tsx`. The org twin of\n * `HOST_PLUGIN`, and like it the one org route whose leaf segment is data:\n * every named org route in this enum wins over it.\n */\n ORG_PLUGIN = '/[orgSlug]/[pluginSlug]',\n ORG_PLUGINS = '/[orgSlug]/plugins',\n // One plugin, as installed in this workspace (AGL-1007): scope, settings\n // and permissions in one place. The segment takes EITHER identifier\n // (AGL-1010) — a listing id for a marketplace install, a registry id for a\n // first-party plugin — hence `pluginRef` rather than `listingId`.\n ORG_PLUGIN_INSTALLATION = '/[orgSlug]/plugins/[pluginRef]',\n // Org-scope marketplace (AGL-772): the single place to browse, view and\n // install marketplace items. The per-site tab it replaced, and the retired\n // `/[orgSlug]/marketplace` seller area, were removed with the rest of the\n // `marketplace` naming in AGL-975 — the word is being freed for a public\n // forum, and a redirect stub sitting on `/marketplace` would have taken the\n // one path that feature wants.\n /*\n * The marketplace's own SECTIONS are not here any more (AGL-3080). Eight\n * of them — browse, licences, upload, the four seller cards and the\n * publish form — were hand-written console routes, and became an\n * `orgNavItems` declaration whose `sections` name those segments once, in\n * the plugin. Restating them here was a second copy of a list the plugin\n * owns, and every entry had zero callers the moment the routes went.\n *\n * What stays is what the CONSOLE still builds: the surface itself, a\n * listing, a publisher, and the installed tab that the org plugins card\n * links across to. A console page linking into a plugin's surface is the\n * ordinary case; a console page enumerating that surface's sections is\n * not.\n */\n ORG_MARKETPLACE = '/[orgSlug]/marketplace',\n /*\n * Marketplace SECTIONS are routes (AGL-2501), for the reason the settings\n * and account sections are: a panel that is not open should cost nothing,\n * and the seller half of this hub reads the organization's REVENUE.\n *\n * The four seller sections are the reason the layout gates rather than the\n * rail. As tabs they were simply not rendered for a member without\n * `publishToMarketplace`; as routes each one is reachable by typing its URL,\n * so the refusal has to sit above them all rather than in the list that\n * draws them.\n *\n * Every segment here is shorter than the ten characters `createResourceUid`\n * emits, so none of them can ever shadow a real `[listingId]` sitting at the\n * same level. `upload` rather than `publish` because `publish/plugin` is\n * already a route beneath this one, and two directories contributing the\n * same segment is a tree Next cannot resolve a layout for.\n */\n ORG_MARKETPLACE_INSTALLED = '/[orgSlug]/marketplace/installed',\n ORG_MARKETPLACE_LISTING = '/[orgSlug]/marketplace/[listingId]',\n ORG_MARKETPLACE_PUBLISHER = '/[orgSlug]/marketplace/publisher/[handle]',\n // Publishing a plugin is a page, not a modal (AGL-1078): the most\n // consequential thing a publisher does now has a URL, so it can be linked,\n // reloaded, and reached from a listing to ship an update.\n ORG_SETTINGS = '/[orgSlug]/settings',\n /*\n * Settings SECTIONS are routes (AGL-2501). `HubTabs` mounted every panel —\n * `keepMounted`, with `lazy` off by default and passed by nobody — so\n * opening General also mounted the API-keys, SSO and data-export cards and\n * ran every read in them. Routes mount one page and code-split per route.\n * `ORG_SETTINGS` itself redirects to General.\n */\n ORG_SETTINGS_GENERAL = '/[orgSlug]/settings/general',\n ORG_SETTINGS_PROFILE = '/[orgSlug]/settings/profile',\n ORG_SETTINGS_PLUGINS = '/[orgSlug]/settings/plugins',\n ORG_SETTINGS_API_KEYS = '/[orgSlug]/settings/api-keys',\n ORG_SETTINGS_BRANDING = '/[orgSlug]/settings/branding',\n ORG_SETTINGS_SSO = '/[orgSlug]/settings/sso',\n ORG_SETTINGS_OWNERSHIP = '/[orgSlug]/settings/ownership',\n /**\n * Export the people a workspace holds and file a person's erasure\n * (AGL-2839) — outside the CRM, so it is there on every plan.\n */\n ORG_SETTINGS_PRIVACY = '/[orgSlug]/settings/privacy',\n ORG_SETTINGS_DELETE = '/[orgSlug]/settings/delete',\n /**\n * Billing, section by section (AGL-2501).\n *\n * `MANAGE_BILLING` is unchanged and is the PLAN section — it resolves to a\n * page inside a route group, which adds no path segment. That is what makes\n * this split free: every link in the console, every hash anchor\n * (`#addons`, `#collaborator-seats`) and Stripe's own dunning mail all point\n * here already and none of them moves. No redirect, and no `?tab=` shim to\n * carry forever.\n */\n MANAGE_BILLING = '/[orgSlug]/billing',\n MANAGE_BILLING_USAGE = '/[orgSlug]/billing/usage',\n MANAGE_BILLING_INVOICES = '/[orgSlug]/billing/invoices',\n MANAGE_BILLING_SETTINGS = '/[orgSlug]/billing/settings',\n // The ONE billing URL that carries no org (AGL-2430).\n //\n // Everything else on this table is org-scoped, which is correct for a\n // console and wrong for the only caller that cannot template a URL:\n // Stripe's dunning emails. \"Payment method updates\" takes a single static\n // link for every customer in the account — there is no `{{org}}` to\n // interpolate — so a table with no org-agnostic billing entry means those\n // emails can only ever point at a page that is not billing. Today all four\n // of them point at the marketing homepage, which is how a failed card\n // becomes a cancelled subscription with no reachable way to fix it.\n //\n // This resolves the org from the SESSION instead of from the URL: one\n // workspace goes straight through, several offer a choice, none says so.\n // `billing` is a reserved subdomain (`RESERVED_SUBDOMAINS`), so no org can\n // hold this slug and the literal segment can never shadow a real\n // `/[orgSlug]`.\n BILLING_ENTRY = '/billing',\n MANAGE_USER_SETTINGS = '/manage/user',\n /*\n * Account SECTIONS are routes (AGL-2501). `HubTabs` mounted every panel —\n * `keepMounted`, with `lazy` off by default and passed by nobody — so\n * opening Account also mounted the email-addresses, passkeys, recent\n * sign-ins, data-export and close-account cards and ran every read in them.\n * Routes mount one page and code-split per route.\n *\n * The segments are the ids the panels carried, because a transactional\n * email already in people's inboxes links `/manage/user?tab=security`:\n * `MANAGE_USER_SETTINGS` forwards each of those ids to the section that\n * holds it, and the segment matching the id is what keeps that map a\n * one-liner rather than a second naming scheme.\n */\n MANAGE_USER_ACCOUNT = '/manage/user/account',\n MANAGE_USER_EMAILS = '/manage/user/emails',\n MANAGE_USER_PROFILE = '/manage/user/profile',\n MANAGE_USER_BASIC = '/manage/user/basic',\n MANAGE_USER_SECURITY = '/manage/user/security',\n MANAGE_USER_CLOSE = '/manage/user/close',\n MANAGE_NOTIFICATIONS = '/manage/notifications',\n /*\n * The preferences, off the feed (AGL-3226).\n *\n * They lived in the header of the feed itself, which was already the\n * tallest thing on that page before per-channel and per-scope settings\n * multiplied them — and it put an edit form above the list it edits, which\n * the console does nowhere else.\n *\n * A CHILD of the feed rather than a sibling under `/manage`, because the\n * relationship is exactly that: it configures what the page above it shows.\n * That also keeps `/manage/notifications` the address in the transactional\n * mail already in people's inboxes, which keeps working and now carries a\n * button to here.\n */\n MANAGE_NOTIFICATION_SETTINGS = '/manage/notifications/settings',\n /*\n * A URL for the defect channel (AGL-2486). \"Report an issue\" was reachable\n * only by opening the account menu, so nothing outside the console could\n * send anyone to it — the repo's README pointed at GitHub issues, which we\n * do not use, and every other \"tell us about it\" surface had the same\n * problem. User-level rather than org-scoped because the dialog is: it\n * needs no workspace, no role and no paid plan.\n */\n MANAGE_REPORT_ISSUE = '/manage/report-issue',\n AUTH_SIGN_IN = '/signin',\n AUTH_SIGN_OUT = '/signout',\n AUTH_SIGN_UP = '/signup',\n AUTH_VERIFY_EMAIL = '/verify-email',\n HOST_LIST = '/[orgSlug]/hosts',\n HOST_CONTENT = '/[orgSlug]/hosts/[host]/content',\n // The collection is a PATH SEGMENT, not `?collection=` (AGL-2498).\n //\n // It was a query parameter because the manager began as one page with a\n // Select on it, and the Select was the only thing that knew which\n // collection you were looking at. That is the wrong owner: which collection\n // is open is the page's IDENTITY, not a filter applied to it. As a\n // parameter it could be dropped by any link that rebuilt the query, it\n // sorted below `?tab=` in a URL nobody could read, and — the reason this\n // moved — it left the entry beneath it with nowhere to hang: an entry is\n // addressed `collection + entry`, and half of that address cannot live in\n // the path while the other half lives in the query.\n //\n // The segment is the collection's SLUG, not its document id, and that is a\n // correction rather than a preference. Collection document ids are not\n // uniform — the seeded ones were given readable ids (`blog`, `changelog`)\n // and everything created since gets a `createResourceUid`, so routing by id\n // produced `/content/changelog` beside `/content/QgXv7lU_rG` on ONE site.\n //\n // A slug is the right key on the merits too: it is unique per host and kind\n // (claimed in a transaction by /api/hosts/collections), it is the\n // collection's own public address, and it is the thing an author recognises.\n // A document id is still ACCEPTED and rewritten to the slug form, so no\n // existing link breaks.\n //\n // `?collection=` still resolves the same way (AGL-845's DAM deep links are\n // in the wild).\n HOST_CONTENT_COLLECTION = '/[orgSlug]/hosts/[host]/content/[collectionSlug]',\n // One entry's detail page (AGL-2498). `entries` is a LITERAL segment rather\n // than `content/[collectionId]/[entryId]`, and it is load-bearing twice\n // over: it mirrors the Firestore path the entry actually lives at\n // (`collections/{c}/entries/{e}`), and it keeps the id space clear so a\n // later sibling — a collection's own settings page, say — cannot be\n // shadowed by an entry that happens to be called the same thing.\n //\n // `[entryId]` also carries the sentinel `new`, which is a draft that has no\n // document yet. Entry ids come from `createResourceUid`, so nothing real\n // can collide with it.\n CONTENT_ENTRY_DETAILS = '/[orgSlug]/hosts/[host]/content/[collectionSlug]/entries/[entryId]',\n MANAGE_TEAM = '/[orgSlug]/team',\n /*\n * Team SECTIONS are routes, not tabs (AGL-2501). A section's bundle then\n * arrives when a reader opens it and never before, the section is linkable,\n * and the active state is a fact about the URL rather than state that has to\n * be kept in sync with it. `MANAGE_TEAM` itself redirects to members.\n */\n MANAGE_TEAM_MEMBERS = '/[orgSlug]/team/members',\n MANAGE_TEAM_ROLES = '/[orgSlug]/team/roles',\n MANAGE_TEAM_ACTIVITY = '/[orgSlug]/team/activity',\n MANAGE_TEAM_MEMBER = '/[orgSlug]/team/[uid]',\n // Support is an UMBRELLA, not a page (AGL-1158). The two channels beneath\n // it are separate features that were only ever one screen: they are gated\n // differently (tickets need a first-response commitment, from Pro; the\n // forum is open to every tier including Free) and they fail independently.\n // Sharing one route made them share a blast radius — AGL-1157 was one bad\n // line in the common loader that emptied BOTH lists at once.\n //\n // `MANAGE_SUPPORT` itself stays, and stays the nav tab's href: it now\n // forwards to whichever channel the org's tier makes primary, so a\n // forum-only workspace lands on a whole forum instead of a half-empty page\n // beside a ticket card it may not use.\n MANAGE_SUPPORT = '/[orgSlug]/support',\n MANAGE_SUPPORT_TICKETS = '/[orgSlug]/support/tickets',\n MANAGE_SUPPORT_FORUM = '/[orgSlug]/support/forum',\n HOST_DASHBOARD = '/[orgSlug]/hosts/[host]',\n // The catch-all console page a plugin's own nav items resolve against\n // (`app/(app)/[orgSlug]/hosts/[host]/[pluginSlug]/page.tsx`). Plugin slugs\n // are open-ended, so this is the one route whose leaf segment is data.\n HOST_PLUGIN = '/[orgSlug]/hosts/[host]/[pluginSlug]',\n HOST_INBOX = '/[orgSlug]/hosts/[host]/inbox',\n HOST_CONTACTS = '/[orgSlug]/hosts/[host]/contacts',\n HOST_MEDIA = '/[orgSlug]/hosts/[host]/media',\n HOST_SETUP = '/[orgSlug]/hosts/[host]/setup',\n /*\n * Setup SECTIONS are routes (AGL-2501), so an unopened one costs neither a\n * read nor a byte. `HOST_SETUP` stays the nav tab's href and redirects to\n * Basic details, honouring the `?tab=` ids these sections were deep-linked\n * by — unlike the settings and marketplace hubs, links holding those ids are\n * demonstrably in the wild and two are built in this repo.\n */\n HOST_SETUP_DETAILS = '/[orgSlug]/hosts/[host]/setup/details',\n HOST_SETUP_SEO = '/[orgSlug]/hosts/[host]/setup/seo',\n HOST_SETUP_TRACKING = '/[orgSlug]/hosts/[host]/setup/tracking',\n HOST_SETUP_THEME = '/[orgSlug]/hosts/[host]/setup/theme',\n HOST_SETUP_EMAILS = '/[orgSlug]/hosts/[host]/setup/emails',\n // Host Admin area (AGL-1014): owner/admin-only controls — per-site plugin\n // enablement and the Danger zone — out of the Setup page collaborators\n // legitimately visit.\n HOST_ADMIN = '/[orgSlug]/hosts/[host]/admin',\n /*\n * Site-admin SECTIONS are routes (AGL-2501), so an unopened one costs neither\n * a read nor a byte. `HOST_ADMIN` redirects to Plugins and still honours the\n * `?tab=` ids these sections were deep-linked by.\n */\n /*\n * The site as an OBJECT: its name in the console and the subdomain it\n * answers to. First in the rail because an address and a name are what\n * identify the thing every other section governs.\n */\n HOST_ADMIN_GENERAL = '/[orgSlug]/hosts/[host]/admin/general',\n HOST_ADMIN_PLUGINS = '/[orgSlug]/hosts/[host]/admin/plugins',\n /*\n * One plugin, as installed on ONE site (AGL-428, AGL-1014) — the\n * site-scoped twin of `ORG_PLUGIN_INSTALLATION`, reached by clicking a row\n * on the site's plugin\n * list exactly as the workspace list opens its own detail page. The segment\n * is `[pluginRef]` and accepts either identifier for the same reason the\n * workspace route does (AGL-1010): a marketplace install is keyed by listing\n * id, a first-party plugin by its registry id, and an admin does not think\n * of the two as different things.\n */\n HOST_ADMIN_PLUGIN = '/[orgSlug]/hosts/[host]/admin/plugins/[pluginRef]',\n HOST_ADMIN_DOMAIN = '/[orgSlug]/hosts/[host]/admin/domain',\n HOST_ADMIN_SECURITY = '/[orgSlug]/hosts/[host]/admin/security',\n /*\n * The designed screen behind each status code, and the switch that shows the\n * 503 one at every address at once (AGL-3178).\n *\n * Beside Security because both answer what a visitor's browser is handed on\n * a page the site did not design for them, and above Activity because it is\n * a control rather than a record.\n */\n HOST_ADMIN_ERROR_PAGES = '/[orgSlug]/hosts/[host]/admin/error-pages',\n HOST_ADMIN_ACTIVITY = '/[orgSlug]/hosts/[host]/admin/activity',\n /*\n * Backup, restore and publishing the site as a template — moving the whole\n * site somewhere else, or bringing it back. Its own section rather than the\n * Danger zone: restore overwrites, but export and publishing a template do\n * not, and filing all three under a destructive heading mislabels two of\n * them.\n */\n HOST_ADMIN_BACKUP = '/[orgSlug]/hosts/[host]/admin/backup',\n HOST_ADMIN_DANGER = '/[orgSlug]/hosts/[host]/admin/danger',\n HOST_THEME = '/[orgSlug]/hosts/[host]/theme',\n /*\n * The automation section: Workflows, Actions and Webhooks under one parent.\n * The parent is named for what the three have in common, so no tab shares a\n * name with the section that contains it.\n */\n HOST_AUTOMATION = '/[orgSlug]/hosts/[host]/automation',\n /**\n * The address the automation section answered to before it had a name of\n * its own. Nothing links here — `HOST_AUTOMATION` is what the nav, the\n * search results and the activity feed build — but the console redirects it\n * permanently, so a held bookmark still lands on the section (and keeps its\n * `?tab=`, which never changed).\n *\n * Kept on the table rather than as a bare literal in `next.config.js`\n * because the redirect and the address it serves are one fact:\n * `automation-section.spec.ts` reads this entry and fails if the rule for\n * it goes missing.\n */\n HOST_WORKFLOWS = '/[orgSlug]/hosts/[host]/workflows',\n HOST_DATA = '/[orgSlug]/hosts/[host]/data',\n HOST_LOGIC = '/[orgSlug]/hosts/[host]/logic',\n HOST_PRODUCTS = '/[orgSlug]/hosts/[host]/products',\n HOST_COMPONENTS = '/[orgSlug]/hosts/[host]/components',\n // Component detail (AGL-693): the listing links here, and the besigner is\n // reached from here — matching SCREEN_DETAILS rather than jumping a row\n // straight into the editor.\n COMPONENT_DETAILS = '/[orgSlug]/hosts/[host]/components/[componentId]',\n HOST_FORMS = '/[orgSlug]/hosts/[host]/forms',\n // A form is a designable document, so its surface is the component one:\n // a list, a detail page that owns the versions, and the besigner reached\n // from there. Deliberately NOT a row that jumps into the editor — a form's\n // routing and consent field are edited on the detail page, and those decide\n // what the design is then checked against.\n FORM_DETAILS = '/[orgSlug]/hosts/[host]/forms/[formId]',\n HOST_TEMPLATES = '/[orgSlug]/hosts/[host]/templates',\n // Template detail (AGL-694), the counterpart to COMPONENT_DETAILS. Note\n // TEMPLATE_BESIGNER carries no versionId — templates version but never\n // publish, so there is no \"current\" pointer to route through.\n TEMPLATE_DETAILS = '/[orgSlug]/hosts/[host]/templates/[templateId]',\n HOST_MARKETING = '/[orgSlug]/hosts/[host]/marketing',\n HOST_BOOKINGS = '/[orgSlug]/hosts/[host]/bookings',\n // Events now come from the events-calendar plugin, served by the generic\n // `[orgSlug]/hosts/[host]/[pluginSlug]` route (AGL-394).\n HOST_REDIRECTS = '/[orgSlug]/hosts/[host]/redirects',\n HOST_USERS = '/[orgSlug]/hosts/[host]/users',\n HOST_ANALYTICS = '/[orgSlug]/hosts/[host]/analytics',\n COMPONENT_BESIGNER = '/[orgSlug]/hosts/[host]/components/[componentId]/versions/[versionId]/besigner',\n FORM_BESIGNER = '/[orgSlug]/hosts/[host]/forms/[formId]/versions/[versionId]/besigner',\n TEMPLATE_BESIGNER = '/[orgSlug]/hosts/[host]/templates/[templateId]/besigner',\n LAYOUT_BESIGNER = '/[orgSlug]/hosts/[host]/layouts/[layoutId]/versions/[versionId]/besigner',\n // Draft preview for every besigner document kind (AGL-1203). Screens had\n // the only preview route; components, layouts and templates showed a\n // Preview button that did nothing. Each renders the same localStorage\n // snapshot through the site renderer, so they work on localhost with no\n // deployment. TEMPLATE_PREVIEW carries no versionId, matching\n // TEMPLATE_BESIGNER.\n COMPONENT_PREVIEW = '/[orgSlug]/hosts/[host]/components/[componentId]/versions/[versionId]/preview',\n FORM_PREVIEW = '/[orgSlug]/hosts/[host]/forms/[formId]/versions/[versionId]/preview',\n TEMPLATE_PREVIEW = '/[orgSlug]/hosts/[host]/templates/[templateId]/preview',\n LAYOUT_PREVIEW = '/[orgSlug]/hosts/[host]/layouts/[layoutId]/versions/[versionId]/preview',\n // The list sits at the bare path, like HOST_COMPONENTS. It used to be\n // `/layouts/list`: the pages router had a `layouts/index.tsx` that\n // redirected to `layouts/list.tsx`, and the App Router migration carried\n // the pair over verbatim. Components, added later, had no such legacy and\n // put its list at the bare path — which is the shape we want everywhere.\n // `/layouts/list` still serves the same page so old links survive — an\n // alias, not a redirect, because the bare path used to answer a cached 308\n // pointing at `/list` and redirecting back would loop. See the comment in\n // `app/(app)/[orgSlug]/hosts/[host]/screens/list/page.tsx`.\n HOST_LAYOUTS = '/[orgSlug]/hosts/[host]/layouts',\n // Layout detail (AGL-695), completing the list → detail → besigner shape\n // across screens, components, templates and layouts.\n LAYOUT_DETAILS = '/[orgSlug]/hosts/[host]/layouts/[layoutId]',\n SCREEN_BESIGNER = '/[orgSlug]/hosts/[host]/screens/[screenId]/versions/[versionId]/besigner',\n // Per-site email besigner (AGL-770): a site owner designs a transactional\n // email their site sends. The host-scoped sibling of the staff system-email\n // editor (ADMIN_EMAIL_BESIGNER).\n HOST_EMAIL_BESIGNER = '/[orgSlug]/hosts/[host]/emails/[templateKey]/versions/[versionId]/besigner',\n SCREEN_DETAILS = '/[orgSlug]/hosts/[host]/screens/[screenId]/versions/[versionId]/view',\n SCREEN_PREVIEW = '/[orgSlug]/hosts/[host]/screens/[screenId]/versions/[versionId]/preview',\n // Bare path, matching HOST_LAYOUTS and HOST_COMPONENTS — see the note on\n // HOST_LAYOUTS. `/screens/list` still serves the same page as an alias.\n HOST_SCREENS = '/[orgSlug]/hosts/[host]/screens',\n}\n\n/**\n * Params required to build each {@link Route}.\n *\n * Deliberately NOT extended from an index signature (AGL-685). It used to be\n * `extends Record<keyof any, any>`, which meant any `Route` missing an entry\n * here silently typed its payload as `any` — `Route.HOST_SETUP` was one of\n * them, and `buildRoute(Route.HOST_SETUP, { hostId })` compiled cleanly while\n * emitting `/<orgSlug?>/hosts/<host?>/setup`. Without the index signature a\n * new `Route` with no entry is a compile error, which is the whole point of\n * the payload being required in the first place.\n */\nexport interface RoutePayload {\n [Route.AUTH_SIGN_UP]: undefined\n [Route.AUTH_SIGN_IN]: undefined\n [Route.AUTH_SIGN_OUT]: undefined\n [Route.AUTH_VERIFY_EMAIL]: undefined\n [Route.SCREEN_BESIGNER]: {\n orgSlug: string\n host: string\n screenId: string\n versionId: string\n }\n [Route.ORG_HOME]: { orgSlug: string }\n [Route.HOST_DASHBOARD]: { orgSlug: string; host: string }\n [Route.ADMIN_ORGS]: undefined\n [Route.ADMIN_ORG_DETAIL]: { orgId: string }\n [Route.ADMIN_ORG_HOST_DETAIL]: { orgId: string; hostId: string }\n [Route.ADMIN_OVERVIEW]: undefined\n [Route.ADMIN_COUPONS]: undefined\n [Route.ADMIN_LOCKDOWN]: undefined\n [Route.ADMIN_MEDIA_QUARANTINE]: undefined\n [Route.ADMIN_ABUSE_REPORTS]: undefined\n [Route.ADMIN_HEALTH]: undefined\n [Route.ADMIN_MAINTENANCE]: undefined\n [Route.ADMIN_REVENUE]: undefined\n [Route.ADMIN_MARGIN_UTILIZATION]: undefined\n [Route.ADMIN_TAX_RETURN]: undefined\n [Route.ADMIN_AUDIT]: undefined\n [Route.ADMIN_USERS]: undefined\n [Route.ADMIN_USER_DETAIL]: { uid: string }\n [Route.ADMIN_FLAGS]: undefined\n [Route.ADMIN_SETTINGS]: undefined\n [Route.ADMIN_STAFF_PAGE]: { staffPage: string }\n [Route.ADMIN_SUPPORT]: undefined\n [Route.ADMIN_CONTACT_SUPPRESSIONS]: undefined\n [Route.ADMIN_EMAILS]: undefined\n [Route.ADMIN_EMAIL_BESIGNER]: { templateKey: string; versionId: string }\n [Route.HOST_EMAIL_BESIGNER]: {\n orgSlug: string\n host: string\n templateKey: string\n versionId: string\n }\n [Route.ORG_MEDIA]: { orgSlug: string }\n [Route.ORG_DATA]: { orgSlug: string }\n [Route.ORG_CONTACTS]: { orgSlug: string }\n [Route.ORG_CRM]: { orgSlug: string }\n [Route.ORG_PLUGIN]: { orgSlug: string; pluginSlug: string }\n [Route.ORG_PLUGINS]: { orgSlug: string }\n [Route.ORG_PLUGIN_INSTALLATION]: { orgSlug: string; pluginRef: string }\n [Route.ORG_MARKETPLACE]: { orgSlug: string }\n [Route.ORG_MARKETPLACE_INSTALLED]: { orgSlug: string }\n [Route.ORG_MARKETPLACE_LISTING]: { orgSlug: string; listingId: string }\n [Route.ORG_MARKETPLACE_PUBLISHER]: { orgSlug: string; handle: string }\n [Route.MANAGE_USER_SETTINGS]: undefined\n [Route.MANAGE_USER_ACCOUNT]: undefined\n [Route.MANAGE_USER_EMAILS]: undefined\n [Route.MANAGE_USER_PROFILE]: undefined\n [Route.MANAGE_USER_BASIC]: undefined\n [Route.MANAGE_USER_SECURITY]: undefined\n [Route.MANAGE_USER_CLOSE]: undefined\n [Route.MANAGE_NOTIFICATIONS]: undefined\n [Route.MANAGE_NOTIFICATION_SETTINGS]: undefined\n [Route.MANAGE_REPORT_ISSUE]: undefined\n [Route.ORG_SETTINGS]: { orgSlug: string }\n [Route.ORG_SETTINGS_GENERAL]: { orgSlug: string }\n [Route.ORG_SETTINGS_PROFILE]: { orgSlug: string }\n [Route.ORG_SETTINGS_PLUGINS]: { orgSlug: string }\n [Route.ORG_SETTINGS_API_KEYS]: { orgSlug: string }\n [Route.ORG_SETTINGS_BRANDING]: { orgSlug: string }\n [Route.ORG_SETTINGS_SSO]: { orgSlug: string }\n [Route.ORG_SETTINGS_OWNERSHIP]: { orgSlug: string }\n [Route.ORG_SETTINGS_PRIVACY]: { orgSlug: string }\n [Route.ORG_SETTINGS_DELETE]: { orgSlug: string }\n [Route.HOST_LIST]: { orgSlug: string }\n [Route.HOST_CONTENT]: { orgSlug: string; host: string }\n [Route.HOST_CONTENT_COLLECTION]: {\n orgSlug: string\n host: string\n /** The collection's slug — see the route. A document id also resolves. */\n collectionSlug: string\n }\n [Route.CONTENT_ENTRY_DETAILS]: {\n orgSlug: string\n host: string\n collectionSlug: string\n /**\n * An entry id, or the literal `new` for a draft with no document yet.\n *\n * An ID here while the collection above is a SLUG, deliberately. An entry\n * slug is unique only WITHIN its collection and it is editable on the\n * detail page — so routing by it would move the console address out from\n * under someone in the middle of typing in the slug field, and a draft has\n * no slug at all to route by. The entry id is also what every activity\n * record and the DAM's \"Used on\" list already carry.\n */\n entryId: string\n }\n [Route.MANAGE_TEAM]: { orgSlug: string }\n [Route.MANAGE_TEAM_MEMBERS]: { orgSlug: string }\n [Route.MANAGE_TEAM_ROLES]: { orgSlug: string }\n [Route.MANAGE_TEAM_ACTIVITY]: { orgSlug: string }\n [Route.MANAGE_TEAM_MEMBER]: { orgSlug: string; uid: string }\n [Route.MANAGE_SUPPORT]: { orgSlug: string }\n [Route.MANAGE_SUPPORT_TICKETS]: { orgSlug: string }\n [Route.MANAGE_SUPPORT_FORUM]: { orgSlug: string }\n [Route.MANAGE_BILLING]: { orgSlug: string }\n [Route.MANAGE_BILLING_USAGE]: { orgSlug: string }\n [Route.MANAGE_BILLING_INVOICES]: { orgSlug: string }\n [Route.MANAGE_BILLING_SETTINGS]: { orgSlug: string }\n [Route.BILLING_ENTRY]: undefined\n [Route.HOST_INBOX]: { orgSlug: string; host: string }\n [Route.HOST_CONTACTS]: { orgSlug: string; host: string }\n [Route.HOST_SETUP]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_DETAILS]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_SEO]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_TRACKING]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_THEME]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_EMAILS]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_GENERAL]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_PLUGINS]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_PLUGIN]: { orgSlug: string; host: string; pluginRef: string }\n [Route.HOST_ADMIN_DOMAIN]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_SECURITY]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_ERROR_PAGES]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_ACTIVITY]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_BACKUP]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_DANGER]: { orgSlug: string; host: string }\n [Route.HOST_PLUGIN]: { orgSlug: string; host: string; pluginSlug: string }\n [Route.HOST_MEDIA]: { orgSlug: string; host: string }\n [Route.HOST_THEME]: { orgSlug: string; host: string }\n [Route.HOST_AUTOMATION]: { orgSlug: string; host: string }\n [Route.HOST_WORKFLOWS]: { orgSlug: string; host: string }\n [Route.HOST_DATA]: { orgSlug: string; host: string }\n [Route.HOST_LOGIC]: { orgSlug: string; host: string }\n [Route.HOST_PRODUCTS]: { orgSlug: string; host: string }\n [Route.HOST_COMPONENTS]: { orgSlug: string; host: string }\n [Route.COMPONENT_DETAILS]: {\n orgSlug: string\n host: string\n componentId: string\n }\n [Route.HOST_FORMS]: { orgSlug: string; host: string }\n [Route.FORM_DETAILS]: {\n orgSlug: string\n host: string\n formId: string\n }\n [Route.HOST_TEMPLATES]: { orgSlug: string; host: string }\n [Route.TEMPLATE_DETAILS]: {\n orgSlug: string\n host: string\n templateId: string\n }\n [Route.HOST_MARKETING]: { orgSlug: string; host: string }\n [Route.HOST_BOOKINGS]: { orgSlug: string; host: string }\n [Route.HOST_REDIRECTS]: { orgSlug: string; host: string }\n [Route.HOST_USERS]: { orgSlug: string; host: string }\n [Route.HOST_ANALYTICS]: { orgSlug: string; host: string }\n [Route.COMPONENT_BESIGNER]: {\n orgSlug: string\n host: string\n componentId: string\n versionId: string\n }\n [Route.FORM_BESIGNER]: {\n orgSlug: string\n host: string\n formId: string\n versionId: string\n }\n [Route.TEMPLATE_BESIGNER]: {\n orgSlug: string\n host: string\n templateId: string\n }\n [Route.LAYOUT_BESIGNER]: {\n orgSlug: string\n host: string\n layoutId: string\n versionId: string\n }\n [Route.COMPONENT_PREVIEW]: {\n orgSlug: string\n host: string\n componentId: string\n versionId: string\n }\n [Route.FORM_PREVIEW]: {\n orgSlug: string\n host: string\n formId: string\n versionId: string\n }\n [Route.TEMPLATE_PREVIEW]: {\n orgSlug: string\n host: string\n templateId: string\n }\n [Route.LAYOUT_PREVIEW]: {\n orgSlug: string\n host: string\n layoutId: string\n versionId: string\n }\n [Route.HOST_LAYOUTS]: { orgSlug: string; host: string }\n [Route.LAYOUT_DETAILS]: { orgSlug: string; host: string; layoutId: string }\n [Route.SCREEN_DETAILS]: {\n orgSlug: string\n host: string\n screenId: string\n versionId: string\n }\n [Route.SCREEN_PREVIEW]: {\n orgSlug: string\n host: string\n screenId: string\n versionId: string\n }\n [Route.HOST_SCREENS]: { orgSlug: string; host: string }\n}\n\nexport const routeReplacePattern = /\\[([^\\]]+)\\]/g\n\n/**\n * Builds a concrete path from a {@link Route} template. The payload is\n * REQUIRED whenever the route declares params (e.g. `orgSlug`, `hostId`) and\n * omitted only for param-less routes — so a forgotten `orgSlug` is a compile\n * error, not a `/<orgSlug?>/…` link that breaks at runtime (AGL-621).\n */\nexport function buildRoute<Tmpl extends Route>(\n template: Tmpl,\n ...[payload]: RoutePayload[Tmpl] extends undefined\n ? [payload?: undefined]\n : [payload: RoutePayload[Tmpl]]\n) {\n return template.replace(routeReplacePattern, (match, key) => {\n const value = (payload as Record<string, unknown> | undefined)?.[key]\n return value != null ? String(value) : `<${key}?>`\n })\n}\n"],"names":["Route","routeReplacePattern","buildRoute","template","payload","replace","match","key","value","String"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/console-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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// URL scheme (AGL-621): the org is a first-class path segment `[orgSlug]`\n// so the URL — not client-side precedence — is the source of truth for the\n// active workspace. Host routes nest under `/[orgSlug]/hosts/[host]`; the\n// org area (settings, team, billing, media, data, plugins, support,\n// marketplace) lives directly under `/[orgSlug]`. User-level `manage/*`,\n// staff `admin/*`, and `auth` routes are NOT org-scoped. Hosts stay keyed\n// by doc id here; AGL-622 swaps `[hostId]` for the subdomain slug.\nexport enum Route {\n ADMIN_ORGS = '/admin/orgs',\n ADMIN_ORG_DETAIL = '/admin/orgs/[orgId]',\n ADMIN_ORG_HOST_DETAIL = '/admin/orgs/[orgId]/host/[hostId]',\n ADMIN_OVERVIEW = '/admin/overview',\n // Staff coupon management (AGL-1105): create discount coupons + promotion\n // codes and read the live net-margin rating before committing to one.\n ADMIN_COUPONS = '/admin/coupons',\n // The panic button (AGL-1501): platform/org/host/user lockdown controls.\n ADMIN_LOCKDOWN = '/admin/lockdown',\n // The narrow lever beside it (AGL-1687): disable or release ONE uploaded\n // file. Its own page rather than a panel on Lockdown because the input is\n // an asset, not a scope — the two forms share no field but the reason.\n ADMIN_MEDIA_QUARANTINE = '/admin/media-quarantine',\n // The INPUT to the two levers above (AGL-1964). Lockdown and quarantine are\n // both things an operator does after learning there is a problem; until\n // this page there was no way for anyone outside the company to say there\n // was one. Reports arrive from the unauthenticated form the tenant runtime\n // serves at /api/report-abuse on every origin.\n ADMIN_ABUSE_REPORTS = '/admin/abuse-reports',\n // The same input, one collection over (AGL-2310). The marketplace's own\n // report button wrote `marketplaceReports` from the day it shipped and\n // nothing read it — every report filed, acknowledged, and unreachable. This\n // is that queue, deliberately shaped like the abuse one rather than as a\n // second vocabulary for the same act.\n // The probes, on a screen (AGL-1900). /api/health/{backups,rate-limits,\n // signups} and /api/admin/email-health each answered a bad-day question to\n // a curl and to nothing else; this is where an operator reads them.\n ADMIN_HEALTH = '/admin/health',\n // The maintenance jobs, on a screen (AGL-1949). audit-archive,\n // reap-plugin-artifacts and reverify-plugin-versions accepted the shared\n // cron secret and nothing else, so the only way to preview or run one was a\n // shell holding the production secret. Separate from Health because Health\n // reads and this acts.\n ADMIN_MAINTENANCE = '/admin/maintenance',\n // The Texas sales tax return (AGL-1900). The figures AGL-1811 computes had\n // only a curl to reach them; filing happens on a fixed quarterly calendar\n // from 2026-09-01, so the return needs a URL a person can bookmark.\n ADMIN_REVENUE = '/admin/revenue',\n // Realised band utilization and the margin that follows from it. Beside\n // Revenue because the two are the halves of one question: that page reports\n // what came in, this one what serving it cost.\n ADMIN_MARGIN_UTILIZATION = '/admin/margin-utilization',\n ADMIN_TAX_RETURN = '/admin/tax-return',\n ADMIN_AUDIT = '/admin/audit',\n ADMIN_USERS = '/admin/users',\n ADMIN_USER_DETAIL = '/admin/users/[uid]',\n ADMIN_FLAGS = '/admin/flags',\n // Platform-wide settings that are not a release flag (AGL-2486). The\n // free-workspace ceiling was the first of them and it opened the\n // Organizations LIST, above the table — a global lever wedged into the\n // screen for browsing individual orgs. Settings that describe the platform\n // rather than one org belong on their own tab, beside the flags.\n ADMIN_SETTINGS = '/admin/settings',\n // One submission or listed plugin in full (AGL-959): the queue index is\n // for scanning, this is where a reviewer reads the manifest, weighs the\n // verifier findings and acts.\n // A page a plugin adds to the staff area (AGL-2939), rendered by the\n // console's generic staff route. The console's own staff routes win the\n // segments they use.\n ADMIN_STAFF_PAGE = '/admin/[staffPage]',\n // Staff support-ticket queue (AGL-849): the operator side of the\n // subscriber `MANAGE_SUPPORT_TICKETS` page — every org's tickets in one place.\n ADMIN_SUPPORT = '/admin/support',\n // Do-not-contact list (AGL-1592): the intake and the queue for Privacy\n // Policy v4 §11's marketing call/text opt-out, and for \"delete the phone\n // number you hold for me\".\n ADMIN_CONTACT_SUPPRESSIONS = '/admin/contact-suppressions',\n ADMIN_EMAILS = '/admin/emails',\n // The one besigner route with no host and no org in it (AGL-749). A system\n // email belongs to the platform, not to a workspace, so it is staff-scoped\n // like the rest of `/admin/*` rather than nested under `[orgSlug]`.\n ADMIN_EMAIL_BESIGNER = '/admin/emails/[templateKey]/versions/[versionId]/besigner',\n ORG_HOME = '/[orgSlug]',\n ORG_MEDIA = '/[orgSlug]/media',\n ORG_DATA = '/[orgSlug]/data',\n /*\n * The organization's address book.\n *\n * Org-scoped rather than a section of a site, because it answers the one\n * question a site page cannot: which of the organization's sites know a\n * given person. A contact document is shared by every site in the org — one\n * human who touched two of them is one row — and until this route there was\n * no surface anywhere that showed the deduped person, so the dedupe the\n * shared address book exists for was invisible and the billing unit\n * (unique people per org) had nothing standing behind it.\n *\n * It shows identity, which sites hold the person, and consent per site. The\n * per-site CRM — notes, tags, timeline, order history — stays on the site's\n * own Contacts surface, because those are the holder's business records and\n * this is the one route designed to cross between holders.\n *\n * A LEGACY ADDRESS since AGL-2630: the page behind it redirects, permanently,\n * to the org-level CRM hub's contacts section. It stays declared so a link\n * kept from before — a bookmark, a docs page, an email — still resolves,\n * and so the redirect has a constant to be built from rather than a string.\n */\n ORG_CONTACTS = '/[orgSlug]/contacts',\n /*\n * The organization-level CRM hub (AGL-2630): the same sections the site hub\n * has — `/[orgSlug]/crm/<section>[/<recordId>]` — mounted with an ORG scope,\n * for an org-wide member who works every site's people at once. A bare\n * `/crm` lands on the first section the reader may open, as the site hub's\n * does. What this route adds over the site hub is the cross-site fact the\n * old address book carried — which sites know a person — and what it takes\n * from it is the site every create must name, since a record is always\n * captured BY a site.\n */\n ORG_CRM = '/[orgSlug]/crm',\n /*\n * The generic ORGANIZATION-level plugin route (AGL-2974): every surface an\n * extension declares in `orgNavItems` is served here, at\n * `/[orgSlug]/<href>[/<section>…]`, by\n * `app/(app)/[orgSlug]/[...pluginSlug]/page.tsx`. The org twin of\n * `HOST_PLUGIN`, and like it the one org route whose leaf segment is data:\n * every named org route in this enum wins over it.\n */\n ORG_PLUGIN = '/[orgSlug]/[pluginSlug]',\n ORG_PLUGINS = '/[orgSlug]/plugins',\n // One plugin, as installed in this workspace (AGL-1007): scope, settings\n // and permissions in one place. The segment takes EITHER identifier\n // (AGL-1010) — a listing id for a marketplace install, a registry id for a\n // first-party plugin — hence `pluginRef` rather than `listingId`.\n ORG_PLUGIN_INSTALLATION = '/[orgSlug]/plugins/[pluginRef]',\n // Org-scope marketplace (AGL-772): the single place to browse, view and\n // install marketplace items. The per-site tab it replaced, and the retired\n // `/[orgSlug]/marketplace` seller area, were removed with the rest of the\n // `marketplace` naming in AGL-975 — the word is being freed for a public\n // forum, and a redirect stub sitting on `/marketplace` would have taken the\n // one path that feature wants.\n /*\n * The marketplace's own SECTIONS are not here any more (AGL-3080). Eight\n * of them — browse, licences, upload, the four seller cards and the\n * publish form — were hand-written console routes, and became an\n * `orgNavItems` declaration whose `sections` name those segments once, in\n * the plugin. Restating them here was a second copy of a list the plugin\n * owns, and every entry had zero callers the moment the routes went.\n *\n * What stays is what the CONSOLE still builds: the surface itself, a\n * listing, a publisher, and the installed tab that the org plugins card\n * links across to. A console page linking into a plugin's surface is the\n * ordinary case; a console page enumerating that surface's sections is\n * not.\n */\n ORG_MARKETPLACE = '/[orgSlug]/marketplace',\n /*\n * Marketplace SECTIONS are routes (AGL-2501), for the reason the settings\n * and account sections are: a panel that is not open should cost nothing,\n * and the seller half of this hub reads the organization's REVENUE.\n *\n * The four seller sections are the reason the layout gates rather than the\n * rail. As tabs they were simply not rendered for a member without\n * `publishToMarketplace`; as routes each one is reachable by typing its URL,\n * so the refusal has to sit above them all rather than in the list that\n * draws them.\n *\n * Every segment here is shorter than the ten characters `createResourceUid`\n * emits, so none of them can ever shadow a real `[listingId]` sitting at the\n * same level. `upload` rather than `publish` because `publish/plugin` is\n * already a route beneath this one, and two directories contributing the\n * same segment is a tree Next cannot resolve a layout for.\n */\n ORG_MARKETPLACE_INSTALLED = '/[orgSlug]/marketplace/installed',\n ORG_MARKETPLACE_LISTING = '/[orgSlug]/marketplace/[listingId]',\n ORG_MARKETPLACE_PUBLISHER = '/[orgSlug]/marketplace/publisher/[handle]',\n // Publishing a plugin is a page, not a modal (AGL-1078): the most\n // consequential thing a publisher does now has a URL, so it can be linked,\n // reloaded, and reached from a listing to ship an update.\n ORG_SETTINGS = '/[orgSlug]/settings',\n /*\n * Settings SECTIONS are routes (AGL-2501). `HubTabs` mounted every panel —\n * `keepMounted`, with `lazy` off by default and passed by nobody — so\n * opening General also mounted the API-keys, SSO and data-export cards and\n * ran every read in them. Routes mount one page and code-split per route.\n * `ORG_SETTINGS` itself redirects to General.\n */\n ORG_SETTINGS_GENERAL = '/[orgSlug]/settings/general',\n ORG_SETTINGS_PROFILE = '/[orgSlug]/settings/profile',\n ORG_SETTINGS_PLUGINS = '/[orgSlug]/settings/plugins',\n ORG_SETTINGS_API_KEYS = '/[orgSlug]/settings/api-keys',\n ORG_SETTINGS_BRANDING = '/[orgSlug]/settings/branding',\n ORG_SETTINGS_SSO = '/[orgSlug]/settings/sso',\n ORG_SETTINGS_OWNERSHIP = '/[orgSlug]/settings/ownership',\n /**\n * Export the people a workspace holds and file a person's erasure\n * (AGL-2839) — outside the CRM, so it is there on every plan.\n */\n ORG_SETTINGS_PRIVACY = '/[orgSlug]/settings/privacy',\n ORG_SETTINGS_DELETE = '/[orgSlug]/settings/delete',\n /**\n * Billing, section by section (AGL-2501).\n *\n * `MANAGE_BILLING` is unchanged and is the PLAN section — it resolves to a\n * page inside a route group, which adds no path segment. That is what makes\n * this split free: every link in the console, every hash anchor\n * (`#addons`, `#collaborator-seats`) and Stripe's own dunning mail all point\n * here already and none of them moves. No redirect, and no `?tab=` shim to\n * carry forever.\n */\n MANAGE_BILLING = '/[orgSlug]/billing',\n MANAGE_BILLING_USAGE = '/[orgSlug]/billing/usage',\n MANAGE_BILLING_INVOICES = '/[orgSlug]/billing/invoices',\n MANAGE_BILLING_SETTINGS = '/[orgSlug]/billing/settings',\n // The ONE billing URL that carries no org (AGL-2430).\n //\n // Everything else on this table is org-scoped, which is correct for a\n // console and wrong for the only caller that cannot template a URL:\n // Stripe's dunning emails. \"Payment method updates\" takes a single static\n // link for every customer in the account — there is no `{{org}}` to\n // interpolate — so a table with no org-agnostic billing entry means those\n // emails can only ever point at a page that is not billing. Today all four\n // of them point at the marketing homepage, which is how a failed card\n // becomes a cancelled subscription with no reachable way to fix it.\n //\n // This resolves the org from the SESSION instead of from the URL: one\n // workspace goes straight through, several offer a choice, none says so.\n // `billing` is a reserved subdomain (`RESERVED_SUBDOMAINS`), so no org can\n // hold this slug and the literal segment can never shadow a real\n // `/[orgSlug]`.\n BILLING_ENTRY = '/billing',\n MANAGE_USER_SETTINGS = '/manage/user',\n /*\n * Account SECTIONS are routes (AGL-2501). `HubTabs` mounted every panel —\n * `keepMounted`, with `lazy` off by default and passed by nobody — so\n * opening Account also mounted the email-addresses, passkeys, recent\n * sign-ins, data-export and close-account cards and ran every read in them.\n * Routes mount one page and code-split per route.\n *\n * The segments are the ids the panels carried, because a transactional\n * email already in people's inboxes links `/manage/user?tab=security`:\n * `MANAGE_USER_SETTINGS` forwards each of those ids to the section that\n * holds it, and the segment matching the id is what keeps that map a\n * one-liner rather than a second naming scheme.\n */\n MANAGE_USER_ACCOUNT = '/manage/user/account',\n MANAGE_USER_EMAILS = '/manage/user/emails',\n MANAGE_USER_PROFILE = '/manage/user/profile',\n MANAGE_USER_BASIC = '/manage/user/basic',\n MANAGE_USER_SECURITY = '/manage/user/security',\n MANAGE_USER_CLOSE = '/manage/user/close',\n MANAGE_NOTIFICATIONS = '/manage/notifications',\n /*\n * The preferences, off the feed (AGL-3226).\n *\n * They lived in the header of the feed itself, which was already the\n * tallest thing on that page before per-channel and per-scope settings\n * multiplied them — and it put an edit form above the list it edits, which\n * the console does nowhere else.\n *\n * A CHILD of the feed rather than a sibling under `/manage`, because the\n * relationship is exactly that: it configures what the page above it shows.\n * That also keeps `/manage/notifications` the address in the transactional\n * mail already in people's inboxes, which keeps working and now carries a\n * button to here.\n */\n MANAGE_NOTIFICATION_SETTINGS = '/manage/notifications/settings',\n /*\n * A URL for the defect channel (AGL-2486). \"Report an issue\" was reachable\n * only by opening the account menu, so nothing outside the console could\n * send anyone to it — the repo's README pointed at GitHub issues, which we\n * do not use, and every other \"tell us about it\" surface had the same\n * problem. User-level rather than org-scoped because the dialog is: it\n * needs no workspace, no role and no paid plan.\n */\n MANAGE_REPORT_ISSUE = '/manage/report-issue',\n AUTH_SIGN_IN = '/signin',\n AUTH_SIGN_OUT = '/signout',\n AUTH_SIGN_UP = '/signup',\n AUTH_VERIFY_EMAIL = '/verify-email',\n HOST_LIST = '/[orgSlug]/hosts',\n HOST_CONTENT = '/[orgSlug]/hosts/[host]/content',\n // The collection is a PATH SEGMENT, not `?collection=` (AGL-2498).\n //\n // It was a query parameter because the manager began as one page with a\n // Select on it, and the Select was the only thing that knew which\n // collection you were looking at. That is the wrong owner: which collection\n // is open is the page's IDENTITY, not a filter applied to it. As a\n // parameter it could be dropped by any link that rebuilt the query, it\n // sorted below `?tab=` in a URL nobody could read, and — the reason this\n // moved — it left the entry beneath it with nowhere to hang: an entry is\n // addressed `collection + entry`, and half of that address cannot live in\n // the path while the other half lives in the query.\n //\n // The segment is the collection's SLUG, not its document id, and that is a\n // correction rather than a preference. Collection document ids are not\n // uniform — the seeded ones were given readable ids (`blog`, `changelog`)\n // and everything created since gets a `createResourceUid`, so routing by id\n // produced `/content/changelog` beside `/content/QgXv7lU_rG` on ONE site.\n //\n // A slug is the right key on the merits too: it is unique per host and kind\n // (claimed in a transaction by /api/hosts/collections), it is the\n // collection's own public address, and it is the thing an author recognises.\n // A document id is still ACCEPTED and rewritten to the slug form, so no\n // existing link breaks.\n //\n // `?collection=` still resolves the same way (AGL-845's DAM deep links are\n // in the wild).\n HOST_CONTENT_COLLECTION = '/[orgSlug]/hosts/[host]/content/[collectionSlug]',\n // One entry's detail page (AGL-2498). `entries` is a LITERAL segment rather\n // than `content/[collectionId]/[entryId]`, and it is load-bearing twice\n // over: it mirrors the Firestore path the entry actually lives at\n // (`collections/{c}/entries/{e}`), and it keeps the id space clear so a\n // later sibling — a collection's own settings page, say — cannot be\n // shadowed by an entry that happens to be called the same thing.\n //\n // `[entryId]` also carries the sentinel `new`, which is a draft that has no\n // document yet. Entry ids come from `createResourceUid`, so nothing real\n // can collide with it.\n CONTENT_ENTRY_DETAILS = '/[orgSlug]/hosts/[host]/content/[collectionSlug]/entries/[entryId]',\n MANAGE_TEAM = '/[orgSlug]/team',\n /*\n * Team SECTIONS are routes, not tabs (AGL-2501). A section's bundle then\n * arrives when a reader opens it and never before, the section is linkable,\n * and the active state is a fact about the URL rather than state that has to\n * be kept in sync with it. `MANAGE_TEAM` itself redirects to members.\n */\n MANAGE_TEAM_MEMBERS = '/[orgSlug]/team/members',\n MANAGE_TEAM_ROLES = '/[orgSlug]/team/roles',\n MANAGE_TEAM_ACTIVITY = '/[orgSlug]/team/activity',\n MANAGE_TEAM_MEMBER = '/[orgSlug]/team/[uid]',\n // Support is an UMBRELLA, not a page (AGL-1158). The two channels beneath\n // it are separate features that were only ever one screen: they are gated\n // differently (tickets need a first-response commitment, from Pro; the\n // forum is open to every tier including Free) and they fail independently.\n // Sharing one route made them share a blast radius — AGL-1157 was one bad\n // line in the common loader that emptied BOTH lists at once.\n //\n // `MANAGE_SUPPORT` itself stays, and stays the nav tab's href: it now\n // forwards to whichever channel the org's tier makes primary, so a\n // forum-only workspace lands on a whole forum instead of a half-empty page\n // beside a ticket card it may not use.\n MANAGE_SUPPORT = '/[orgSlug]/support',\n MANAGE_SUPPORT_TICKETS = '/[orgSlug]/support/tickets',\n MANAGE_SUPPORT_FORUM = '/[orgSlug]/support/forum',\n /*\n * The ONE support URL that carries no org (AGL-3265), and the second member\n * of the apex category `BILLING_ENTRY` opened.\n *\n * Everything a person is handed when they need help — a receipt, a system\n * email footer, a Stripe customer portal, a self-host runbook — stores ONE\n * link for every customer, with no workspace to interpolate. `/[orgSlug]`\n * cannot be templated into any of them, so the only support address those\n * surfaces could carry was one outside the console entirely.\n *\n * This resolves the workspace from the SESSION instead: one workspace goes\n * straight through, several offer a choice, none says so. It forwards to\n * `MANAGE_SUPPORT`, which is itself an umbrella — so the tier decision about\n * tickets versus the forum stays in the one place that already makes it.\n *\n * `support` is a reserved subdomain (`RESERVED_SUBDOMAINS` in\n * `host-naming.ts`), so no org can hold this slug and the literal segment\n * can never shadow a real `/[orgSlug]`. That is the same guarantee\n * `BILLING_ENTRY` rests on, and it is a precondition rather than a\n * coincidence: an apex route whose segment an org could claim would take a\n * workspace's own address away from it.\n */\n SUPPORT_ENTRY = '/support',\n HOST_DASHBOARD = '/[orgSlug]/hosts/[host]',\n // The catch-all console page a plugin's own nav items resolve against\n // (`app/(app)/[orgSlug]/hosts/[host]/[pluginSlug]/page.tsx`). Plugin slugs\n // are open-ended, so this is the one route whose leaf segment is data.\n HOST_PLUGIN = '/[orgSlug]/hosts/[host]/[pluginSlug]',\n HOST_INBOX = '/[orgSlug]/hosts/[host]/inbox',\n HOST_CONTACTS = '/[orgSlug]/hosts/[host]/contacts',\n HOST_MEDIA = '/[orgSlug]/hosts/[host]/media',\n HOST_SETUP = '/[orgSlug]/hosts/[host]/setup',\n /*\n * Setup SECTIONS are routes (AGL-2501), so an unopened one costs neither a\n * read nor a byte. `HOST_SETUP` stays the nav tab's href and redirects to\n * Basic details, honouring the `?tab=` ids these sections were deep-linked\n * by — unlike the settings and marketplace hubs, links holding those ids are\n * demonstrably in the wild and two are built in this repo.\n */\n HOST_SETUP_DETAILS = '/[orgSlug]/hosts/[host]/setup/details',\n HOST_SETUP_SEO = '/[orgSlug]/hosts/[host]/setup/seo',\n HOST_SETUP_TRACKING = '/[orgSlug]/hosts/[host]/setup/tracking',\n HOST_SETUP_THEME = '/[orgSlug]/hosts/[host]/setup/theme',\n HOST_SETUP_EMAILS = '/[orgSlug]/hosts/[host]/setup/emails',\n // Host Admin area (AGL-1014): owner/admin-only controls — per-site plugin\n // enablement and the Danger zone — out of the Setup page collaborators\n // legitimately visit.\n HOST_ADMIN = '/[orgSlug]/hosts/[host]/admin',\n /*\n * Site-admin SECTIONS are routes (AGL-2501), so an unopened one costs neither\n * a read nor a byte. `HOST_ADMIN` redirects to Plugins and still honours the\n * `?tab=` ids these sections were deep-linked by.\n */\n /*\n * The site as an OBJECT: its name in the console and the subdomain it\n * answers to. First in the rail because an address and a name are what\n * identify the thing every other section governs.\n */\n HOST_ADMIN_GENERAL = '/[orgSlug]/hosts/[host]/admin/general',\n HOST_ADMIN_PLUGINS = '/[orgSlug]/hosts/[host]/admin/plugins',\n /*\n * One plugin, as installed on ONE site (AGL-428, AGL-1014) — the\n * site-scoped twin of `ORG_PLUGIN_INSTALLATION`, reached by clicking a row\n * on the site's plugin\n * list exactly as the workspace list opens its own detail page. The segment\n * is `[pluginRef]` and accepts either identifier for the same reason the\n * workspace route does (AGL-1010): a marketplace install is keyed by listing\n * id, a first-party plugin by its registry id, and an admin does not think\n * of the two as different things.\n */\n HOST_ADMIN_PLUGIN = '/[orgSlug]/hosts/[host]/admin/plugins/[pluginRef]',\n HOST_ADMIN_DOMAIN = '/[orgSlug]/hosts/[host]/admin/domain',\n HOST_ADMIN_SECURITY = '/[orgSlug]/hosts/[host]/admin/security',\n /*\n * The designed screen behind each status code, and the switch that shows the\n * 503 one at every address at once (AGL-3178).\n *\n * Beside Security because both answer what a visitor's browser is handed on\n * a page the site did not design for them, and above Activity because it is\n * a control rather than a record.\n */\n HOST_ADMIN_ERROR_PAGES = '/[orgSlug]/hosts/[host]/admin/error-pages',\n HOST_ADMIN_ACTIVITY = '/[orgSlug]/hosts/[host]/admin/activity',\n /*\n * Backup, restore and publishing the site as a template — moving the whole\n * site somewhere else, or bringing it back. Its own section rather than the\n * Danger zone: restore overwrites, but export and publishing a template do\n * not, and filing all three under a destructive heading mislabels two of\n * them.\n */\n HOST_ADMIN_BACKUP = '/[orgSlug]/hosts/[host]/admin/backup',\n HOST_ADMIN_DANGER = '/[orgSlug]/hosts/[host]/admin/danger',\n HOST_THEME = '/[orgSlug]/hosts/[host]/theme',\n /*\n * The automation section: Workflows, Actions and Webhooks under one parent.\n * The parent is named for what the three have in common, so no tab shares a\n * name with the section that contains it.\n */\n HOST_AUTOMATION = '/[orgSlug]/hosts/[host]/automation',\n /**\n * The address the automation section answered to before it had a name of\n * its own. Nothing links here — `HOST_AUTOMATION` is what the nav, the\n * search results and the activity feed build — but the console redirects it\n * permanently, so a held bookmark still lands on the section (and keeps its\n * `?tab=`, which never changed).\n *\n * Kept on the table rather than as a bare literal in `next.config.js`\n * because the redirect and the address it serves are one fact:\n * `automation-section.spec.ts` reads this entry and fails if the rule for\n * it goes missing.\n */\n HOST_WORKFLOWS = '/[orgSlug]/hosts/[host]/workflows',\n HOST_DATA = '/[orgSlug]/hosts/[host]/data',\n HOST_LOGIC = '/[orgSlug]/hosts/[host]/logic',\n HOST_PRODUCTS = '/[orgSlug]/hosts/[host]/products',\n HOST_COMPONENTS = '/[orgSlug]/hosts/[host]/components',\n // Component detail (AGL-693): the listing links here, and the besigner is\n // reached from here — matching SCREEN_DETAILS rather than jumping a row\n // straight into the editor.\n COMPONENT_DETAILS = '/[orgSlug]/hosts/[host]/components/[componentId]',\n HOST_FORMS = '/[orgSlug]/hosts/[host]/forms',\n // A form is a designable document, so its surface is the component one:\n // a list, a detail page that owns the versions, and the besigner reached\n // from there. Deliberately NOT a row that jumps into the editor — a form's\n // routing and consent field are edited on the detail page, and those decide\n // what the design is then checked against.\n FORM_DETAILS = '/[orgSlug]/hosts/[host]/forms/[formId]',\n HOST_TEMPLATES = '/[orgSlug]/hosts/[host]/templates',\n // Template detail (AGL-694), the counterpart to COMPONENT_DETAILS. Note\n // TEMPLATE_BESIGNER carries no versionId — templates version but never\n // publish, so there is no \"current\" pointer to route through.\n TEMPLATE_DETAILS = '/[orgSlug]/hosts/[host]/templates/[templateId]',\n HOST_MARKETING = '/[orgSlug]/hosts/[host]/marketing',\n HOST_BOOKINGS = '/[orgSlug]/hosts/[host]/bookings',\n // Events now come from the events-calendar plugin, served by the generic\n // `[orgSlug]/hosts/[host]/[pluginSlug]` route (AGL-394).\n HOST_REDIRECTS = '/[orgSlug]/hosts/[host]/redirects',\n HOST_USERS = '/[orgSlug]/hosts/[host]/users',\n HOST_ANALYTICS = '/[orgSlug]/hosts/[host]/analytics',\n COMPONENT_BESIGNER = '/[orgSlug]/hosts/[host]/components/[componentId]/versions/[versionId]/besigner',\n FORM_BESIGNER = '/[orgSlug]/hosts/[host]/forms/[formId]/versions/[versionId]/besigner',\n TEMPLATE_BESIGNER = '/[orgSlug]/hosts/[host]/templates/[templateId]/besigner',\n LAYOUT_BESIGNER = '/[orgSlug]/hosts/[host]/layouts/[layoutId]/versions/[versionId]/besigner',\n // Draft preview for every besigner document kind (AGL-1203). Screens had\n // the only preview route; components, layouts and templates showed a\n // Preview button that did nothing. Each renders the same localStorage\n // snapshot through the site renderer, so they work on localhost with no\n // deployment. TEMPLATE_PREVIEW carries no versionId, matching\n // TEMPLATE_BESIGNER.\n COMPONENT_PREVIEW = '/[orgSlug]/hosts/[host]/components/[componentId]/versions/[versionId]/preview',\n FORM_PREVIEW = '/[orgSlug]/hosts/[host]/forms/[formId]/versions/[versionId]/preview',\n TEMPLATE_PREVIEW = '/[orgSlug]/hosts/[host]/templates/[templateId]/preview',\n LAYOUT_PREVIEW = '/[orgSlug]/hosts/[host]/layouts/[layoutId]/versions/[versionId]/preview',\n // The list sits at the bare path, like HOST_COMPONENTS. It used to be\n // `/layouts/list`: the pages router had a `layouts/index.tsx` that\n // redirected to `layouts/list.tsx`, and the App Router migration carried\n // the pair over verbatim. Components, added later, had no such legacy and\n // put its list at the bare path — which is the shape we want everywhere.\n // `/layouts/list` still serves the same page so old links survive — an\n // alias, not a redirect, because the bare path used to answer a cached 308\n // pointing at `/list` and redirecting back would loop. See the comment in\n // `app/(app)/[orgSlug]/hosts/[host]/screens/list/page.tsx`.\n HOST_LAYOUTS = '/[orgSlug]/hosts/[host]/layouts',\n // Layout detail (AGL-695), completing the list → detail → besigner shape\n // across screens, components, templates and layouts.\n LAYOUT_DETAILS = '/[orgSlug]/hosts/[host]/layouts/[layoutId]',\n SCREEN_BESIGNER = '/[orgSlug]/hosts/[host]/screens/[screenId]/versions/[versionId]/besigner',\n // Per-site email besigner (AGL-770): a site owner designs a transactional\n // email their site sends. The host-scoped sibling of the staff system-email\n // editor (ADMIN_EMAIL_BESIGNER).\n HOST_EMAIL_BESIGNER = '/[orgSlug]/hosts/[host]/emails/[templateKey]/versions/[versionId]/besigner',\n SCREEN_DETAILS = '/[orgSlug]/hosts/[host]/screens/[screenId]/versions/[versionId]/view',\n SCREEN_PREVIEW = '/[orgSlug]/hosts/[host]/screens/[screenId]/versions/[versionId]/preview',\n // Bare path, matching HOST_LAYOUTS and HOST_COMPONENTS — see the note on\n // HOST_LAYOUTS. `/screens/list` still serves the same page as an alias.\n HOST_SCREENS = '/[orgSlug]/hosts/[host]/screens',\n}\n\n/**\n * Params required to build each {@link Route}.\n *\n * Deliberately NOT extended from an index signature (AGL-685). It used to be\n * `extends Record<keyof any, any>`, which meant any `Route` missing an entry\n * here silently typed its payload as `any` — `Route.HOST_SETUP` was one of\n * them, and `buildRoute(Route.HOST_SETUP, { hostId })` compiled cleanly while\n * emitting `/<orgSlug?>/hosts/<host?>/setup`. Without the index signature a\n * new `Route` with no entry is a compile error, which is the whole point of\n * the payload being required in the first place.\n */\nexport interface RoutePayload {\n [Route.AUTH_SIGN_UP]: undefined\n [Route.AUTH_SIGN_IN]: undefined\n [Route.AUTH_SIGN_OUT]: undefined\n [Route.AUTH_VERIFY_EMAIL]: undefined\n [Route.SCREEN_BESIGNER]: {\n orgSlug: string\n host: string\n screenId: string\n versionId: string\n }\n [Route.ORG_HOME]: { orgSlug: string }\n [Route.HOST_DASHBOARD]: { orgSlug: string; host: string }\n [Route.ADMIN_ORGS]: undefined\n [Route.ADMIN_ORG_DETAIL]: { orgId: string }\n [Route.ADMIN_ORG_HOST_DETAIL]: { orgId: string; hostId: string }\n [Route.ADMIN_OVERVIEW]: undefined\n [Route.ADMIN_COUPONS]: undefined\n [Route.ADMIN_LOCKDOWN]: undefined\n [Route.ADMIN_MEDIA_QUARANTINE]: undefined\n [Route.ADMIN_ABUSE_REPORTS]: undefined\n [Route.ADMIN_HEALTH]: undefined\n [Route.ADMIN_MAINTENANCE]: undefined\n [Route.ADMIN_REVENUE]: undefined\n [Route.ADMIN_MARGIN_UTILIZATION]: undefined\n [Route.ADMIN_TAX_RETURN]: undefined\n [Route.ADMIN_AUDIT]: undefined\n [Route.ADMIN_USERS]: undefined\n [Route.ADMIN_USER_DETAIL]: { uid: string }\n [Route.ADMIN_FLAGS]: undefined\n [Route.ADMIN_SETTINGS]: undefined\n [Route.ADMIN_STAFF_PAGE]: { staffPage: string }\n [Route.ADMIN_SUPPORT]: undefined\n [Route.ADMIN_CONTACT_SUPPRESSIONS]: undefined\n [Route.ADMIN_EMAILS]: undefined\n [Route.ADMIN_EMAIL_BESIGNER]: { templateKey: string; versionId: string }\n [Route.HOST_EMAIL_BESIGNER]: {\n orgSlug: string\n host: string\n templateKey: string\n versionId: string\n }\n [Route.ORG_MEDIA]: { orgSlug: string }\n [Route.ORG_DATA]: { orgSlug: string }\n [Route.ORG_CONTACTS]: { orgSlug: string }\n [Route.ORG_CRM]: { orgSlug: string }\n [Route.ORG_PLUGIN]: { orgSlug: string; pluginSlug: string }\n [Route.ORG_PLUGINS]: { orgSlug: string }\n [Route.ORG_PLUGIN_INSTALLATION]: { orgSlug: string; pluginRef: string }\n [Route.ORG_MARKETPLACE]: { orgSlug: string }\n [Route.ORG_MARKETPLACE_INSTALLED]: { orgSlug: string }\n [Route.ORG_MARKETPLACE_LISTING]: { orgSlug: string; listingId: string }\n [Route.ORG_MARKETPLACE_PUBLISHER]: { orgSlug: string; handle: string }\n [Route.MANAGE_USER_SETTINGS]: undefined\n [Route.MANAGE_USER_ACCOUNT]: undefined\n [Route.MANAGE_USER_EMAILS]: undefined\n [Route.MANAGE_USER_PROFILE]: undefined\n [Route.MANAGE_USER_BASIC]: undefined\n [Route.MANAGE_USER_SECURITY]: undefined\n [Route.MANAGE_USER_CLOSE]: undefined\n [Route.MANAGE_NOTIFICATIONS]: undefined\n [Route.MANAGE_NOTIFICATION_SETTINGS]: undefined\n [Route.MANAGE_REPORT_ISSUE]: undefined\n [Route.ORG_SETTINGS]: { orgSlug: string }\n [Route.ORG_SETTINGS_GENERAL]: { orgSlug: string }\n [Route.ORG_SETTINGS_PROFILE]: { orgSlug: string }\n [Route.ORG_SETTINGS_PLUGINS]: { orgSlug: string }\n [Route.ORG_SETTINGS_API_KEYS]: { orgSlug: string }\n [Route.ORG_SETTINGS_BRANDING]: { orgSlug: string }\n [Route.ORG_SETTINGS_SSO]: { orgSlug: string }\n [Route.ORG_SETTINGS_OWNERSHIP]: { orgSlug: string }\n [Route.ORG_SETTINGS_PRIVACY]: { orgSlug: string }\n [Route.ORG_SETTINGS_DELETE]: { orgSlug: string }\n [Route.HOST_LIST]: { orgSlug: string }\n [Route.HOST_CONTENT]: { orgSlug: string; host: string }\n [Route.HOST_CONTENT_COLLECTION]: {\n orgSlug: string\n host: string\n /** The collection's slug — see the route. A document id also resolves. */\n collectionSlug: string\n }\n [Route.CONTENT_ENTRY_DETAILS]: {\n orgSlug: string\n host: string\n collectionSlug: string\n /**\n * An entry id, or the literal `new` for a draft with no document yet.\n *\n * An ID here while the collection above is a SLUG, deliberately. An entry\n * slug is unique only WITHIN its collection and it is editable on the\n * detail page — so routing by it would move the console address out from\n * under someone in the middle of typing in the slug field, and a draft has\n * no slug at all to route by. The entry id is also what every activity\n * record and the DAM's \"Used on\" list already carry.\n */\n entryId: string\n }\n [Route.MANAGE_TEAM]: { orgSlug: string }\n [Route.MANAGE_TEAM_MEMBERS]: { orgSlug: string }\n [Route.MANAGE_TEAM_ROLES]: { orgSlug: string }\n [Route.MANAGE_TEAM_ACTIVITY]: { orgSlug: string }\n [Route.MANAGE_TEAM_MEMBER]: { orgSlug: string; uid: string }\n [Route.MANAGE_SUPPORT]: { orgSlug: string }\n [Route.MANAGE_SUPPORT_TICKETS]: { orgSlug: string }\n [Route.MANAGE_SUPPORT_FORUM]: { orgSlug: string }\n [Route.MANAGE_BILLING]: { orgSlug: string }\n [Route.MANAGE_BILLING_USAGE]: { orgSlug: string }\n [Route.MANAGE_BILLING_INVOICES]: { orgSlug: string }\n [Route.MANAGE_BILLING_SETTINGS]: { orgSlug: string }\n [Route.BILLING_ENTRY]: undefined\n [Route.SUPPORT_ENTRY]: undefined\n [Route.HOST_INBOX]: { orgSlug: string; host: string }\n [Route.HOST_CONTACTS]: { orgSlug: string; host: string }\n [Route.HOST_SETUP]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_DETAILS]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_SEO]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_TRACKING]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_THEME]: { orgSlug: string; host: string }\n [Route.HOST_SETUP_EMAILS]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_GENERAL]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_PLUGINS]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_PLUGIN]: { orgSlug: string; host: string; pluginRef: string }\n [Route.HOST_ADMIN_DOMAIN]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_SECURITY]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_ERROR_PAGES]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_ACTIVITY]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_BACKUP]: { orgSlug: string; host: string }\n [Route.HOST_ADMIN_DANGER]: { orgSlug: string; host: string }\n [Route.HOST_PLUGIN]: { orgSlug: string; host: string; pluginSlug: string }\n [Route.HOST_MEDIA]: { orgSlug: string; host: string }\n [Route.HOST_THEME]: { orgSlug: string; host: string }\n [Route.HOST_AUTOMATION]: { orgSlug: string; host: string }\n [Route.HOST_WORKFLOWS]: { orgSlug: string; host: string }\n [Route.HOST_DATA]: { orgSlug: string; host: string }\n [Route.HOST_LOGIC]: { orgSlug: string; host: string }\n [Route.HOST_PRODUCTS]: { orgSlug: string; host: string }\n [Route.HOST_COMPONENTS]: { orgSlug: string; host: string }\n [Route.COMPONENT_DETAILS]: {\n orgSlug: string\n host: string\n componentId: string\n }\n [Route.HOST_FORMS]: { orgSlug: string; host: string }\n [Route.FORM_DETAILS]: {\n orgSlug: string\n host: string\n formId: string\n }\n [Route.HOST_TEMPLATES]: { orgSlug: string; host: string }\n [Route.TEMPLATE_DETAILS]: {\n orgSlug: string\n host: string\n templateId: string\n }\n [Route.HOST_MARKETING]: { orgSlug: string; host: string }\n [Route.HOST_BOOKINGS]: { orgSlug: string; host: string }\n [Route.HOST_REDIRECTS]: { orgSlug: string; host: string }\n [Route.HOST_USERS]: { orgSlug: string; host: string }\n [Route.HOST_ANALYTICS]: { orgSlug: string; host: string }\n [Route.COMPONENT_BESIGNER]: {\n orgSlug: string\n host: string\n componentId: string\n versionId: string\n }\n [Route.FORM_BESIGNER]: {\n orgSlug: string\n host: string\n formId: string\n versionId: string\n }\n [Route.TEMPLATE_BESIGNER]: {\n orgSlug: string\n host: string\n templateId: string\n }\n [Route.LAYOUT_BESIGNER]: {\n orgSlug: string\n host: string\n layoutId: string\n versionId: string\n }\n [Route.COMPONENT_PREVIEW]: {\n orgSlug: string\n host: string\n componentId: string\n versionId: string\n }\n [Route.FORM_PREVIEW]: {\n orgSlug: string\n host: string\n formId: string\n versionId: string\n }\n [Route.TEMPLATE_PREVIEW]: {\n orgSlug: string\n host: string\n templateId: string\n }\n [Route.LAYOUT_PREVIEW]: {\n orgSlug: string\n host: string\n layoutId: string\n versionId: string\n }\n [Route.HOST_LAYOUTS]: { orgSlug: string; host: string }\n [Route.LAYOUT_DETAILS]: { orgSlug: string; host: string; layoutId: string }\n [Route.SCREEN_DETAILS]: {\n orgSlug: string\n host: string\n screenId: string\n versionId: string\n }\n [Route.SCREEN_PREVIEW]: {\n orgSlug: string\n host: string\n screenId: string\n versionId: string\n }\n [Route.HOST_SCREENS]: { orgSlug: string; host: string }\n}\n\nexport const routeReplacePattern = /\\[([^\\]]+)\\]/g\n\n/**\n * Builds a concrete path from a {@link Route} template. The payload is\n * REQUIRED whenever the route declares params (e.g. `orgSlug`, `hostId`) and\n * omitted only for param-less routes — so a forgotten `orgSlug` is a compile\n * error, not a `/<orgSlug?>/…` link that breaks at runtime (AGL-621).\n */\nexport function buildRoute<Tmpl extends Route>(\n template: Tmpl,\n ...[payload]: RoutePayload[Tmpl] extends undefined\n ? [payload?: undefined]\n : [payload: RoutePayload[Tmpl]]\n) {\n return template.replace(routeReplacePattern, (match, key) => {\n const value = (payload as Record<string, unknown> | undefined)?.[key]\n return value != null ? String(value) : `<${key}?>`\n })\n}\n"],"names":["Route","routeReplacePattern","buildRoute","template","payload","replace","match","key","value","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,0EAA0E;AAC1E,2EAA2E;AAC3E,0EAA0E;AAC1E,oEAAoE;AACpE,yEAAyE;AACzE,0EAA0E;AAC1E,mEAAmE;AACnE,OAAO,IAAA,AAAKA,+BAAAA;;;;;IAKV,0EAA0E;IAC1E,sEAAsE;;IAEtE,yEAAyE;;IAEzE,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;;IAEvE,4EAA4E;IAC5E,wEAAwE;IACxE,yEAAyE;IACzE,2EAA2E;IAC3E,+CAA+C;;IAE/C,wEAAwE;IACxE,uEAAuE;IACvE,4EAA4E;IAC5E,yEAAyE;IACzE,sCAAsC;IACtC,wEAAwE;IACxE,2EAA2E;IAC3E,oEAAoE;;IAEpE,+DAA+D;IAC/D,yEAAyE;IACzE,4EAA4E;IAC5E,2EAA2E;IAC3E,uBAAuB;;IAEvB,2EAA2E;IAC3E,0EAA0E;IAC1E,oEAAoE;;IAEpE,wEAAwE;IACxE,4EAA4E;IAC5E,+CAA+C;;;;;;;IAO/C,qEAAqE;IACrE,iEAAiE;IACjE,uEAAuE;IACvE,2EAA2E;IAC3E,iEAAiE;;IAEjE,wEAAwE;IACxE,wEAAwE;IACxE,8BAA8B;IAC9B,qEAAqE;IACrE,wEAAwE;IACxE,qBAAqB;;IAErB,iEAAiE;IACjE,+EAA+E;;IAE/E,uEAAuE;IACvE,yEAAyE;IACzE,2BAA2B;;;IAG3B,2EAA2E;IAC3E,2EAA2E;IAC3E,oEAAoE;;;;;IAKpE;;;;;;;;;;;;;;;;;;;;GAoBC;IAED;;;;;;;;;GASC;IAED;;;;;;;GAOC;;IAGD,yEAAyE;IACzE,oEAAoE;IACpE,2EAA2E;IAC3E,kEAAkE;;IAElE,wEAAwE;IACxE,2EAA2E;IAC3E,0EAA0E;IAC1E,yEAAyE;IACzE,4EAA4E;IAC5E,+BAA+B;IAC/B;;;;;;;;;;;;;GAaC;IAED;;;;;;;;;;;;;;;;GAgBC;;;IAID,kEAAkE;IAClE,2EAA2E;IAC3E,0DAA0D;;IAE1D;;;;;;GAMC;;;;;;;IAQD;;;GAGC;;IAGD;;;;;;;;;GASC;;;;IAKD,sDAAsD;IACtD,EAAE;IACF,sEAAsE;IACtE,oEAAoE;IACpE,0EAA0E;IAC1E,oEAAoE;IACpE,0EAA0E;IAC1E,2EAA2E;IAC3E,sEAAsE;IACtE,oEAAoE;IACpE,EAAE;IACF,sEAAsE;IACtE,yEAAyE;IACzE,2EAA2E;IAC3E,iEAAiE;IACjE,gBAAgB;;;IAGhB;;;;;;;;;;;;GAYC;;;;;;;IAQD;;;;;;;;;;;;;GAaC;IAED;;;;;;;GAOC;;;;;;;IAQD,mEAAmE;IACnE,EAAE;IACF,wEAAwE;IACxE,kEAAkE;IAClE,4EAA4E;IAC5E,mEAAmE;IACnE,uEAAuE;IACvE,yEAAyE;IACzE,yEAAyE;IACzE,0EAA0E;IAC1E,oDAAoD;IACpD,EAAE;IACF,2EAA2E;IAC3E,uEAAuE;IACvE,0EAA0E;IAC1E,4EAA4E;IAC5E,0EAA0E;IAC1E,EAAE;IACF,4EAA4E;IAC5E,kEAAkE;IAClE,6EAA6E;IAC7E,wEAAwE;IACxE,wBAAwB;IACxB,EAAE;IACF,2EAA2E;IAC3E,gBAAgB;;IAEhB,4EAA4E;IAC5E,wEAAwE;IACxE,kEAAkE;IAClE,wEAAwE;IACxE,oEAAoE;IACpE,iEAAiE;IACjE,EAAE;IACF,4EAA4E;IAC5E,yEAAyE;IACzE,uBAAuB;;;IAGvB;;;;;GAKC;;;;IAKD,0EAA0E;IAC1E,0EAA0E;IAC1E,uEAAuE;IACvE,2EAA2E;IAC3E,0EAA0E;IAC1E,6DAA6D;IAC7D,EAAE;IACF,sEAAsE;IACtE,mEAAmE;IACnE,2EAA2E;IAC3E,uCAAuC;;;;IAIvC;;;;;;;;;;;;;;;;;;;;;GAqBC;;IAGD,sEAAsE;IACtE,2EAA2E;IAC3E,uEAAuE;;;;;;IAMvE;;;;;;GAMC;;;;;IAMD,0EAA0E;IAC1E,uEAAuE;IACvE,sBAAsB;;IAEtB;;;;GAIC,GACD;;;;GAIC;;IAGD;;;;;;;;;GASC;;;IAID;;;;;;;GAOC;;IAGD;;;;;;GAMC;;;IAID;;;;GAIC;IAED;;;;;;;;;;;GAWC;;;;;IAMD,0EAA0E;IAC1E,wEAAwE;IACxE,4BAA4B;;;IAG5B,wEAAwE;IACxE,yEAAyE;IACzE,2EAA2E;IAC3E,4EAA4E;IAC5E,2CAA2C;;;IAG3C,wEAAwE;IACxE,uEAAuE;IACvE,8DAA8D;;;;IAI9D,yEAAyE;IACzE,yDAAyD;;;;;;;;IAQzD,yEAAyE;IACzE,qEAAqE;IACrE,sEAAsE;IACtE,wEAAwE;IACxE,8DAA8D;IAC9D,qBAAqB;;;;;IAKrB,sEAAsE;IACtE,mEAAmE;IACnE,yEAAyE;IACzE,0EAA0E;IAC1E,yEAAyE;IACzE,uEAAuE;IACvE,2EAA2E;IAC3E,0EAA0E;IAC1E,4DAA4D;;IAE5D,yEAAyE;IACzE,qDAAqD;;;IAGrD,0EAA0E;IAC1E,4EAA4E;IAC5E,iCAAiC;;;;IAIjC,yEAAyE;IACzE,wEAAwE;;WA5f9DA;MA8fX;AA4OD,OAAO,MAAMC,sBAAsB,gBAAe;AAElD;;;;;CAKC,GACD,OAAO,SAASC,WACdC,QAAc,EACd,GAAG,CAACC,QAAQ,AAEqB;IAEjC,OAAOD,SAASE,OAAO,CAACJ,qBAAqB,CAACK,OAAOC;QACnD,MAAMC,QAASJ,2BAAD,AAACA,OAAiD,CAACG,IAAI;QACrE,OAAOC,SAAS,OAAOC,OAAOD,SAAS,CAAC,CAAC,EAAED,IAAI,EAAE,CAAC;IACpD;AACF"}
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { AglynPostalAddress } from '../foundation';
25
25
  import type { ContactCustomValue, ContactLifecycleStage } from './crm';
26
+ import type { EmailState } from './email-state';
26
27
  import { type MarketingBasis } from './marketing-consent';
27
28
  export type ContactSource = 'form' | 'member' | 'order' | 'booking' | 'newsletter' | 'api' | 'manual' | 'import' | 'account';
28
29
  /**
@@ -160,6 +161,13 @@ export interface HostContact {
160
161
  * holder's view of them.
161
162
  */
162
163
  nextTaskAtMs?: number | null;
164
+ /**
165
+ * The last verdict on the address (AGL-3245) — bounced, blocked,
166
+ * unsubscribed, do-not-contact — written by the platform's senders only,
167
+ * on the SHARED row because the address is shared. Absent means nothing
168
+ * is known. See `email-state.ts`.
169
+ */
170
+ emailState?: EmailState;
163
171
  }
164
172
  /** Timeline cap: keeps the doc small; older interactions age out. */
165
173
  export declare const CONTACT_INTERACTIONS_CAP = 50;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contacts.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 * Contacts CRM v1 (AGL-197): one contact per normalized email per host,\n * fed by the four existing capture silos (forms, members, orders,\n * bookings). Pure data module — the admin-SDK upsert lives in\n * `@aglyn/tenant-data-admin` (`upsertHostContact`) and the console reads\n * client-side under the host-admin rules.\n */\n\nimport type { AglynPostalAddress } from '../foundation'\nimport { consentGroupForHost } from './consent-groups'\nimport type { ContactCustomValue, ContactLifecycleStage } from './crm'\nimport {\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n readMarketingBasis,\n type MarketingBasis,\n} from './marketing-consent'\n\nexport type ContactSource =\n | 'form'\n | 'member'\n | 'order'\n | 'booking'\n | 'newsletter'\n // AGL-2276: `POST /v1/contacts`. A first-class source rather than a blank\n // one, because \"where did this person come from\" is the question the\n // console's source filter exists to answer, and an integration importing a\n // CRM is a different answer from a visitor filling in a form. Adding it to\n // the union is what makes `SOURCE_LABELS` and the filter cover it — a raw\n // string written past the type would render as `api` and match no filter.\n | 'api'\n // CRM v2 (AGL-2595): the two doors that are not a capture at all. A person\n // typed into the console by a member of the team, and a person who arrived\n // in a file. Both are first-class for the reason `api` is — a merchant\n // asking \"who did we add ourselves\" and \"who came from the spreadsheet\" is\n // asking the source filter, and a source the union does not name is one it\n // cannot answer for.\n | 'manual'\n | 'import'\n // AGL-3185: the platform's own console — an account created there, and\n // the product-updates decision that account makes at sign-up, in its\n // preferences or at the one-time prompt. First-class for the reason `api`\n // is: the operator's CRM asks \"who signed up for the product\", and\n // `member` is a SITE's member, which an account holder of the platform\n // is not.\n | 'account'\n\n/**\n * How a capture source reads on screen.\n *\n * Typed `Record<ContactSource, string>` so a source added to the union cannot\n * ship without a label, and kept beside the union rather than in the console\n * that first needed it: the contacts filter, the campaign audience and the\n * dynamic-list rule editor all name the same six sources, and three copies of\n * this map is three places for `order` to stop reading as \"Customer\".\n */\nexport const CONTACT_SOURCE_LABELS: Record<ContactSource, string> = {\n form: 'Form',\n member: 'Member',\n order: 'Customer',\n booking: 'Booking',\n newsletter: 'Newsletter',\n api: 'API',\n manual: 'Added by hand',\n import: 'Import',\n account: 'Account',\n}\n\nexport interface ContactInteraction {\n type: ContactSource\n /** Source doc id (formSubmissions/siteMembers/orders/bookings). */\n refId?: string\n /** Epoch millis — Timestamps don't serialize into arrays cleanly. */\n atMs: number\n summary?: string\n /**\n * WHICH SITE this interaction happened on.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one person, and splitting them into two rows would\n * bill the org twice for the same address and lose the dedupe that makes a\n * multi-brand account worth having. But the HISTORY on that shared row is\n * not shared: a booking made on one client's site is that client's, and a\n * timeline that cannot be split by site shows an agency's client the other\n * clients' activity.\n *\n * Optional because a row written before this field existed carries none,\n * and an interaction whose site is unknown must read as unattributed rather\n * than be assigned to whoever is looking. Nothing may infer it from the\n * contact's own capture attribution: `capturedByHostIds` says which sites\n * met this person, not which of them produced any particular visit.\n */\n hostId?: string\n /**\n * THE ENTRY POINT: the form this capture came in through.\n *\n * `sources` answers \"what KIND of surface met this person\" and is what the\n * console's capture filter reads; it cannot answer \"which form\", because\n * every form on the site sets the same `form` flag. The id rather than the\n * name, for the reason the submission carries the id: a form's caption is\n * editable and a stored name splits the moment somebody renames it.\n *\n * Beside {@link refId} rather than derived from it. The submission the\n * interaction names does carry the form, but reading it back would be a\n * document read per row of a timeline the console renders straight out of\n * the contact.\n */\n formId?: string\n /**\n * THE ENTRY POINT: the page the person was on when this happened.\n *\n * The same `path` the submission stores. It belongs on the interaction and\n * not on the contact, because a person who came in through the pricing page\n * and returned through a blog post has two entry points and one row —\n * a single field at the top would keep whichever capture wrote last.\n */\n path?: string\n}\n\n/** `orgs/{orgId}/contacts/{contactId}` doc shape. */\nexport interface HostContact {\n /** Normalized (trimmed, lowercased) email — the dedupe key. */\n email: string\n name?: string\n sources: Partial<Record<ContactSource, true>>\n /** Newest-first, capped — the profile timeline. */\n interactions: ContactInteraction[]\n tags?: string[]\n notes?: string\n /**\n * Every site that has captured this person, in no order.\n *\n * An ARRAY and a top-level field, because this is the one attribution that\n * has to survive being a QUERY: \"everyone captured on A, B or C\" is an\n * audience, and `array-contains-any` is what answers it. The per-interaction\n * host says which visit belonged to whom; this says which sites have a\n * relationship at all, and only the second shape can be filtered on in\n * Firestore.\n *\n * Grows by `arrayUnion` on every capture, including the ones that merge\n * onto an existing row — which is exactly what the create-only `hostId`\n * beside it could never do.\n */\n capturedByHostIds?: string[]\n /**\n * Every form this person has come in through, in no order (AGL-2612).\n *\n * The entry point proper is `interactions[].formId` inside the capturing\n * holder's facet, and that stays the record: which form produced which\n * visit is one holder's business. But Firestore cannot select documents\n * by a field of an array ELEMENT, and a facet path is per group, so\n * \"everyone who filled in this form\" — the question the form's own page\n * asks of the Contacts list — has to be a top-level array an\n * `array-contains` can hit, exactly as `capturedByHostIds` is for sites\n * and `companyIds` is for companies. A form id is minted under one site,\n * so a query on it is one site's captures by construction.\n *\n * Grows by `arrayUnion` on every form capture, create or merge, bounded at\n * {@link CONTACT_FORM_IDS_CAP}: a person who has filled in more forms than\n * that keeps the first twenty and the timeline keeps the rest. The facet is\n * the truth and this is its index — a reader answering \"which form\" reads\n * the interaction; only a QUERY reads this.\n */\n formIds?: string[]\n /*\n * SEARCH KEYS (AGL-2596), and nothing more.\n *\n * The console's global search reads a window of contact documents and\n * matches over their top-level fields; it never resolves a holder's facet,\n * because it is one search over a dozen collections and a facet read is a\n * contacts-only idea. So the two profile values a person is looked up BY —\n * their phone number and the company they work for — are mirrored here by\n * every writer that sets them on a facet, as plain values a query can hit.\n *\n * The FACET is the record. These are a last-writer-wins echo of it: two\n * holders who keep different numbers for one person each overwrite the\n * echo and each keep their own facet value, and nothing renders these to a\n * reader — the list, the record page and the export all read through the\n * viewing group's facet. `ORG_CONTACT_FIELDS` below is an allow-list, so\n * neither reaches the org view either.\n */\n /** E.164, mirrored from the last facet write that set one. */\n phone?: string\n /** The company name as typed, mirrored the same way. */\n companyName?: string\n /**\n * The OTHER addresses this person answers to (AGL-2625).\n *\n * A contact is keyed on one email, so one human with a work address and a\n * personal one is two documents until somebody merges them. The merge keeps\n * the survivor's `email` as the identity and records the merged record's\n * address here, so a later capture on it — a form filled in from the\n * personal account — resolves to this row rather than minting the second\n * document again. `emailIndex` (see {@link CONTACT_EMAIL_INDEX_COLLECTION})\n * is the lookup; this array is the record of why an entry there points\n * where it does, and what the record page shows as \"also\".\n *\n * Shared identity like `email`, not a facet field: which addresses are one\n * person is a fact about the person, and a holder that did not perform the\n * merge still needs its capture on the alternate address to land on the\n * shared row. Bounded at {@link CONTACT_ALTERNATE_EMAILS_CAP}.\n */\n alternateEmails?: string[]\n /**\n * When the earliest OPEN task filed against this person is due, epoch\n * ms, or `null` when none is (AGL-2661) — see `CrmDeal.nextTaskAtMs` in\n * `crm.ts` for the rule. On the SHARED row rather than a facet, because a\n * task lives at the organization level and names the contact, not a\n * holder's view of them.\n */\n nextTaskAtMs?: number | null\n}\n\n/** Timeline cap: keeps the doc small; older interactions age out. */\nexport const CONTACT_INTERACTIONS_CAP = 50\n\n/** The field holding {@link HostContact.alternateEmails}. */\nexport const CONTACT_ALTERNATE_EMAILS_FIELD = 'alternateEmails'\n\n/**\n * The most alternate addresses one contact carries. Ten is past any real\n * person's mailbox count, and it bounds the index writes one merge fans out.\n */\nexport const CONTACT_ALTERNATE_EMAILS_CAP = 10\n\n/**\n * `orgs/{orgId}/emailIndex/{personKey}` → `{ email, contactId }` (AGL-2625).\n *\n * The address-to-contact lookup the capture door consults BEFORE the\n * per-document `email ==` query, so an address recorded as an alternate on a\n * merged row resolves to the survivor. Keyed by `personKey` — the sha256 of\n * the normalized address — so a document id never carries the address\n * itself, the same derivation a lead and a list membership use.\n *\n * WRITTEN LAZILY, never backfilled: the query on `email` stays as the\n * fallback, and a contact that has no entry gets one on the next write that\n * finds it. A row the index has never seen therefore costs one extra read\n * once, and nothing needs to run over the existing collection.\n *\n * Server-written only; the rules close it to every client.\n */\nexport const CONTACT_EMAIL_INDEX_COLLECTION = 'emailIndex'\n\n/**\n * Every address a contact answers to — the primary first, then the\n * alternates — normalized and deduplicated, so an index writer and a lead\n * lookup walk one list.\n */\nexport function contactEmails(\n contact: Record<string, unknown> | null | undefined,\n): string[] {\n const out: string[] = []\n const seen = new Set<string>()\n const push = (value: unknown) => {\n const email = normalizeContactEmail(value)\n if (email && !seen.has(email)) {\n seen.add(email)\n out.push(email)\n }\n }\n push((contact ?? {})['email'])\n const alternates = (contact ?? {})[CONTACT_ALTERNATE_EMAILS_FIELD]\n if (Array.isArray(alternates)) alternates.forEach(push)\n return out\n}\n\n/** The top-level field naming every form a contact came in through — see `HostContact.formIds`. */\nexport const CONTACT_FORM_IDS_FIELD = 'formIds'\n\n/**\n * How many forms the {@link CONTACT_FORM_IDS_FIELD} mirror holds.\n *\n * The same figure as the tag cap: enough that no real person reaches it,\n * small enough that the array never becomes the document. Past it the\n * capture stops adding rather than dropping the oldest, because the mirror\n * is an index of the facet's timeline and an index that silently forgets an\n * entry the timeline still holds would answer a form's contact list wrongly\n * in the one direction nobody would notice.\n */\nexport const CONTACT_FORM_IDS_CAP = 20\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** Normalized dedupe key, or null when the input isn't a usable email. */\nexport function normalizeContactEmail(input: unknown): string | null {\n const email = String(input ?? '')\n .trim()\n .toLowerCase()\n return EMAIL_PATTERN.test(email) && email.length <= 320 ? email : null\n}\n\n/**\n * First usable email in a free-form form-submission fields map — forms\n * don't guarantee a canonical email field, so prefer keys that look like\n * email fields before falling back to any email-shaped value.\n */\nexport function extractEmailFromFields(\n fields: Record<string, unknown> | null | undefined,\n): string | null {\n const entries = Object.entries(fields ?? {})\n const preferred = entries.find(([key]) => /email/i.test(key))\n const fromPreferred = normalizeContactEmail(preferred?.[1])\n if (fromPreferred) return fromPreferred\n for (const [, value] of entries) {\n const email = normalizeContactEmail(value)\n if (email) return email\n }\n return null\n}\n\n/**\n * Merges a new interaction into a contact: source flag set, interaction\n * prepended, timeline capped. Pure — the caller persists the result.\n */\nexport function mergeContactInteraction(\n existing: Pick<HostContact, 'sources' | 'interactions'> & {\n name?: string\n },\n update: { source: ContactSource; interaction: ContactInteraction; name?: string },\n): Pick<HostContact, 'sources' | 'interactions'> & { name?: string } {\n return {\n // Existing names win — a later anonymous form shouldn't blank a name.\n name: existing.name || update.name,\n sources: { ...existing.sources, [update.source]: true as const },\n interactions: [update.interaction, ...(existing.interactions ?? [])].slice(\n 0,\n CONTACT_INTERACTIONS_CAP,\n ),\n }\n}\n\n/**\n * THE PER-HOLDER FACET on a shared contact document.\n *\n * ## What is shared and what is not\n *\n * One human who touched two sites is ONE document. That is not a compromise:\n * it is the dedupe the shared address book exists for, it is what keeps a\n * bounce and a suppression describing one person, and it is what makes a\n * multi-brand account bill once for one human instead of once per site. A\n * document per host would push an agency toward twelve separate accounts and\n * throw the whole advantage away.\n *\n * But almost nothing ON that document is legitimately shared.\n *\n * - **SHARED IDENTITY** — `email`, and the canonical `name`. One human, one\n * identity. This is the only part two unrelated businesses may both see,\n * and it is the minimum that makes them one row.\n * - **PER-HOLDER FACET** — everything in this interface. A note, a tag, a\n * call log and a lifetime value are the HOLDER's own business records.\n * Host A's note about a customer is Host A's; a competitor sharing the\n * platform must not read it, and while these lived at the top of the\n * document every site in the org could.\n * - **CONSENT AND VISIBILITY** — per declared group, decided by\n * `marketing-consent.ts` and by `visibleTo`.\n *\n * ## Keyed by GROUP, not by host\n *\n * A business that declared three sites to be one sender is one CRM: a note\n * written on the shop belongs beside the booking made on the booking page,\n * because one team keeps both. Across groups, never — and an undeclared site\n * is a group of one, so the agency case is isolated with nothing configured.\n *\n * ## Commercial figures are per-facet, and that is not a billing change\n *\n * `ltvCents` and `ordersCount` answer \"what is this person worth to ME\". Two\n * unrelated merchants who both sell to one person have two different answers\n * and neither is entitled to the other's. Billing counts DOCUMENTS, and there\n * is still one document, so a person held by two hosts stays one billable\n * contact.\n */\nexport interface ContactFacet {\n /**\n * This holder's display name for the person, overriding the canonical one.\n *\n * The canonical `name` is shared and mutable, so without this a rename on\n * one site changes what an unrelated business sees in its own CRM — two\n * companies that do not know each other exist editing each other's records.\n * The override is written by whoever edits the name from their own console;\n * the canonical field stays as the identity of last resort, for a holder\n * that has never set one.\n */\n name?: string\n /** Which capture surfaces THIS holder saw the person through. */\n sources: Partial<Record<ContactSource, true>>\n /** THIS holder's timeline, newest-first and capped. */\n interactions: ContactInteraction[]\n /** THIS holder's tags. */\n tags?: string[]\n /** THIS holder's notes. */\n notes?: string\n /**\n * The campaigns THIS holder has filed the person under.\n *\n * A label on the CRM record, not an audience: who a campaign's mail reaches\n * is decided by the campaign's lists and by each send's own picker, and\n * nothing in the send path reads this. It answers \"who did we put in the\n * spring push\", which is a question about the holder's own working set.\n *\n * Per-holder for the reason the notes are: two unrelated businesses sharing\n * one row must not read each other's segmentation of a person they both\n * know. See `campaign-membership.ts` for the field and the path.\n */\n campaignIds?: string[]\n /** Gross of fees and refunds — see `upsertHostContact`. */\n ltvCents?: number\n ordersCount?: number\n lastPurchaseAtMs?: number\n firstPurchaseAtMs?: number\n refundedCents?: number\n refundedOrdersCount?: number\n lastRefundAtMs?: number\n /*\n * CRM v2 (AGL-2595): the profile a sales team keeps on a person.\n *\n * Per-holder like everything above them, and for the same reason: a phone\n * number, a job title, an owner and a lifecycle stage are one business's\n * knowledge of a person, and two unrelated businesses sharing the row must\n * not read each other's. `companyId` points at a company document in the\n * same scope, never at the org's whole company list. `custom` is keyed by\n * `ContactFieldDefinition.key`, so a holder's field definitions and a\n * holder's values live under the same group and cannot be joined across\n * one.\n *\n * ⛔ None of these reach the org view. `ORG_CONTACT_FIELDS` is an\n * allow-list, and the org-view spec seeds every one of these with a\n * sentinel and proves it never surfaces.\n */\n /** E.164 — `normalizePhone` before writing. */\n phone?: string\n jobTitle?: string\n /** `orgs/{orgId}/companies/{companyId}`, in this holder's scope. */\n companyId?: string\n /**\n * The company's name as this holder knows it.\n *\n * Free text, so a person can be filed under a company before one exists\n * as a record; the company picker that later sets `companyId` writes the\n * picked company's name here too, so the row reads the same whether the\n * link was made or the name typed. The top-level `companyName` on the\n * document is the search echo of this value — see `HostContact`.\n */\n companyName?: string\n address?: AglynPostalAddress | null\n /** The team member responsible for the relationship. */\n ownerUid?: string\n lifecycleStage?: ContactLifecycleStage\n /** Custom field values, keyed by `ContactFieldDefinition.key`. */\n custom?: Record<string, ContactCustomValue>\n /**\n * When this person last OPENED or CLICKED a campaign sent by one of this\n * holder's sites, epoch ms (AGL-2616).\n *\n * Stamped by the delivery webhook, forward-only, on the first open and the\n * first click of each message — the same bound the per-person rollup on\n * `emailDeliveries/{key}` keeps, so a reader opening one newsletter six\n * times is one write. Per-holder because it answers \"is this person still\n * reading OUR mail\": the address-level rollup counts every sender's mail\n * and a sibling business's, and a re-engagement audience built on that\n * would mail people who are reading somebody else.\n *\n * Absent until the first engagement after the stamp shipped; a message\n * opened before then advanced the address-level rollup and nothing here.\n */\n lastEmailEngagementAtMs?: number\n /**\n * The org-library files THIS holder has attached to the person\n * (AGL-2662), by media document id.\n *\n * Per-holder for the reason every other field here is: an agency running\n * two client brands has one contact document between them, and a signed\n * contract one client filed is not the other client's to see. Ids rather\n * than URLs so a file moved between folders keeps its attachment, and so\n * a private asset is still served through the signed CDN door.\n */\n mediaIds?: string[]\n}\n\n/** The map field holding the facets: `{ [groupId]: ContactFacet }`. */\nexport const CONTACT_FACETS_FIELD = 'facets'\n\n/**\n * The facet one group holds, or an empty one.\n *\n * `map[groupId]` — a lookup, not a search, for the reason the consent map is\n * shaped the same way: there is no expression of this form that returns\n * another holder's notes.\n *\n * An absent facet reads EMPTY rather than falling back to the top-level\n * fields. A fallback would hand every holder in the org the fields the\n * pre-facet document carried at its top, which is the disclosure this shape\n * exists to end; the migration moves them into the capturing group's facet\n * instead.\n */\nexport function readContactFacet(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): ContactFacet {\n const facets = (contact ?? {})[CONTACT_FACETS_FIELD]\n const facet =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? (facets as Record<string, unknown>)[groupId]\n : undefined\n if (!facet || typeof facet !== 'object' || Array.isArray(facet)) {\n return { sources: {}, interactions: [] }\n }\n const value = facet as Record<string, unknown>\n return {\n ...(value as unknown as ContactFacet),\n sources: (value['sources'] ?? {}) as ContactFacet['sources'],\n interactions: Array.isArray(value['interactions'])\n ? (value['interactions'] as ContactInteraction[])\n : [],\n }\n}\n\n/** The dotted Firestore path to one field of one holder's facet. */\nexport function contactFacetPath(groupId: string, field: string): string {\n if (!groupId) throw new Error('a contact facet must name a holder')\n return `${CONTACT_FACETS_FIELD}.${groupId}.${field}`\n}\n\n/**\n * The name a given holder should SEE for this person.\n *\n * Their own override first, the canonical identity second. Nothing falls\n * through to another holder's override: that would be one business's edit\n * showing up in another's CRM, which is the thing the override exists to\n * prevent.\n */\nexport function contactDisplayName(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string {\n const facet = readContactFacet(contact, groupId)\n if (facet.name) return facet.name\n const canonical = (contact ?? {})['name']\n return typeof canonical === 'string' ? canonical : ''\n}\n\n/**\n * Every group that holds this contact — the reference count behind a detach.\n *\n * Read from `visibleTo` rather than from the facet map, because `visibleTo`\n * is what both enforcement layers actually evaluate: a holder that can still\n * READ the row is still holding it whether or not they ever wrote a note. A\n * count taken from the facets would drop a holder who has one and no facet\n * and leave them able to see a document nothing believes they hold.\n */\nexport function contactHolderTokens(\n contact: Record<string, unknown> | null | undefined,\n): string[] {\n const visibleTo = (contact ?? {})['visibleTo']\n return Array.isArray(visibleTo)\n ? visibleTo.filter((token): token is string => typeof token === 'string')\n : []\n}\n\n/** What a holder letting go of a contact should do to the document. */\nexport type ContactDetach =\n | {\n /** Nobody else holds it. Delete the document. */\n action: 'delete'\n }\n | {\n /** Other holders remain. Drop this one's half. */\n action: 'detach'\n /**\n * Field-level removals, as a patch. Dotted paths so the OTHER holders'\n * facets and consent entries are untouched — a nested write would\n * replace the whole map and take every other holder's records with it.\n */\n remove: string[]\n /** Scope tokens to pull out of `visibleTo`. */\n removeTokens: string[]\n /** Host ids to pull out of the capture attribution. */\n removeHostIds: string[]\n }\n\n/**\n * DELETE IS A DETACH.\n *\n * One holder removing a contact from their own CRM must not destroy another\n * holder's relationship with that person: they have their own notes, their\n * own order history and their own consent, none of which the deleting holder\n * ever had a claim on. So a delete drops the deleting group's facet, its\n * consent entries, its capture attribution and its scope tokens — and the\n * DOCUMENT dies only when the last holder lets go.\n *\n * ⛔ This is NOT the erasure path. A privacy erasure removes the person\n * everywhere regardless of how many holders remain, and routing it through\n * reference counting would turn a lawful erasure into a partial one — the same\n * absent-versus-invisible defect this area keeps hitting, in the one place\n * where getting it wrong is a legal failure rather than a bug. Erasure calls\n * the document delete directly and must never consult this function.\n *\n * @param group the holder letting go, with every site it covers.\n */\nexport function planContactDetach(\n contact: Record<string, unknown> | null | undefined,\n group: { groupId: string; hostIds: readonly string[] },\n): ContactDetach {\n const held = contactHolderTokens(contact)\n const leaving = new Set(group.hostIds.map((id) => `host:${id}`))\n /*\n * A remaining holder is a token naming somebody else. `'org'` counts as one\n * and blocks the delete on purpose: an org-wide row is held by every site\n * in the account, so one site letting go leaves it held. Narrowing it would\n * be a scope decision, and a delete button is not where that belongs.\n */\n const remaining = held.filter((token) => !leaving.has(token))\n if (!remaining.length) return { action: 'delete' }\n return {\n action: 'detach',\n remove: [\n `${CONTACT_FACETS_FIELD}.${group.groupId}`,\n ...group.hostIds.map((id) => `marketingConsentByHost.${id}`),\n ],\n removeTokens: [...leaving],\n removeHostIds: [...group.hostIds],\n }\n}\n\n/**\n * The interactions one group of sites may see on a shared contact.\n *\n * An interaction with NO host is shown to everyone: it predates the\n * attribution, so hiding it would empty every existing timeline, and it is\n * already visible to anyone who can read the row. An interaction that names a\n * site is shown only to that site's own group — the agency case, where one\n * client must not read another client's bookings off a person they both know.\n */\nexport function interactionsForGroup(\n interactions: readonly ContactInteraction[] | undefined,\n hostIds: readonly string[],\n): ContactInteraction[] {\n const reach = new Set(hostIds)\n return (interactions ?? []).filter(\n (interaction) => !interaction.hostId || reach.has(interaction.hostId),\n )\n}\n\n/** `orgs/{orgId}/contactSegments/{id}` — a saved audience filter. */\nexport interface ContactSegment {\n name: string\n /** Match contacts sharing at least one tag (empty = any). */\n tags?: string[]\n /** Match contacts with at least one of these sources (empty = any). */\n sources?: ContactSource[]\n}\n\n/** Segment matching (AGL-199): AND across filter kinds, OR within one. */\nexport function contactMatchesSegment(\n contact: Pick<HostContact, 'tags' | 'sources'>,\n segment: Pick<ContactSegment, 'tags' | 'sources'>,\n): boolean {\n if (segment.tags?.length) {\n const contactTags = new Set(\n (contact.tags ?? []).map((tag) => tag.toLowerCase()),\n )\n if (!segment.tags.some((tag) => contactTags.has(tag.toLowerCase()))) {\n return false\n }\n }\n if (segment.sources?.length) {\n if (!segment.sources.some((source) => contact.sources?.[source])) {\n return false\n }\n }\n return true\n}\n\n/*==========================================\n * THE ORG-LEVEL VIEW: the complement of the facet reader above.\n *\n * {@link readContactFacet} answers \"what does THIS holder know about this\n * person\". Everything below answers the question no host page can:\n *\n * who does this ORGANIZATION know, and which of its sites know them.\n *\n * That question is the entire justification for one shared document. Dedupe,\n * suppression and honest billing all rest on one human being one row, and\n * with no surface that shows the deduped person the model's benefit is\n * invisible and the billing unit — unique people per org — looks arbitrary.\n *\n * ## The org view crosses the host boundary, so it carries LESS, not more\n *\n * It is the one place in the product designed to read across holders, which\n * makes it the one place where a careless field re-creates the disclosure the\n * facets exist to end. So the projection is an ALLOW-LIST\n * ({@link ORG_CONTACT_FIELDS}) rather than an omission: identity, capture\n * attribution and consent state. Notes, tags, timelines, call logs and\n * commercial figures are the holder's own business records and are not read\n * here at all — not summarized, not counted, not indicated by their absence.\n *\n * An allow-list rather than a `delete facets` is the load-bearing choice. A\n * subtraction is correct only for as long as somebody remembers to extend it,\n * and a field added to {@link ContactFacet} tomorrow would ride out through a\n * subtraction and be caught by an allow-list.\n *\n * ## The NAME is the canonical one, never a holder's override\n *\n * {@link contactDisplayName} prefers the viewing group's own override, which\n * is right on a host page and wrong here: an override is a name ONE business\n * chose, and rendering it at org level shows an agency the label its client\n * picked. The shared identity is what every holder already sees, so it is the\n * only name that belongs to the org.\n *=========================================*/\n\n/**\n * Every field of a contact document the org view may read.\n *\n * Stated as a value so it can be asserted against — see the org-view spec,\n * which seeds each facet field with a sentinel and proves none of them\n * reaches a row.\n */\nexport const ORG_CONTACT_FIELDS = [\n 'email',\n 'name',\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n] as const\n\n/**\n * What one site's relationship with one person amounts to, at org level.\n *\n * Consent is per (contact, host-or-group) and there is no expression that\n * returns another controller's grant, so a row cannot carry one consent\n * verdict. It carries one PER CAPTURING SITE, each naming the controller it\n * was read for — a bare \"consented\" with nobody attached would be exactly the\n * confusion the per-brand model was built to prevent.\n */\nexport interface OrgContactHostConsent {\n /** The site this verdict answers about, and no other. */\n hostId: string\n /** The controller the basis runs to — the declared group, or the site. */\n groupId: string\n /**\n * The group's disclosed name, or `null` for a site that pools with nobody.\n *\n * `null` is not \"unknown\": it is the group of one, where the site's own\n * name is the disclosure and inventing a second would put a label in front\n * of a reader that nothing else uses.\n */\n groupName: string | null\n /** Whether the controller is a DECLARED group rather than the site alone. */\n declared: boolean\n /** `granted` | `declined` | `unrecorded` — never collapsed to a boolean. */\n basis: MarketingBasis\n}\n\n/** One person, as the organization may see them across all of its sites. */\nexport interface OrgContactRow {\n $id: string\n /** The dedupe key, and the reason two captures are one row. */\n email: string\n /** The SHARED identity. Never a holder's own override. */\n name: string\n /** Every site that has captured this person, sorted for a stable render. */\n capturedByHostIds: string[]\n /** One verdict per capturing site, in the same order. */\n consent: OrgContactHostConsent[]\n}\n\n/**\n * Every site that has captured this person.\n *\n * Sorted, because the array is maintained by `arrayUnion` and therefore\n * carries capture order, which is not an order anything should render: two\n * readers of the same row would see the sites in different places on the\n * screen depending on which site met the person first.\n */\nexport function contactCaptureHostIds(\n contact: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (contact ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return [\n ...new Set(\n raw\n .map((id) => String(id ?? '').trim())\n .filter((id): id is string => id !== ''),\n ),\n ].sort()\n}\n\n/**\n * One contact document, projected to what an ORG may see.\n *\n * @param org - the org document — the only input that can resolve a declared\n * consent group, and required for that reason. Without it every site reads\n * as a group of one, which understates the disclosure a person was given\n * and is the safe direction to be wrong in.\n */\nexport function orgContactRow(\n contact: Record<string, unknown> | null | undefined,\n contactId: string,\n org: Record<string, unknown> | null | undefined,\n): OrgContactRow {\n const record = contact ?? {}\n const capturedByHostIds = contactCaptureHostIds(record)\n return {\n $id: contactId,\n email: typeof record['email'] === 'string' ? record['email'] : '',\n name: typeof record['name'] === 'string' ? record['name'] : '',\n capturedByHostIds,\n consent: capturedByHostIds.map((hostId) => {\n /*\n * READ FOR THE SITE, one call per site. `readMarketingBasis` requires\n * the group it is being read for and has no default, so this cannot\n * accidentally report a grant that belongs to somebody else — the\n * argument is the guarantee, and passing the whole row through one\n * call would have had to invent a controller to ask about.\n */\n const group = consentGroupForHost(org, hostId)\n const { basis } = readMarketingBasis(record, group)\n return {\n hostId,\n groupId: group.groupId,\n groupName: group.name,\n declared: group.declared,\n basis,\n }\n }),\n }\n}\n\n/**\n * How a basis reads on screen.\n *\n * Three labels because there are three states. \"Opted in\" and \"No record\" are\n * not opposites and must never collapse into one negative: absence is the\n * commonest case by far — a record captured before the checkbox existed — and\n * showing it as a refusal would tell a merchant they may not mail somebody\n * who never said so.\n */\nexport const MARKETING_BASIS_LABELS: Record<MarketingBasis, string> = {\n granted: 'Opted in',\n declined: 'Opted out',\n unrecorded: 'No record',\n}\n\n/**\n * One consent verdict, WITH THE CONTROLLER IT RUNS TO, as one string.\n *\n * ⛔ There is no function here that returns a basis on its own, and that is\n * the point. A basis is per (person, controller): the same person can be\n * opted in to one brand in an org and opted out of another, so \"consented\"\n * with nobody attached is not a shortened truth, it is a different and false\n * claim — precisely the confusion the per-brand model was built to prevent.\n * Joining the two into a single value is what stops a caller rendering half\n * of it.\n *\n * The controller is the DECLARED GROUP when there is one, because that is who\n * the person was told they would hear from, and the site otherwise. A group\n * of one is named by the site, which is what the capture surface already\n * showed them.\n *\n * @param siteName - how this site reads — the caller resolves it, since the\n * contact document holds ids and not names. An empty one falls back to the\n * id: a site whose name cannot be resolved must still be NAMED, because\n * dropping it would understate which brands hold a relationship.\n */\nexport function orgContactConsentLabel(\n entry: OrgContactHostConsent,\n siteName?: string,\n): string {\n const controller = entry.declared\n ? (entry.groupName ?? entry.groupId)\n : siteName || entry.hostId\n return `${controller} · ${MARKETING_BASIS_LABELS[entry.basis]}`\n}\n"],"names":["consentGroupForHost","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","readMarketingBasis","CONTACT_SOURCE_LABELS","form","member","order","booking","newsletter","api","manual","import","account","CONTACT_INTERACTIONS_CAP","CONTACT_ALTERNATE_EMAILS_FIELD","CONTACT_ALTERNATE_EMAILS_CAP","CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","contact","out","seen","Set","push","value","email","normalizeContactEmail","has","add","alternates","Array","isArray","forEach","CONTACT_FORM_IDS_FIELD","CONTACT_FORM_IDS_CAP","EMAIL_PATTERN","input","String","trim","toLowerCase","test","length","extractEmailFromFields","fields","entries","Object","preferred","find","key","fromPreferred","mergeContactInteraction","existing","update","name","sources","source","interactions","interaction","slice","CONTACT_FACETS_FIELD","readContactFacet","groupId","facets","facet","undefined","contactFacetPath","field","Error","contactDisplayName","canonical","contactHolderTokens","visibleTo","filter","token","planContactDetach","group","held","leaving","hostIds","map","id","remaining","action","remove","removeTokens","removeHostIds","interactionsForGroup","reach","hostId","contactMatchesSegment","segment","tags","contactTags","tag","some","ORG_CONTACT_FIELDS","contactCaptureHostIds","raw","sort","orgContactRow","contactId","org","record","capturedByHostIds","$id","consent","basis","groupName","declared","MARKETING_BASIS_LABELS","granted","declined","unrecorded","orgContactConsentLabel","entry","siteName","controller"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contacts.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 * Contacts CRM v1 (AGL-197): one contact per normalized email per host,\n * fed by the four existing capture silos (forms, members, orders,\n * bookings). Pure data module — the admin-SDK upsert lives in\n * `@aglyn/tenant-data-admin` (`upsertHostContact`) and the console reads\n * client-side under the host-admin rules.\n */\n\nimport type { AglynPostalAddress } from '../foundation'\nimport { consentGroupForHost } from './consent-groups'\nimport type { ContactCustomValue, ContactLifecycleStage } from './crm'\nimport type { EmailState } from './email-state'\nimport {\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n readMarketingBasis,\n type MarketingBasis,\n} from './marketing-consent'\n\nexport type ContactSource =\n | 'form'\n | 'member'\n | 'order'\n | 'booking'\n | 'newsletter'\n // AGL-2276: `POST /v1/contacts`. A first-class source rather than a blank\n // one, because \"where did this person come from\" is the question the\n // console's source filter exists to answer, and an integration importing a\n // CRM is a different answer from a visitor filling in a form. Adding it to\n // the union is what makes `SOURCE_LABELS` and the filter cover it — a raw\n // string written past the type would render as `api` and match no filter.\n | 'api'\n // CRM v2 (AGL-2595): the two doors that are not a capture at all. A person\n // typed into the console by a member of the team, and a person who arrived\n // in a file. Both are first-class for the reason `api` is — a merchant\n // asking \"who did we add ourselves\" and \"who came from the spreadsheet\" is\n // asking the source filter, and a source the union does not name is one it\n // cannot answer for.\n | 'manual'\n | 'import'\n // AGL-3185: the platform's own console — an account created there, and\n // the product-updates decision that account makes at sign-up, in its\n // preferences or at the one-time prompt. First-class for the reason `api`\n // is: the operator's CRM asks \"who signed up for the product\", and\n // `member` is a SITE's member, which an account holder of the platform\n // is not.\n | 'account'\n\n/**\n * How a capture source reads on screen.\n *\n * Typed `Record<ContactSource, string>` so a source added to the union cannot\n * ship without a label, and kept beside the union rather than in the console\n * that first needed it: the contacts filter, the campaign audience and the\n * dynamic-list rule editor all name the same six sources, and three copies of\n * this map is three places for `order` to stop reading as \"Customer\".\n */\nexport const CONTACT_SOURCE_LABELS: Record<ContactSource, string> = {\n form: 'Form',\n member: 'Member',\n order: 'Customer',\n booking: 'Booking',\n newsletter: 'Newsletter',\n api: 'API',\n manual: 'Added by hand',\n import: 'Import',\n account: 'Account',\n}\n\nexport interface ContactInteraction {\n type: ContactSource\n /** Source doc id (formSubmissions/siteMembers/orders/bookings). */\n refId?: string\n /** Epoch millis — Timestamps don't serialize into arrays cleanly. */\n atMs: number\n summary?: string\n /**\n * WHICH SITE this interaction happened on.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one person, and splitting them into two rows would\n * bill the org twice for the same address and lose the dedupe that makes a\n * multi-brand account worth having. But the HISTORY on that shared row is\n * not shared: a booking made on one client's site is that client's, and a\n * timeline that cannot be split by site shows an agency's client the other\n * clients' activity.\n *\n * Optional because a row written before this field existed carries none,\n * and an interaction whose site is unknown must read as unattributed rather\n * than be assigned to whoever is looking. Nothing may infer it from the\n * contact's own capture attribution: `capturedByHostIds` says which sites\n * met this person, not which of them produced any particular visit.\n */\n hostId?: string\n /**\n * THE ENTRY POINT: the form this capture came in through.\n *\n * `sources` answers \"what KIND of surface met this person\" and is what the\n * console's capture filter reads; it cannot answer \"which form\", because\n * every form on the site sets the same `form` flag. The id rather than the\n * name, for the reason the submission carries the id: a form's caption is\n * editable and a stored name splits the moment somebody renames it.\n *\n * Beside {@link refId} rather than derived from it. The submission the\n * interaction names does carry the form, but reading it back would be a\n * document read per row of a timeline the console renders straight out of\n * the contact.\n */\n formId?: string\n /**\n * THE ENTRY POINT: the page the person was on when this happened.\n *\n * The same `path` the submission stores. It belongs on the interaction and\n * not on the contact, because a person who came in through the pricing page\n * and returned through a blog post has two entry points and one row —\n * a single field at the top would keep whichever capture wrote last.\n */\n path?: string\n}\n\n/** `orgs/{orgId}/contacts/{contactId}` doc shape. */\nexport interface HostContact {\n /** Normalized (trimmed, lowercased) email — the dedupe key. */\n email: string\n name?: string\n sources: Partial<Record<ContactSource, true>>\n /** Newest-first, capped — the profile timeline. */\n interactions: ContactInteraction[]\n tags?: string[]\n notes?: string\n /**\n * Every site that has captured this person, in no order.\n *\n * An ARRAY and a top-level field, because this is the one attribution that\n * has to survive being a QUERY: \"everyone captured on A, B or C\" is an\n * audience, and `array-contains-any` is what answers it. The per-interaction\n * host says which visit belonged to whom; this says which sites have a\n * relationship at all, and only the second shape can be filtered on in\n * Firestore.\n *\n * Grows by `arrayUnion` on every capture, including the ones that merge\n * onto an existing row — which is exactly what the create-only `hostId`\n * beside it could never do.\n */\n capturedByHostIds?: string[]\n /**\n * Every form this person has come in through, in no order (AGL-2612).\n *\n * The entry point proper is `interactions[].formId` inside the capturing\n * holder's facet, and that stays the record: which form produced which\n * visit is one holder's business. But Firestore cannot select documents\n * by a field of an array ELEMENT, and a facet path is per group, so\n * \"everyone who filled in this form\" — the question the form's own page\n * asks of the Contacts list — has to be a top-level array an\n * `array-contains` can hit, exactly as `capturedByHostIds` is for sites\n * and `companyIds` is for companies. A form id is minted under one site,\n * so a query on it is one site's captures by construction.\n *\n * Grows by `arrayUnion` on every form capture, create or merge, bounded at\n * {@link CONTACT_FORM_IDS_CAP}: a person who has filled in more forms than\n * that keeps the first twenty and the timeline keeps the rest. The facet is\n * the truth and this is its index — a reader answering \"which form\" reads\n * the interaction; only a QUERY reads this.\n */\n formIds?: string[]\n /*\n * SEARCH KEYS (AGL-2596), and nothing more.\n *\n * The console's global search reads a window of contact documents and\n * matches over their top-level fields; it never resolves a holder's facet,\n * because it is one search over a dozen collections and a facet read is a\n * contacts-only idea. So the two profile values a person is looked up BY —\n * their phone number and the company they work for — are mirrored here by\n * every writer that sets them on a facet, as plain values a query can hit.\n *\n * The FACET is the record. These are a last-writer-wins echo of it: two\n * holders who keep different numbers for one person each overwrite the\n * echo and each keep their own facet value, and nothing renders these to a\n * reader — the list, the record page and the export all read through the\n * viewing group's facet. `ORG_CONTACT_FIELDS` below is an allow-list, so\n * neither reaches the org view either.\n */\n /** E.164, mirrored from the last facet write that set one. */\n phone?: string\n /** The company name as typed, mirrored the same way. */\n companyName?: string\n /**\n * The OTHER addresses this person answers to (AGL-2625).\n *\n * A contact is keyed on one email, so one human with a work address and a\n * personal one is two documents until somebody merges them. The merge keeps\n * the survivor's `email` as the identity and records the merged record's\n * address here, so a later capture on it — a form filled in from the\n * personal account — resolves to this row rather than minting the second\n * document again. `emailIndex` (see {@link CONTACT_EMAIL_INDEX_COLLECTION})\n * is the lookup; this array is the record of why an entry there points\n * where it does, and what the record page shows as \"also\".\n *\n * Shared identity like `email`, not a facet field: which addresses are one\n * person is a fact about the person, and a holder that did not perform the\n * merge still needs its capture on the alternate address to land on the\n * shared row. Bounded at {@link CONTACT_ALTERNATE_EMAILS_CAP}.\n */\n alternateEmails?: string[]\n /**\n * When the earliest OPEN task filed against this person is due, epoch\n * ms, or `null` when none is (AGL-2661) — see `CrmDeal.nextTaskAtMs` in\n * `crm.ts` for the rule. On the SHARED row rather than a facet, because a\n * task lives at the organization level and names the contact, not a\n * holder's view of them.\n */\n nextTaskAtMs?: number | null\n /**\n * The last verdict on the address (AGL-3245) — bounced, blocked,\n * unsubscribed, do-not-contact — written by the platform's senders only,\n * on the SHARED row because the address is shared. Absent means nothing\n * is known. See `email-state.ts`.\n */\n emailState?: EmailState\n}\n\n/** Timeline cap: keeps the doc small; older interactions age out. */\nexport const CONTACT_INTERACTIONS_CAP = 50\n\n/** The field holding {@link HostContact.alternateEmails}. */\nexport const CONTACT_ALTERNATE_EMAILS_FIELD = 'alternateEmails'\n\n/**\n * The most alternate addresses one contact carries. Ten is past any real\n * person's mailbox count, and it bounds the index writes one merge fans out.\n */\nexport const CONTACT_ALTERNATE_EMAILS_CAP = 10\n\n/**\n * `orgs/{orgId}/emailIndex/{personKey}` → `{ email, contactId }` (AGL-2625).\n *\n * The address-to-contact lookup the capture door consults BEFORE the\n * per-document `email ==` query, so an address recorded as an alternate on a\n * merged row resolves to the survivor. Keyed by `personKey` — the sha256 of\n * the normalized address — so a document id never carries the address\n * itself, the same derivation a lead and a list membership use.\n *\n * WRITTEN LAZILY, never backfilled: the query on `email` stays as the\n * fallback, and a contact that has no entry gets one on the next write that\n * finds it. A row the index has never seen therefore costs one extra read\n * once, and nothing needs to run over the existing collection.\n *\n * Server-written only; the rules close it to every client.\n */\nexport const CONTACT_EMAIL_INDEX_COLLECTION = 'emailIndex'\n\n/**\n * Every address a contact answers to — the primary first, then the\n * alternates — normalized and deduplicated, so an index writer and a lead\n * lookup walk one list.\n */\nexport function contactEmails(\n contact: Record<string, unknown> | null | undefined,\n): string[] {\n const out: string[] = []\n const seen = new Set<string>()\n const push = (value: unknown) => {\n const email = normalizeContactEmail(value)\n if (email && !seen.has(email)) {\n seen.add(email)\n out.push(email)\n }\n }\n push((contact ?? {})['email'])\n const alternates = (contact ?? {})[CONTACT_ALTERNATE_EMAILS_FIELD]\n if (Array.isArray(alternates)) alternates.forEach(push)\n return out\n}\n\n/** The top-level field naming every form a contact came in through — see `HostContact.formIds`. */\nexport const CONTACT_FORM_IDS_FIELD = 'formIds'\n\n/**\n * How many forms the {@link CONTACT_FORM_IDS_FIELD} mirror holds.\n *\n * The same figure as the tag cap: enough that no real person reaches it,\n * small enough that the array never becomes the document. Past it the\n * capture stops adding rather than dropping the oldest, because the mirror\n * is an index of the facet's timeline and an index that silently forgets an\n * entry the timeline still holds would answer a form's contact list wrongly\n * in the one direction nobody would notice.\n */\nexport const CONTACT_FORM_IDS_CAP = 20\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** Normalized dedupe key, or null when the input isn't a usable email. */\nexport function normalizeContactEmail(input: unknown): string | null {\n const email = String(input ?? '')\n .trim()\n .toLowerCase()\n return EMAIL_PATTERN.test(email) && email.length <= 320 ? email : null\n}\n\n/**\n * First usable email in a free-form form-submission fields map — forms\n * don't guarantee a canonical email field, so prefer keys that look like\n * email fields before falling back to any email-shaped value.\n */\nexport function extractEmailFromFields(\n fields: Record<string, unknown> | null | undefined,\n): string | null {\n const entries = Object.entries(fields ?? {})\n const preferred = entries.find(([key]) => /email/i.test(key))\n const fromPreferred = normalizeContactEmail(preferred?.[1])\n if (fromPreferred) return fromPreferred\n for (const [, value] of entries) {\n const email = normalizeContactEmail(value)\n if (email) return email\n }\n return null\n}\n\n/**\n * Merges a new interaction into a contact: source flag set, interaction\n * prepended, timeline capped. Pure — the caller persists the result.\n */\nexport function mergeContactInteraction(\n existing: Pick<HostContact, 'sources' | 'interactions'> & {\n name?: string\n },\n update: { source: ContactSource; interaction: ContactInteraction; name?: string },\n): Pick<HostContact, 'sources' | 'interactions'> & { name?: string } {\n return {\n // Existing names win — a later anonymous form shouldn't blank a name.\n name: existing.name || update.name,\n sources: { ...existing.sources, [update.source]: true as const },\n interactions: [update.interaction, ...(existing.interactions ?? [])].slice(\n 0,\n CONTACT_INTERACTIONS_CAP,\n ),\n }\n}\n\n/**\n * THE PER-HOLDER FACET on a shared contact document.\n *\n * ## What is shared and what is not\n *\n * One human who touched two sites is ONE document. That is not a compromise:\n * it is the dedupe the shared address book exists for, it is what keeps a\n * bounce and a suppression describing one person, and it is what makes a\n * multi-brand account bill once for one human instead of once per site. A\n * document per host would push an agency toward twelve separate accounts and\n * throw the whole advantage away.\n *\n * But almost nothing ON that document is legitimately shared.\n *\n * - **SHARED IDENTITY** — `email`, and the canonical `name`. One human, one\n * identity. This is the only part two unrelated businesses may both see,\n * and it is the minimum that makes them one row.\n * - **PER-HOLDER FACET** — everything in this interface. A note, a tag, a\n * call log and a lifetime value are the HOLDER's own business records.\n * Host A's note about a customer is Host A's; a competitor sharing the\n * platform must not read it, and while these lived at the top of the\n * document every site in the org could.\n * - **CONSENT AND VISIBILITY** — per declared group, decided by\n * `marketing-consent.ts` and by `visibleTo`.\n *\n * ## Keyed by GROUP, not by host\n *\n * A business that declared three sites to be one sender is one CRM: a note\n * written on the shop belongs beside the booking made on the booking page,\n * because one team keeps both. Across groups, never — and an undeclared site\n * is a group of one, so the agency case is isolated with nothing configured.\n *\n * ## Commercial figures are per-facet, and that is not a billing change\n *\n * `ltvCents` and `ordersCount` answer \"what is this person worth to ME\". Two\n * unrelated merchants who both sell to one person have two different answers\n * and neither is entitled to the other's. Billing counts DOCUMENTS, and there\n * is still one document, so a person held by two hosts stays one billable\n * contact.\n */\nexport interface ContactFacet {\n /**\n * This holder's display name for the person, overriding the canonical one.\n *\n * The canonical `name` is shared and mutable, so without this a rename on\n * one site changes what an unrelated business sees in its own CRM — two\n * companies that do not know each other exist editing each other's records.\n * The override is written by whoever edits the name from their own console;\n * the canonical field stays as the identity of last resort, for a holder\n * that has never set one.\n */\n name?: string\n /** Which capture surfaces THIS holder saw the person through. */\n sources: Partial<Record<ContactSource, true>>\n /** THIS holder's timeline, newest-first and capped. */\n interactions: ContactInteraction[]\n /** THIS holder's tags. */\n tags?: string[]\n /** THIS holder's notes. */\n notes?: string\n /**\n * The campaigns THIS holder has filed the person under.\n *\n * A label on the CRM record, not an audience: who a campaign's mail reaches\n * is decided by the campaign's lists and by each send's own picker, and\n * nothing in the send path reads this. It answers \"who did we put in the\n * spring push\", which is a question about the holder's own working set.\n *\n * Per-holder for the reason the notes are: two unrelated businesses sharing\n * one row must not read each other's segmentation of a person they both\n * know. See `campaign-membership.ts` for the field and the path.\n */\n campaignIds?: string[]\n /** Gross of fees and refunds — see `upsertHostContact`. */\n ltvCents?: number\n ordersCount?: number\n lastPurchaseAtMs?: number\n firstPurchaseAtMs?: number\n refundedCents?: number\n refundedOrdersCount?: number\n lastRefundAtMs?: number\n /*\n * CRM v2 (AGL-2595): the profile a sales team keeps on a person.\n *\n * Per-holder like everything above them, and for the same reason: a phone\n * number, a job title, an owner and a lifecycle stage are one business's\n * knowledge of a person, and two unrelated businesses sharing the row must\n * not read each other's. `companyId` points at a company document in the\n * same scope, never at the org's whole company list. `custom` is keyed by\n * `ContactFieldDefinition.key`, so a holder's field definitions and a\n * holder's values live under the same group and cannot be joined across\n * one.\n *\n * ⛔ None of these reach the org view. `ORG_CONTACT_FIELDS` is an\n * allow-list, and the org-view spec seeds every one of these with a\n * sentinel and proves it never surfaces.\n */\n /** E.164 — `normalizePhone` before writing. */\n phone?: string\n jobTitle?: string\n /** `orgs/{orgId}/companies/{companyId}`, in this holder's scope. */\n companyId?: string\n /**\n * The company's name as this holder knows it.\n *\n * Free text, so a person can be filed under a company before one exists\n * as a record; the company picker that later sets `companyId` writes the\n * picked company's name here too, so the row reads the same whether the\n * link was made or the name typed. The top-level `companyName` on the\n * document is the search echo of this value — see `HostContact`.\n */\n companyName?: string\n address?: AglynPostalAddress | null\n /** The team member responsible for the relationship. */\n ownerUid?: string\n lifecycleStage?: ContactLifecycleStage\n /** Custom field values, keyed by `ContactFieldDefinition.key`. */\n custom?: Record<string, ContactCustomValue>\n /**\n * When this person last OPENED or CLICKED a campaign sent by one of this\n * holder's sites, epoch ms (AGL-2616).\n *\n * Stamped by the delivery webhook, forward-only, on the first open and the\n * first click of each message — the same bound the per-person rollup on\n * `emailDeliveries/{key}` keeps, so a reader opening one newsletter six\n * times is one write. Per-holder because it answers \"is this person still\n * reading OUR mail\": the address-level rollup counts every sender's mail\n * and a sibling business's, and a re-engagement audience built on that\n * would mail people who are reading somebody else.\n *\n * Absent until the first engagement after the stamp shipped; a message\n * opened before then advanced the address-level rollup and nothing here.\n */\n lastEmailEngagementAtMs?: number\n /**\n * The org-library files THIS holder has attached to the person\n * (AGL-2662), by media document id.\n *\n * Per-holder for the reason every other field here is: an agency running\n * two client brands has one contact document between them, and a signed\n * contract one client filed is not the other client's to see. Ids rather\n * than URLs so a file moved between folders keeps its attachment, and so\n * a private asset is still served through the signed CDN door.\n */\n mediaIds?: string[]\n}\n\n/** The map field holding the facets: `{ [groupId]: ContactFacet }`. */\nexport const CONTACT_FACETS_FIELD = 'facets'\n\n/**\n * The facet one group holds, or an empty one.\n *\n * `map[groupId]` — a lookup, not a search, for the reason the consent map is\n * shaped the same way: there is no expression of this form that returns\n * another holder's notes.\n *\n * An absent facet reads EMPTY rather than falling back to the top-level\n * fields. A fallback would hand every holder in the org the fields the\n * pre-facet document carried at its top, which is the disclosure this shape\n * exists to end; the migration moves them into the capturing group's facet\n * instead.\n */\nexport function readContactFacet(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): ContactFacet {\n const facets = (contact ?? {})[CONTACT_FACETS_FIELD]\n const facet =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? (facets as Record<string, unknown>)[groupId]\n : undefined\n if (!facet || typeof facet !== 'object' || Array.isArray(facet)) {\n return { sources: {}, interactions: [] }\n }\n const value = facet as Record<string, unknown>\n return {\n ...(value as unknown as ContactFacet),\n sources: (value['sources'] ?? {}) as ContactFacet['sources'],\n interactions: Array.isArray(value['interactions'])\n ? (value['interactions'] as ContactInteraction[])\n : [],\n }\n}\n\n/** The dotted Firestore path to one field of one holder's facet. */\nexport function contactFacetPath(groupId: string, field: string): string {\n if (!groupId) throw new Error('a contact facet must name a holder')\n return `${CONTACT_FACETS_FIELD}.${groupId}.${field}`\n}\n\n/**\n * The name a given holder should SEE for this person.\n *\n * Their own override first, the canonical identity second. Nothing falls\n * through to another holder's override: that would be one business's edit\n * showing up in another's CRM, which is the thing the override exists to\n * prevent.\n */\nexport function contactDisplayName(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string {\n const facet = readContactFacet(contact, groupId)\n if (facet.name) return facet.name\n const canonical = (contact ?? {})['name']\n return typeof canonical === 'string' ? canonical : ''\n}\n\n/**\n * Every group that holds this contact — the reference count behind a detach.\n *\n * Read from `visibleTo` rather than from the facet map, because `visibleTo`\n * is what both enforcement layers actually evaluate: a holder that can still\n * READ the row is still holding it whether or not they ever wrote a note. A\n * count taken from the facets would drop a holder who has one and no facet\n * and leave them able to see a document nothing believes they hold.\n */\nexport function contactHolderTokens(\n contact: Record<string, unknown> | null | undefined,\n): string[] {\n const visibleTo = (contact ?? {})['visibleTo']\n return Array.isArray(visibleTo)\n ? visibleTo.filter((token): token is string => typeof token === 'string')\n : []\n}\n\n/** What a holder letting go of a contact should do to the document. */\nexport type ContactDetach =\n | {\n /** Nobody else holds it. Delete the document. */\n action: 'delete'\n }\n | {\n /** Other holders remain. Drop this one's half. */\n action: 'detach'\n /**\n * Field-level removals, as a patch. Dotted paths so the OTHER holders'\n * facets and consent entries are untouched — a nested write would\n * replace the whole map and take every other holder's records with it.\n */\n remove: string[]\n /** Scope tokens to pull out of `visibleTo`. */\n removeTokens: string[]\n /** Host ids to pull out of the capture attribution. */\n removeHostIds: string[]\n }\n\n/**\n * DELETE IS A DETACH.\n *\n * One holder removing a contact from their own CRM must not destroy another\n * holder's relationship with that person: they have their own notes, their\n * own order history and their own consent, none of which the deleting holder\n * ever had a claim on. So a delete drops the deleting group's facet, its\n * consent entries, its capture attribution and its scope tokens — and the\n * DOCUMENT dies only when the last holder lets go.\n *\n * ⛔ This is NOT the erasure path. A privacy erasure removes the person\n * everywhere regardless of how many holders remain, and routing it through\n * reference counting would turn a lawful erasure into a partial one — the same\n * absent-versus-invisible defect this area keeps hitting, in the one place\n * where getting it wrong is a legal failure rather than a bug. Erasure calls\n * the document delete directly and must never consult this function.\n *\n * @param group the holder letting go, with every site it covers.\n */\nexport function planContactDetach(\n contact: Record<string, unknown> | null | undefined,\n group: { groupId: string; hostIds: readonly string[] },\n): ContactDetach {\n const held = contactHolderTokens(contact)\n const leaving = new Set(group.hostIds.map((id) => `host:${id}`))\n /*\n * A remaining holder is a token naming somebody else. `'org'` counts as one\n * and blocks the delete on purpose: an org-wide row is held by every site\n * in the account, so one site letting go leaves it held. Narrowing it would\n * be a scope decision, and a delete button is not where that belongs.\n */\n const remaining = held.filter((token) => !leaving.has(token))\n if (!remaining.length) return { action: 'delete' }\n return {\n action: 'detach',\n remove: [\n `${CONTACT_FACETS_FIELD}.${group.groupId}`,\n ...group.hostIds.map((id) => `marketingConsentByHost.${id}`),\n ],\n removeTokens: [...leaving],\n removeHostIds: [...group.hostIds],\n }\n}\n\n/**\n * The interactions one group of sites may see on a shared contact.\n *\n * An interaction with NO host is shown to everyone: it predates the\n * attribution, so hiding it would empty every existing timeline, and it is\n * already visible to anyone who can read the row. An interaction that names a\n * site is shown only to that site's own group — the agency case, where one\n * client must not read another client's bookings off a person they both know.\n */\nexport function interactionsForGroup(\n interactions: readonly ContactInteraction[] | undefined,\n hostIds: readonly string[],\n): ContactInteraction[] {\n const reach = new Set(hostIds)\n return (interactions ?? []).filter(\n (interaction) => !interaction.hostId || reach.has(interaction.hostId),\n )\n}\n\n/** `orgs/{orgId}/contactSegments/{id}` — a saved audience filter. */\nexport interface ContactSegment {\n name: string\n /** Match contacts sharing at least one tag (empty = any). */\n tags?: string[]\n /** Match contacts with at least one of these sources (empty = any). */\n sources?: ContactSource[]\n}\n\n/** Segment matching (AGL-199): AND across filter kinds, OR within one. */\nexport function contactMatchesSegment(\n contact: Pick<HostContact, 'tags' | 'sources'>,\n segment: Pick<ContactSegment, 'tags' | 'sources'>,\n): boolean {\n if (segment.tags?.length) {\n const contactTags = new Set(\n (contact.tags ?? []).map((tag) => tag.toLowerCase()),\n )\n if (!segment.tags.some((tag) => contactTags.has(tag.toLowerCase()))) {\n return false\n }\n }\n if (segment.sources?.length) {\n if (!segment.sources.some((source) => contact.sources?.[source])) {\n return false\n }\n }\n return true\n}\n\n/*==========================================\n * THE ORG-LEVEL VIEW: the complement of the facet reader above.\n *\n * {@link readContactFacet} answers \"what does THIS holder know about this\n * person\". Everything below answers the question no host page can:\n *\n * who does this ORGANIZATION know, and which of its sites know them.\n *\n * That question is the entire justification for one shared document. Dedupe,\n * suppression and honest billing all rest on one human being one row, and\n * with no surface that shows the deduped person the model's benefit is\n * invisible and the billing unit — unique people per org — looks arbitrary.\n *\n * ## The org view crosses the host boundary, so it carries LESS, not more\n *\n * It is the one place in the product designed to read across holders, which\n * makes it the one place where a careless field re-creates the disclosure the\n * facets exist to end. So the projection is an ALLOW-LIST\n * ({@link ORG_CONTACT_FIELDS}) rather than an omission: identity, capture\n * attribution and consent state. Notes, tags, timelines, call logs and\n * commercial figures are the holder's own business records and are not read\n * here at all — not summarized, not counted, not indicated by their absence.\n *\n * An allow-list rather than a `delete facets` is the load-bearing choice. A\n * subtraction is correct only for as long as somebody remembers to extend it,\n * and a field added to {@link ContactFacet} tomorrow would ride out through a\n * subtraction and be caught by an allow-list.\n *\n * ## The NAME is the canonical one, never a holder's override\n *\n * {@link contactDisplayName} prefers the viewing group's own override, which\n * is right on a host page and wrong here: an override is a name ONE business\n * chose, and rendering it at org level shows an agency the label its client\n * picked. The shared identity is what every holder already sees, so it is the\n * only name that belongs to the org.\n *=========================================*/\n\n/**\n * Every field of a contact document the org view may read.\n *\n * Stated as a value so it can be asserted against — see the org-view spec,\n * which seeds each facet field with a sentinel and proves none of them\n * reaches a row.\n */\nexport const ORG_CONTACT_FIELDS = [\n 'email',\n 'name',\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n] as const\n\n/**\n * What one site's relationship with one person amounts to, at org level.\n *\n * Consent is per (contact, host-or-group) and there is no expression that\n * returns another controller's grant, so a row cannot carry one consent\n * verdict. It carries one PER CAPTURING SITE, each naming the controller it\n * was read for — a bare \"consented\" with nobody attached would be exactly the\n * confusion the per-brand model was built to prevent.\n */\nexport interface OrgContactHostConsent {\n /** The site this verdict answers about, and no other. */\n hostId: string\n /** The controller the basis runs to — the declared group, or the site. */\n groupId: string\n /**\n * The group's disclosed name, or `null` for a site that pools with nobody.\n *\n * `null` is not \"unknown\": it is the group of one, where the site's own\n * name is the disclosure and inventing a second would put a label in front\n * of a reader that nothing else uses.\n */\n groupName: string | null\n /** Whether the controller is a DECLARED group rather than the site alone. */\n declared: boolean\n /** `granted` | `declined` | `unrecorded` — never collapsed to a boolean. */\n basis: MarketingBasis\n}\n\n/** One person, as the organization may see them across all of its sites. */\nexport interface OrgContactRow {\n $id: string\n /** The dedupe key, and the reason two captures are one row. */\n email: string\n /** The SHARED identity. Never a holder's own override. */\n name: string\n /** Every site that has captured this person, sorted for a stable render. */\n capturedByHostIds: string[]\n /** One verdict per capturing site, in the same order. */\n consent: OrgContactHostConsent[]\n}\n\n/**\n * Every site that has captured this person.\n *\n * Sorted, because the array is maintained by `arrayUnion` and therefore\n * carries capture order, which is not an order anything should render: two\n * readers of the same row would see the sites in different places on the\n * screen depending on which site met the person first.\n */\nexport function contactCaptureHostIds(\n contact: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (contact ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return [\n ...new Set(\n raw\n .map((id) => String(id ?? '').trim())\n .filter((id): id is string => id !== ''),\n ),\n ].sort()\n}\n\n/**\n * One contact document, projected to what an ORG may see.\n *\n * @param org - the org document — the only input that can resolve a declared\n * consent group, and required for that reason. Without it every site reads\n * as a group of one, which understates the disclosure a person was given\n * and is the safe direction to be wrong in.\n */\nexport function orgContactRow(\n contact: Record<string, unknown> | null | undefined,\n contactId: string,\n org: Record<string, unknown> | null | undefined,\n): OrgContactRow {\n const record = contact ?? {}\n const capturedByHostIds = contactCaptureHostIds(record)\n return {\n $id: contactId,\n email: typeof record['email'] === 'string' ? record['email'] : '',\n name: typeof record['name'] === 'string' ? record['name'] : '',\n capturedByHostIds,\n consent: capturedByHostIds.map((hostId) => {\n /*\n * READ FOR THE SITE, one call per site. `readMarketingBasis` requires\n * the group it is being read for and has no default, so this cannot\n * accidentally report a grant that belongs to somebody else — the\n * argument is the guarantee, and passing the whole row through one\n * call would have had to invent a controller to ask about.\n */\n const group = consentGroupForHost(org, hostId)\n const { basis } = readMarketingBasis(record, group)\n return {\n hostId,\n groupId: group.groupId,\n groupName: group.name,\n declared: group.declared,\n basis,\n }\n }),\n }\n}\n\n/**\n * How a basis reads on screen.\n *\n * Three labels because there are three states. \"Opted in\" and \"No record\" are\n * not opposites and must never collapse into one negative: absence is the\n * commonest case by far — a record captured before the checkbox existed — and\n * showing it as a refusal would tell a merchant they may not mail somebody\n * who never said so.\n */\nexport const MARKETING_BASIS_LABELS: Record<MarketingBasis, string> = {\n granted: 'Opted in',\n declined: 'Opted out',\n unrecorded: 'No record',\n}\n\n/**\n * One consent verdict, WITH THE CONTROLLER IT RUNS TO, as one string.\n *\n * ⛔ There is no function here that returns a basis on its own, and that is\n * the point. A basis is per (person, controller): the same person can be\n * opted in to one brand in an org and opted out of another, so \"consented\"\n * with nobody attached is not a shortened truth, it is a different and false\n * claim — precisely the confusion the per-brand model was built to prevent.\n * Joining the two into a single value is what stops a caller rendering half\n * of it.\n *\n * The controller is the DECLARED GROUP when there is one, because that is who\n * the person was told they would hear from, and the site otherwise. A group\n * of one is named by the site, which is what the capture surface already\n * showed them.\n *\n * @param siteName - how this site reads — the caller resolves it, since the\n * contact document holds ids and not names. An empty one falls back to the\n * id: a site whose name cannot be resolved must still be NAMED, because\n * dropping it would understate which brands hold a relationship.\n */\nexport function orgContactConsentLabel(\n entry: OrgContactHostConsent,\n siteName?: string,\n): string {\n const controller = entry.declared\n ? (entry.groupName ?? entry.groupId)\n : siteName || entry.hostId\n return `${controller} · ${MARKETING_BASIS_LABELS[entry.basis]}`\n}\n"],"names":["consentGroupForHost","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","readMarketingBasis","CONTACT_SOURCE_LABELS","form","member","order","booking","newsletter","api","manual","import","account","CONTACT_INTERACTIONS_CAP","CONTACT_ALTERNATE_EMAILS_FIELD","CONTACT_ALTERNATE_EMAILS_CAP","CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","contact","out","seen","Set","push","value","email","normalizeContactEmail","has","add","alternates","Array","isArray","forEach","CONTACT_FORM_IDS_FIELD","CONTACT_FORM_IDS_CAP","EMAIL_PATTERN","input","String","trim","toLowerCase","test","length","extractEmailFromFields","fields","entries","Object","preferred","find","key","fromPreferred","mergeContactInteraction","existing","update","name","sources","source","interactions","interaction","slice","CONTACT_FACETS_FIELD","readContactFacet","groupId","facets","facet","undefined","contactFacetPath","field","Error","contactDisplayName","canonical","contactHolderTokens","visibleTo","filter","token","planContactDetach","group","held","leaving","hostIds","map","id","remaining","action","remove","removeTokens","removeHostIds","interactionsForGroup","reach","hostId","contactMatchesSegment","segment","tags","contactTags","tag","some","ORG_CONTACT_FIELDS","contactCaptureHostIds","raw","sort","orgContactRow","contactId","org","record","capturedByHostIds","$id","consent","basis","groupName","declared","MARKETING_BASIS_LABELS","granted","declined","unrecorded","orgContactConsentLabel","entry","siteName","controller"],"mappings":"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"}
@@ -47,6 +47,7 @@ import { type AglynPostalAddress, type OrgCrmAssignmentRule } from '../foundatio
47
47
  import { type ConsentGroup } from './consent-groups';
48
48
  import { type ContactLifecycleStage, type CrmActivityKind, type CrmTaskKind } from './crm-kinds';
49
49
  import { type ContactInteraction, type ContactSegment, type ContactSource } from './contacts';
50
+ import type { EmailState } from './email-state';
50
51
  import { type ScopeToken } from './scope-tokens';
51
52
  export * from './crm-kinds';
52
53
  /**
@@ -604,6 +605,12 @@ export interface CrmActivity extends CrmScoped {
604
605
  deliveryState?: CrmEmailDeliveryState;
605
606
  /** When the delivery state last moved, epoch ms. */
606
607
  deliveryAtMs?: number;
608
+ /**
609
+ * What the receiving server said when the state moved to a failure
610
+ * (AGL-3245): a bounce's diagnostic, scrubbed of the address. Absent on a
611
+ * progression state.
612
+ */
613
+ deliveryDetail?: string;
607
614
  /**
608
615
  * `hosts/{hostId}/bookings/{bookingId}` — the booking a `meeting` was
609
616
  * filed from (AGL-2660), so a row the Bookings plugin wrote can be told
@@ -1352,6 +1359,20 @@ export interface CrmLeadFields extends CrmLeadProfile {
1352
1359
  dealId?: string;
1353
1360
  /** The company the conversion created or linked, when it named one. */
1354
1361
  companyId?: string;
1362
+ /**
1363
+ * The last verdict on the lead's address (AGL-3245) — bounced, blocked,
1364
+ * unsubscribed, do-not-contact — written by the platform's senders only,
1365
+ * never by a form or the lead's editor. Absent means nothing is known.
1366
+ */
1367
+ emailState?: EmailState;
1368
+ /**
1369
+ * The campaigns the lead is filed under (AGL-3254): container ids from
1370
+ * `hosts/{hostId}/emailCampaigns`, at the top of the document the way a
1371
+ * form carries them (`campaign-membership.ts`), never names. Written by
1372
+ * the New lead drawer, the import, the bulk bar and a sequence's enroll;
1373
+ * handed to the contact's facet when the lead converts.
1374
+ */
1375
+ campaignIds?: string[];
1355
1376
  }
1356
1377
  /** The longest text one profile field holds — the contact's own cap. */
1357
1378
  export declare const CRM_LEAD_TEXT_MAX = 120;