@cosmicdrift/kumiko-bundled-features 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.
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +2 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +11 -4
- package/dist/billing-foundation/consumer-protection/consent-text.d.ts +6 -1
- package/dist/billing-foundation/consumer-protection/consent-text.js +30 -5
- package/dist/billing-foundation/events.d.ts +1 -0
- package/dist/billing-foundation/events.js +1 -0
- package/dist/billing-foundation/handlers/billing-plans.query.d.ts +1 -1
- package/dist/billing-foundation/handlers/billing-plans.query.js +11 -9
- package/dist/billing-foundation/i18n.js +1 -0
- package/dist/billing-foundation/types.d.ts +7 -0
- package/dist/billing-foundation/validate-options.js +4 -1
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.js +7 -15
- package/dist/billing-foundation/web/consent-fields.d.ts +28 -0
- package/dist/billing-foundation/web/consent-fields.js +43 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +9 -17
- package/dist/channel-in-app/constants.js +4 -9
- package/dist/channel-in-app/feature.js +1 -1
- package/dist/channel-in-app/handlers/inbox.query.d.ts +4 -1
- package/dist/channel-in-app/handlers/inbox.query.js +23 -8
- package/dist/channel-in-app/handlers/unread-count.query.js +2 -9
- package/dist/channel-in-app/in-app-channel.js +23 -11
- package/dist/channel-in-app/in-app-target.d.ts +5 -0
- package/dist/channel-in-app/in-app-target.js +34 -0
- package/dist/channel-in-app/index.d.ts +1 -0
- package/dist/channel-in-app/index.js +1 -0
- package/dist/metrics/compute.d.ts +3 -0
- package/dist/metrics/compute.js +10 -3
- package/dist/metrics/feature.js +6 -2
- package/dist/metrics/types.d.ts +5 -1
- package/dist/metrics/validate.js +3 -0
- package/package.json +14 -14
- package/src/billing-foundation/changes.json +12 -0
- package/src/channel-in-app/changes.json +6 -0
- package/src/metrics/changes.json +6 -0
|
@@ -5,6 +5,7 @@ export type CheckoutMode = "subscription" | "payment";
|
|
|
5
5
|
export declare const consentPayloadSchema: z.ZodObject<{
|
|
6
6
|
earlyPerformanceRequested: z.ZodBoolean;
|
|
7
7
|
withdrawalLossAcknowledged: z.ZodBoolean;
|
|
8
|
+
documentsAcknowledged: z.ZodOptional<z.ZodBoolean>;
|
|
8
9
|
consentTextVersion: z.ZodString;
|
|
9
10
|
locale: z.ZodString;
|
|
10
11
|
}, z.core.$strict>;
|
|
@@ -15,6 +16,7 @@ export type PreparedConsent = {
|
|
|
15
16
|
readonly locale: string;
|
|
16
17
|
readonly submitMessage: string;
|
|
17
18
|
readonly consentTextVersion: string;
|
|
19
|
+
readonly documentsAcknowledged: boolean;
|
|
18
20
|
readonly termsHash: string;
|
|
19
21
|
readonly termsTemplateVersion: number;
|
|
20
22
|
};
|
|
@@ -8,13 +8,14 @@ import * as z from "zod";
|
|
|
8
8
|
import { requireTemplateResolver, TEXT_BLOCK_KIND, TemplateNotFoundError, } from "../../template-resolver/index.js";
|
|
9
9
|
import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
|
|
10
10
|
import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, checkoutConsentRecordedPayloadSchema, PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
|
|
11
|
-
import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./consent-text.js";
|
|
11
|
+
import { consentTextVersion, documentsAcknowledgementTextFor, resolveConsentLocale, submitMessageFor, } from "./consent-text.js";
|
|
12
12
|
// Optional at the schema level so a missing/unchecked consent reaches
|
|
13
13
|
// prepareConsent and fails as 422 consent_required, not as a zod 400.
|
|
14
14
|
export const consentPayloadSchema = z
|
|
15
15
|
.object({
|
|
16
16
|
earlyPerformanceRequested: z.boolean(),
|
|
17
17
|
withdrawalLossAcknowledged: z.boolean(),
|
|
18
|
+
documentsAcknowledged: z.boolean().optional(),
|
|
18
19
|
consentTextVersion: z.string().min(1).max(64),
|
|
19
20
|
locale: z.string().min(2).max(35),
|
|
20
21
|
})
|
|
@@ -39,14 +40,18 @@ export function assertValidOrderItem(item, priceId) {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
export async function prepareConsent(ctx, options, consent, mode) {
|
|
42
|
-
|
|
43
|
+
const documentsRequired = options.documentsAcknowledgement === true;
|
|
44
|
+
if (!consent?.earlyPerformanceRequested ||
|
|
45
|
+
!consent.withdrawalLossAcknowledged ||
|
|
46
|
+
(documentsRequired && consent.documentsAcknowledged !== true)) {
|
|
43
47
|
throw new UnprocessableError("consent_required", {
|
|
44
48
|
i18nKey: "billing-foundation.errors.consentRequired",
|
|
45
|
-
message: "billing-foundation:
|
|
49
|
+
message: "billing-foundation: all consent flags must be true to start a checkout",
|
|
46
50
|
});
|
|
47
51
|
}
|
|
48
52
|
const locale = resolveConsentLocale(consent.locale);
|
|
49
|
-
|
|
53
|
+
const documentsText = documentsAcknowledgementTextFor(options, locale);
|
|
54
|
+
if (consent.consentTextVersion !== consentTextVersion(locale, documentsText)) {
|
|
50
55
|
throw new UnprocessableError("consent_text_outdated", {
|
|
51
56
|
i18nKey: "billing-foundation.errors.consentTextOutdated",
|
|
52
57
|
message: `billing-foundation: consent text version "${consent.consentTextVersion}" is not current for locale "${locale}"`,
|
|
@@ -78,6 +83,7 @@ export async function prepareConsent(ctx, options, consent, mode) {
|
|
|
78
83
|
locale,
|
|
79
84
|
submitMessage: submitMessageFor(locale, mode),
|
|
80
85
|
consentTextVersion: consent.consentTextVersion,
|
|
86
|
+
documentsAcknowledged: documentsRequired,
|
|
81
87
|
termsHash: createHash("sha256").update(terms.content).digest("hex"),
|
|
82
88
|
termsTemplateVersion: terms.version,
|
|
83
89
|
};
|
|
@@ -100,6 +106,7 @@ export async function recordConsent(ctx, prepared, details) {
|
|
|
100
106
|
interval: details.price?.interval ?? null,
|
|
101
107
|
intervalCount: details.price?.intervalCount ?? null,
|
|
102
108
|
consentTextVersion: prepared.consentTextVersion,
|
|
109
|
+
...(prepared.documentsAcknowledged && { documentsAcknowledged: true }),
|
|
103
110
|
termsHash: prepared.termsHash,
|
|
104
111
|
termsTemplateVersion: prepared.termsTemplateVersion,
|
|
105
112
|
locale: prepared.locale,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConsumerProtectionOptions, type LegalLinkSet } from "../types.ts";
|
|
1
2
|
import type { ConsentLocale } from "../web/consumer-protection/consent-locale.ts";
|
|
2
3
|
export { CONSENT_LOCALES, type ConsentLocale, FALLBACK_CONSENT_LOCALE, resolveConsentLocale, } from "../web/consumer-protection/consent-locale.ts";
|
|
3
4
|
export type ConsentTexts = {
|
|
@@ -7,5 +8,9 @@ export type ConsentTexts = {
|
|
|
7
8
|
readonly paymentSubmitMessage: string;
|
|
8
9
|
};
|
|
9
10
|
export declare const CONSENT_TEXTS: Readonly<Record<ConsentLocale, ConsentTexts>>;
|
|
10
|
-
export declare function
|
|
11
|
+
export declare function legalLinksPerLocale(legalLinks: ConsumerProtectionOptions["legalLinks"]): Readonly<Record<ConsentLocale, LegalLinkSet>>;
|
|
12
|
+
/** The documents checkbox text for a locale, or undefined when the app did not
|
|
13
|
+
* opt in. The variant follows whether that locale has a refund link. */
|
|
14
|
+
export declare function documentsAcknowledgementTextFor(options: ConsumerProtectionOptions, locale: ConsentLocale): string | undefined;
|
|
15
|
+
export declare function consentTextVersion(locale: ConsentLocale, documentsAcknowledgementText?: string): string;
|
|
11
16
|
export declare function submitMessageFor(locale: ConsentLocale, mode: "subscription" | "payment"): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { isLegalLinkSet } from "../types.js";
|
|
2
3
|
export { CONSENT_LOCALES, FALLBACK_CONSENT_LOCALE, resolveConsentLocale, } from "../web/consumer-protection/consent-locale.js";
|
|
3
4
|
export const CONSENT_TEXTS = {
|
|
4
5
|
de: {
|
|
@@ -14,13 +15,37 @@ export const CONSENT_TEXTS = {
|
|
|
14
15
|
paymentSubmitMessage: "Paid one-off payment: by clicking the button you commit to paying.",
|
|
15
16
|
},
|
|
16
17
|
};
|
|
18
|
+
const DOCUMENTS_ACKNOWLEDGEMENT_TEXTS = {
|
|
19
|
+
de: {
|
|
20
|
+
withRefund: "Ich habe die AGB und die Erstattungsrichtlinie gelesen und bin mit ihnen einverstanden.",
|
|
21
|
+
withoutRefund: "Ich habe die AGB gelesen und bin mit ihnen einverstanden.",
|
|
22
|
+
},
|
|
23
|
+
en: {
|
|
24
|
+
withRefund: "I have read and agree to the Terms and the Refund Policy.",
|
|
25
|
+
withoutRefund: "I have read and agree to the Terms.",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export function legalLinksPerLocale(legalLinks) {
|
|
29
|
+
return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
|
|
30
|
+
}
|
|
31
|
+
/** The documents checkbox text for a locale, or undefined when the app did not
|
|
32
|
+
* opt in. The variant follows whether that locale has a refund link. */
|
|
33
|
+
export function documentsAcknowledgementTextFor(options, locale) {
|
|
34
|
+
if (!options.documentsAcknowledgement)
|
|
35
|
+
return undefined;
|
|
36
|
+
const texts = DOCUMENTS_ACKNOWLEDGEMENT_TEXTS[locale];
|
|
37
|
+
return legalLinksPerLocale(options.legalLinks)[locale].refund !== undefined
|
|
38
|
+
? texts.withRefund
|
|
39
|
+
: texts.withoutRefund;
|
|
40
|
+
}
|
|
17
41
|
const CONSENT_TEXT_VERSION_LENGTH = 16;
|
|
18
|
-
|
|
42
|
+
// The documents text joins the hash only when shown, so apps without the opt-in keep their version.
|
|
43
|
+
export function consentTextVersion(locale, documentsAcknowledgementText) {
|
|
19
44
|
const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
45
|
+
const hash = createHash("sha256").update(`${earlyPerformance}\n${withdrawalLoss}`);
|
|
46
|
+
if (documentsAcknowledgementText !== undefined)
|
|
47
|
+
hash.update(`\n${documentsAcknowledgementText}`);
|
|
48
|
+
return hash.digest("hex").slice(0, CONSENT_TEXT_VERSION_LENGTH);
|
|
24
49
|
}
|
|
25
50
|
export function submitMessageFor(locale, mode) {
|
|
26
51
|
const texts = CONSENT_TEXTS[locale];
|
|
@@ -63,6 +63,7 @@ export declare const checkoutConsentRecordedPayloadSchema: z.ZodObject<{
|
|
|
63
63
|
interval: z.ZodNullable<z.ZodString>;
|
|
64
64
|
intervalCount: z.ZodNullable<z.ZodNumber>;
|
|
65
65
|
consentTextVersion: z.ZodString;
|
|
66
|
+
documentsAcknowledged: z.ZodOptional<z.ZodBoolean>;
|
|
66
67
|
termsHash: z.ZodString;
|
|
67
68
|
termsTemplateVersion: z.ZodNumber;
|
|
68
69
|
locale: z.ZodString;
|
|
@@ -102,6 +102,7 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
|
|
|
102
102
|
interval: z.string().nullable(),
|
|
103
103
|
intervalCount: z.number().nullable(),
|
|
104
104
|
consentTextVersion: z.string().min(1).max(64),
|
|
105
|
+
documentsAcknowledged: z.boolean().optional(),
|
|
105
106
|
termsHash: z.string().length(64),
|
|
106
107
|
termsTemplateVersion: z.number(),
|
|
107
108
|
locale: z.string().min(2).max(35),
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import {
|
|
2
|
+
import type { BillingPlanCatalog, ResolvedBillingFoundationOptions } from "../types.ts";
|
|
3
3
|
export declare function createBillingPlansQuery(options: ResolvedBillingFoundationOptions, catalog: BillingPlanCatalog): QueryHandlerDef;
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
// when `createBillingFoundationFeature` gets a `catalog`.
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { filterOfferedProviders, listCatalogProviders, } from "../checkout-core.js";
|
|
6
|
-
import { CONSENT_TEXTS, consentTextVersion, } from "../consumer-protection/consent-text.js";
|
|
6
|
+
import { CONSENT_TEXTS, consentTextVersion, documentsAcknowledgementTextFor, legalLinksPerLocale, } from "../consumer-protection/consent-text.js";
|
|
7
7
|
import { buildProviderPlanViews, loadBillingPlansBase, toBillingPlansResult, } from "../plan-catalog.js";
|
|
8
|
-
import { isLegalLinkSet, } from "../types.js";
|
|
9
8
|
const billingPlansSchema = z.object({}).strict();
|
|
10
9
|
export function createBillingPlansQuery(options, catalog) {
|
|
11
10
|
return {
|
|
@@ -23,8 +22,8 @@ export function createBillingPlansQuery(options, catalog) {
|
|
|
23
22
|
...result,
|
|
24
23
|
consumerProtection: {
|
|
25
24
|
consentTexts: {
|
|
26
|
-
de: consentTextView("de"),
|
|
27
|
-
en: consentTextView("en"),
|
|
25
|
+
de: consentTextView(consumerProtection, "de"),
|
|
26
|
+
en: consentTextView(consumerProtection, "en"),
|
|
28
27
|
},
|
|
29
28
|
legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
|
|
30
29
|
},
|
|
@@ -62,10 +61,13 @@ async function providerView(ctx, catalog, base, provider, providerTexts, known)
|
|
|
62
61
|
plans: views.plans,
|
|
63
62
|
};
|
|
64
63
|
}
|
|
65
|
-
function
|
|
66
|
-
return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
|
|
67
|
-
}
|
|
68
|
-
function consentTextView(locale) {
|
|
64
|
+
function consentTextView(consumerProtection, locale) {
|
|
69
65
|
const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
|
|
70
|
-
|
|
66
|
+
const documentsAcknowledgement = documentsAcknowledgementTextFor(consumerProtection, locale);
|
|
67
|
+
return {
|
|
68
|
+
earlyPerformance,
|
|
69
|
+
withdrawalLoss,
|
|
70
|
+
...(documentsAcknowledgement !== undefined && { documentsAcknowledgement }),
|
|
71
|
+
consentTextVersion: consentTextVersion(locale, documentsAcknowledgement),
|
|
72
|
+
};
|
|
71
73
|
}
|
|
@@ -78,6 +78,7 @@ export const BILLING_FOUNDATION_I18N = {
|
|
|
78
78
|
"billing-foundation.consent.link.terms": { en: "Terms" },
|
|
79
79
|
"billing-foundation.consent.link.withdrawal": { en: "Withdrawal policy" },
|
|
80
80
|
"billing-foundation.consent.link.privacy": { en: "Privacy policy" },
|
|
81
|
+
"billing-foundation.consent.link.refund": { en: "Refund policy" },
|
|
81
82
|
"billing-foundation.consent.order": { en: "Order with obligation to pay" },
|
|
82
83
|
"billing-foundation.plans.priceFrom": { en: "from" },
|
|
83
84
|
"billing-foundation.consent.chooseProvider": { en: "Payment provider" },
|
|
@@ -345,6 +345,8 @@ export type LegalLinkSet = {
|
|
|
345
345
|
readonly terms: string;
|
|
346
346
|
readonly withdrawal: string;
|
|
347
347
|
readonly privacy: string;
|
|
348
|
+
/** Link to the refund policy; shown next to the other links when set. */
|
|
349
|
+
readonly refund?: string;
|
|
348
350
|
};
|
|
349
351
|
export declare function isLegalLinkSet(links: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>): links is LegalLinkSet;
|
|
350
352
|
/** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
|
|
@@ -382,6 +384,9 @@ export type ConsumerProtectionOptions = {
|
|
|
382
384
|
/** Root-relative paths or absolute https URLs. One set for every locale, or
|
|
383
385
|
* one set per consent locale (`de` and `en` required). */
|
|
384
386
|
readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
|
|
387
|
+
/** Adds a required first checkbox "documents read and accepted" (terms, privacy,
|
|
388
|
+
* and the refund policy when `legalLinks.refund` is set). Off by default. */
|
|
389
|
+
readonly documentsAcknowledgement?: boolean;
|
|
385
390
|
};
|
|
386
391
|
export type OneOffCheckoutRequest = {
|
|
387
392
|
readonly tenantId: TenantId;
|
|
@@ -476,6 +481,8 @@ export type BillingPlansResult = {
|
|
|
476
481
|
readonly consentTexts: Readonly<Record<"de" | "en", {
|
|
477
482
|
readonly earlyPerformance: string;
|
|
478
483
|
readonly withdrawalLoss: string;
|
|
484
|
+
/** Present only when `documentsAcknowledgement` is on. */
|
|
485
|
+
readonly documentsAcknowledgement?: string;
|
|
479
486
|
readonly consentTextVersion: string;
|
|
480
487
|
}>>;
|
|
481
488
|
readonly legalLinks: Readonly<Record<ConsentLocale, LegalLinkSet>>;
|
|
@@ -73,7 +73,10 @@ function validatePerLocaleLegalLinks(legalLinks) {
|
|
|
73
73
|
}
|
|
74
74
|
function validateLegalLinkSet(set, path) {
|
|
75
75
|
for (const [key, link] of Object.entries(set)) {
|
|
76
|
-
|
|
76
|
+
// skip: refund is optional
|
|
77
|
+
if (key === "refund" && link === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
if (typeof link !== "string" || !isRootRelativeOrHttpsUrl(link)) {
|
|
77
80
|
throw new Error(`createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
@@ -6,6 +6,7 @@ type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection
|
|
|
6
6
|
export type CheckoutConsentPayload = {
|
|
7
7
|
readonly earlyPerformanceRequested: true;
|
|
8
8
|
readonly withdrawalLossAcknowledged: true;
|
|
9
|
+
readonly documentsAcknowledged?: true;
|
|
9
10
|
readonly consentTextVersion: string;
|
|
10
11
|
readonly locale: string;
|
|
11
12
|
};
|
|
@@ -2,41 +2,33 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import { ChoiceCards } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
+
import { ConsentCheckboxes, ConsentLinks, useConsentFieldState } from "./consent-fields.js";
|
|
5
6
|
import { resolveConsentLocale } from "./consumer-protection/consent-locale.js";
|
|
6
7
|
export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
|
|
7
8
|
// kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
|
|
8
9
|
export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, providerChoice, note, onOrder, onConsentTextOutdated, }) {
|
|
9
10
|
const t = useTranslation();
|
|
10
11
|
const uiLocale = useLocale().locale();
|
|
11
|
-
const { Modal, Text,
|
|
12
|
-
const [earlyPerformance, setEarlyPerformance] = useState(false);
|
|
13
|
-
const [withdrawalLoss, setWithdrawalLoss] = useState(false);
|
|
12
|
+
const { Modal, Text, Button, Banner } = usePrimitives();
|
|
14
13
|
const [submitting, setSubmitting] = useState(false);
|
|
15
14
|
const [errorText, setErrorText] = useState(null);
|
|
16
15
|
const consentLocale = resolveConsentLocale(uiLocale);
|
|
17
16
|
const texts = consumerProtection?.consentTexts[consentLocale];
|
|
18
17
|
const legalLinks = consumerProtection?.legalLinks[consentLocale];
|
|
19
|
-
const
|
|
18
|
+
const consentFields = useConsentFieldState(texts);
|
|
19
|
+
const consentGiven = consumerProtection === undefined || consentFields.allGiven;
|
|
20
20
|
async function order() {
|
|
21
21
|
setSubmitting(true);
|
|
22
22
|
setErrorText(null);
|
|
23
|
-
const result = await onOrder(
|
|
24
|
-
? undefined
|
|
25
|
-
: {
|
|
26
|
-
earlyPerformanceRequested: true,
|
|
27
|
-
withdrawalLossAcknowledged: true,
|
|
28
|
-
consentTextVersion: texts.consentTextVersion,
|
|
29
|
-
locale: consentLocale,
|
|
30
|
-
});
|
|
23
|
+
const result = await onOrder(consentFields.buildPayload(consentLocale));
|
|
31
24
|
if (result.isSuccess)
|
|
32
25
|
return;
|
|
33
26
|
setSubmitting(false);
|
|
34
27
|
setErrorText(t(result.error.i18nKey, result.error.i18nParams));
|
|
35
28
|
if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
|
|
36
|
-
|
|
37
|
-
setWithdrawalLoss(false);
|
|
29
|
+
consentFields.reset();
|
|
38
30
|
onConsentTextOutdated();
|
|
39
31
|
}
|
|
40
32
|
}
|
|
41
|
-
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [providerChoice !== undefined && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.consent.chooseProvider") }), _jsx(ChoiceCards, { value: providerChoice.value, options: providerChoice.options, onChange: providerChoice.onChange, ariaLabel: t("billing-foundation.consent.chooseProvider"), testId: "checkout-provider-choice" })] })), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] })), note !== undefined && (_jsx(Text, { variant: "muted", testId: "checkout-consent-note", children: note }))] }), legalLinks !== undefined &&
|
|
33
|
+
return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [providerChoice !== undefined && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.consent.chooseProvider") }), _jsx(ChoiceCards, { value: providerChoice.value, options: providerChoice.options, onChange: providerChoice.onChange, ariaLabel: t("billing-foundation.consent.chooseProvider"), testId: "checkout-provider-choice" })] })), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] })), note !== undefined && (_jsx(Text, { variant: "muted", testId: "checkout-consent-note", children: note }))] }), legalLinks !== undefined && _jsx(ConsentLinks, { legalLinks: legalLinks }), texts !== undefined && (_jsx(ConsentCheckboxes, { idPrefix: "consent", texts: texts, state: consentFields, disabled: submitting })), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !consentGiven || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
|
|
42
34
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { BillingPlansResult, LegalLinkSet } from "../types.ts";
|
|
3
|
+
import type { CheckoutConsentPayload } from "./checkout-consent-dialog.tsx";
|
|
4
|
+
type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
|
|
5
|
+
export type ConsentTextView = ConsumerProtectionView["consentTexts"]["de"];
|
|
6
|
+
export type ConsentChecks = {
|
|
7
|
+
readonly documentsAcknowledged: boolean;
|
|
8
|
+
readonly earlyPerformance: boolean;
|
|
9
|
+
readonly withdrawalLoss: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type ConsentFieldState = {
|
|
12
|
+
readonly checks: ConsentChecks;
|
|
13
|
+
readonly setCheck: (key: keyof ConsentChecks, value: boolean) => void;
|
|
14
|
+
readonly reset: () => void;
|
|
15
|
+
readonly allGiven: boolean;
|
|
16
|
+
readonly buildPayload: (locale: string) => CheckoutConsentPayload | undefined;
|
|
17
|
+
};
|
|
18
|
+
export declare function useConsentFieldState(texts: ConsentTextView | undefined): ConsentFieldState;
|
|
19
|
+
export declare function ConsentLinks({ legalLinks }: {
|
|
20
|
+
readonly legalLinks: LegalLinkSet;
|
|
21
|
+
}): ReactNode;
|
|
22
|
+
export declare function ConsentCheckboxes({ idPrefix, texts, state, disabled, }: {
|
|
23
|
+
readonly idPrefix: string;
|
|
24
|
+
readonly texts: ConsentTextView;
|
|
25
|
+
readonly state: ConsentFieldState;
|
|
26
|
+
readonly disabled: boolean;
|
|
27
|
+
}): ReactNode;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
const NOTHING_CHECKED = {
|
|
5
|
+
documentsAcknowledged: false,
|
|
6
|
+
earlyPerformance: false,
|
|
7
|
+
withdrawalLoss: false,
|
|
8
|
+
};
|
|
9
|
+
export function useConsentFieldState(texts) {
|
|
10
|
+
const [checks, setChecks] = useState(NOTHING_CHECKED);
|
|
11
|
+
const documentsRequired = texts?.documentsAcknowledgement !== undefined;
|
|
12
|
+
return {
|
|
13
|
+
checks,
|
|
14
|
+
setCheck: (key, value) => setChecks((current) => ({ ...current, [key]: value })),
|
|
15
|
+
reset: () => setChecks(NOTHING_CHECKED),
|
|
16
|
+
allGiven: checks.earlyPerformance &&
|
|
17
|
+
checks.withdrawalLoss &&
|
|
18
|
+
(!documentsRequired || checks.documentsAcknowledged),
|
|
19
|
+
buildPayload: (locale) => texts === undefined
|
|
20
|
+
? undefined
|
|
21
|
+
: {
|
|
22
|
+
earlyPerformanceRequested: true,
|
|
23
|
+
withdrawalLossAcknowledged: true,
|
|
24
|
+
...(documentsRequired && { documentsAcknowledged: true }),
|
|
25
|
+
consentTextVersion: texts.consentTextVersion,
|
|
26
|
+
locale,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function ConsentLinks({ legalLinks }) {
|
|
31
|
+
const t = useTranslation();
|
|
32
|
+
const { Link } = usePrimitives();
|
|
33
|
+
return (_jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") }), legalLinks.refund !== undefined && (_jsx(Link, { href: legalLinks.refund, target: "_blank", children: t("billing-foundation.consent.link.refund") }))] }));
|
|
34
|
+
}
|
|
35
|
+
export function ConsentCheckboxes({ idPrefix, texts, state, disabled, }) {
|
|
36
|
+
const { Field, Input } = usePrimitives();
|
|
37
|
+
const boxes = [
|
|
38
|
+
{ key: "documentsAcknowledged", slug: "documents", label: texts.documentsAcknowledgement },
|
|
39
|
+
{ key: "earlyPerformance", slug: "early-performance", label: texts.earlyPerformance },
|
|
40
|
+
{ key: "withdrawalLoss", slug: "withdrawal-loss", label: texts.withdrawalLoss },
|
|
41
|
+
];
|
|
42
|
+
return (_jsx(_Fragment, { children: boxes.map(({ key, slug, label }) => label === undefined ? null : (_jsx(Field, { id: `${idPrefix}-${slug}`, label: label, layout: "inline", children: _jsx(Input, { kind: "boolean", id: `${idPrefix}-${slug}`, name: `${idPrefix}-${slug}`, value: state.checks[key], onChange: (value) => state.setCheck(key, value), disabled: disabled }) }, key))) }));
|
|
43
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useLocale, useMutation, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
-
import { CONSENT_TEXT_OUTDATED_CODE
|
|
5
|
+
import { CONSENT_TEXT_OUTDATED_CODE } from "./checkout-consent-dialog.js";
|
|
6
|
+
import { ConsentCheckboxes, ConsentLinks, useConsentFieldState } from "./consent-fields.js";
|
|
6
7
|
import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.js";
|
|
7
8
|
import { resolveConsentLocale } from "./consumer-protection/consent-locale.js";
|
|
8
9
|
import { intervalKey } from "./plan-price.js";
|
|
@@ -11,28 +12,20 @@ import { recurringAmountsOf } from "./plan-switch-recurring.js";
|
|
|
11
12
|
export function PlanSwitchConfirmDialog({ open, onOpenChange, tier, fromPlanName, toPlanName, consumerProtection, onApplied, onConsentTextOutdated, }) {
|
|
12
13
|
const t = useTranslation();
|
|
13
14
|
const locale = useLocale().locale();
|
|
14
|
-
const { Modal, Text,
|
|
15
|
+
const { Modal, Text, Button, Banner } = usePrimitives();
|
|
15
16
|
const previewQuery = useQuery(SubscriptionFoundationQueries.planSwitchPreview, { tier });
|
|
16
17
|
const switchMutation = useMutation(SubscriptionFoundationHandlers.switchPlan);
|
|
17
|
-
const [earlyPerformance, setEarlyPerformance] = useState(false);
|
|
18
|
-
const [withdrawalLoss, setWithdrawalLoss] = useState(false);
|
|
19
18
|
const [submitting, setSubmitting] = useState(false);
|
|
20
19
|
const [errorText, setErrorText] = useState(null);
|
|
21
20
|
const consentLocale = resolveConsentLocale(locale);
|
|
22
21
|
const texts = consumerProtection?.consentTexts[consentLocale];
|
|
23
22
|
const legalLinks = consumerProtection?.legalLinks[consentLocale];
|
|
24
|
-
const
|
|
23
|
+
const consentFields = useConsentFieldState(texts);
|
|
24
|
+
const consentGiven = consumerProtection === undefined || consentFields.allGiven;
|
|
25
25
|
async function confirm() {
|
|
26
26
|
setSubmitting(true);
|
|
27
27
|
setErrorText(null);
|
|
28
|
-
const consent =
|
|
29
|
-
? undefined
|
|
30
|
-
: {
|
|
31
|
-
earlyPerformanceRequested: true,
|
|
32
|
-
withdrawalLossAcknowledged: true,
|
|
33
|
-
consentTextVersion: texts.consentTextVersion,
|
|
34
|
-
locale: consentLocale,
|
|
35
|
-
};
|
|
28
|
+
const consent = consentFields.buildPayload(consentLocale);
|
|
36
29
|
const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
|
|
37
30
|
if (result.isSuccess) {
|
|
38
31
|
onApplied();
|
|
@@ -41,8 +34,7 @@ export function PlanSwitchConfirmDialog({ open, onOpenChange, tier, fromPlanName
|
|
|
41
34
|
setSubmitting(false);
|
|
42
35
|
setErrorText(t(result.error.i18nKey, result.error.i18nParams));
|
|
43
36
|
if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
|
|
44
|
-
|
|
45
|
-
setWithdrawalLoss(false);
|
|
37
|
+
consentFields.reset();
|
|
46
38
|
onConsentTextOutdated();
|
|
47
39
|
}
|
|
48
40
|
}
|
|
@@ -66,5 +58,5 @@ export function PlanSwitchConfirmDialog({ open, onOpenChange, tier, fromPlanName
|
|
|
66
58
|
amount: money(reply.preview.credit, reply.preview.currency),
|
|
67
59
|
}) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.recurring", {
|
|
68
60
|
amount: recurringPrice,
|
|
69
|
-
}) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.effective") }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.pendingNote") })] })), legalLinks !== undefined &&
|
|
61
|
+
}) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.effective") }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.pendingNote") })] })), legalLinks !== undefined && _jsx(ConsentLinks, { legalLinks: legalLinks }), texts !== undefined && (_jsx(ConsentCheckboxes, { idPrefix: "plan-switch", texts: texts, state: consentFields, disabled: submitting })), errorText !== null && (_jsx(Banner, { variant: "error", testId: "plan-switch-confirm-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "plan-switch-confirm-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !consentGiven || reply === null || submitting, loading: submitting, onClick: confirm, testId: "plan-switch-confirm-order", children: t("billing-foundation.planSwitch.order") })] })] }) }));
|
|
70
62
|
}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export const
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
};
|
|
6
|
-
export const InAppQueries = {
|
|
7
|
-
inbox: "channel-in-app:query:inbox",
|
|
8
|
-
unreadCount: "channel-in-app:query:unread-count",
|
|
9
|
-
};
|
|
1
|
+
import { IN_APP_NOTIFICATION_FEATURE, IN_APP_NOTIFICATION_QUERIES, IN_APP_NOTIFICATION_WRITES, } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
|
+
export const CHANNEL_IN_APP_FEATURE = IN_APP_NOTIFICATION_FEATURE;
|
|
3
|
+
export const InAppHandlers = IN_APP_NOTIFICATION_WRITES;
|
|
4
|
+
export const InAppQueries = IN_APP_NOTIFICATION_QUERIES;
|
|
@@ -8,7 +8,7 @@ import { inAppChannel } from "./in-app-channel.js";
|
|
|
8
8
|
import { inAppMessagesTableMeta } from "./tables.js";
|
|
9
9
|
export function createChannelInAppFeature() {
|
|
10
10
|
return defineFeature("channel-in-app", (r) => {
|
|
11
|
-
r.describe("Persists notifications to an in-app inbox table so users can
|
|
11
|
+
r.describe("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.");
|
|
12
12
|
r.uiHints({
|
|
13
13
|
displayLabel: "In-App Inbox",
|
|
14
14
|
category: "notifications",
|
|
@@ -2,6 +2,9 @@ import * as z from "zod";
|
|
|
2
2
|
export declare const inboxQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"inbox", z.ZodObject<{
|
|
3
3
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
4
4
|
unreadOnly: z.ZodDefault<z.ZodBoolean>;
|
|
5
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
5
6
|
}, z.core.$strip>, {
|
|
6
|
-
rows:
|
|
7
|
+
rows: any[];
|
|
8
|
+
nextCursor: string | null;
|
|
9
|
+
total: number;
|
|
7
10
|
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -1,12 +1,15 @@
|
|
|
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.js";
|
|
4
5
|
import { inAppMessagesTable } from "../tables.js";
|
|
6
|
+
const SERIAL_ID_PATTERN = /^\d{1,15}$/;
|
|
5
7
|
export const inboxQuery = defineQueryHandler({
|
|
6
8
|
name: "inbox",
|
|
7
9
|
schema: z.object({
|
|
8
10
|
limit: z.number().min(1).max(100).default(50),
|
|
9
11
|
unreadOnly: z.boolean().default(false),
|
|
12
|
+
cursor: z.string().regex(SERIAL_ID_PATTERN).optional(),
|
|
10
13
|
}),
|
|
11
14
|
access: {
|
|
12
15
|
openToAll: {
|
|
@@ -14,15 +17,27 @@ export const inboxQuery = defineQueryHandler({
|
|
|
14
17
|
"inAppMessagesTable by the caller's own id",
|
|
15
18
|
},
|
|
16
19
|
},
|
|
17
|
-
description: "Lists the calling user's own in-app notification messages newest-first, optionally only the unread ones;
|
|
20
|
+
description: "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.",
|
|
18
21
|
handler: async (query, ctx) => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const { limit, unreadOnly, cursor } = query.payload;
|
|
23
|
+
const ownMessages = { userId: query.user.id };
|
|
24
|
+
if (unreadOnly)
|
|
25
|
+
ownMessages["isRead"] = false;
|
|
26
|
+
const pageWhere = cursor === undefined ? ownMessages : { ...ownMessages, id: { lt: Number(cursor) } };
|
|
27
|
+
// One extra row tells whether another page exists without a second query.
|
|
28
|
+
const fetched = await selectMany(ctx.db, inAppMessagesTable, pageWhere, {
|
|
29
|
+
limit: limit + 1,
|
|
30
|
+
orderBy: { col: "id", direction: "desc" },
|
|
25
31
|
});
|
|
26
|
-
|
|
32
|
+
const page = fetched.slice(0, limit);
|
|
33
|
+
const lastRow = page[page.length - 1];
|
|
34
|
+
const nextCursor = fetched.length > limit && lastRow ? String(lastRow["id"]) : null;
|
|
35
|
+
const [totalRow] = await ctx.db.aggregate(inAppMessagesTable, { measure: { fn: "count" } }, ownMessages);
|
|
36
|
+
const rows = page.map((row) => {
|
|
37
|
+
const storedData = row["data"];
|
|
38
|
+
const data = parseStoredNotificationData(typeof storedData === "string" ? storedData : null);
|
|
39
|
+
return { ...row, target: targetOfNotificationData(data) };
|
|
40
|
+
});
|
|
41
|
+
return { rows, nextCursor, total: totalRow?.value ?? 0 };
|
|
27
42
|
},
|
|
28
43
|
});
|
|
@@ -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.js";
|
|
@@ -13,13 +12,7 @@ export const unreadCountQuery = defineQueryHandler({
|
|
|
13
12
|
},
|
|
14
13
|
description: "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.",
|
|
15
14
|
handler: async (query, ctx) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// user-scoped, with at most a few hundred rows; use raw .unsafe() with COUNT(*) if that grows.
|
|
19
|
-
const rows = await selectMany(ctx.db, inAppMessagesTable, {
|
|
20
|
-
userId: query.user.id,
|
|
21
|
-
isRead: false,
|
|
22
|
-
});
|
|
23
|
-
return { count: rows.length };
|
|
15
|
+
const [countRow] = await ctx.db.aggregate(inAppMessagesTable, { measure: { fn: "count" } }, { userId: query.user.id, isRead: false });
|
|
16
|
+
return { count: countRow?.value ?? 0 };
|
|
24
17
|
},
|
|
25
18
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
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";
|
|
4
|
+
import { targetOfNotificationData } from "./in-app-target.js";
|
|
2
5
|
import { inAppMessagesTable } from "./tables.js";
|
|
6
|
+
const log = createFallbackLogger("channel-in-app");
|
|
3
7
|
export const inAppChannel = {
|
|
4
8
|
name: "inApp",
|
|
5
9
|
mode: "inline",
|
|
@@ -10,24 +14,32 @@ export const inAppChannel = {
|
|
|
10
14
|
async send(address, message, ctx) {
|
|
11
15
|
// address is the user-id string after the ES migration — keep it as-is.
|
|
12
16
|
const userId = address;
|
|
17
|
+
// A malformed target drops only the link, never the notification.
|
|
18
|
+
const target = targetOfNotificationData(message.data);
|
|
19
|
+
const storedData = message.data === undefined
|
|
20
|
+
? null
|
|
21
|
+
: JSON.stringify({ ...message.data, target: target ?? undefined });
|
|
13
22
|
const row = await ctx.db.insertOne(inAppMessagesTable, {
|
|
14
23
|
userId,
|
|
15
24
|
notificationType: message.notificationType,
|
|
16
25
|
title: message.title,
|
|
17
26
|
body: message.body ?? null,
|
|
18
|
-
data:
|
|
27
|
+
data: storedData,
|
|
19
28
|
});
|
|
20
29
|
if (ctx.sseBroker) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
notificationType: message.notificationType,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// The row is stored, so a failing push (e.g. Redis down) must not turn the delivery into
|
|
31
|
+
// a failure that gets retried and stored twice. Clients fetch the inbox on reconnect.
|
|
32
|
+
try {
|
|
33
|
+
await ctx.sseBroker.pushToChannel(tenantChannel(ctx.tenantId), {
|
|
34
|
+
type: IN_APP_NOTIFICATION_DELIVERED_EVENT,
|
|
35
|
+
data: { id: row?.id, userId, notificationType: message.notificationType },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
log.warn("in-app SSE push failed; the notification is stored", {
|
|
40
|
+
error: error instanceof Error ? error.message : String(error),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
31
43
|
}
|
|
32
44
|
return { status: "sent", address };
|
|
33
45
|
},
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { InAppNotificationTarget } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
|
+
export declare function inAppTarget(screenId: string, entityId?: string): InAppNotificationTarget;
|
|
3
|
+
export declare function parseInAppTarget(value: unknown): InAppNotificationTarget | null;
|
|
4
|
+
export declare function targetOfNotificationData(data: unknown): InAppNotificationTarget | null;
|
|
5
|
+
export declare function parseStoredNotificationData(stored: string | null): unknown;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
const SCREEN_ID_PATTERN = /^[^\s:]+:screen:[^\s:]+$/;
|
|
3
|
+
const targetSchema = z.object({
|
|
4
|
+
screenId: z.string().regex(SCREEN_ID_PATTERN),
|
|
5
|
+
entityId: z.string().min(1).optional(),
|
|
6
|
+
});
|
|
7
|
+
export function inAppTarget(screenId, entityId) {
|
|
8
|
+
return entityId === undefined ? { screenId } : { screenId, entityId };
|
|
9
|
+
}
|
|
10
|
+
// Returns null for anything that is not a well-formed target, so a malformed `data.target`
|
|
11
|
+
// drops only the link and never the notification.
|
|
12
|
+
export function parseInAppTarget(value) {
|
|
13
|
+
const parsed = targetSchema.safeParse(value);
|
|
14
|
+
if (!parsed.success)
|
|
15
|
+
return null;
|
|
16
|
+
const { screenId, entityId } = parsed.data;
|
|
17
|
+
return inAppTarget(screenId, entityId);
|
|
18
|
+
}
|
|
19
|
+
// Reads `target` out of the stored notification data (a JSON string) or out of a raw data object.
|
|
20
|
+
export function targetOfNotificationData(data) {
|
|
21
|
+
if (typeof data !== "object" || data === null || !("target" in data))
|
|
22
|
+
return null;
|
|
23
|
+
return parseInAppTarget(data.target);
|
|
24
|
+
}
|
|
25
|
+
export function parseStoredNotificationData(stored) {
|
|
26
|
+
if (stored === null)
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(stored);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -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,5 @@
|
|
|
1
1
|
export { CHANNEL_IN_APP_FEATURE, InAppHandlers, InAppQueries } from "./constants.js";
|
|
2
2
|
export { createChannelInAppFeature } from "./feature.js";
|
|
3
3
|
export { inAppChannel } from "./in-app-channel.js";
|
|
4
|
+
export { inAppTarget } from "./in-app-target.js";
|
|
4
5
|
export { inAppMessagesTable } from "./tables.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WhereObject } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { AggregateKey } from "@cosmicdrift/kumiko-types/aggregate-types";
|
|
2
3
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
4
|
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
4
5
|
import type { MetricDefinition, MetricResult } from "./types.ts";
|
|
@@ -11,5 +12,7 @@ export type MetricRunParams = {
|
|
|
11
12
|
readonly filterWhere: WhereObject;
|
|
12
13
|
/** Extra filter that narrows the source to the caller's scope. */
|
|
13
14
|
readonly scopeWhere: WhereObject;
|
|
15
|
+
/** Names for referenced-row group keys; absent where the caller cannot resolve them (system scope). */
|
|
16
|
+
readonly referenceLabels?: (keys: readonly AggregateKey[]) => Promise<ReadonlyMap<string, string>>;
|
|
14
17
|
};
|
|
15
18
|
export declare function runMetric(db: TenantDb, metric: MetricDefinition, params: MetricRunParams): Promise<MetricResult>;
|
package/dist/metrics/compute.js
CHANGED
|
@@ -75,7 +75,7 @@ function groupSegments(stackRows, labelOf) {
|
|
|
75
75
|
}
|
|
76
76
|
return segmentsByGroup;
|
|
77
77
|
}
|
|
78
|
-
function buildRows(metric, aggregates, total, labelOf) {
|
|
78
|
+
function buildRows(metric, aggregates, total, labelOf, groupLabelOf) {
|
|
79
79
|
const segmentsByGroup = groupSegments(aggregates.stacks, labelOf);
|
|
80
80
|
return aggregates.groups.map((row) => {
|
|
81
81
|
const key = keyText(row.keys[0]);
|
|
@@ -83,7 +83,7 @@ function buildRows(metric, aggregates, total, labelOf) {
|
|
|
83
83
|
return {
|
|
84
84
|
id: key,
|
|
85
85
|
key,
|
|
86
|
-
label:
|
|
86
|
+
label: groupLabelOf(key),
|
|
87
87
|
value: rowValue,
|
|
88
88
|
fraction: fractionOf(rowValue, total),
|
|
89
89
|
...(metric.stackBy !== undefined && { segments: segmentsByGroup.get(key) ?? [] }),
|
|
@@ -104,6 +104,11 @@ function buildSeries(rows, seriesRows, fillBuckets) {
|
|
|
104
104
|
points: fillBuckets(bucketsByGroup.get(row.key) ?? new Map()),
|
|
105
105
|
}));
|
|
106
106
|
}
|
|
107
|
+
async function resolveReferencedGroupLabels(metric, params, groups) {
|
|
108
|
+
if (params.referenceLabels === undefined || metric.groupBy === undefined)
|
|
109
|
+
return new Map();
|
|
110
|
+
return params.referenceLabels(groups.map((row) => row.keys[0] ?? null));
|
|
111
|
+
}
|
|
107
112
|
export async function runMetric(db, metric, params) {
|
|
108
113
|
const range = metric.window ?? params.range;
|
|
109
114
|
const bucket = resolveBucket(metric, range);
|
|
@@ -129,7 +134,9 @@ export async function runMetric(db, metric, params) {
|
|
|
129
134
|
row.value,
|
|
130
135
|
])))
|
|
131
136
|
: [];
|
|
132
|
-
const
|
|
137
|
+
const referencedLabels = await resolveReferencedGroupLabels(metric, params, aggregates.groups);
|
|
138
|
+
const groupLabelOf = (key) => metric.groupLabels?.[key] ?? referencedLabels.get(key) ?? key;
|
|
139
|
+
const rows = buildRows(metric, aggregates, value, labelOf, groupLabelOf);
|
|
133
140
|
return {
|
|
134
141
|
value,
|
|
135
142
|
previousValue,
|
package/dist/metrics/feature.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { extractTableInfo } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { access, defineFeature, defineQueryHandler, referenceGroupLabels, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
5
|
import * as z from "zod";
|
|
@@ -45,7 +46,7 @@ function createTenantMetricQuery(metric, now) {
|
|
|
45
46
|
name: metric.id,
|
|
46
47
|
description: metric.description,
|
|
47
48
|
schema: tenantPayloadSchema(metric),
|
|
48
|
-
access: { roles: access.admin },
|
|
49
|
+
access: { roles: metric.access?.roles ?? access.admin },
|
|
49
50
|
handler: async (query, ctx) => runMetric(ctx.db, metric, {
|
|
50
51
|
range: query.payload.range,
|
|
51
52
|
timeZone: query.payload.timeZone,
|
|
@@ -53,6 +54,9 @@ function createTenantMetricQuery(metric, now) {
|
|
|
53
54
|
filterWhere: await resolveMetricFilterWhere(metric, query.payload, ctx),
|
|
54
55
|
// ctx.db already reads own-tenant + SYSTEM reference rows; this narrows to own-tenant only.
|
|
55
56
|
scopeWhere: { tenantId: query.user.tenantId },
|
|
57
|
+
referenceLabels: (keys) => metric.groupBy === undefined
|
|
58
|
+
? Promise.resolve(new Map())
|
|
59
|
+
: referenceGroupLabels(ctx, query.user, extractTableInfo(metric.source).name, metric.groupBy, keys, ctx.db),
|
|
56
60
|
}),
|
|
57
61
|
});
|
|
58
62
|
}
|
package/dist/metrics/types.d.ts
CHANGED
|
@@ -33,8 +33,12 @@ export type MetricDefinition = {
|
|
|
33
33
|
readonly groupBy?: string;
|
|
34
34
|
/** Second dimension, becomes `rows[].segments`; only without bucket. */
|
|
35
35
|
readonly stackBy?: string;
|
|
36
|
-
/** Key value → i18n key, applied to groupBy and stackBy keys. */
|
|
36
|
+
/** 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. */
|
|
37
37
|
readonly groupLabels?: Readonly<Record<string, string>>;
|
|
38
|
+
/** Roles that may read the tenant query; default is the admin roles. Ignored for scope "system", which stays SystemAdmin only. */
|
|
39
|
+
readonly access?: {
|
|
40
|
+
readonly roles: readonly string[];
|
|
41
|
+
};
|
|
38
42
|
readonly scopes: readonly MetricScope[];
|
|
39
43
|
};
|
|
40
44
|
export declare function defineMetric(definition: MetricDefinition): MetricDefinition;
|
package/dist/metrics/validate.js
CHANGED
|
@@ -25,6 +25,9 @@ function validateMetric(metric) {
|
|
|
25
25
|
fail("has an id that is not kebab-case");
|
|
26
26
|
if (metric.scopes.length === 0)
|
|
27
27
|
fail("declares no scopes");
|
|
28
|
+
if (metric.access !== undefined && metric.access.roles.length === 0) {
|
|
29
|
+
fail("declares access with an empty roles list");
|
|
30
|
+
}
|
|
28
31
|
const info = extractTableInfo(metric.source);
|
|
29
32
|
validateColumns(metric, info, fail);
|
|
30
33
|
validateFilters(metric, info, fail);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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>",
|
|
@@ -566,9 +566,9 @@
|
|
|
566
566
|
}
|
|
567
567
|
},
|
|
568
568
|
"dependencies": {
|
|
569
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
570
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
571
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
569
|
+
"@cosmicdrift/kumiko-framework": "0.399.0",
|
|
570
|
+
"@cosmicdrift/kumiko-renderer": "0.399.0",
|
|
571
|
+
"@cosmicdrift/kumiko-types": "0.399.0",
|
|
572
572
|
"@mollie/api-client": "^4.5.0",
|
|
573
573
|
"@node-rs/argon2": "^2.0.2",
|
|
574
574
|
"@tiptap/core": "^3.31.3",
|
|
@@ -584,10 +584,10 @@
|
|
|
584
584
|
"@types/qrcode": "^1.5.5",
|
|
585
585
|
"clsx": "^2.1.1",
|
|
586
586
|
"happy-dom": "^21.0.4",
|
|
587
|
-
"hono": "^4.13.
|
|
587
|
+
"hono": "^4.13.14",
|
|
588
588
|
"imapflow": "^1.7.8",
|
|
589
589
|
"ioredis": "^5.10.1",
|
|
590
|
-
"jose": "^6.2.
|
|
590
|
+
"jose": "^6.2.13",
|
|
591
591
|
"lucide-react": "^1.14.0",
|
|
592
592
|
"mailparser": "^3.12.4",
|
|
593
593
|
"marked": "^18.0.3",
|
|
@@ -1157,18 +1157,18 @@
|
|
|
1157
1157
|
"LICENSE"
|
|
1158
1158
|
],
|
|
1159
1159
|
"devDependencies": {
|
|
1160
|
-
"@testing-library/user-event": "^14.6.
|
|
1161
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1162
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1163
|
-
"@cosmicdrift/kumiko-locale-fr": "0.
|
|
1164
|
-
"@cosmicdrift/kumiko-locale-it": "0.
|
|
1165
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
1160
|
+
"@testing-library/user-event": "^14.6.8",
|
|
1161
|
+
"@cosmicdrift/kumiko-locale-de": "0.399.0",
|
|
1162
|
+
"@cosmicdrift/kumiko-locale-es": "0.399.0",
|
|
1163
|
+
"@cosmicdrift/kumiko-locale-fr": "0.399.0",
|
|
1164
|
+
"@cosmicdrift/kumiko-locale-it": "0.399.0",
|
|
1165
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.399.0",
|
|
1166
1166
|
"jsqr": "^1.4.0",
|
|
1167
1167
|
"unpdf": "^1.8.1",
|
|
1168
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
1168
|
+
"@cosmicdrift/kumiko-testing": "0.399.0"
|
|
1169
1169
|
},
|
|
1170
1170
|
"peerDependencies": {
|
|
1171
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
1171
|
+
"@cosmicdrift/kumiko-testing": "0.399.0"
|
|
1172
1172
|
},
|
|
1173
1173
|
"peerDependenciesMeta": {
|
|
1174
1174
|
"@cosmicdrift/kumiko-testing": {
|
|
@@ -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",
|
|
@@ -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",
|
package/src/metrics/changes.json
CHANGED
|
@@ -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",
|