@aglyn/aglyn 1.0.0-beta.147 → 1.0.0-beta.150

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 (54) hide show
  1. package/package.json +10 -10
  2. package/src/lib/app-utils/binding-token-catalog.js +9 -2
  3. package/src/lib/app-utils/binding-token-catalog.js.map +1 -1
  4. package/src/lib/app-utils/collection-entries.d.ts +52 -3
  5. package/src/lib/app-utils/collection-entries.js +98 -9
  6. package/src/lib/app-utils/collection-entries.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +2 -34
  8. package/src/lib/app-utils/console-routes.js +29 -11
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contexts.d.ts +1 -0
  11. package/src/lib/app-utils/contexts.js +1 -0
  12. package/src/lib/app-utils/contexts.js.map +1 -1
  13. package/src/lib/app-utils/docs-help.generated.d.ts +13 -1
  14. package/src/lib/app-utils/docs-help.generated.js +37 -0
  15. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  16. package/src/lib/app-utils/docs-index.generated.js +1 -1
  17. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  18. package/src/lib/app-utils/media-src.d.ts +93 -0
  19. package/src/lib/app-utils/media-src.js +114 -0
  20. package/src/lib/app-utils/media-src.js.map +1 -0
  21. package/src/lib/app-utils/notifications.d.ts +136 -2
  22. package/src/lib/app-utils/notifications.js +139 -3
  23. package/src/lib/app-utils/notifications.js.map +1 -1
  24. package/src/lib/app-utils/page-header-help-context.d.ts +83 -0
  25. package/src/lib/app-utils/page-header-help-context.js +56 -0
  26. package/src/lib/app-utils/page-header-help-context.js.map +1 -0
  27. package/src/lib/app-utils/release-flags.js +5 -4
  28. package/src/lib/app-utils/release-flags.js.map +1 -1
  29. package/src/lib/foundation/definitions/host-content-collections.d.ts +42 -40
  30. package/src/lib/foundation/definitions/host-content-collections.js +47 -109
  31. package/src/lib/foundation/definitions/host-content-collections.js.map +1 -1
  32. package/src/lib/plugin-manager/feature-plugins.d.ts +121 -22
  33. package/src/lib/plugin-manager/feature-plugins.js +41 -18
  34. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  35. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +13 -0
  36. package/src/lib/plugin-manager/first-party-plugins.generated.js +257 -0
  37. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  38. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +16 -0
  39. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  40. package/src/lib/plugin-manager/plugin-host-collections.d.ts +27 -5
  41. package/src/lib/plugin-manager/plugin-host-collections.js +25 -16
  42. package/src/lib/plugin-manager/plugin-host-collections.js.map +1 -1
  43. package/src/lib/plugin-manager/plugin-media-publish.d.ts +88 -0
  44. package/src/lib/plugin-manager/plugin-media-publish.js +118 -0
  45. package/src/lib/plugin-manager/plugin-media-publish.js.map +1 -0
  46. package/src/lib/plugin-manager/plugin-org-capacity.d.ts +57 -0
  47. package/src/lib/plugin-manager/plugin-org-capacity.js +68 -0
  48. package/src/lib/plugin-manager/plugin-org-capacity.js.map +1 -0
  49. package/src/lib/plugin-manager/plugin-route-metadata.d.ts +141 -0
  50. package/src/lib/plugin-manager/plugin-route-metadata.js +140 -0
  51. package/src/lib/plugin-manager/plugin-route-metadata.js.map +1 -0
  52. package/src/lib/plugin-manager/record-captured-contact.d.ts +89 -0
  53. package/src/lib/plugin-manager/record-captured-contact.js +128 -0
  54. package/src/lib/plugin-manager/record-captured-contact.js.map +1 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A usable `src` for a picked media asset.
