@cosmicdrift/kumiko-bundled-features 0.348.1 → 0.350.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/channel-email/email-channel.js +13 -5
- package/dist/channel-email/pii-guard.js +6 -1
- package/dist/channel-email/smtp-transport.js +1 -0
- package/dist/channel-email/types.d.ts +1 -0
- package/dist/delivery/jobs.js +3 -1
- package/dist/delivery/types.d.ts +3 -0
- package/dist/metrics/compute.d.ts +2 -0
- package/dist/metrics/compute.js +3 -3
- package/dist/metrics/feature.js +20 -9
- package/dist/metrics/filters.d.ts +19 -0
- package/dist/metrics/filters.js +78 -0
- package/dist/metrics/index.d.ts +1 -1
- package/dist/metrics/types.d.ts +10 -0
- package/dist/metrics/validate.js +29 -0
- package/dist/renderer-simple/simple-renderer.js +49 -13
- package/dist/user-data-rights/email-templates.d.ts +1 -0
- package/dist/user-data-rights/email-templates.js +26 -4
- package/dist/user-data-rights/lib/default-mailers.js +8 -8
- package/package.json +9 -9
- package/src/channel-email/changes.json +6 -0
- package/src/metrics/changes.json +6 -0
|
@@ -78,13 +78,15 @@ export function createEmailChannel(options) {
|
|
|
78
78
|
title: message.title,
|
|
79
79
|
body: message.body,
|
|
80
80
|
};
|
|
81
|
-
const
|
|
81
|
+
const rendererInput = {
|
|
82
82
|
template: message.notificationType,
|
|
83
83
|
variables,
|
|
84
84
|
locale: message.locale,
|
|
85
|
-
}
|
|
85
|
+
};
|
|
86
|
+
const html = await renderer.render(rendererInput);
|
|
87
|
+
const text = renderer.renderText ? await renderer.renderText(rendererInput) : undefined;
|
|
86
88
|
const subject = variables["subject"] ?? message.title; // @cast-boundary dynamic-key
|
|
87
|
-
return { html, subject };
|
|
89
|
+
return { html, subject, ...(text !== undefined && { text }) };
|
|
88
90
|
}
|
|
89
91
|
return {
|
|
90
92
|
name: "email",
|
|
@@ -101,9 +103,15 @@ export function createEmailChannel(options) {
|
|
|
101
103
|
return renderMessage(message);
|
|
102
104
|
},
|
|
103
105
|
async send(address, message, _ctx, rendered) {
|
|
104
|
-
const { html, subject } = rendered ?? (await renderMessage(message));
|
|
106
|
+
const { html, subject, text } = rendered ?? (await renderMessage(message));
|
|
105
107
|
const envelope = emailEnvelopeFrom(message.data);
|
|
106
|
-
await transport.send(guardEmailMessage({
|
|
108
|
+
await transport.send(guardEmailMessage({
|
|
109
|
+
to: address,
|
|
110
|
+
subject,
|
|
111
|
+
html,
|
|
112
|
+
...(text !== undefined && { text }),
|
|
113
|
+
...envelope,
|
|
114
|
+
}));
|
|
107
115
|
return { status: "sent", address };
|
|
108
116
|
},
|
|
109
117
|
};
|
|
@@ -28,7 +28,9 @@ export function guardEmailMessage(message) {
|
|
|
28
28
|
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
const leaking = message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
31
|
+
const leaking = message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
32
|
+
message.html.includes(CIPHERTEXT_MARKER) ||
|
|
33
|
+
message.text?.includes(CIPHERTEXT_MARKER) === true;
|
|
32
34
|
if (!leaking)
|
|
33
35
|
return message;
|
|
34
36
|
const detail = "[channel-email] mail subject/body contains a PII ciphertext " +
|
|
@@ -41,6 +43,9 @@ export function guardEmailMessage(message) {
|
|
|
41
43
|
...message,
|
|
42
44
|
subject: message.subject.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
43
45
|
html: message.html.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
46
|
+
...(message.text !== undefined && {
|
|
47
|
+
text: message.text.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
48
|
+
}),
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
export function withPiiCiphertextGuard(transport) {
|
|
@@ -54,6 +54,7 @@ export function createSmtpTransport(options) {
|
|
|
54
54
|
to: message.to,
|
|
55
55
|
subject: message.subject,
|
|
56
56
|
html: message.html,
|
|
57
|
+
...(message.text !== undefined && { text: message.text }),
|
|
57
58
|
...(message.replyTo && { replyTo: message.replyTo }),
|
|
58
59
|
...(message.headers && { headers: message.headers }),
|
|
59
60
|
});
|
package/dist/delivery/jobs.js
CHANGED
|
@@ -28,7 +28,9 @@ const renderJobPayloadSchema = z.object({
|
|
|
28
28
|
message: channelMessageSchema,
|
|
29
29
|
});
|
|
30
30
|
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
31
|
-
rendered: z
|
|
31
|
+
rendered: z
|
|
32
|
+
.object({ html: z.string(), subject: z.string(), text: z.string().optional() })
|
|
33
|
+
.optional(),
|
|
32
34
|
});
|
|
33
35
|
function requireTenantScopedDeps(ctx, tenantId) {
|
|
34
36
|
const registry = ctx.registry;
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type ChannelResult = {
|
|
|
26
26
|
export type RenderedMessage = {
|
|
27
27
|
readonly html: string;
|
|
28
28
|
readonly subject: string;
|
|
29
|
+
readonly text?: string;
|
|
29
30
|
};
|
|
30
31
|
export declare const DELIVERY_CHANNEL_MODES: readonly ["inline", "queued"];
|
|
31
32
|
export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
|
|
@@ -44,6 +45,8 @@ export type RendererInput = {
|
|
|
44
45
|
export type NotificationRenderer = {
|
|
45
46
|
readonly name: string;
|
|
46
47
|
render(input: RendererInput): Promise<string>;
|
|
48
|
+
/** Plain-text version of the same mail; the email channel sends it as the text part of a multipart/alternative mail. */
|
|
49
|
+
renderText?(input: RendererInput): Promise<string>;
|
|
47
50
|
};
|
|
48
51
|
export type DeliveryLogEntry = {
|
|
49
52
|
readonly tenantId: TenantId;
|
|
@@ -7,6 +7,8 @@ export type MetricRunParams = {
|
|
|
7
7
|
readonly range: MetricRange;
|
|
8
8
|
readonly timeZone: string;
|
|
9
9
|
readonly now: Temporal.Instant;
|
|
10
|
+
/** Payload-driven narrowing (MetricDefinition.filters); scopeWhere wins on conflicts. */
|
|
11
|
+
readonly filterWhere: WhereObject;
|
|
10
12
|
/** Extra filter that narrows the source to the caller's scope. */
|
|
11
13
|
readonly scopeWhere: WhereObject;
|
|
12
14
|
};
|
package/dist/metrics/compute.js
CHANGED
|
@@ -18,8 +18,8 @@ function fractionOf(value, total) {
|
|
|
18
18
|
return 0;
|
|
19
19
|
return Math.min(1, Math.max(0, value / total));
|
|
20
20
|
}
|
|
21
|
-
function buildWheres(metric, window, scopeWhere) {
|
|
22
|
-
const baseWhere = { ...metric.where, ...scopeWhere };
|
|
21
|
+
function buildWheres(metric, window, filterWhere, scopeWhere) {
|
|
22
|
+
const baseWhere = { ...metric.where, ...filterWhere, ...scopeWhere };
|
|
23
23
|
const { timeField } = metric;
|
|
24
24
|
if (timeField === undefined || window === undefined) {
|
|
25
25
|
return { current: baseWhere, previous: undefined };
|
|
@@ -112,7 +112,7 @@ export async function runMetric(db, metric, params) {
|
|
|
112
112
|
const bucketDimension = timeField !== undefined && bucket !== undefined
|
|
113
113
|
? { field: timeField, bucket, timeZone: params.timeZone }
|
|
114
114
|
: undefined;
|
|
115
|
-
const wheres = buildWheres(metric, window, params.scopeWhere);
|
|
115
|
+
const wheres = buildWheres(metric, window, params.filterWhere, params.scopeWhere);
|
|
116
116
|
const aggregates = await fetchAggregates(db, metric, wheres, bucketDimension);
|
|
117
117
|
const value = aggregates.total[0]?.value ?? null;
|
|
118
118
|
const previousValue = aggregates.previous?.[0]?.value ?? null;
|
package/dist/metrics/feature.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Temporal } from "temporal-polyfill";
|
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { runMetric } from "./compute.js";
|
|
6
6
|
import { METRIC_RANGES, METRICS_FEATURE, METRICS_SYSTEM_FEATURE, } from "./constants.js";
|
|
7
|
+
import { metricFilterPayloadShape, metricFilterWhere, resolveMetricFilterWhere, } from "./filters.js";
|
|
7
8
|
import { METRICS_I18N } from "./i18n.js";
|
|
8
9
|
import { metricHasTenantColumn, validateMetrics } from "./validate.js";
|
|
9
10
|
import { isValidTimeZone } from "./window.js";
|
|
@@ -17,12 +18,20 @@ const invalidTimeZone = {
|
|
|
17
18
|
path: ["timeZone"],
|
|
18
19
|
message: "Unknown IANA time zone",
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
function tenantPayloadSchema(metric) {
|
|
22
|
+
return z
|
|
23
|
+
.object({ ...metricFilterPayloadShape(metric), ...windowFields })
|
|
24
|
+
.refine((payload) => isValidTimeZone(payload.timeZone), invalidTimeZone);
|
|
25
|
+
}
|
|
26
|
+
function systemPayloadSchema(metric) {
|
|
27
|
+
return z
|
|
28
|
+
.object({
|
|
29
|
+
...metricFilterPayloadShape(metric),
|
|
30
|
+
...windowFields,
|
|
31
|
+
tenantId: z.uuid().optional(),
|
|
32
|
+
})
|
|
33
|
+
.refine((payload) => isValidTimeZone(payload.timeZone), invalidTimeZone);
|
|
34
|
+
}
|
|
26
35
|
const defaultClock = () => Temporal.Now.instant();
|
|
27
36
|
function metricsForScope(metrics, scope) {
|
|
28
37
|
return metrics.filter((metric) => metric.scopes.includes(scope));
|
|
@@ -35,12 +44,13 @@ function createTenantMetricQuery(metric, now) {
|
|
|
35
44
|
return defineQueryHandler({
|
|
36
45
|
name: metric.id,
|
|
37
46
|
description: metric.description,
|
|
38
|
-
schema: tenantPayloadSchema,
|
|
47
|
+
schema: tenantPayloadSchema(metric),
|
|
39
48
|
access: { roles: access.admin },
|
|
40
|
-
handler: (query, ctx) => runMetric(ctx.db, metric, {
|
|
49
|
+
handler: async (query, ctx) => runMetric(ctx.db, metric, {
|
|
41
50
|
range: query.payload.range,
|
|
42
51
|
timeZone: query.payload.timeZone,
|
|
43
52
|
now: now(),
|
|
53
|
+
filterWhere: await resolveMetricFilterWhere(metric, query.payload, ctx),
|
|
44
54
|
// ctx.db already reads own-tenant + SYSTEM reference rows; this narrows to own-tenant only.
|
|
45
55
|
scopeWhere: { tenantId: query.user.tenantId },
|
|
46
56
|
}),
|
|
@@ -50,7 +60,7 @@ function createSystemMetricQuery(metric, now) {
|
|
|
50
60
|
return defineQueryHandler({
|
|
51
61
|
name: metric.id,
|
|
52
62
|
description: metric.description,
|
|
53
|
-
schema: systemPayloadSchema,
|
|
63
|
+
schema: systemPayloadSchema(metric),
|
|
54
64
|
access: { roles: access.systemAdmin },
|
|
55
65
|
handler: (query, ctx) => {
|
|
56
66
|
if (!ctx.systemDb) {
|
|
@@ -75,6 +85,7 @@ function createSystemMetricQuery(metric, now) {
|
|
|
75
85
|
range: query.payload.range,
|
|
76
86
|
timeZone: query.payload.timeZone,
|
|
77
87
|
now: now(),
|
|
88
|
+
filterWhere: metricFilterWhere(metric, query.payload),
|
|
78
89
|
scopeWhere: tenantId === undefined ? {} : { tenantId },
|
|
79
90
|
});
|
|
80
91
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import type { MetricDefinition, MetricFilterResolver } from "./types.js";
|
|
5
|
+
export declare const RESERVED_PAYLOAD_KEYS: readonly string[];
|
|
6
|
+
export type FilterValueKind = "uuid" | "number" | "string";
|
|
7
|
+
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
8
|
+
export declare function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined;
|
|
9
|
+
export type MetricFilterEntry = {
|
|
10
|
+
readonly payloadKey: string;
|
|
11
|
+
readonly column: string;
|
|
12
|
+
readonly resolver?: MetricFilterResolver;
|
|
13
|
+
};
|
|
14
|
+
export declare function metricFilterEntries(metric: MetricDefinition): readonly MetricFilterEntry[];
|
|
15
|
+
/** "" and null mean "no filter" (the dashboard's "(all)" option). */
|
|
16
|
+
export declare function metricFilterPayloadShape(metric: MetricDefinition): Record<string, z.ZodType>;
|
|
17
|
+
/** Column-equality filters only; resolver filters need resolveMetricFilterWhere. */
|
|
18
|
+
export declare function metricFilterWhere(metric: MetricDefinition, payload: Readonly<Record<string, unknown>>): WhereObject;
|
|
19
|
+
export declare function resolveMetricFilterWhere(metric: MetricDefinition, payload: Readonly<Record<string, unknown>>, ctx: HandlerContext): Promise<WhereObject>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { extractTableInfo } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
export const RESERVED_PAYLOAD_KEYS = ["range", "timeZone", "tenantId"];
|
|
4
|
+
// Dashboard select options always send strings.
|
|
5
|
+
const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
|
|
6
|
+
const MAX_FILTER_STRING_LENGTH = 200;
|
|
7
|
+
const NUMERIC_PG_TYPES = new Set([
|
|
8
|
+
"integer",
|
|
9
|
+
"int4",
|
|
10
|
+
"smallint",
|
|
11
|
+
"int2",
|
|
12
|
+
"bigint",
|
|
13
|
+
"serial",
|
|
14
|
+
"bigserial",
|
|
15
|
+
"double precision",
|
|
16
|
+
]);
|
|
17
|
+
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
18
|
+
export function filterValueKindOf(pgType) {
|
|
19
|
+
if (pgType === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (pgType === "uuid")
|
|
22
|
+
return "uuid";
|
|
23
|
+
if (pgType === "text")
|
|
24
|
+
return "string";
|
|
25
|
+
if (NUMERIC_PG_TYPES.has(pgType) || pgType.startsWith("numeric"))
|
|
26
|
+
return "number";
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
export function metricFilterEntries(metric) {
|
|
30
|
+
return Object.entries(metric.filters ?? {}).map(([payloadKey, spec]) => typeof spec === "string"
|
|
31
|
+
? { payloadKey, column: spec }
|
|
32
|
+
: { payloadKey, column: spec.column, resolver: spec });
|
|
33
|
+
}
|
|
34
|
+
function filterValueSchema(kind) {
|
|
35
|
+
if (kind === "uuid")
|
|
36
|
+
return z.uuid();
|
|
37
|
+
if (kind === "number")
|
|
38
|
+
return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
|
|
39
|
+
return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
|
|
40
|
+
}
|
|
41
|
+
/** "" and null mean "no filter" (the dashboard's "(all)" option). */
|
|
42
|
+
export function metricFilterPayloadShape(metric) {
|
|
43
|
+
const info = extractTableInfo(metric.source);
|
|
44
|
+
const shape = {};
|
|
45
|
+
for (const { payloadKey, column, resolver } of metricFilterEntries(metric)) {
|
|
46
|
+
const kind = resolver !== undefined
|
|
47
|
+
? (resolver.valueKind ?? "string")
|
|
48
|
+
: filterValueKindOf(info.pgTypeOf(info.columnOf(column)));
|
|
49
|
+
if (kind === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
shape[payloadKey] = z.union([z.literal(""), z.null(), filterValueSchema(kind)]).optional();
|
|
52
|
+
}
|
|
53
|
+
return shape;
|
|
54
|
+
}
|
|
55
|
+
function activeFilterValue(payload, key) {
|
|
56
|
+
const value = payload[key];
|
|
57
|
+
return value === undefined || value === null || value === "" ? undefined : value;
|
|
58
|
+
}
|
|
59
|
+
/** Column-equality filters only; resolver filters need resolveMetricFilterWhere. */
|
|
60
|
+
export function metricFilterWhere(metric, payload) {
|
|
61
|
+
const where = {};
|
|
62
|
+
for (const { payloadKey, column, resolver } of metricFilterEntries(metric)) {
|
|
63
|
+
const value = activeFilterValue(payload, payloadKey);
|
|
64
|
+
if (resolver === undefined && value !== undefined)
|
|
65
|
+
where[column] = value;
|
|
66
|
+
}
|
|
67
|
+
return where;
|
|
68
|
+
}
|
|
69
|
+
export async function resolveMetricFilterWhere(metric, payload, ctx) {
|
|
70
|
+
const where = metricFilterWhere(metric, payload);
|
|
71
|
+
for (const { payloadKey, column, resolver } of metricFilterEntries(metric)) {
|
|
72
|
+
const value = activeFilterValue(payload, payloadKey);
|
|
73
|
+
if (resolver === undefined || (typeof value !== "string" && typeof value !== "number"))
|
|
74
|
+
continue;
|
|
75
|
+
where[column] = await resolver.resolve(value, ctx);
|
|
76
|
+
}
|
|
77
|
+
return where;
|
|
78
|
+
}
|
package/dist/metrics/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { METRIC_RANGES, METRICS_FEATURE, METRICS_SYSTEM_FEATURE, type MetricRang
|
|
|
2
2
|
export { activeTenantsMetric, activeUsersMetric, auditWritesMetric, DEFAULT_METRICS, deliveriesByChannelMetric, failedDeliveriesMetric, failedJobRunsMetric, jobRunsByStatusMetric, tenantJobFailuresMetric, } from "./default-metrics.js";
|
|
3
3
|
export { createMetricsFeature, createSystemMetricsFeature } from "./feature.js";
|
|
4
4
|
export { METRICS_I18N } from "./i18n.js";
|
|
5
|
-
export { defineMetric, type MetricDefinition, type MetricPoint, type MetricResult, type MetricRow, type MetricSegment, type MetricSeries, type MetricsFeatureOptions, } from "./types.js";
|
|
5
|
+
export { defineMetric, type MetricDefinition, type MetricFilterResolver, type MetricPoint, type MetricResult, type MetricRow, type MetricSegment, type MetricSeries, type MetricsFeatureOptions, } from "./types.js";
|
package/dist/metrics/types.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type { AggregateMeasure, AggregateTimeBucket } from "@cosmicdrift/kumiko-types/aggregate-types";
|
|
3
4
|
import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
5
|
import type { WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
5
6
|
import type { Temporal } from "temporal-polyfill";
|
|
6
7
|
import type { MetricRange, MetricScope } from "./constants.js";
|
|
8
|
+
/** Maps a payload value (e.g. a folder id) to the values `column` may have (e.g. the folder and its subfolders' loan ids). Runs with the tenant handler context, so reads stay tenant-scoped; the result only narrows. An empty result yields no rows. */
|
|
9
|
+
export type MetricFilterResolver = {
|
|
10
|
+
readonly column: string;
|
|
11
|
+
readonly resolve: (value: string | number, ctx: HandlerContext) => Promise<readonly (string | number)[]>;
|
|
12
|
+
/** Type of the payload value, not of `column`. Default "string". */
|
|
13
|
+
readonly valueKind?: "uuid" | "number" | "string";
|
|
14
|
+
};
|
|
7
15
|
export type MetricDefinition = {
|
|
8
16
|
readonly id: string;
|
|
9
17
|
readonly description: string;
|
|
@@ -12,6 +20,8 @@ export type MetricDefinition = {
|
|
|
12
20
|
readonly requires?: string;
|
|
13
21
|
readonly measure: AggregateMeasure;
|
|
14
22
|
readonly where?: WhereObject;
|
|
23
|
+
/** Payload key → source column (equality) or a resolver (IN). e.g. `{ folderId: "folderId" }`. Narrows the result only; the tenant scope is always applied on top and never replaced. Resolvers need a metric without scope "system". */
|
|
24
|
+
readonly filters?: Readonly<Record<string, string | MetricFilterResolver>>;
|
|
15
25
|
/** timestamptz column; without it the metric is a snapshot with no window. */
|
|
16
26
|
readonly timeField?: string;
|
|
17
27
|
/** Fixed window, ignores the payload range. */
|
package/dist/metrics/validate.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { extractTableInfo, isTimestamptzType } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { METRIC_RANGES } from "./constants.js";
|
|
3
|
+
import { filterValueKindOf, metricFilterEntries, RESERVED_PAYLOAD_KEYS } from "./filters.js";
|
|
3
4
|
const KEBAB_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
5
|
+
const FILTER_PAYLOAD_KEY = /^[a-z][a-zA-Z0-9]*$/;
|
|
4
6
|
const TENANT_ID_FIELD = "tenantId";
|
|
5
7
|
export function metricHasTenantColumn(metric) {
|
|
6
8
|
return extractTableInfo(metric.source).hasColumn(TENANT_ID_FIELD);
|
|
@@ -25,6 +27,7 @@ function validateMetric(metric) {
|
|
|
25
27
|
fail("declares no scopes");
|
|
26
28
|
const info = extractTableInfo(metric.source);
|
|
27
29
|
validateColumns(metric, info, fail);
|
|
30
|
+
validateFilters(metric, info, fail);
|
|
28
31
|
validateTimeSettings(metric, info, fail);
|
|
29
32
|
validateShape(metric, fail);
|
|
30
33
|
if (metric.scopes.includes("tenant") && !metricHasTenantColumn(metric)) {
|
|
@@ -47,6 +50,32 @@ function validateColumns(metric, info, fail) {
|
|
|
47
50
|
if (metric.timeField !== undefined)
|
|
48
51
|
requireColumn(metric.timeField, "timeField");
|
|
49
52
|
}
|
|
53
|
+
function validateFilters(metric, info, fail) {
|
|
54
|
+
const baseWhereColumns = new Set(Object.keys(metric.where ?? {}).map((f) => info.columnOf(f)));
|
|
55
|
+
for (const { payloadKey, column: field, resolver } of metricFilterEntries(metric)) {
|
|
56
|
+
if (resolver !== undefined && metric.scopes.includes("system")) {
|
|
57
|
+
fail(`filter "${payloadKey}" uses a resolver, which runs with the tenant context and cannot be combined with scope "system"`);
|
|
58
|
+
}
|
|
59
|
+
if (!FILTER_PAYLOAD_KEY.test(payloadKey)) {
|
|
60
|
+
fail(`has filter key "${payloadKey}" that is not a camelCase identifier`);
|
|
61
|
+
}
|
|
62
|
+
if (RESERVED_PAYLOAD_KEYS.includes(payloadKey)) {
|
|
63
|
+
fail(`has filter key "${payloadKey}" that is reserved by the metric payload`);
|
|
64
|
+
}
|
|
65
|
+
if (!info.hasColumn(field))
|
|
66
|
+
fail(`filter "${payloadKey}" column "${field}" is not a column of ${info.name}`);
|
|
67
|
+
const column = info.columnOf(field);
|
|
68
|
+
if (column === info.columnOf(TENANT_ID_FIELD)) {
|
|
69
|
+
fail(`filter "${payloadKey}" targets the tenant column, which only the scope may set`);
|
|
70
|
+
}
|
|
71
|
+
if (baseWhereColumns.has(column)) {
|
|
72
|
+
fail(`filter "${payloadKey}" column "${field}" is already fixed by the metric where`);
|
|
73
|
+
}
|
|
74
|
+
if (filterValueKindOf(info.pgTypeOf(column)) === undefined) {
|
|
75
|
+
fail(`filter "${payloadKey}" column "${field}" has type ${info.pgTypeOf(column)}, only uuid, text and numeric columns can be filtered`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
50
79
|
function validateTimeSettings(metric, info, fail) {
|
|
51
80
|
const { timeField } = metric;
|
|
52
81
|
if (timeField === undefined) {
|
|
@@ -73,25 +73,57 @@ function renderBrandingHeader(branding, primaryColor, logoUrl) {
|
|
|
73
73
|
: `<span style="font-size:18px;font-weight:700;color:${escapeHtmlAttr(primaryColor)}">${escapeHtml(branding.productName ?? "")}</span>`;
|
|
74
74
|
return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
|
|
75
75
|
}
|
|
76
|
-
function
|
|
77
|
-
if (!branding)
|
|
78
|
-
return "";
|
|
76
|
+
function resolveBrandingFooter(branding, locale) {
|
|
79
77
|
const pick = (value) => resolveLocalized(value, locale, branding.defaultLocale);
|
|
80
78
|
const links = (branding.footerLinks ?? []).flatMap((link) => {
|
|
81
79
|
const url = pick(link.url);
|
|
82
80
|
if (url === undefined || !isHttpUrl(url))
|
|
83
81
|
return [];
|
|
84
|
-
return [
|
|
85
|
-
`<a href="${escapeHtmlAttr(url)}" style="color:#999">${escapeHtml(pick(link.label) ?? "")}</a>`,
|
|
86
|
-
];
|
|
82
|
+
return [{ label: pick(link.label) ?? "", url }];
|
|
87
83
|
});
|
|
88
|
-
|
|
84
|
+
return { footerText: pick(branding.footerText), links };
|
|
85
|
+
}
|
|
86
|
+
function renderBrandingFooter(branding, locale) {
|
|
87
|
+
if (!branding)
|
|
88
|
+
return "";
|
|
89
|
+
const { footerText, links: resolvedLinks } = resolveBrandingFooter(branding, locale);
|
|
90
|
+
const links = resolvedLinks.map((link) => `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`);
|
|
89
91
|
const footerPartsHtml = [footerText ? escapeHtml(footerText) : "", links.join(" · ")].filter((part) => part !== "");
|
|
90
92
|
if (footerPartsHtml.length === 0)
|
|
91
93
|
return "";
|
|
92
94
|
const footerHtml = footerPartsHtml.join("<br />");
|
|
93
95
|
return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
|
|
94
96
|
}
|
|
97
|
+
function brandingFooterLines(branding, locale) {
|
|
98
|
+
if (!branding)
|
|
99
|
+
return [];
|
|
100
|
+
const { footerText, links } = resolveBrandingFooter(branding, locale);
|
|
101
|
+
return [
|
|
102
|
+
...(footerText ? [footerText] : []),
|
|
103
|
+
...links.map((link) => (link.label ? `${link.label}: ${link.url}` : link.url)),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
function templateContent(variables) {
|
|
107
|
+
const data = variables; // @cast-boundary render-helper
|
|
108
|
+
// Without structured fields, title + body become the header and a single text section.
|
|
109
|
+
return {
|
|
110
|
+
header: data.header ?? data.title,
|
|
111
|
+
sections: data.sections ?? (data.body ? [{ text: data.body }] : undefined),
|
|
112
|
+
footer: data.footer,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Markdown stays as written: its source is already readable plain text.
|
|
116
|
+
function sectionText(section) {
|
|
117
|
+
if ("text" in section)
|
|
118
|
+
return section.text;
|
|
119
|
+
if ("heading" in section)
|
|
120
|
+
return section.heading;
|
|
121
|
+
if ("markdown" in section)
|
|
122
|
+
return section.markdown;
|
|
123
|
+
if ("button" in section)
|
|
124
|
+
return `${section.button.label}: ${section.button.url}`;
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
95
127
|
function renderSection(section, primaryColor) {
|
|
96
128
|
if ("text" in section) {
|
|
97
129
|
return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
|
|
@@ -118,10 +150,7 @@ export function createSimpleRenderer(branding) {
|
|
|
118
150
|
return {
|
|
119
151
|
name: "simple",
|
|
120
152
|
async render(input) {
|
|
121
|
-
const
|
|
122
|
-
// Fallback: if no structured fields, use title + body as header + single text section
|
|
123
|
-
const header = data.header ?? data.title;
|
|
124
|
-
const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
|
|
153
|
+
const { header, sections, footer } = templateContent(input.variables);
|
|
125
154
|
const parts = [];
|
|
126
155
|
parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
|
|
127
156
|
parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
|
|
@@ -134,13 +163,20 @@ export function createSimpleRenderer(branding) {
|
|
|
134
163
|
parts.push(renderSection(section, primaryColor));
|
|
135
164
|
}
|
|
136
165
|
}
|
|
137
|
-
if (
|
|
138
|
-
parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(
|
|
166
|
+
if (footer) {
|
|
167
|
+
parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(footer)}</p>`);
|
|
139
168
|
}
|
|
140
169
|
parts.push(renderBrandingFooter(branding, input.locale));
|
|
141
170
|
parts.push("</div></body></html>");
|
|
142
171
|
return parts.join("");
|
|
143
172
|
},
|
|
173
|
+
async renderText(input) {
|
|
174
|
+
const { header, sections, footer } = templateContent(input.variables);
|
|
175
|
+
const brandingFooter = brandingFooterLines(branding, input.locale).join("\n");
|
|
176
|
+
return [header, ...(sections ?? []).map(sectionText), footer, brandingFooter]
|
|
177
|
+
.filter((block) => block !== undefined && block !== "")
|
|
178
|
+
.join("\n\n");
|
|
179
|
+
},
|
|
144
180
|
};
|
|
145
181
|
}
|
|
146
182
|
export const simpleRenderer = createSimpleRenderer();
|
|
@@ -45,7 +45,13 @@ export function renderExportReadyEmail(args) {
|
|
|
45
45
|
<p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t(locale, "gdpr.mail.exportReady.button") })}</p>
|
|
46
46
|
<p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }))}</p>
|
|
47
47
|
${renderFallbackUrl({ url: args.downloadUrl, label: t(locale, "gdpr.mail.fallbackUrl") })}`;
|
|
48
|
-
|
|
48
|
+
const text = plainTextBody([
|
|
49
|
+
t(locale, "gdpr.mail.greeting"),
|
|
50
|
+
t(locale, "gdpr.mail.exportReady.intro", { app }),
|
|
51
|
+
`${t(locale, "gdpr.mail.exportReady.button")}: ${args.downloadUrl}`,
|
|
52
|
+
t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }),
|
|
53
|
+
]);
|
|
54
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
49
55
|
}
|
|
50
56
|
export function renderExportFailedEmail(args) {
|
|
51
57
|
const locale = args.locale ?? "en";
|
|
@@ -54,7 +60,11 @@ export function renderExportFailedEmail(args) {
|
|
|
54
60
|
const body = `
|
|
55
61
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
56
62
|
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.exportFailed.intro", { app }))}</p>`;
|
|
57
|
-
|
|
63
|
+
const text = plainTextBody([
|
|
64
|
+
t(locale, "gdpr.mail.greeting"),
|
|
65
|
+
t(locale, "gdpr.mail.exportFailed.intro", { app }),
|
|
66
|
+
]);
|
|
67
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
58
68
|
}
|
|
59
69
|
export function renderDeletionRequestedEmail(args) {
|
|
60
70
|
const locale = args.locale ?? "en";
|
|
@@ -65,7 +75,12 @@ export function renderDeletionRequestedEmail(args) {
|
|
|
65
75
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
66
76
|
<p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.intro", { app, when }))}</p>
|
|
67
77
|
<p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.cancel"))}</p>`;
|
|
68
|
-
|
|
78
|
+
const text = plainTextBody([
|
|
79
|
+
t(locale, "gdpr.mail.greeting"),
|
|
80
|
+
t(locale, "gdpr.mail.deletionRequested.intro", { app, when }),
|
|
81
|
+
t(locale, "gdpr.mail.deletionRequested.cancel"),
|
|
82
|
+
]);
|
|
83
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
69
84
|
}
|
|
70
85
|
export function renderDeletionExecutedEmail(args) {
|
|
71
86
|
const locale = args.locale ?? "en";
|
|
@@ -75,7 +90,14 @@ export function renderDeletionExecutedEmail(args) {
|
|
|
75
90
|
const body = `
|
|
76
91
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
77
92
|
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }))}</p>`;
|
|
78
|
-
|
|
93
|
+
const text = plainTextBody([
|
|
94
|
+
t(locale, "gdpr.mail.greeting"),
|
|
95
|
+
t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }),
|
|
96
|
+
]);
|
|
97
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
98
|
+
}
|
|
99
|
+
function plainTextBody(paragraphs) {
|
|
100
|
+
return paragraphs.join("\n\n");
|
|
79
101
|
}
|
|
80
102
|
function wrapCell(bodyHtml) {
|
|
81
103
|
return `<tr><td>${bodyHtml}</td></tr>`;
|
|
@@ -29,34 +29,34 @@ function localeFor(userLocale, defaults) {
|
|
|
29
29
|
export function makeDefaultExportReadyEmail(resolveTransport, defaults = {}) {
|
|
30
30
|
return async (args) => {
|
|
31
31
|
const transport = await resolveTransport(args.tenantId);
|
|
32
|
-
const { subject, html } = renderExportReadyEmail({
|
|
32
|
+
const { subject, html, text } = renderExportReadyEmail({
|
|
33
33
|
downloadUrl: args.downloadUrl,
|
|
34
34
|
expiresAt: args.expiresAt,
|
|
35
35
|
locale: localeFor(args.userLocale, defaults),
|
|
36
36
|
appName: defaults.appName,
|
|
37
37
|
});
|
|
38
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
38
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
export function makeDefaultExportFailedEmail(resolveTransport, defaults = {}) {
|
|
42
42
|
return async (args) => {
|
|
43
43
|
const transport = await resolveTransport(args.tenantId);
|
|
44
|
-
const { subject, html } = renderExportFailedEmail({
|
|
44
|
+
const { subject, html, text } = renderExportFailedEmail({
|
|
45
45
|
locale: localeFor(args.userLocale, defaults),
|
|
46
46
|
appName: defaults.appName,
|
|
47
47
|
});
|
|
48
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
48
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
export function makeDefaultDeletionRequestedEmail(resolveTransport, defaults = {}) {
|
|
52
52
|
return async (args) => {
|
|
53
53
|
const transport = await resolveTransport(args.tenantId);
|
|
54
|
-
const { subject, html } = renderDeletionRequestedEmail({
|
|
54
|
+
const { subject, html, text } = renderDeletionRequestedEmail({
|
|
55
55
|
gracePeriodEnd: args.gracePeriodEnd,
|
|
56
56
|
locale: localeFor(args.userLocale, defaults),
|
|
57
57
|
appName: defaults.appName,
|
|
58
58
|
});
|
|
59
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
59
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
export function makeDefaultDeletionExecutedEmail(resolveTransport, defaults = {}) {
|
|
@@ -70,11 +70,11 @@ export function makeDefaultDeletionExecutedEmail(resolveTransport, defaults = {}
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
const transport = await resolveTransport(tenantId);
|
|
73
|
-
const { subject, html } = renderDeletionExecutedEmail({
|
|
73
|
+
const { subject, html, text } = renderDeletionExecutedEmail({
|
|
74
74
|
executedAt: args.executedAt,
|
|
75
75
|
locale: localeFor(args.userLocale, defaults),
|
|
76
76
|
appName: defaults.appName,
|
|
77
77
|
});
|
|
78
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
78
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
79
79
|
};
|
|
80
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.350.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -503,12 +503,12 @@
|
|
|
503
503
|
}
|
|
504
504
|
},
|
|
505
505
|
"dependencies": {
|
|
506
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
507
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
508
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
509
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
510
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
511
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
506
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.350.0",
|
|
507
|
+
"@cosmicdrift/kumiko-framework": "0.350.0",
|
|
508
|
+
"@cosmicdrift/kumiko-headless": "0.350.0",
|
|
509
|
+
"@cosmicdrift/kumiko-renderer": "0.350.0",
|
|
510
|
+
"@cosmicdrift/kumiko-renderer-web": "0.350.0",
|
|
511
|
+
"@cosmicdrift/kumiko-types": "0.350.0",
|
|
512
512
|
"@mollie/api-client": "^4.5.0",
|
|
513
513
|
"@node-rs/argon2": "^2.0.2",
|
|
514
514
|
"@types/mailparser": "^3.4.6",
|
|
@@ -1025,8 +1025,8 @@
|
|
|
1025
1025
|
],
|
|
1026
1026
|
"devDependencies": {
|
|
1027
1027
|
"@testing-library/user-event": "^14.6.1",
|
|
1028
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1029
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1028
|
+
"@cosmicdrift/kumiko-locale-de": "0.350.0",
|
|
1029
|
+
"@cosmicdrift/kumiko-locale-es": "0.350.0",
|
|
1030
1030
|
"jsqr": "^1.4.0"
|
|
1031
1031
|
}
|
|
1032
1032
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.350.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Mails get a plain-text part (EmailMessage.text, NotificationRenderer.renderText, simple renderer and GDPR mails fill it)",
|
|
6
|
+
"detail": "Email text part\n`EmailMessage` gets an optional `text`. With it the SMTP transport sends `multipart/alternative` with a plain-text part. `NotificationRenderer` gets an optional `renderText`, which the email channel sends next to the HTML through the queued render and send jobs. `createSimpleRenderer` implements it from the template data (header, sections, footer, branding footer; buttons as `label: url`). The GDPR default mails return and send `text` as well. The PII guard checks and redacts `text` like the body."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.348.1",
|
|
4
10
|
"type": "fix",
|
package/src/metrics/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.349.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Metrics accept payload filters that narrow the aggregate without replacing the tenant scope",
|
|
6
|
+
"detail": "metrics: payload filters on a metric\n`MetricDefinition.filters` maps payload keys to source columns, e.g. `{ folderId: \"folderId\" }`. The metric query then accepts that key and narrows the aggregate to the value, for the current and previous window and for groups, stacks and series. The tenant scope is applied last, so a filter value from another tenant yields 0, never that tenant's numbers. `\"\"`, `null` and a missing key mean no filter. The value type follows the column (uuid, numeric, text); an invalid value is a 400. Definitions are rejected at boot for non-camelCase keys, the reserved keys `range`/`timeZone`/`tenantId`, unknown columns, the tenant column, columns already fixed by `where`, and column types other than uuid, text and numeric.\nA filter can also be a resolver `{ column, resolve(value, ctx), valueKind? }`: the payload value (e.g. a folder id) is mapped to the allowed values of `column` through the tenant handler context (IN filter, empty result = no rows), for assignments that live in another table. Resolvers are rejected on metrics with scope `system`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.335.0",
|
|
4
10
|
"type": "fix",
|