@cosmicdrift/kumiko-samples 0.398.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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts +4 -6
- package/packages/bundled-features/src/channel-in-app/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/constants.ts +9 -9
- package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/handlers/inbox.query.ts +31 -7
- package/packages/bundled-features/src/channel-in-app/handlers/unread-count.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +25 -11
- package/packages/bundled-features/src/channel-in-app/in-app-target.ts +37 -0
- package/packages/bundled-features/src/channel-in-app/index.ts +1 -0
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/compute.ts +19 -2
- package/packages/bundled-features/src/metrics/feature.ts +14 -1
- package/packages/bundled-features/src/metrics/types.ts +3 -1
- package/packages/bundled-features/src/metrics/validate.ts +3 -0
- 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.
|
|
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.
|
|
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>",
|
|
@@ -1,4 +1,10 @@
|
|
|
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
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.398.0",
|
|
4
10
|
"type": "improvement",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-text.ts
CHANGED
|
@@ -45,14 +45,12 @@ const DOCUMENTS_ACKNOWLEDGEMENT_TEXTS: Readonly<
|
|
|
45
45
|
> = {
|
|
46
46
|
de: {
|
|
47
47
|
withRefund:
|
|
48
|
-
"Ich habe die AGB
|
|
49
|
-
withoutRefund:
|
|
50
|
-
"Ich habe die AGB und die Datenschutzerklärung gelesen. Mit den AGB bin ich einverstanden.",
|
|
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.",
|
|
51
50
|
},
|
|
52
51
|
en: {
|
|
53
|
-
withRefund:
|
|
54
|
-
|
|
55
|
-
withoutRefund: "I have read the Terms and the Privacy Policy, and I agree to the Terms.",
|
|
52
|
+
withRefund: "I have read and agree to the Terms and the Refund Policy.",
|
|
53
|
+
withoutRefund: "I have read and agree to the Terms.",
|
|
56
54
|
},
|
|
57
55
|
};
|
|
58
56
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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;
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
35
|
+
data: storedData,
|
|
24
36
|
});
|
|
25
37
|
|
|
26
38
|
if (ctx.sseBroker) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
notificationType: message.notificationType,
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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
|
|
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
|
|
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"
|