19
+ *
20
+ * Lifted out of the listing detail editor when the publish form gained a
21
+ * media picker of its own (AGL-1080).
22
+ *
23
+ * NO LONGER THE WRITER FOR MARKDOWN BODIES (AGL-1705). This helper used to
24
+ * justify prefixing the origin by saying the markdown "renders elsewhere".
25
+ * That claim was checked and is false: the marketplace README's only renderer
26
+ * is the console's own `marketplaceListing` slot, and `apps/tenant` does not
27
+ * import the marketplace plugin. The two README writers and the blog body now
28
+ * store `mediaNodeSrc`'s `media:` reference, which `resolveMediaSrc` turns
29
+ * back into this same `/api/media/cdn/…` path at render — without freezing
30
+ * either the origin or the route shape into the document.
31
+ *
32
+ * What is left for this helper is the listing's image FIELDS —
33
+ * `previewImageUrl`, `logoUrl`, `screenshots` — read out of band by
34
+ * `og:image` and by `resolveSocialImage`, where an absolute URL is the point
35
+ * (AGL-1701). The origin prefix stays for them.
36
+ */
37
+ export declare function mediaSrc(media: {
38
+ url?: string;
39
+ cdnPath?: string;
40
+ }): string;
41
+ /**
42
+ * A `src` for a THUMBNAIL — a grid tile, a picker cell, a preview strip.
43
+ *
44
+ * Separate from {@link mediaSrc} because the two answer different questions.
45
+ * `mediaSrc` produces a URL that gets *persisted* (markdown bodies, listing
46
+ * images), so it must stay width-free. This one produces a URL that is only
47
+ * ever rendered, so it can ask for the smallest generated WebP variant.
48
+ *
49
+ * The DAM grid read `media.url` directly, which is the raw
50
+ * `firebasestorage.googleapis.com` download URL: every tile fetched the
51
+ * FULL-SIZE original straight from Cloud Storage, bypassing both the WebP
52
+ * variants (AGL-175) and the Vercel edge cache. A library page of 24 assets
53
+ * at ~200 KB each is ~4.8 MB of Storage egress per view, re-fetched by every
54
+ * viewer, where the 320px variants are ~15 KB each.
55
+ *
56
+ * `?w=` degrades safely: `serveMediaCdn` only serves a variant when the
57
+ * asset's `variants` array actually contains that width, and otherwise
58
+ * serves the original — so an SVG, a small logo, or an asset uploaded before
59
+ * variants existed still renders, just without the saving.
60
+ */
61
+ export declare function mediaThumbnailSrc(media: {
62
+ url?: string;
63
+ cdnPath?: string;
64
+ }, width: number): string;
65
+ /**
66
+ * A `src` for a VIDEO's poster still, or `undefined` when it has none
67
+ * (AGL-2742).
68
+ *
69
+ * The DAM grid drew a video tile as a `<video src>` pointing at the master.
70
+ * A browser paints a first frame by fetching enough of the file to decode
71
+ * one, so a folder of eight clips was tens of megabytes of Storage egress
72
+ * per view — the same defect {@link mediaThumbnailSrc} was written to fix for
73
+ * images, on the type where each tile costs a hundred times more. And unlike
74
+ * an image tile it could not be helped by a variant, because until now a
75
+ * video had nothing smaller to serve.
76
+ *
77
+ * Returns `undefined` rather than a fallback URL on purpose: the caller has
78
+ * to branch anyway (a `<video>` with no poster still needs `preload="none"`
79
+ * so it stops fetching), and an "always answers" helper would hand back a URL
80
+ * that 404s for every video uploaded before this shipped.
81
+ *
82
+ * Mirrors `Aglyn.mediaPosterSrc` for a media DOCUMENT rather than a stored
83
+ * `media:` reference — the console holds documents, the renderer holds
84
+ * references, and neither can use the other's input.
85
+ */
86
+ export declare function mediaPosterThumbnailSrc(media: {
87
+ url?: string;
88
+ cdnPath?: string;
89
+ poster?: {
90
+ variants?: number[];
91
+ } | null;
92
+ }, width?: number): string | undefined;
93
+ export default mediaSrc;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * A usable `src` for a picked media asset.
18
+ *
19
+ * Lifted out of the listing detail editor when the publish form gained a
20
+ * media picker of its own (AGL-1080).
21
+ *
22
+ * NO LONGER THE WRITER FOR MARKDOWN BODIES (AGL-1705). This helper used to
23
+ * justify prefixing the origin by saying the markdown "renders elsewhere".
24
+ * That claim was checked and is false: the marketplace README's only renderer
25
+ * is the console's own `marketplaceListing` slot, and `apps/tenant` does not
26
+ * import the marketplace plugin. The two README writers and the blog body now
27
+ * store `mediaNodeSrc`'s `media:` reference, which `resolveMediaSrc` turns
28
+ * back into this same `/api/media/cdn/…` path at render — without freezing
29
+ * either the origin or the route shape into the document.
30
+ *
31
+ * What is left for this helper is the listing's image FIELDS —
32
+ * `previewImageUrl`, `logoUrl`, `screenshots` — read out of band by
33
+ * `og:image` and by `resolveSocialImage`, where an absolute URL is the point
34
+ * (AGL-1701). The origin prefix stays for them.
35
+ */ export function mediaSrc(media) {
36
+ // `cdnPath` FIRST (AGL-1215). It is keyed by media id, so it survives a
37
+ // folder move — which physically copies the object, rewrites `url` and
38
+ // deletes the original, permanently breaking anything holding the old
39
+ // raw URL. Preferring `url` meant this helper emitted the fragile form
40
+ // for every paid org, where both fields are always populated. `url`
41
+ // stays the fallback for free-tier orgs (`cdnPath` is a paid `mediaCdn`
42
+ // entitlement) and legacy uploads that predate it.
43
+ if (media.cdnPath) return typeof window === 'undefined' ? media.cdnPath : `${window.location.origin}${media.cdnPath}`;
44
+ if (media.url) return media.url;
45
+ return '';
46
+ }
47
+ /**
48
+ * A `src` for a THUMBNAIL — a grid tile, a picker cell, a preview strip.
49
+ *
50
+ * Separate from {@link mediaSrc} because the two answer different questions.
51
+ * `mediaSrc` produces a URL that gets *persisted* (markdown bodies, listing
52
+ * images), so it must stay width-free. This one produces a URL that is only
53
+ * ever rendered, so it can ask for the smallest generated WebP variant.
54
+ *
55
+ * The DAM grid read `media.url` directly, which is the raw
56
+ * `firebasestorage.googleapis.com` download URL: every tile fetched the
57
+ * FULL-SIZE original straight from Cloud Storage, bypassing both the WebP
58
+ * variants (AGL-175) and the Vercel edge cache. A library page of 24 assets
59
+ * at ~200 KB each is ~4.8 MB of Storage egress per view, re-fetched by every
60
+ * viewer, where the 320px variants are ~15 KB each.
61
+ *
62
+ * `?w=` degrades safely: `serveMediaCdn` only serves a variant when the
63
+ * asset's `variants` array actually contains that width, and otherwise
64
+ * serves the original — so an SVG, a small logo, or an asset uploaded before
65
+ * variants existed still renders, just without the saving.
66
+ */ export function mediaThumbnailSrc(media, width) {
67
+ const cdnPath = media.cdnPath;
68
+ // No `cdnPath` means a free-tier or private asset, and a width parameter
69
+ // on a raw storage URL means nothing — appending one would only fork the
70
+ // browser cache for identical bytes.
71
+ if (!cdnPath) return mediaSrc(media);
72
+ // A signed private URL already carries `?exp=&sig=`; a second `?` makes a
73
+ // path the route rejects outright.
74
+ if (cdnPath.includes('?')) return mediaSrc(media);
75
+ return `${mediaSrc(media)}?w=${width}`;
76
+ }
77
+ /**
78
+ * A `src` for a VIDEO's poster still, or `undefined` when it has none
79
+ * (AGL-2742).
80
+ *
81
+ * The DAM grid drew a video tile as a `<video src>` pointing at the master.
82
+ * A browser paints a first frame by fetching enough of the file to decode
83
+ * one, so a folder of eight clips was tens of megabytes of Storage egress
84
+ * per view — the same defect {@link mediaThumbnailSrc} was written to fix for
85
+ * images, on the type where each tile costs a hundred times more. And unlike
86
+ * an image tile it could not be helped by a variant, because until now a
87
+ * video had nothing smaller to serve.
88
+ *
89
+ * Returns `undefined` rather than a fallback URL on purpose: the caller has
90
+ * to branch anyway (a `<video>` with no poster still needs `preload="none"`
91
+ * so it stops fetching), and an "always answers" helper would hand back a URL
92
+ * that 404s for every video uploaded before this shipped.
93
+ *
94
+ * Mirrors `Aglyn.mediaPosterSrc` for a media DOCUMENT rather than a stored
95
+ * `media:` reference — the console holds documents, the renderer holds
96
+ * references, and neither can use the other's input.
97
+ */ export function mediaPosterThumbnailSrc(media, width) {
98
+ var _media_poster_variants;
99
+ // A poster is only reachable through the CDN route, so a free-tier or
100
+ // private asset has no URL that could serve one — the same reason
101
+ // `mediaThumbnailSrc` gives up on `?w=` there.
102
+ if (!media.poster || !media.cdnPath || media.cdnPath.includes('?')) {
103
+ return undefined;
104
+ }
105
+ const variants = (_media_poster_variants = media.poster.variants) != null ? _media_poster_variants : [];
106
+ // Only a width that was actually generated. `serveMediaCdn` would fall
107
+ // back to the full poster for any other, which works and defeats the point
108
+ // — a 1920px still in a 116px tile.
109
+ const narrowed = width && variants.includes(width) ? `&w=${width}` : '';
110
+ return `${mediaSrc(media)}?poster=1${narrowed}`;
111
+ }
112
+ export default mediaSrc;
113
+
114
+ //# sourceMappingURL=media-src.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-src.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A usable `src` for a picked media asset.\n *\n * Lifted out of the listing detail editor when the publish form gained a\n * media picker of its own (AGL-1080).\n *\n * NO LONGER THE WRITER FOR MARKDOWN BODIES (AGL-1705). This helper used to\n * justify prefixing the origin by saying the markdown \"renders elsewhere\".\n * That claim was checked and is false: the marketplace README's only renderer\n * is the console's own `marketplaceListing` slot, and `apps/tenant` does not\n * import the marketplace plugin. The two README writers and the blog body now\n * store `mediaNodeSrc`'s `media:` reference, which `resolveMediaSrc` turns\n * back into this same `/api/media/cdn/…` path at render — without freezing\n * either the origin or the route shape into the document.\n *\n * What is left for this helper is the listing's image FIELDS —\n * `previewImageUrl`, `logoUrl`, `screenshots` — read out of band by\n * `og:image` and by `resolveSocialImage`, where an absolute URL is the point\n * (AGL-1701). The origin prefix stays for them.\n */\nexport function mediaSrc(media: {\n url?: string\n cdnPath?: string\n}): string {\n // `cdnPath` FIRST (AGL-1215). It is keyed by media id, so it survives a\n // folder move — which physically copies the object, rewrites `url` and\n // deletes the original, permanently breaking anything holding the old\n // raw URL. Preferring `url` meant this helper emitted the fragile form\n // for every paid org, where both fields are always populated. `url`\n // stays the fallback for free-tier orgs (`cdnPath` is a paid `mediaCdn`\n // entitlement) and legacy uploads that predate it.\n if (media.cdnPath)\n return typeof window === 'undefined'\n ? media.cdnPath\n : `${window.location.origin}${media.cdnPath}`\n if (media.url) return media.url\n return ''\n}\n\n/**\n * A `src` for a THUMBNAIL — a grid tile, a picker cell, a preview strip.\n *\n * Separate from {@link mediaSrc} because the two answer different questions.\n * `mediaSrc` produces a URL that gets *persisted* (markdown bodies, listing\n * images), so it must stay width-free. This one produces a URL that is only\n * ever rendered, so it can ask for the smallest generated WebP variant.\n *\n * The DAM grid read `media.url` directly, which is the raw\n * `firebasestorage.googleapis.com` download URL: every tile fetched the\n * FULL-SIZE original straight from Cloud Storage, bypassing both the WebP\n * variants (AGL-175) and the Vercel edge cache. A library page of 24 assets\n * at ~200 KB each is ~4.8 MB of Storage egress per view, re-fetched by every\n * viewer, where the 320px variants are ~15 KB each.\n *\n * `?w=` degrades safely: `serveMediaCdn` only serves a variant when the\n * asset's `variants` array actually contains that width, and otherwise\n * serves the original — so an SVG, a small logo, or an asset uploaded before\n * variants existed still renders, just without the saving.\n */\nexport function mediaThumbnailSrc(\n media: { url?: string; cdnPath?: string },\n width: number,\n): string {\n const cdnPath = media.cdnPath\n // No `cdnPath` means a free-tier or private asset, and a width parameter\n // on a raw storage URL means nothing — appending one would only fork the\n // browser cache for identical bytes.\n if (!cdnPath) return mediaSrc(media)\n // A signed private URL already carries `?exp=&sig=`; a second `?` makes a\n // path the route rejects outright.\n if (cdnPath.includes('?')) return mediaSrc(media)\n return `${mediaSrc(media)}?w=${width}`\n}\n\n/**\n * A `src` for a VIDEO's poster still, or `undefined` when it has none\n * (AGL-2742).\n *\n * The DAM grid drew a video tile as a `<video src>` pointing at the master.\n * A browser paints a first frame by fetching enough of the file to decode\n * one, so a folder of eight clips was tens of megabytes of Storage egress\n * per view — the same defect {@link mediaThumbnailSrc} was written to fix for\n * images, on the type where each tile costs a hundred times more. And unlike\n * an image tile it could not be helped by a variant, because until now a\n * video had nothing smaller to serve.\n *\n * Returns `undefined` rather than a fallback URL on purpose: the caller has\n * to branch anyway (a `<video>` with no poster still needs `preload=\"none\"`\n * so it stops fetching), and an \"always answers\" helper would hand back a URL\n * that 404s for every video uploaded before this shipped.\n *\n * Mirrors `Aglyn.mediaPosterSrc` for a media DOCUMENT rather than a stored\n * `media:` reference — the console holds documents, the renderer holds\n * references, and neither can use the other's input.\n */\nexport function mediaPosterThumbnailSrc(\n media: {\n url?: string\n cdnPath?: string\n poster?: { variants?: number[] } | null\n },\n width?: number,\n): string | undefined {\n // A poster is only reachable through the CDN route, so a free-tier or\n // private asset has no URL that could serve one — the same reason\n // `mediaThumbnailSrc` gives up on `?w=` there.\n if (!media.poster || !media.cdnPath || media.cdnPath.includes('?')) {\n return undefined\n }\n const variants = media.poster.variants ?? []\n // Only a width that was actually generated. `serveMediaCdn` would fall\n // back to the full poster for any other, which works and defeats the point\n // — a 1920px still in a 116px tile.\n const narrowed = width && variants.includes(width) ? `&w=${width}` : ''\n return `${mediaSrc(media)}?poster=1${narrowed}`\n}\n\nexport default mediaSrc\n"],"names":["mediaSrc","media","cdnPath","window","location","origin","url","mediaThumbnailSrc","width","includes","mediaPosterThumbnailSrc","poster","undefined","variants","narrowed"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASA,SAASC,KAGxB;IACC,wEAAwE;IACxE,uEAAuE;IACvE,sEAAsE;IACtE,uEAAuE;IACvE,oEAAoE;IACpE,wEAAwE;IACxE,mDAAmD;IACnD,IAAIA,MAAMC,OAAO,EACf,OAAO,OAAOC,WAAW,cACrBF,MAAMC,OAAO,GACb,GAAGC,OAAOC,QAAQ,CAACC,MAAM,GAAGJ,MAAMC,OAAO,EAAE;IACjD,IAAID,MAAMK,GAAG,EAAE,OAAOL,MAAMK,GAAG;IAC/B,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,kBACdN,KAAyC,EACzCO,KAAa;IAEb,MAAMN,UAAUD,MAAMC,OAAO;IAC7B,yEAAyE;IACzE,yEAAyE;IACzE,qCAAqC;IACrC,IAAI,CAACA,SAAS,OAAOF,SAASC;IAC9B,0EAA0E;IAC1E,mCAAmC;IACnC,IAAIC,QAAQO,QAAQ,CAAC,MAAM,OAAOT,SAASC;IAC3C,OAAO,GAAGD,SAASC,OAAO,GAAG,EAAEO,OAAO;AACxC;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASE,wBACdT,KAIC,EACDO,KAAc;QAQGP;IANjB,sEAAsE;IACtE,kEAAkE;IAClE,+CAA+C;IAC/C,IAAI,CAACA,MAAMU,MAAM,IAAI,CAACV,MAAMC,OAAO,IAAID,MAAMC,OAAO,CAACO,QAAQ,CAAC,MAAM;QAClE,OAAOG;IACT;IACA,MAAMC,YAAWZ,yBAAAA,MAAMU,MAAM,CAACE,QAAQ,YAArBZ,yBAAyB,EAAE;IAC5C,uEAAuE;IACvE,2EAA2E;IAC3E,oCAAoC;IACpC,MAAMa,WAAWN,SAASK,SAASJ,QAAQ,CAACD,SAAS,CAAC,GAAG,EAAEA,OAAO,GAAG;IACrE,OAAO,GAAGR,SAASC,OAAO,SAAS,EAAEa,UAAU;AACjD;AAEA,eAAed,SAAQ"}
@@ -22,7 +22,7 @@ import type { ITimestamp } from '@aglyn/shared-util-timestamp';
22
22
  * their owner. Types follow the common SaaS taxonomy so the console can
