@cosmicdrift/kumiko-samples 0.397.0 → 0.399.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +4 -4
  3. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  4. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +19 -4
  5. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +42 -5
  6. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  7. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +21 -18
  8. package/packages/bundled-features/src/billing-foundation/i18n.ts +1 -0
  9. package/packages/bundled-features/src/billing-foundation/types.ts +7 -0
  10. package/packages/bundled-features/src/billing-foundation/validate-options.ts +3 -1
  11. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +14 -51
  12. package/packages/bundled-features/src/billing-foundation/web/consent-fields.tsx +111 -0
  13. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +14 -58
  14. package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
  15. package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
  16. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
  17. package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
  18. package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
  19. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
  20. package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
  21. package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
  22. package/packages/bundled-features/src/metrics/changes.json +6 -0
  23. package/packages/bundled-features/src/metrics/compute.ts +19 -2
  24. package/packages/bundled-features/src/metrics/feature.ts +14 -1
  25. package/packages/bundled-features/src/metrics/types.ts +3 -1
  26. package/packages/bundled-features/src/metrics/validate.ts +3 -0
  27. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.397.0",
3
+ "version": "0.399.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.397.0",
3
+ "version": "0.399.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -179,10 +179,10 @@
179
179
  "@types/qrcode": "^1.5.5",
180
180
  "clsx": "^2.1.1",
181
181
  "happy-dom": "^21.0.4",
182
- "hono": "^4.13.1",
182
+ "hono": "^4.13.14",
183
183
  "imapflow": "^1.7.8",
184
184
  "ioredis": "^5.10.1",
185
- "jose": "^6.2.3",
185
+ "jose": "^6.2.13",
186
186
  "lucide-react": "^1.14.0",
187
187
  "mailparser": "^3.12.4",
188
188
  "marked": "^18.0.3",
@@ -752,7 +752,7 @@
752
752
  "LICENSE"
753
753
  ],