23
23
  * icon/group them without a registry.
24
24
  */
25
- export type AglynNotificationType = 'billing.invoice' | 'billing.paymentFailed' | 'billing.subscriptionCanceled' | 'billing.usage' | 'team.invite' | 'team.roleChanged' | 'team.hostAccessGranted' | 'content.formSubmission' | 'content.booking' | 'content.order' | 'content.lowStock' | 'content.taskAssigned' | 'content.taskReminder' | 'content.contactAssigned' | 'content.leadAssigned' | 'content.crmDailyDigest' | 'content.insightsDigest' | 'marketplace.review' | 'support.ticketOpened' | 'support.ticketReply' | 'system.announcement' | 'system.pluginVerifierRegression' | 'system.signInMethodRemoved' | 'system.scopeDrift' | 'system.ssoDomainUnverified' | 'system.formSubmissionsPaused' | 'system.visitorRecordsPaused' | 'system.abuseReportUrgent' | 'system.dmcaCounterNotice' | 'system.bandwidthCeilingTripped' | 'system.bandwidthCapEngaged' | 'system.billingWebhookHalfApplied' | 'system.disputeUnattributed';
25
+ export type AglynNotificationType = 'billing.invoice' | 'billing.paymentFailed' | 'billing.subscriptionCanceled' | 'billing.usage' | 'team.invite' | 'team.roleChanged' | 'team.hostAccessGranted' | 'content.formSubmission' | 'content.booking' | 'content.order' | 'content.lowStock' | 'content.taskAssigned' | 'content.taskReminder' | 'content.contactAssigned' | 'content.leadAssigned' | 'content.crmDailyDigest' | 'content.insightsDigest' | 'marketplace.review' | 'support.ticketOpened' | 'support.ticketReply' | 'system.announcement' | 'system.pluginVerifierRegression' | 'system.signInMethodRemoved' | 'system.scopeDrift' | 'system.ssoDomainUnverified' | 'system.formSubmissionsPaused' | 'system.visitorRecordsPaused' | 'system.abuseReportUrgent' | 'system.dmcaCounterNotice' | 'system.bandwidthCeilingTripped' | 'system.bandwidthCapEngaged' | 'system.billingWebhookHalfApplied' | 'system.disputeUnattributed' | 'staff.userSignedUp' | 'staff.orgCreated';
26
26
  export interface AglynNotification {
27
27
  $id?: string;
28
28
  type: AglynNotificationType;
@@ -38,8 +38,17 @@ export interface AglynNotification {
38
38
  }
39
39
  export declare const NOTIFICATION_TYPE_LABELS: Record<AglynNotificationType, string>;
40
40
  /** Preference buckets (AGL-267): the prefix before the dot. */
41
- export type NotificationCategory = 'billing' | 'team' | 'content' | 'marketplace' | 'support' | 'system';
41
+ export type NotificationCategory = 'billing' | 'team' | 'content' | 'marketplace' | 'support' | 'system' | 'staff';
42
42
  export declare const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string>;
43
+ /**
44
+ * The categories only staff can receive (AGL-3225).
45
+ *
46
+ * The settings page hides these rows from everybody else, because a switch
47
+ * for mail that can never arrive is a promise the product does not keep. It
48
+ * is presentation only: `notifyStaff` is what decides the audience, and it
49
+ * enumerates the `staff` claim rather than reading this.
50
+ */
51
+ export declare const STAFF_NOTIFICATION_CATEGORIES: ReadonlySet<NotificationCategory>;
43
52
  export declare function notificationCategory(type: AglynNotificationType | string): NotificationCategory;
44
53
  /**
45
54
  * Per-user mute map stored at `users/{uid}.notificationPrefs`
@@ -69,3 +78,128 @@ export declare function crmDailyDigestEnabled(prefs: Record<string, boolean> | n
69
78
  export declare const INSIGHT_DIGESTS_FIELD = "insightDigests";
70
79
  /** Whether a person asked for a workspace's weekly insights. */
71
80
  export declare function insightDigestSubscribed(value: Record<string, boolean> | null | undefined, orgId: string): boolean;
81
+ /**
82
+ * The channels a notification can travel on (AGL-3223).
83
+ *
84
+ * `console` is the feed at `/manage/notifications` and the app-bar dropdown
85
+ * that reads it — the only channel that existed. `email` is the message the
86
+ * fan-out sends beside that doc when the recipient asked for one.
87
+ */
88
+ export type NotificationChannel = 'console' | 'email';
89
+ /**
90
+ * One scope's answer for one category. **Tri-state on purpose**: `true` and
91
+ * `false` decide, and an ABSENT key inherits from the scope above.
92
+ *
93
+ * Inheritance is the whole reason this is a partial rather than a pair of
94
+ * booleans. A person who wants form submissions from one busy site and not
95
+ * from the other five has to be able to say that about the one site without
96
+ * restating their answer for every other category at every other scope — and
97
+ * a two-valued leaf cannot express "I have not said", so every override would
98
+ * have to be written out in full and would then stop tracking the account
99
+ * default it was never meant to detach from.
100
+ */
101
+ export interface NotificationChannelPrefs {
102
+ console?: boolean;
103
+ email?: boolean;
104
+ }
105
+ export type NotificationCategoryPrefs = Partial<Record<NotificationCategory, NotificationChannelPrefs>>;
106
+ /**
107
+ * Per-scope, per-channel notification preferences (AGL-3223), stored at
108
+ * `users/{uid}.notificationSettings`.
109
+ *
110
+ * Three layers, narrowest first when resolving: the SITE a notification
111
+ * concerns, then the WORKSPACE, then the account. Anything none of them
112
+ * answers falls to {@link NOTIFICATION_CHANNEL_DEFAULTS}.
113
+ *
114
+ * ON THE USER DOCUMENT, not on `orgs/{orgId}/members/{uid}`, and that is a
115
+ * cost decision rather than a modelling preference. `notifyUsers` already
116
+ * does exactly one `getAll` over the recipients' user docs to read their
117
+ * category mutes, so every layer living here means per-site preferences are
118
+ * read for free on the fan-out's hot path. The member row would add a read
119
+ * per recipient per notification to answer a question the document already in
120
+ * hand could have answered.
121
+ */
122
+ export interface NotificationSettings {
123
+ account?: NotificationCategoryPrefs;
124
+ /** Keyed by org id. */
125
+ orgs?: Record<string, NotificationCategoryPrefs>;
126
+ /** Keyed by host id — a site's own answer, narrower than its workspace's. */
127
+ hosts?: Record<string, NotificationCategoryPrefs>;
128
+ }
129
+ export declare const NOTIFICATION_SETTINGS_FIELD = "notificationSettings";
130
+ /**
131
+ * What a category does when nobody has said otherwise.
132
+ *
133
+ * Console on, email OFF, everywhere. Email defaults off because the inbox is
134
+ * not ours to fill: the product already sends transactional mail nobody opted
135
+ * into — welcome, verification, invites, dunning, usage alerts — and every one
136
+ * of those leaves on the same domain a customer's password reset depends on.
137
+ * Turning a busy site's form submissions into mail by default would put that
138
+ * domain's reputation behind traffic the recipient never asked for.
139
+ *
140
+ * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a
141
+ * compile error here until somebody decides what it does by default, which is
142
+ * the one question a new category must not be able to ship without answering.
143
+ */
144
+ export declare const NOTIFICATION_CHANNEL_DEFAULTS: Record<NotificationCategory, Record<NotificationChannel, boolean>>;
145
+ /**
146
+ * The types that send their OWN email and must never be mailed again by the
147
+ * generic channel (AGL-3224).
148
+ *
149
+ * Both digests compose a message the fan-out could not reproduce — a day's
150
+ * owed tasks, a week's figures — and send it from their own route under their
151
+ * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient
152
+ * who switches the `content` email channel on would otherwise receive the
153
+ * digest twice: once as the digest, once as a one-line "Daily CRM digest"
154
+ * notification saying that the digest happened.
155
+ */
156
+ export declare const NOTIFICATION_SELF_SENT_EMAIL_TYPES: ReadonlySet<string>;
157
+ /**
158
+ * Whether a channel is on for one notification, at its own scope.
159
+ *
160
+ * Resolution order, first answer wins: the notification's SITE, its
161
+ * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope
162
+ * that holds no entry for the category, or an entry with the channel key
163
+ * absent, does not answer — see {@link NotificationChannelPrefs}.
164
+ *
165
+ * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces
166
+ * (AGL-267), and it sits BELOW the account layer rather than beside it.
167
+ * Nothing migrates it: a person who never opens the new settings page keeps
168
+ * being governed by the mutes they set years ago, and the first thing they do
169
+ * set on the account layer overrides the old map for that category without
170
+ * disturbing the rest of it. It answers for `console` only, because the map
171
+ * predates there being a second channel and reading a console mute as an
172
+ * email preference would be inventing an answer its author never gave.
173
+ */
174
+ export declare function notificationChannelEnabled(settings: NotificationSettings | null | undefined, channel: NotificationChannel, type: AglynNotificationType | string, scope?: {
175
+ orgId?: string | null;
176
+ hostId?: string | null;
177
+ }, legacyPrefs?: Record<string, boolean> | null): boolean;
178
+ /**
179
+ * What one scope says, with no inheritance applied — what the settings page
180
+ * renders as Inherit / On / Off, and `undefined` is Inherit.
181
+ *
182
+ * `scope` names the layer directly rather than being derived from a
183
+ * notification, because the page edits a layer whether or not anything has
184
+ * ever arrived from it.
185
+ */
186
+ export declare function notificationScopePref(settings: NotificationSettings | null | undefined, scope: {
187
+ kind: 'account';
188
+ } | {
189
+ kind: 'org' | 'host';
190
+ id: string;
191
+ }, category: NotificationCategory, channel: NotificationChannel): boolean | undefined;
192
+ /**
193
+ * The scopes this person has said something different about — what the
194
+ * settings page lists so an override is never somewhere you have to go
195
+ * looking for.
196
+ *
197
+ * Returns ids, not names: the page holds the org and host rosters and this
198
+ * module holds no lookups. An id with no name is still worth listing, because
199
+ * a preference about a workspace somebody left is exactly the kind of
200
+ * leftover that is invisible until it is listed.
201
+ */
202
+ export declare function notificationOverriddenScopes(settings: NotificationSettings | null | undefined): {
203
+ orgIds: string[];
204
+ hostIds: string[];
205
+ };
@@ -46,7 +46,9 @@
46
46
  'system.bandwidthCeilingTripped': 'Bandwidth ceiling reached',
47
47
  'system.bandwidthCapEngaged': 'Monthly traffic limit reached',
48
48
  'system.billingWebhookHalfApplied': 'Billing webhook half applied',
49
- 'system.disputeUnattributed': 'Card dispute with no owner'
49
+ 'system.disputeUnattributed': 'Card dispute with no owner',
50
+ 'staff.userSignedUp': 'New account',
51
+ 'staff.orgCreated': 'New workspace'
50
52
  };
51
53
  export const NOTIFICATION_CATEGORY_LABELS = {
52
54
  billing: 'Billing',
@@ -54,8 +56,19 @@ export const NOTIFICATION_CATEGORY_LABELS = {
54
56
  content: 'Forms & bookings',
55
57
  marketplace: 'Marketplace',
56
58
  support: 'Support',
57
- system: 'Product & system'
59
+ system: 'Product & system',
60
+ staff: 'Platform growth'
58
61
  };
62
+ /**
63
+ * The categories only staff can receive (AGL-3225).
64
+ *
65
+ * The settings page hides these rows from everybody else, because a switch
66
+ * for mail that can never arrive is a promise the product does not keep. It
67
+ * is presentation only: `notifyStaff` is what decides the audience, and it
68
+ * enumerates the `staff` claim rather than reading this.
69
+ */ export const STAFF_NOTIFICATION_CATEGORIES = new Set([
70
+ 'staff'
71
+ ]);
59
72
  export function notificationCategory(type) {
60
73
  const prefix = String(type).split('.')[0];
61
74
  return [
@@ -64,7 +77,8 @@ export function notificationCategory(type) {
64
77
  'content',
65
78
  'marketplace',
66
79
  'support',
67
- 'system'
80
+ 'system',
81
+ 'staff'
68
82
  ].includes(prefix) ? prefix : 'system';
69
83
  }
70
84
  /**
@@ -97,5 +111,127 @@ export function crmDailyDigestEnabled(prefs) {
97
111
  /** Whether a person asked for a workspace's weekly insights. */ export function insightDigestSubscribed(value, orgId) {
98
112
  return Boolean(orgId) && (value == null ? void 0 : value[orgId]) === true;
99
113
  }
114
+ export const NOTIFICATION_SETTINGS_FIELD = 'notificationSettings';
115
+ /**
116
+ * What a category does when nobody has said otherwise.
117
+ *
118
+ * Console on, email OFF, everywhere. Email defaults off because the inbox is
119
+ * not ours to fill: the product already sends transactional mail nobody opted
120
+ * into — welcome, verification, invites, dunning, usage alerts — and every one
121
+ * of those leaves on the same domain a customer's password reset depends on.
122
+ * Turning a busy site's form submissions into mail by default would put that
123
+ * domain's reputation behind traffic the recipient never asked for.
124
+ *
125
+ * Exhaustive `Record` deliberately: a new {@link NotificationCategory} is a
126
+ * compile error here until somebody decides what it does by default, which is
127
+ * the one question a new category must not be able to ship without answering.
128
+ */ export const NOTIFICATION_CHANNEL_DEFAULTS = {
129
+ billing: {
130
+ console: true,
131
+ email: false
132
+ },
133
+ team: {
134
+ console: true,
135
+ email: false
136
+ },
137
+ content: {
138
+ console: true,
139
+ email: false
140
+ },
141
+ marketplace: {
142
+ console: true,
143
+ email: false
144
+ },
145
+ support: {
146
+ console: true,
147
+ email: false
148
+ },
149
+ system: {
150
+ console: true,
151
+ email: false
152
+ },
153
+ // Console ON, because the complaint this answers is that staff never heard
154
+ // about a sign-up at all; email off, like everything else, because that is
155
+ // what the channel defaults to and a sign-up is not urgent enough to be the
156
+ // exception that starts filling inboxes by default.
157
+ staff: {
158
+ console: true,
159
+ email: false
160
+ }
161
+ };
162
+ /**
163
+ * The types that send their OWN email and must never be mailed again by the
164
+ * generic channel (AGL-3224).
165
+ *
166
+ * Both digests compose a message the fan-out could not reproduce — a day's
167
+ * owed tasks, a week's figures — and send it from their own route under their
168
+ * own switch (`digestPrefs.crmDaily`, `insightDigests.{orgId}`). A recipient
169
+ * who switches the `content` email channel on would otherwise receive the
170
+ * digest twice: once as the digest, once as a one-line "Daily CRM digest"
171
+ * notification saying that the digest happened.
172
+ */ export const NOTIFICATION_SELF_SENT_EMAIL_TYPES = new Set([
173
+ 'content.crmDailyDigest',
174
+ 'content.insightsDigest'
175
+ ]);
176
+ /**
177
+ * Whether a channel is on for one notification, at its own scope.
178
+ *
179
+ * Resolution order, first answer wins: the notification's SITE, its
180
+ * WORKSPACE, the account, then {@link NOTIFICATION_CHANNEL_DEFAULTS}. A scope
181
+ * that holds no entry for the category, or an entry with the channel key
182
+ * absent, does not answer — see {@link NotificationChannelPrefs}.
183
+ *
184
+ * `legacyPrefs` is the flat `notificationPrefs` mute map this replaces
185
+ * (AGL-267), and it sits BELOW the account layer rather than beside it.
186
+ * Nothing migrates it: a person who never opens the new settings page keeps
187
+ * being governed by the mutes they set years ago, and the first thing they do
188
+ * set on the account layer overrides the old map for that category without
189
+ * disturbing the rest of it. It answers for `console` only, because the map
190
+ * predates there being a second channel and reading a console mute as an
191
+ * email preference would be inventing an answer its author never gave.
192
+ */ export function notificationChannelEnabled(settings, channel, type, scope, legacyPrefs) {
193
+ var _settings_hosts, _settings_orgs;
194
+ const category = notificationCategory(type);
195
+ const layers = [
196
+ (scope == null ? void 0 : scope.hostId) ? settings == null ? void 0 : (_settings_hosts = settings.hosts) == null ? void 0 : _settings_hosts[scope.hostId] : undefined,
197
+ (scope == null ? void 0 : scope.orgId) ? settings == null ? void 0 : (_settings_orgs = settings.orgs) == null ? void 0 : _settings_orgs[scope.orgId] : undefined,
198
+ settings == null ? void 0 : settings.account
199
+ ];
200
+ for (const layer of layers){
201
+ var _layer_category;
202
+ const answer = layer == null ? void 0 : (_layer_category = layer[category]) == null ? void 0 : _layer_category[channel];
203
+ if (typeof answer === 'boolean') return answer;
204
+ }
205
+ if (channel === 'console' && notificationMuted(legacyPrefs, type)) return false;
206
+ return NOTIFICATION_CHANNEL_DEFAULTS[category][channel];
207
+ }
208
+ /**
209
+ * What one scope says, with no inheritance applied — what the settings page
210
+ * renders as Inherit / On / Off, and `undefined` is Inherit.
211
+ *
212
+ * `scope` names the layer directly rather than being derived from a
213
+ * notification, because the page edits a layer whether or not anything has
214
+ * ever arrived from it.
215
+ */ export function notificationScopePref(settings, scope, category, channel) {
216
+ var _settings_orgs, _settings_hosts, _layer_category;
217
+ const layer = scope.kind === 'account' ? settings == null ? void 0 : settings.account : scope.kind === 'org' ? settings == null ? void 0 : (_settings_orgs = settings.orgs) == null ? void 0 : _settings_orgs[scope.id] : settings == null ? void 0 : (_settings_hosts = settings.hosts) == null ? void 0 : _settings_hosts[scope.id];
218
+ return layer == null ? void 0 : (_layer_category = layer[category]) == null ? void 0 : _layer_category[channel];
219
+ }
220
+ /**
221
+ * The scopes this person has said something different about — what the
222
+ * settings page lists so an override is never somewhere you have to go
223
+ * looking for.
224
+ *
225
+ * Returns ids, not names: the page holds the org and host rosters and this
226
+ * module holds no lookups. An id with no name is still worth listing, because
227
+ * a preference about a workspace somebody left is exactly the kind of
228
+ * leftover that is invisible until it is listed.
229
+ */ export function notificationOverriddenScopes(settings) {
230
+ const named = (layers)=>Object.entries(layers != null ? layers : {}).filter(([, prefs])=>Object.values(prefs != null ? prefs : {}).some((channels)=>Object.values(channels != null ? channels : {}).some((value)=>typeof value === 'boolean'))).map(([id])=>id).sort();
231
+ return {
232
+ orgIds: named(settings == null ? void 0 : settings.orgs),
233
+ hostIds: named(settings == null ? void 0 : settings.hosts)
234
+ };
235
+ }
100
236
 
101
237
  //# sourceMappingURL=notifications.js.map