754
754
  "devDependencies": {
755
- "@testing-library/user-event": "^14.6.1",
755
+ "@testing-library/user-event": "^14.6.8",
756
756
  "@cosmicdrift/kumiko-locale-de": "workspace:*",
757
757
  "@cosmicdrift/kumiko-locale-es": "workspace:*",
758
758
  "@cosmicdrift/kumiko-locale-fr": "workspace:*",
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.399.0",
4
+ "type": "improvement",
5
+ "title": "Shorter documents checkbox text in the consent box",
6
+ "detail": "With `consumerProtection.documentsAcknowledgement: true` the checkbox now reads \"I have read and agree to the Terms and the Refund Policy.\" (de: \"Ich habe die AGB und die Erstattungsrichtlinie gelesen und bin mit ihnen einverstanden.\"), or without a refund link only the terms. The privacy policy stays a link. The text is part of `consentTextVersion`, so apps with the option on get a new version hash; consents recorded earlier keep their stored version."
7
+ },
8
+ {
9
+ "version": "0.398.0",
10
+ "type": "improvement",
11
+ "title": "Consent box takes a refund policy link and an optional documents checkbox",
12
+ "detail": "`legalLinks.refund` is rendered next to the other links in the checkout and plan-switch dialogs. `consumerProtection.documentsAcknowledgement: true` adds a required first checkbox whose text depends on whether a refund link is set for the locale. The text joins `consentTextVersion` only when the option is on, `start-plan-checkout`, `create-checkout-session` and `switch-plan` then reject a consent without `documentsAcknowledged: true` with 422 `consent_required`, and the recorded consent stores the flag. Without the option nothing changes, including the version. New i18n key `billing-foundation.consent.link.refund`."
13
+ },
2
14
  {
3
15
  "version": "0.394.0",
4
16
  "type": "improvement",
@@ -20,7 +20,12 @@ import {
20
20
  SUBSCRIPTION_AGGREGATE_TYPE,
21
21
  } from "../events.ts";
22
22
  import type { ConsumerProtectionOptions, ProviderPrice } from "../types.ts";
23
- import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./consent-text.ts";
23
+ import {
24
+ consentTextVersion,
25
+ documentsAcknowledgementTextFor,
26
+ resolveConsentLocale,
27
+ submitMessageFor,
28
+ } from "./consent-text.ts";
24
29
 
25
30
  export type CheckoutMode = "subscription" | "payment";
26
31
 
@@ -30,6 +35,7 @@ export const consentPayloadSchema = z
30
35
  .object({
31
36
  earlyPerformanceRequested: z.boolean(),
32
37
  withdrawalLossAcknowledged: z.boolean(),
38
+ documentsAcknowledged: z.boolean().optional(),
33
39
  consentTextVersion: z.string().min(1).max(64),
34
40
  locale: z.string().min(2).max(35),
35
41
  })
@@ -42,6 +48,7 @@ export type PreparedConsent = {
42
48
  readonly locale: string;
43
49
  readonly submitMessage: string;
44
50
  readonly consentTextVersion: string;
51
+ readonly documentsAcknowledged: boolean;
45
52
  readonly termsHash: string;
46
53
  readonly termsTemplateVersion: number;
47
54
  };
@@ -88,14 +95,20 @@ export async function prepareConsent(
88
95
  consent: ConsentPayload | undefined,
89
96
  mode: CheckoutMode,
90
97
  ): Promise<PreparedConsent> {
91
- if (!consent?.earlyPerformanceRequested || !consent.withdrawalLossAcknowledged) {
98
+ const documentsRequired = options.documentsAcknowledgement === true;
99
+ if (
100
+ !consent?.earlyPerformanceRequested ||
101
+ !consent.withdrawalLossAcknowledged ||
102
+ (documentsRequired && consent.documentsAcknowledged !== true)
103
+ ) {
92
104
  throw new UnprocessableError("consent_required", {
93
105
  i18nKey: "billing-foundation.errors.consentRequired",
94
- message: "billing-foundation: both consent flags must be true to start a checkout",
106
+ message: "billing-foundation: all consent flags must be true to start a checkout",
95
107
  });
96
108
  }
97
109
  const locale = resolveConsentLocale(consent.locale);
98
- if (consent.consentTextVersion !== consentTextVersion(locale)) {
110
+ const documentsText = documentsAcknowledgementTextFor(options, locale);
111
+ if (consent.consentTextVersion !== consentTextVersion(locale, documentsText)) {
99
112
  throw new UnprocessableError("consent_text_outdated", {
100
113
  i18nKey: "billing-foundation.errors.consentTextOutdated",
101
114
  message: `billing-foundation: consent text version "${consent.consentTextVersion}" is not current for locale "${locale}"`,
@@ -128,6 +141,7 @@ export async function prepareConsent(
128
141
  locale,
129
142
  submitMessage: submitMessageFor(locale, mode),
130
143
  consentTextVersion: consent.consentTextVersion,
144
+ documentsAcknowledged: documentsRequired,
131
145
  termsHash: createHash("sha256").update(terms.content).digest("hex"),
132
146
  termsTemplateVersion: terms.version,
133
147
  };
@@ -160,6 +174,7 @@ export async function recordConsent(
160
174
  interval: details.price?.interval ?? null,
161
175
  intervalCount: details.price?.intervalCount ?? null,
162
176
  consentTextVersion: prepared.consentTextVersion,
177
+ ...(prepared.documentsAcknowledged && { documentsAcknowledged: true }),
163
178
  termsHash: prepared.termsHash,
164
179
  termsTemplateVersion: prepared.termsTemplateVersion,
165
180
  locale: prepared.locale,
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { type ConsumerProtectionOptions, isLegalLinkSet, type LegalLinkSet } from "../types.ts";
2
3
  import type { ConsentLocale } from "../web/consumer-protection/consent-locale.ts";
3
4
 
4
5
  export {
@@ -39,14 +40,50 @@ export const CONSENT_TEXTS: Readonly<Record<ConsentLocale, ConsentTexts>> = {
39
40
  },
40
41
  };
41
42
 
43
+ const DOCUMENTS_ACKNOWLEDGEMENT_TEXTS: Readonly<
44
+ Record<ConsentLocale, { readonly withRefund: string; readonly withoutRefund: string }>
45
+ > = {
46
+ de: {
47
+ withRefund:
48
+ "Ich habe die AGB und die Erstattungsrichtlinie gelesen und bin mit ihnen einverstanden.",
49
+ withoutRefund: "Ich habe die AGB gelesen und bin mit ihnen einverstanden.",
50
+ },
51
+ en: {
52
+ withRefund: "I have read and agree to the Terms and the Refund Policy.",
53
+ withoutRefund: "I have read and agree to the Terms.",
54
+ },
55
+ };
56
+
57
+ export function legalLinksPerLocale(
58
+ legalLinks: ConsumerProtectionOptions["legalLinks"],
59
+ ): Readonly<Record<ConsentLocale, LegalLinkSet>> {
60
+ return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
61
+ }
62
+
63
+ /** The documents checkbox text for a locale, or undefined when the app did not
64
+ * opt in. The variant follows whether that locale has a refund link. */
65
+ export function documentsAcknowledgementTextFor(
66
+ options: ConsumerProtectionOptions,
67
+ locale: ConsentLocale,
68
+ ): string | undefined {
69
+ if (!options.documentsAcknowledgement) return undefined;
70
+ const texts = DOCUMENTS_ACKNOWLEDGEMENT_TEXTS[locale];
71
+ return legalLinksPerLocale(options.legalLinks)[locale].refund !== undefined
72
+ ? texts.withRefund
73
+ : texts.withoutRefund;
74
+ }
75
+
42
76
  const CONSENT_TEXT_VERSION_LENGTH = 16;
43
77
 
44
- export function consentTextVersion(locale: ConsentLocale): string {
78
+ // The documents text joins the hash only when shown, so apps without the opt-in keep their version.
79
+ export function consentTextVersion(
80
+ locale: ConsentLocale,
81
+ documentsAcknowledgementText?: string,
82
+ ): string {
45
83
  const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
46
- return createHash("sha256")
47
- .update(`${earlyPerformance}\n${withdrawalLoss}`)
48
- .digest("hex")
49
- .slice(0, CONSENT_TEXT_VERSION_LENGTH);
84
+ const hash = createHash("sha256").update(`${earlyPerformance}\n${withdrawalLoss}`);
85
+ if (documentsAcknowledgementText !== undefined) hash.update(`\n${documentsAcknowledgementText}`);
86
+ return hash.digest("hex").slice(0, CONSENT_TEXT_VERSION_LENGTH);
50
87
  }
51
88
 
52
89
  export function submitMessageFor(locale: ConsentLocale, mode: "subscription" | "payment"): string {
@@ -139,6 +139,7 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
139
139
  interval: z.string().nullable(),
140
140
  intervalCount: z.number().nullable(),
141
141
  consentTextVersion: z.string().min(1).max(64),
142
+ documentsAcknowledged: z.boolean().optional(),
142
143
  termsHash: z.string().length(64),
143
144
  termsTemplateVersion: z.number(),
144
145
  locale: z.string().min(2).max(35),
@@ -13,6 +13,8 @@ import {
13
13
  type CONSENT_LOCALES,
14
14
  CONSENT_TEXTS,
15
15
  consentTextVersion,
16
+ documentsAcknowledgementTextFor,
17
+ legalLinksPerLocale,
16
18
  } from "../consumer-protection/consent-text.ts";
17
19
  import {
18
20
  type BillingPlansBase,
@@ -21,14 +23,12 @@ import {
21
23
  type ProviderPlanViews,
22
24
  toBillingPlansResult,
23
25
  } from "../plan-catalog.ts";
24
- import {
25
- type BillingPlanCatalog,
26
- type BillingPlansResult,
27
- type BillingProviderView,
28
- type ConsumerProtectionOptions,
29
- isLegalLinkSet,
30
- type LegalLinkSet,
31
- type ResolvedBillingFoundationOptions,
26
+ import type {
27
+ BillingPlanCatalog,
28
+ BillingPlansResult,
29
+ BillingProviderView,
30
+ ConsumerProtectionOptions,
31
+ ResolvedBillingFoundationOptions,
32
32
  } from "../types.ts";
33
33
 
34
34
  const billingPlansSchema = z.object({}).strict();
@@ -52,8 +52,8 @@ export function createBillingPlansQuery(
52
52
  ...result,
53
53
  consumerProtection: {
54
54
  consentTexts: {
55
- de: consentTextView("de"),
56
- en: consentTextView("en"),
55
+ de: consentTextView(consumerProtection, "de"),
56
+ en: consentTextView(consumerProtection, "en"),
57
57
  },
58
58
  legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
59
59
  },
@@ -116,13 +116,16 @@ async function providerView(
116
116
  };
117
117
  }
118
118
 
119
- function legalLinksPerLocale(
120
- legalLinks: ConsumerProtectionOptions["legalLinks"],
121
- ): Readonly<Record<(typeof CONSENT_LOCALES)[number], LegalLinkSet>> {
122
- return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
123
- }
124
-
125
- function consentTextView(locale: (typeof CONSENT_LOCALES)[number]) {
119
+ function consentTextView(
120
+ consumerProtection: ConsumerProtectionOptions,
121
+ locale: (typeof CONSENT_LOCALES)[number],
122
+ ) {
126
123
  const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
127
- return { earlyPerformance, withdrawalLoss, consentTextVersion: consentTextVersion(locale) };
124
+ const documentsAcknowledgement = documentsAcknowledgementTextFor(consumerProtection, locale);
125
+ return {
126
+ earlyPerformance,
127
+ withdrawalLoss,
128
+ ...(documentsAcknowledgement !== undefined && { documentsAcknowledgement }),
129
+ consentTextVersion: consentTextVersion(locale, documentsAcknowledgement),
130
+ };
128
131
  }
@@ -83,6 +83,7 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
83
83
  "billing-foundation.consent.link.terms": { en: "Terms" },
84
84
  "billing-foundation.consent.link.withdrawal": { en: "Withdrawal policy" },
85
85
  "billing-foundation.consent.link.privacy": { en: "Privacy policy" },
86
+ "billing-foundation.consent.link.refund": { en: "Refund policy" },
86
87
  "billing-foundation.consent.order": { en: "Order with obligation to pay" },
87
88
  "billing-foundation.plans.priceFrom": { en: "from" },
88
89
  "billing-foundation.consent.chooseProvider": { en: "Payment provider" },
@@ -459,6 +459,8 @@ export type LegalLinkSet = {
459
459
  readonly terms: string;
460
460
  readonly withdrawal: string;
461
461
  readonly privacy: string;
462
+ /** Link to the refund policy; shown next to the other links when set. */
463
+ readonly refund?: string;
462
464
  };
463
465
 
464
466
  export function isLegalLinkSet(
@@ -510,6 +512,9 @@ export type ConsumerProtectionOptions = {
510
512
  /** Root-relative paths or absolute https URLs. One set for every locale, or
511
513
  * one set per consent locale (`de` and `en` required). */
512
514
  readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
515
+ /** Adds a required first checkbox "documents read and accepted" (terms, privacy,
516
+ * and the refund policy when `legalLinks.refund` is set). Off by default. */
517
+ readonly documentsAcknowledgement?: boolean;
513
518
  };
514
519
 
515
520
  export type OneOffCheckoutRequest = {
@@ -621,6 +626,8 @@ export type BillingPlansResult = {
621
626
  {
622
627
  readonly earlyPerformance: string;
623
628
  readonly withdrawalLoss: string;
629
+ /** Present only when `documentsAcknowledgement` is on. */
630
+ readonly documentsAcknowledgement?: string;
624
631
  readonly consentTextVersion: string;
625
632
  }
626
633
  >
@@ -96,7 +96,9 @@ function validatePerLocaleLegalLinks(
96
96
 
97
97
  function validateLegalLinkSet(set: LegalLinkSet, path: string): void {
98
98
  for (const [key, link] of Object.entries(set)) {
99
- if (!isRootRelativeOrHttpsUrl(link)) {
99
+ // skip: refund is optional
100
+ if (key === "refund" && link === undefined) continue;
101
+ if (typeof link !== "string" || !isRootRelativeOrHttpsUrl(link)) {
100
102
  throw new Error(
101
103
  `createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`,
102
104
  );
@@ -3,6 +3,7 @@ import type { WriteResult } from "@cosmicdrift/kumiko-renderer/headless";
3
3
  import { ChoiceCards } from "@cosmicdrift/kumiko-renderer/web";
4
4
  import { type ReactNode, useState } from "react";
5
5
  import type { BillingPlansResult } from "../types.ts";
6
+ import { ConsentCheckboxes, ConsentLinks, useConsentFieldState } from "./consent-fields.tsx";
6
7
  import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
7
8
 
8
9
  export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
@@ -12,6 +13,7 @@ type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection
12
13
  export type CheckoutConsentPayload = {
13
14
  readonly earlyPerformanceRequested: true;
14
15
  readonly withdrawalLossAcknowledged: true;
16
+ readonly documentsAcknowledged?: true;
15
17
  readonly consentTextVersion: string;
16
18
  readonly locale: string;
17
19
  };
@@ -60,36 +62,25 @@ export function CheckoutConsentDialog({
60
62
  }: CheckoutConsentDialogProps): ReactNode {
61
63
  const t = useTranslation();
62
64
  const uiLocale = useLocale().locale();
63
- const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
64
- const [earlyPerformance, setEarlyPerformance] = useState(false);
65
- const [withdrawalLoss, setWithdrawalLoss] = useState(false);
65
+ const { Modal, Text, Button, Banner } = usePrimitives();
66
66
  const [submitting, setSubmitting] = useState(false);
67
67
  const [errorText, setErrorText] = useState<string | null>(null);
68
68
 
69
69
  const consentLocale = resolveConsentLocale(uiLocale);
70
70
  const texts = consumerProtection?.consentTexts[consentLocale];
71
71
  const legalLinks = consumerProtection?.legalLinks[consentLocale];
72
- const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
72
+ const consentFields = useConsentFieldState(texts);
73
+ const consentGiven = consumerProtection === undefined || consentFields.allGiven;
73
74
 
74
75
  async function order(): Promise<void> {
75
76
  setSubmitting(true);
76
77
  setErrorText(null);
77
- const result = await onOrder(
78
- texts === undefined
79
- ? undefined
80
- : {
81
- earlyPerformanceRequested: true,
82
- withdrawalLossAcknowledged: true,
83
- consentTextVersion: texts.consentTextVersion,
84
- locale: consentLocale,
85
- },
86
- );
78
+ const result = await onOrder(consentFields.buildPayload(consentLocale));
87
79
  if (result.isSuccess) return;
88
80
  setSubmitting(false);
89
81
  setErrorText(t(result.error.i18nKey, result.error.i18nParams));
90
82
  if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
91
- setEarlyPerformance(false);
92
- setWithdrawalLoss(false);
83
+ consentFields.reset();
93
84
  onConsentTextOutdated();
94
85
  }
95
86
  }
@@ -135,42 +126,14 @@ export function CheckoutConsentDialog({
135
126
  </Text>
136
127
  )}
137
128
  </div>
138
- {legalLinks !== undefined && (
139
- <div className="flex flex-wrap gap-4">
140
- <Link href={legalLinks.terms} target="_blank">
141
- {t("billing-foundation.consent.link.terms")}
142
- </Link>
143
- <Link href={legalLinks.withdrawal} target="_blank">
144
- {t("billing-foundation.consent.link.withdrawal")}
145
- </Link>
146
- <Link href={legalLinks.privacy} target="_blank">
147
- {t("billing-foundation.consent.link.privacy")}
148
- </Link>
149
- </div>
150
- )}
129
+ {legalLinks !== undefined && <ConsentLinks legalLinks={legalLinks} />}
151
130
  {texts !== undefined && (
152
- <>
153
- <Field id="consent-early-performance" label={texts.earlyPerformance} layout="inline">
154
- <Input
155
- kind="boolean"
156
- id="consent-early-performance"
157
- name="consent-early-performance"
158
- value={earlyPerformance}
159
- onChange={setEarlyPerformance}
160
- disabled={submitting}
161
- />
162
- </Field>
163
- <Field id="consent-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
164
- <Input
165
- kind="boolean"
166
- id="consent-withdrawal-loss"
167
- name="consent-withdrawal-loss"
168
- value={withdrawalLoss}
169
- onChange={setWithdrawalLoss}
170
- disabled={submitting}
171
- />
172
- </Field>
173
- </>
131
+ <ConsentCheckboxes
132
+ idPrefix="consent"
133
+ texts={texts}
134
+ state={consentFields}
135
+ disabled={submitting}
136
+ />
174
137
  )}
175
138
  {errorText !== null && (
176
139
  <Banner variant="error" testId="checkout-consent-error">
@@ -0,0 +1,111 @@
1
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { type ReactNode, useState } from "react";
3
+ import type { BillingPlansResult, LegalLinkSet } from "../types.ts";
4
+ import type { CheckoutConsentPayload } from "./checkout-consent-dialog.tsx";
5
+
6
+ type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
7
+ export type ConsentTextView = ConsumerProtectionView["consentTexts"]["de"];
8
+
9
+ export type ConsentChecks = {
10
+ readonly documentsAcknowledged: boolean;
11
+ readonly earlyPerformance: boolean;
12
+ readonly withdrawalLoss: boolean;
13
+ };
14
+
15
+ const NOTHING_CHECKED: ConsentChecks = {
16
+ documentsAcknowledged: false,
17
+ earlyPerformance: false,
18
+ withdrawalLoss: false,
19
+ };
20
+
21
+ export type ConsentFieldState = {
22
+ readonly checks: ConsentChecks;
23
+ readonly setCheck: (key: keyof ConsentChecks, value: boolean) => void;
24
+ readonly reset: () => void;
25
+ readonly allGiven: boolean;
26
+ readonly buildPayload: (locale: string) => CheckoutConsentPayload | undefined;
27
+ };
28
+
29
+ export function useConsentFieldState(texts: ConsentTextView | undefined): ConsentFieldState {
30
+ const [checks, setChecks] = useState<ConsentChecks>(NOTHING_CHECKED);
31
+ const documentsRequired = texts?.documentsAcknowledgement !== undefined;
32
+ return {
33
+ checks,
34
+ setCheck: (key, value) => setChecks((current) => ({ ...current, [key]: value })),
35
+ reset: () => setChecks(NOTHING_CHECKED),
36
+ allGiven:
37
+ checks.earlyPerformance &&
38
+ checks.withdrawalLoss &&
39
+ (!documentsRequired || checks.documentsAcknowledged),
40
+ buildPayload: (locale) =>
41
+ texts === undefined
42
+ ? undefined
43
+ : {
44
+ earlyPerformanceRequested: true,
45
+ withdrawalLossAcknowledged: true,
46
+ ...(documentsRequired && { documentsAcknowledged: true }),
47
+ consentTextVersion: texts.consentTextVersion,
48
+ locale,
49
+ },
50
+ };
51
+ }
52
+
53
+ export function ConsentLinks({ legalLinks }: { readonly legalLinks: LegalLinkSet }): ReactNode {
54
+ const t = useTranslation();
55
+ const { Link } = usePrimitives();
56
+ return (
57
+ <div className="flex flex-wrap gap-4">
58
+ <Link href={legalLinks.terms} target="_blank">
59
+ {t("billing-foundation.consent.link.terms")}
60
+ </Link>
61
+ <Link href={legalLinks.withdrawal} target="_blank">
62
+ {t("billing-foundation.consent.link.withdrawal")}
63
+ </Link>
64
+ <Link href={legalLinks.privacy} target="_blank">
65
+ {t("billing-foundation.consent.link.privacy")}
66
+ </Link>
67
+ {legalLinks.refund !== undefined && (
68
+ <Link href={legalLinks.refund} target="_blank">
69
+ {t("billing-foundation.consent.link.refund")}
70
+ </Link>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export function ConsentCheckboxes({
77
+ idPrefix,
78
+ texts,
79
+ state,
80
+ disabled,
81
+ }: {
82
+ readonly idPrefix: string;
83
+ readonly texts: ConsentTextView;
84
+ readonly state: ConsentFieldState;
85
+ readonly disabled: boolean;
86
+ }): ReactNode {
87
+ const { Field, Input } = usePrimitives();
88
+ const boxes: readonly { key: keyof ConsentChecks; slug: string; label: string | undefined }[] = [
89
+ { key: "documentsAcknowledged", slug: "documents", label: texts.documentsAcknowledgement },
90
+ { key: "earlyPerformance", slug: "early-performance", label: texts.earlyPerformance },
91
+ { key: "withdrawalLoss", slug: "withdrawal-loss", label: texts.withdrawalLoss },
92
+ ];
93
+ return (
94
+ <>
95
+ {boxes.map(({ key, slug, label }) =>
96
+ label === undefined ? null : (
97
+ <Field key={key} id={`${idPrefix}-${slug}`} label={label} layout="inline">
98
+ <Input
99
+ kind="boolean"
100
+ id={`${idPrefix}-${slug}`}
101
+ name={`${idPrefix}-${slug}`}
102
+ value={state.checks[key]}
103
+ onChange={(value) => state.setCheck(key, value)}
104
+ disabled={disabled}
105
+ />
106
+ </Field>
107
+ ),
108
+ )}
109
+ </>
110
+ );
111
+ }
@@ -8,10 +8,8 @@ import {
8
8
  import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
9
9
  import { type ReactNode, useState } from "react";
10
10
  import type { BillingPlansResult, PlanSwitchPreview } from "../types.ts";
11
- import {
12
- type CheckoutConsentPayload,
13
- CONSENT_TEXT_OUTDATED_CODE,
14
- } from "./checkout-consent-dialog.tsx";
11
+ import { CONSENT_TEXT_OUTDATED_CODE } from "./checkout-consent-dialog.tsx";
12
+ import { ConsentCheckboxes, ConsentLinks, useConsentFieldState } from "./consent-fields.tsx";
15
13
  import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.ts";
16
14
  import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
17
15
  import { intervalKey } from "./plan-price.ts";
@@ -53,7 +51,7 @@ export function PlanSwitchConfirmDialog({
53
51
  }: PlanSwitchConfirmDialogProps): ReactNode {
54
52
  const t = useTranslation();
55
53
  const locale = useLocale().locale();
56
- const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
54
+ const { Modal, Text, Button, Banner } = usePrimitives();
57
55
  const previewQuery = useQuery<PlanSwitchPreviewReply>(
58
56
  SubscriptionFoundationQueries.planSwitchPreview,
59
57
  { tier },
@@ -61,28 +59,19 @@ export function PlanSwitchConfirmDialog({
61
59
  const switchMutation = useMutation<{ readonly applied: true }>(
62
60
  SubscriptionFoundationHandlers.switchPlan,
63
61
  );
64
- const [earlyPerformance, setEarlyPerformance] = useState(false);
65
- const [withdrawalLoss, setWithdrawalLoss] = useState(false);
66
62
  const [submitting, setSubmitting] = useState(false);
67
63
  const [errorText, setErrorText] = useState<string | null>(null);
68
64
 
69
65
  const consentLocale = resolveConsentLocale(locale);
70
66
  const texts = consumerProtection?.consentTexts[consentLocale];
71
67
  const legalLinks = consumerProtection?.legalLinks[consentLocale];
72
- const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
68
+ const consentFields = useConsentFieldState(texts);
69
+ const consentGiven = consumerProtection === undefined || consentFields.allGiven;
73
70
 
74
71
  async function confirm(): Promise<void> {
75
72
  setSubmitting(true);
76
73
  setErrorText(null);
77
- const consent: CheckoutConsentPayload | undefined =
78
- texts === undefined
79
- ? undefined
80
- : {
81
- earlyPerformanceRequested: true,
82
- withdrawalLossAcknowledged: true,
83
- consentTextVersion: texts.consentTextVersion,
84
- locale: consentLocale,
85
- };
74
+ const consent = consentFields.buildPayload(consentLocale);
86
75
  const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
87
76
  if (result.isSuccess) {
88
77
  onApplied();
@@ -91,8 +80,7 @@ export function PlanSwitchConfirmDialog({
91
80
  setSubmitting(false);
92
81
  setErrorText(t(result.error.i18nKey, result.error.i18nParams));
93
82
  if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
94
- setEarlyPerformance(false);
95
- setWithdrawalLoss(false);
83
+ consentFields.reset();
96
84
  onConsentTextOutdated();
97
85
  }
98
86
  }
@@ -160,46 +148,14 @@ export function PlanSwitchConfirmDialog({
160
148
  <Text variant="muted">{t("billing-foundation.planSwitch.pendingNote")}</Text>
161
149
  </div>
162
150
  )}
163
- {legalLinks !== undefined && (
164
- <div className="flex flex-wrap gap-4">
165
- <Link href={legalLinks.terms} target="_blank">
166
- {t("billing-foundation.consent.link.terms")}
167
- </Link>
168
- <Link href={legalLinks.withdrawal} target="_blank">
169
- {t("billing-foundation.consent.link.withdrawal")}
170
- </Link>
171
- <Link href={legalLinks.privacy} target="_blank">
172
- {t("billing-foundation.consent.link.privacy")}
173
- </Link>
174
- </div>
175
- )}
151
+ {legalLinks !== undefined && <ConsentLinks legalLinks={legalLinks} />}
176
152
  {texts !== undefined && (
177
- <>
178
- <Field
179
- id="plan-switch-early-performance"
180
- label={texts.earlyPerformance}
181
- layout="inline"
182
- >
183
- <Input
184
- kind="boolean"
185
- id="plan-switch-early-performance"
186
- name="plan-switch-early-performance"
187
- value={earlyPerformance}
188
- onChange={setEarlyPerformance}
189
- disabled={submitting}
190
- />
191
- </Field>
192
- <Field id="plan-switch-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
193
- <Input
194
- kind="boolean"
195
- id="plan-switch-withdrawal-loss"
196
- name="plan-switch-withdrawal-loss"
197
- value={withdrawalLoss}
198
- onChange={setWithdrawalLoss}
199
- disabled={submitting}
200
- />
201
- </Field>
202
- </>
153
+ <ConsentCheckboxes
154
+ idPrefix="plan-switch"
155
+ texts={texts}
156
+ state={consentFields}
157
+ disabled={submitting}
158
+ />
203
159
  )}
204
160
  {errorText !== null && (
205
161
  <Banner variant="error" testId="plan-switch-confirm-error">
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.399.0",
4
+ "type": "improvement",
5
+ "title": "Inbox cursor, total and navigation target for the shell bell",
6
+ "detail": "`channel-in-app:query:inbox` accepts a `cursor` and answers `{ rows, nextCursor, total }`; each row carries `target` (`{ screenId, entityId? }` or null) parsed from `data.target`. Build the target with `inAppTarget(screenId, entityId?)`; a malformed target is dropped and the notification is still delivered. The SSE frame `channel-in-app:event:delivered` now carries only `{ id, userId, notificationType }`, and a failing push no longer fails the delivery because the row is already stored. The shared contract lives in `@cosmicdrift/kumiko-types/in-app-notification-types`."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -1,11 +1,11 @@
1
- export const CHANNEL_IN_APP_FEATURE = "channel-in-app" as const;
1
+ import {
2
+ IN_APP_NOTIFICATION_FEATURE,
3
+ IN_APP_NOTIFICATION_QUERIES,
4
+ IN_APP_NOTIFICATION_WRITES,
5
+ } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
6
 
3
- export const InAppHandlers = {
4
- markRead: "channel-in-app:write:mark-read",
5
- markAllRead: "channel-in-app:write:mark-all-read",
6
- } as const;
7
+ export const CHANNEL_IN_APP_FEATURE = IN_APP_NOTIFICATION_FEATURE;
7
8
 
8
- export const InAppQueries = {
9
- inbox: "channel-in-app:query:inbox",
10
- unreadCount: "channel-in-app:query:unread-count",
11
- } as const;
9
+ export const InAppHandlers = IN_APP_NOTIFICATION_WRITES;
10
+
11
+ export const InAppQueries = IN_APP_NOTIFICATION_QUERIES;
@@ -10,7 +10,7 @@ import { inAppMessagesTableMeta } from "./tables.ts";
10
10
  export function createChannelInAppFeature(): FeatureDefinition {
11
11
  return defineFeature("channel-in-app", (r) => {
12
12
  r.describe(
13
- "Persists notifications to an in-app inbox table so users can retrieve them via `handlers.inbox` and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. Requires `delivery`; no external service needed \u2014 messages are stored in the app's own database.",
13
+ "Persists notifications to an in-app inbox table so users can page through them via `queries.inbox` (cursor, total, parsed `target`) and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. A notification links to a screen when `data.target` is set with `inAppTarget(screenId, entityId?)`. The renderer shell shows a bell with a live unread badge whenever this feature is mounted. Requires `delivery`; no external service needed \u2014 messages are stored in the app's own database.",
14
14
  );
15
15
  r.uiHints({
16
16
  displayLabel: "In-App Inbox",
@@ -1,13 +1,17 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import * as z from "zod";
4
+ import { parseStoredNotificationData, targetOfNotificationData } from "../in-app-target.ts";
4
5
  import { inAppMessagesTable } from "../tables.ts";
5
6
 
7
+ const SERIAL_ID_PATTERN = /^\d{1,15}$/;
8
+
6
9
  export const inboxQuery = defineQueryHandler({
7
10
  name: "inbox",
8
11
  schema: z.object({
9
12
  limit: z.number().min(1).max(100).default(50),
10
13
  unreadOnly: z.boolean().default(false),
14
+ cursor: z.string().regex(SERIAL_ID_PATTERN).optional(),
11
15
  }),
12
16
  access: {
13
17
  openToAll: {
@@ -17,14 +21,34 @@ export const inboxQuery = defineQueryHandler({
17
21
  },
18
22
  },
19
23
  description:
20
- "Lists the calling user's own in-app notification messages newest-first, optionally only the unread ones; use it to show or inspect a user's notification inbox.",
24
+ "Lists the calling user's own in-app notification messages newest-first with a parsed navigation target, optionally only the unread ones. Pass the returned nextCursor as cursor for the next page; total counts all matching messages, not just the page. Use it to show or inspect a user's notification inbox.",
21
25
  handler: async (query, ctx) => {
22
- const where: Record<string, unknown> = { userId: query.user.id };
23
- if (query.payload.unreadOnly) where["isRead"] = false;
24
- const rows = await selectMany(ctx.db, inAppMessagesTable, where, {
25
- limit: query.payload.limit,
26
- orderBy: { col: "createdAt", direction: "desc" },
26
+ const { limit, unreadOnly, cursor } = query.payload;
27
+ const ownMessages: Record<string, unknown> = { userId: query.user.id };
28
+ if (unreadOnly) ownMessages["isRead"] = false;
29
+
30
+ const pageWhere =
31
+ cursor === undefined ? ownMessages : { ...ownMessages, id: { lt: Number(cursor) } };
32
+ // One extra row tells whether another page exists without a second query.
33
+ const fetched = await selectMany(ctx.db, inAppMessagesTable, pageWhere, {
34
+ limit: limit + 1,
35
+ orderBy: { col: "id", direction: "desc" },
36
+ });
37
+ const page = fetched.slice(0, limit);
38
+ const lastRow = page[page.length - 1];
39
+ const nextCursor = fetched.length > limit && lastRow ? String(lastRow["id"]) : null;
40
+
41
+ const [totalRow] = await ctx.db.aggregate(
42
+ inAppMessagesTable,
43
+ { measure: { fn: "count" } },
44
+ ownMessages,
45
+ );
46
+
47
+ const rows = page.map((row) => {
48
+ const storedData = row["data"];
49
+ const data = parseStoredNotificationData(typeof storedData === "string" ? storedData : null);
50
+ return { ...row, target: targetOfNotificationData(data) };
27
51
  });
28
- return { rows };
52
+ return { rows, nextCursor, total: totalRow?.value ?? 0 };
29
53
  },
30
54
  });
@@ -1,4 +1,3 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
1
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import * as z from "zod";
4
3
  import { inAppMessagesTable } from "../tables.ts";
@@ -16,13 +15,11 @@ export const unreadCountQuery = defineQueryHandler({
16
15
  description:
17
16
  "Counts the calling user's unread in-app notification messages; use it for an inbox badge or to check whether a user has pending notifications.",
18
17
  handler: async (query, ctx) => {
19
- // The query helpers in db/query.ts have no aggregate helper, so selectMany returns all matching
20
- // rows and count() runs in JS. This is acceptable because unread counts are low-cardinality and
21
- // user-scoped, with at most a few hundred rows; use raw .unsafe() with COUNT(*) if that grows.
22
- const rows = await selectMany(ctx.db, inAppMessagesTable, {
23
- userId: query.user.id,
24
- isRead: false,
25
- });
26
- return { count: rows.length };
18
+ const [countRow] = await ctx.db.aggregate(
19
+ inAppMessagesTable,
20
+ { measure: { fn: "count" } },
21
+ { userId: query.user.id, isRead: false },
22
+ );
23
+ return { count: countRow?.value ?? 0 };
27
24
  },
28
25
  });
@@ -1,7 +1,12 @@
1
1
  import { tenantChannel } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
3
+ import { IN_APP_NOTIFICATION_DELIVERED_EVENT } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
4
  import type { DeliveryChannel } from "../delivery/index.ts";
5
+ import { targetOfNotificationData } from "./in-app-target.ts";
3
6
  import { inAppMessagesTable } from "./tables.ts";
4
7
 
8
+ const log = createFallbackLogger("channel-in-app");
9
+
5
10
  export const inAppChannel: DeliveryChannel = {
6
11
  name: "inApp",
7
12
  mode: "inline",
@@ -15,25 +20,34 @@ export const inAppChannel: DeliveryChannel = {
15
20
  // address is the user-id string after the ES migration — keep it as-is.
16
21
  const userId = address;
17
22
 
23
+ // A malformed target drops only the link, never the notification.
24
+ const target = targetOfNotificationData(message.data);
25
+ const storedData =
26
+ message.data === undefined
27
+ ? null
28
+ : JSON.stringify({ ...message.data, target: target ?? undefined });
29
+
18
30
  const row = await ctx.db.insertOne<{ id: string }>(inAppMessagesTable, {
19
31
  userId,
20
32
  notificationType: message.notificationType,
21
33
  title: message.title,
22
34
  body: message.body ?? null,
23
- data: message.data ? JSON.stringify(message.data) : null,
35
+ data: storedData,
24
36
  });
25
37
 
26
38
  if (ctx.sseBroker) {
27
- await ctx.sseBroker.pushToChannel(tenantChannel(ctx.tenantId), {
28
- type: "channel-in-app:event:delivered",
29
- data: {
30
- id: row?.id,
31
- userId,
32
- notificationType: message.notificationType,
33
- title: message.title,
34
- body: message.body,
35
- },
36
- });
39
+ // The row is stored, so a failing push (e.g. Redis down) must not turn the delivery into
40
+ // a failure that gets retried and stored twice. Clients fetch the inbox on reconnect.
41
+ try {
42
+ await ctx.sseBroker.pushToChannel(tenantChannel(ctx.tenantId), {
43
+ type: IN_APP_NOTIFICATION_DELIVERED_EVENT,
44
+ data: { id: row?.id, userId, notificationType: message.notificationType },
45
+ });
46
+ } catch (error) {
47
+ log.warn("in-app SSE push failed; the notification is stored", {
48
+ error: error instanceof Error ? error.message : String(error),
49
+ });
50
+ }
37
51
  }
38
52
 
39
53
  return { status: "sent", address };
@@ -0,0 +1,37 @@
1
+ import type { InAppNotificationTarget } from "@cosmicdrift/kumiko-types/in-app-notification-types";
2
+ import * as z from "zod";
3
+
4
+ const SCREEN_ID_PATTERN = /^[^\s:]+:screen:[^\s:]+$/;
5
+
6
+ const targetSchema = z.object({
7
+ screenId: z.string().regex(SCREEN_ID_PATTERN),
8
+ entityId: z.string().min(1).optional(),
9
+ });
10
+
11
+ export function inAppTarget(screenId: string, entityId?: string): InAppNotificationTarget {
12
+ return entityId === undefined ? { screenId } : { screenId, entityId };
13
+ }
14
+
15
+ // Returns null for anything that is not a well-formed target, so a malformed `data.target`
16
+ // drops only the link and never the notification.
17
+ export function parseInAppTarget(value: unknown): InAppNotificationTarget | null {
18
+ const parsed = targetSchema.safeParse(value);
19
+ if (!parsed.success) return null;
20
+ const { screenId, entityId } = parsed.data;
21
+ return inAppTarget(screenId, entityId);
22
+ }
23
+
24
+ // Reads `target` out of the stored notification data (a JSON string) or out of a raw data object.
25
+ export function targetOfNotificationData(data: unknown): InAppNotificationTarget | null {
26
+ if (typeof data !== "object" || data === null || !("target" in data)) return null;
27
+ return parseInAppTarget(data.target);
28
+ }
29
+
30
+ export function parseStoredNotificationData(stored: string | null): unknown {
31
+ if (stored === null) return null;
32
+ try {
33
+ return JSON.parse(stored);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
@@ -1,4 +1,5 @@
1
1
  export { CHANNEL_IN_APP_FEATURE, InAppHandlers, InAppQueries } from "./constants.ts";
2
2
  export { createChannelInAppFeature } from "./feature.ts";
3
3
  export { inAppChannel } from "./in-app-channel.ts";
4
+ export { inAppTarget } from "./in-app-target.ts";
4
5
  export { inAppMessagesTable } from "./tables.ts";
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.399.0",
4
+ "type": "improvement",
5
+ "title": "Metric access roles and group names from a source table",
6
+ "detail": "A metric declares `access: { roles }` to open its tenant query to roles other than the admin roles; an empty list fails boot validation and no `access` keeps the admin-only default. A `groupBy` metric on an entity-table source whose column is a single reference names the rows after the referenced row's label field; the lookup runs through the caller's read access on the target entity, so unreadable or foreign-tenant rows keep their key, and static `groupLabels` still win."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -26,6 +26,10 @@ export type MetricRunParams = {
26
26
  readonly filterWhere: WhereObject;
27
27
  /** Extra filter that narrows the source to the caller's scope. */
28
28
  readonly scopeWhere: WhereObject;
29
+ /** Names for referenced-row group keys; absent where the caller cannot resolve them (system scope). */
30
+ readonly referenceLabels?: (
31
+ keys: readonly AggregateKey[],
32
+ ) => Promise<ReadonlyMap<string, string>>;
29
33
  };
30
34
 
31
35
  const NULL_KEY_TEXT = "—";
@@ -153,6 +157,7 @@ function buildRows(
153
157
  aggregates: MetricAggregates,
154
158
  total: number | null,
155
159
  labelOf: (key: string) => string,
160
+ groupLabelOf: (key: string) => string,
156
161
  ): readonly MetricRow[] {
157
162
  const segmentsByGroup = groupSegments(aggregates.stacks, labelOf);
158
163
  return aggregates.groups.map((row) => {
@@ -161,7 +166,7 @@ function buildRows(
161
166
  return {
162
167
  id: key,
163
168
  key,
164
- label: labelOf(key),
169
+ label: groupLabelOf(key),
165
170
  value: rowValue,
166
171
  fraction: fractionOf(rowValue, total),
167
172
  ...(metric.stackBy !== undefined && { segments: segmentsByGroup.get(key) ?? [] }),
@@ -188,6 +193,15 @@ function buildSeries(
188
193
  }));
189
194
  }
190
195
 
196
+ async function resolveReferencedGroupLabels(
197
+ metric: MetricDefinition,
198
+ params: MetricRunParams,
199
+ groups: readonly AggregateRow[],
200
+ ): Promise<ReadonlyMap<string, string>> {
201
+ if (params.referenceLabels === undefined || metric.groupBy === undefined) return new Map();
202
+ return params.referenceLabels(groups.map((row) => row.keys[0] ?? null));
203
+ }
204
+
191
205
  export async function runMetric(
192
206
  db: TenantDb,
193
207
  metric: MetricDefinition,
@@ -230,7 +244,10 @@ export async function runMetric(
230
244
  ),
231
245
  )
232
246
  : [];
233
- const rows = buildRows(metric, aggregates, value, labelOf);
247
+ const referencedLabels = await resolveReferencedGroupLabels(metric, params, aggregates.groups);
248
+ const groupLabelOf = (key: string): string =>
249
+ metric.groupLabels?.[key] ?? referencedLabels.get(key) ?? key;
250
+ const rows = buildRows(metric, aggregates, value, labelOf, groupLabelOf);
234
251
 
235
252
  return {
236
253
  value,
@@ -1,9 +1,11 @@
1
+ import { extractTableInfo } from "@cosmicdrift/kumiko-framework/db";
1
2
  import {
2
3
  access,
3
4
  defineFeature,
4
5
  defineQueryHandler,
5
6
  type FeatureDefinition,
6
7
  type QueryHandlerDefinition,
8
+ referenceGroupLabels,
7
9
  } from "@cosmicdrift/kumiko-framework/engine";
8
10
  import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
9
11
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
@@ -78,7 +80,7 @@ function createTenantMetricQuery(
78
80
  name: metric.id,
79
81
  description: metric.description,
80
82
  schema: tenantPayloadSchema(metric),
81
- access: { roles: access.admin },
83
+ access: { roles: metric.access?.roles ?? access.admin },
82
84
  handler: async (query, ctx) =>
83
85
  runMetric(ctx.db, metric, {
84
86
  range: query.payload.range,
@@ -87,6 +89,17 @@ function createTenantMetricQuery(
87
89
  filterWhere: await resolveMetricFilterWhere(metric, query.payload, ctx),
88
90
  // ctx.db already reads own-tenant + SYSTEM reference rows; this narrows to own-tenant only.
89
91
  scopeWhere: { tenantId: query.user.tenantId },
92
+ referenceLabels: (keys) =>
93
+ metric.groupBy === undefined
94
+ ? Promise.resolve(new Map())
95
+ : referenceGroupLabels(
96
+ ctx,
97
+ query.user,
98
+ extractTableInfo(metric.source).name,
99
+ metric.groupBy,
100
+ keys,
101
+ ctx.db,
102
+ ),
90
103
  }),
91
104
  });
92
105
  }
@@ -41,8 +41,10 @@ export type MetricDefinition = {
41
41
  readonly groupBy?: string;
42
42
  /** Second dimension, becomes `rows[].segments`; only without bucket. */
43
43
  readonly stackBy?: string;
44
- /** Key value → i18n key, applied to groupBy and stackBy keys. */
44
+ /** Key value → i18n key, applied to groupBy and stackBy keys. Without an entry, a groupBy that is a single reference of an entity-table source is named after the referenced row (tenant scope, through the caller's read access); otherwise the key stays. */
45
45
  readonly groupLabels?: Readonly<Record<string, string>>;
46
+ /** Roles that may read the tenant query; default is the admin roles. Ignored for scope "system", which stays SystemAdmin only. */
47
+ readonly access?: { readonly roles: readonly string[] };
46
48
  readonly scopes: readonly MetricScope[];
47
49
  };
48
50
 
@@ -31,6 +31,9 @@ function validateMetric(metric: MetricDefinition): void {
31
31
  };
32
32
  if (!KEBAB_ID.test(metric.id)) fail("has an id that is not kebab-case");
33
33
  if (metric.scopes.length === 0) fail("declares no scopes");
34
+ if (metric.access !== undefined && metric.access.roles.length === 0) {
35
+ fail("declares access with an empty roles list");
36
+ }
34
37
 
35
38
  const info = extractTableInfo(metric.source);
36
39
  validateColumns(metric, info, fail);
@@ -472,7 +472,7 @@
472
472
  },
473
473
  {
474
474
  "name": "channel-in-app",
475
- "description": "Persists notifications to an in-app inbox table so users can retrieve them via `handlers.inbox` and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. Requires `delivery`; no external service needed — messages are stored in the app's own database.",
475
+ "description": "Persists notifications to an in-app inbox table so users can page through them via `queries.inbox` (cursor, total, parsed `target`) and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. A notification links to a screen when `data.target` is set with `inAppTarget(screenId, entityId?)`. The renderer shell shows a bell with a live unread badge whenever this feature is mounted. Requires `delivery`; no external service needed — messages are stored in the app's own database.",
476
476
  "toggleableDefault": null,
477
477
  "requires": [
478
478
  "delivery"