@cosmicdrift/kumiko-samples 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/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +15 -5
- package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -1
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +1 -0
- package/packages/bundled-features/src/channel-email/types.ts +3 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +3 -0
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/compute.ts +5 -2
- package/packages/bundled-features/src/metrics/feature.ts +24 -9
- package/packages/bundled-features/src/metrics/filters.ts +100 -0
- package/packages/bundled-features/src/metrics/index.ts +1 -0
- package/packages/bundled-features/src/metrics/types.ts +14 -0
- package/packages/bundled-features/src/metrics/validate.ts +34 -0
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +68 -15
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +28 -4
- package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +8 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.350.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": "BUSL-1.1",
|
|
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.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>",
|
|
@@ -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",
|
|
@@ -106,13 +106,15 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
106
106
|
title: message.title,
|
|
107
107
|
body: message.body,
|
|
108
108
|
};
|
|
109
|
-
const
|
|
109
|
+
const rendererInput = {
|
|
110
110
|
template: message.notificationType,
|
|
111
111
|
variables,
|
|
112
112
|
locale: message.locale,
|
|
113
|
-
}
|
|
113
|
+
};
|
|
114
|
+
const html = await renderer.render(rendererInput);
|
|
115
|
+
const text = renderer.renderText ? await renderer.renderText(rendererInput) : undefined;
|
|
114
116
|
const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
|
|
115
|
-
return { html, subject };
|
|
117
|
+
return { html, subject, ...(text !== undefined && { text }) };
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
return {
|
|
@@ -132,9 +134,17 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
132
134
|
},
|
|
133
135
|
|
|
134
136
|
async send(address, message, _ctx, rendered) {
|
|
135
|
-
const { html, subject } = rendered ?? (await renderMessage(message));
|
|
137
|
+
const { html, subject, text } = rendered ?? (await renderMessage(message));
|
|
136
138
|
const envelope = emailEnvelopeFrom(message.data);
|
|
137
|
-
await transport.send(
|
|
139
|
+
await transport.send(
|
|
140
|
+
guardEmailMessage({
|
|
141
|
+
to: address,
|
|
142
|
+
subject,
|
|
143
|
+
html,
|
|
144
|
+
...(text !== undefined && { text }),
|
|
145
|
+
...envelope,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
138
148
|
return { status: "sent", address };
|
|
139
149
|
},
|
|
140
150
|
};
|
|
@@ -36,7 +36,9 @@ export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const leaking =
|
|
39
|
-
message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
39
|
+
message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
40
|
+
message.html.includes(CIPHERTEXT_MARKER) ||
|
|
41
|
+
message.text?.includes(CIPHERTEXT_MARKER) === true;
|
|
40
42
|
if (!leaking) return message;
|
|
41
43
|
const detail =
|
|
42
44
|
"[channel-email] mail subject/body contains a PII ciphertext " +
|
|
@@ -48,6 +50,9 @@ export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
|
48
50
|
...message,
|
|
49
51
|
subject: message.subject.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
50
52
|
html: message.html.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
53
|
+
...(message.text !== undefined && {
|
|
54
|
+
text: message.text.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
55
|
+
}),
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -92,6 +92,7 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
|
|
|
92
92
|
to: message.to,
|
|
93
93
|
subject: message.subject,
|
|
94
94
|
html: message.html,
|
|
95
|
+
...(message.text !== undefined && { text: message.text }),
|
|
95
96
|
...(message.replyTo && { replyTo: message.replyTo }),
|
|
96
97
|
...(message.headers && { headers: message.headers }),
|
|
97
98
|
});
|
|
@@ -3,6 +3,9 @@ export type EmailMessage = {
|
|
|
3
3
|
readonly to: string;
|
|
4
4
|
readonly subject: string;
|
|
5
5
|
readonly html: string;
|
|
6
|
+
// Plain-text alternative to `html`. Set → the mail goes out as
|
|
7
|
+
// multipart/alternative, and clients without HTML show this part.
|
|
8
|
+
readonly text?: string;
|
|
6
9
|
// Per-message From override. Absent → the transport's configured From (the
|
|
7
10
|
// app-wide default). Set when one send must originate from a specific
|
|
8
11
|
// mailbox — a reply from the address the original mail reached, not noreply@.
|
|
@@ -47,7 +47,9 @@ const renderJobPayloadSchema = z.object({
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
50
|
-
rendered: z
|
|
50
|
+
rendered: z
|
|
51
|
+
.object({ html: z.string(), subject: z.string(), text: z.string().optional() })
|
|
52
|
+
.optional(),
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
type RenderJobPayload = z.infer<typeof renderJobPayloadSchema>;
|
|
@@ -48,6 +48,7 @@ export type ChannelResult = {
|
|
|
48
48
|
export type RenderedMessage = {
|
|
49
49
|
readonly html: string;
|
|
50
50
|
readonly subject: string;
|
|
51
|
+
readonly text?: string;
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
// `mode` decides how the delivery-service dispatches a channel:
|
|
@@ -83,6 +84,8 @@ export type RendererInput = {
|
|
|
83
84
|
export type NotificationRenderer = {
|
|
84
85
|
readonly name: string;
|
|
85
86
|
render(input: RendererInput): Promise<string>;
|
|
87
|
+
/** Plain-text version of the same mail; the email channel sends it as the text part of a multipart/alternative mail. */
|
|
88
|
+
renderText?(input: RendererInput): Promise<string>;
|
|
86
89
|
};
|
|
87
90
|
|
|
88
91
|
// --- Delivery Log Entry ---
|
|
@@ -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",
|
|
@@ -22,6 +22,8 @@ export type MetricRunParams = {
|
|
|
22
22
|
readonly range: MetricRange;
|
|
23
23
|
readonly timeZone: string;
|
|
24
24
|
readonly now: Temporal.Instant;
|
|
25
|
+
/** Payload-driven narrowing (MetricDefinition.filters); scopeWhere wins on conflicts. */
|
|
26
|
+
readonly filterWhere: WhereObject;
|
|
25
27
|
/** Extra filter that narrows the source to the caller's scope. */
|
|
26
28
|
readonly scopeWhere: WhereObject;
|
|
27
29
|
};
|
|
@@ -68,9 +70,10 @@ type MetricAggregates = {
|
|
|
68
70
|
function buildWheres(
|
|
69
71
|
metric: MetricDefinition,
|
|
70
72
|
window: MetricWindow | undefined,
|
|
73
|
+
filterWhere: WhereObject,
|
|
71
74
|
scopeWhere: WhereObject,
|
|
72
75
|
): MetricWheres {
|
|
73
|
-
const baseWhere: WhereObject = { ...metric.where, ...scopeWhere };
|
|
76
|
+
const baseWhere: WhereObject = { ...metric.where, ...filterWhere, ...scopeWhere };
|
|
74
77
|
const { timeField } = metric;
|
|
75
78
|
if (timeField === undefined || window === undefined) {
|
|
76
79
|
return { current: baseWhere, previous: undefined };
|
|
@@ -200,7 +203,7 @@ export async function runMetric(
|
|
|
200
203
|
? { field: timeField, bucket, timeZone: params.timeZone }
|
|
201
204
|
: undefined;
|
|
202
205
|
|
|
203
|
-
const wheres = buildWheres(metric, window, params.scopeWhere);
|
|
206
|
+
const wheres = buildWheres(metric, window, params.filterWhere, params.scopeWhere);
|
|
204
207
|
const aggregates = await fetchAggregates(db, metric, wheres, bucketDimension);
|
|
205
208
|
|
|
206
209
|
const value = aggregates.total[0]?.value ?? null;
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
METRICS_SYSTEM_FEATURE,
|
|
16
16
|
type MetricScope,
|
|
17
17
|
} from "./constants.js";
|
|
18
|
+
import {
|
|
19
|
+
metricFilterPayloadShape,
|
|
20
|
+
metricFilterWhere,
|
|
21
|
+
resolveMetricFilterWhere,
|
|
22
|
+
} from "./filters.js";
|
|
18
23
|
import { METRICS_I18N } from "./i18n.js";
|
|
19
24
|
import type { MetricDefinition, MetricsFeatureOptions } from "./types.js";
|
|
20
25
|
import { metricHasTenantColumn, validateMetrics } from "./validate.js";
|
|
@@ -33,13 +38,21 @@ const invalidTimeZone = {
|
|
|
33
38
|
message: "Unknown IANA time zone",
|
|
34
39
|
};
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
function tenantPayloadSchema(metric: MetricDefinition) {
|
|
42
|
+
return z
|
|
43
|
+
.object({ ...metricFilterPayloadShape(metric), ...windowFields })
|
|
44
|
+
.refine((payload) => isValidTimeZone(payload.timeZone), invalidTimeZone);
|
|
45
|
+
}
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
function systemPayloadSchema(metric: MetricDefinition) {
|
|
48
|
+
return z
|
|
49
|
+
.object({
|
|
50
|
+
...metricFilterPayloadShape(metric),
|
|
51
|
+
...windowFields,
|
|
52
|
+
tenantId: z.uuid().optional(),
|
|
53
|
+
})
|
|
54
|
+
.refine((payload) => isValidTimeZone(payload.timeZone), invalidTimeZone);
|
|
55
|
+
}
|
|
43
56
|
|
|
44
57
|
const defaultClock = (): Temporal.Instant => Temporal.Now.instant();
|
|
45
58
|
|
|
@@ -64,13 +77,14 @@ function createTenantMetricQuery(
|
|
|
64
77
|
return defineQueryHandler({
|
|
65
78
|
name: metric.id,
|
|
66
79
|
description: metric.description,
|
|
67
|
-
schema: tenantPayloadSchema,
|
|
80
|
+
schema: tenantPayloadSchema(metric),
|
|
68
81
|
access: { roles: access.admin },
|
|
69
|
-
handler: (query, ctx) =>
|
|
82
|
+
handler: async (query, ctx) =>
|
|
70
83
|
runMetric(ctx.db, metric, {
|
|
71
84
|
range: query.payload.range,
|
|
72
85
|
timeZone: query.payload.timeZone,
|
|
73
86
|
now: now(),
|
|
87
|
+
filterWhere: await resolveMetricFilterWhere(metric, query.payload, ctx),
|
|
74
88
|
// ctx.db already reads own-tenant + SYSTEM reference rows; this narrows to own-tenant only.
|
|
75
89
|
scopeWhere: { tenantId: query.user.tenantId },
|
|
76
90
|
}),
|
|
@@ -84,7 +98,7 @@ function createSystemMetricQuery(
|
|
|
84
98
|
return defineQueryHandler({
|
|
85
99
|
name: metric.id,
|
|
86
100
|
description: metric.description,
|
|
87
|
-
schema: systemPayloadSchema,
|
|
101
|
+
schema: systemPayloadSchema(metric),
|
|
88
102
|
access: { roles: access.systemAdmin },
|
|
89
103
|
handler: (query, ctx) => {
|
|
90
104
|
if (!ctx.systemDb) {
|
|
@@ -111,6 +125,7 @@ function createSystemMetricQuery(
|
|
|
111
125
|
range: query.payload.range,
|
|
112
126
|
timeZone: query.payload.timeZone,
|
|
113
127
|
now: now(),
|
|
128
|
+
filterWhere: metricFilterWhere(metric, query.payload),
|
|
114
129
|
scopeWhere: tenantId === undefined ? {} : { tenantId },
|
|
115
130
|
});
|
|
116
131
|
},
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { extractTableInfo, 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
|
+
|
|
6
|
+
export const RESERVED_PAYLOAD_KEYS: readonly string[] = ["range", "timeZone", "tenantId"];
|
|
7
|
+
|
|
8
|
+
// Dashboard select options always send strings.
|
|
9
|
+
const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
|
|
10
|
+
const MAX_FILTER_STRING_LENGTH = 200;
|
|
11
|
+
const NUMERIC_PG_TYPES: ReadonlySet<string> = new Set([
|
|
12
|
+
"integer",
|
|
13
|
+
"int4",
|
|
14
|
+
"smallint",
|
|
15
|
+
"int2",
|
|
16
|
+
"bigint",
|
|
17
|
+
"serial",
|
|
18
|
+
"bigserial",
|
|
19
|
+
"double precision",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export type FilterValueKind = "uuid" | "number" | "string";
|
|
23
|
+
|
|
24
|
+
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
25
|
+
export function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined {
|
|
26
|
+
if (pgType === undefined) return undefined;
|
|
27
|
+
if (pgType === "uuid") return "uuid";
|
|
28
|
+
if (pgType === "text") return "string";
|
|
29
|
+
if (NUMERIC_PG_TYPES.has(pgType) || pgType.startsWith("numeric")) return "number";
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MetricFilterEntry = {
|
|
34
|
+
readonly payloadKey: string;
|
|
35
|
+
readonly column: string;
|
|
36
|
+
readonly resolver?: MetricFilterResolver;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function metricFilterEntries(metric: MetricDefinition): readonly MetricFilterEntry[] {
|
|
40
|
+
return Object.entries(metric.filters ?? {}).map(([payloadKey, spec]) =>
|
|
41
|
+
typeof spec === "string"
|
|
42
|
+
? { payloadKey, column: spec }
|
|
43
|
+
: { payloadKey, column: spec.column, resolver: spec },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function filterValueSchema(kind: FilterValueKind): z.ZodType {
|
|
48
|
+
if (kind === "uuid") return z.uuid();
|
|
49
|
+
if (kind === "number")
|
|
50
|
+
return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
|
|
51
|
+
return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "" and null mean "no filter" (the dashboard's "(all)" option). */
|
|
55
|
+
export function metricFilterPayloadShape(metric: MetricDefinition): Record<string, z.ZodType> {
|
|
56
|
+
const info = extractTableInfo(metric.source);
|
|
57
|
+
const shape: Record<string, z.ZodType> = {};
|
|
58
|
+
for (const { payloadKey, column, resolver } of metricFilterEntries(metric)) {
|
|
59
|
+
const kind =
|
|
60
|
+
resolver !== undefined
|
|
61
|
+
? (resolver.valueKind ?? "string")
|
|
62
|
+
: filterValueKindOf(info.pgTypeOf(info.columnOf(column)));
|
|
63
|
+
if (kind === undefined) continue;
|
|
64
|
+
shape[payloadKey] = z.union([z.literal(""), z.null(), filterValueSchema(kind)]).optional();
|
|
65
|
+
}
|
|
66
|
+
return shape;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function activeFilterValue(payload: Readonly<Record<string, unknown>>, key: string): unknown {
|
|
70
|
+
const value = payload[key];
|
|
71
|
+
return value === undefined || value === null || value === "" ? undefined : value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Column-equality filters only; resolver filters need resolveMetricFilterWhere. */
|
|
75
|
+
export function metricFilterWhere(
|
|
76
|
+
metric: MetricDefinition,
|
|
77
|
+
payload: Readonly<Record<string, unknown>>,
|
|
78
|
+
): WhereObject {
|
|
79
|
+
const where: WhereObject = {};
|
|
80
|
+
for (const { payloadKey, column, resolver } of metricFilterEntries(metric)) {
|
|
81
|
+
const value = activeFilterValue(payload, payloadKey);
|
|
82
|
+
if (resolver === undefined && value !== undefined) where[column] = value;
|
|
83
|
+
}
|
|
84
|
+
return where;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function resolveMetricFilterWhere(
|
|
88
|
+
metric: MetricDefinition,
|
|
89
|
+
payload: Readonly<Record<string, unknown>>,
|
|
90
|
+
ctx: HandlerContext,
|
|
91
|
+
): Promise<WhereObject> {
|
|
92
|
+
const where = metricFilterWhere(metric, payload);
|
|
93
|
+
for (const { payloadKey, column, resolver } of metricFilterEntries(metric)) {
|
|
94
|
+
const value = activeFilterValue(payload, payloadKey);
|
|
95
|
+
if (resolver === undefined || (typeof value !== "string" && typeof value !== "number"))
|
|
96
|
+
continue;
|
|
97
|
+
where[column] = await resolver.resolve(value, ctx);
|
|
98
|
+
}
|
|
99
|
+
return where;
|
|
100
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type {
|
|
3
4
|
AggregateMeasure,
|
|
4
5
|
AggregateTimeBucket,
|
|
@@ -8,6 +9,17 @@ import type { WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
|
8
9
|
import type { Temporal } from "temporal-polyfill";
|
|
9
10
|
import type { MetricRange, MetricScope } from "./constants.js";
|
|
10
11
|
|
|
12
|
+
/** 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. */
|
|
13
|
+
export type MetricFilterResolver = {
|
|
14
|
+
readonly column: string;
|
|
15
|
+
readonly resolve: (
|
|
16
|
+
value: string | number,
|
|
17
|
+
ctx: HandlerContext,
|
|
18
|
+
) => Promise<readonly (string | number)[]>;
|
|
19
|
+
/** Type of the payload value, not of `column`. Default "string". */
|
|
20
|
+
readonly valueKind?: "uuid" | "number" | "string";
|
|
21
|
+
};
|
|
22
|
+
|
|
11
23
|
export type MetricDefinition = {
|
|
12
24
|
readonly id: string;
|
|
13
25
|
readonly description: string;
|
|
@@ -16,6 +28,8 @@ export type MetricDefinition = {
|
|
|
16
28
|
readonly requires?: string;
|
|
17
29
|
readonly measure: AggregateMeasure;
|
|
18
30
|
readonly where?: WhereObject;
|
|
31
|
+
/** 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". */
|
|
32
|
+
readonly filters?: Readonly<Record<string, string | MetricFilterResolver>>;
|
|
19
33
|
/** timestamptz column; without it the metric is a snapshot with no window. */
|
|
20
34
|
readonly timeField?: string;
|
|
21
35
|
/** Fixed window, ignores the payload range. */
|
|
@@ -1,8 +1,10 @@
|
|
|
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
|
import type { MetricDefinition } from "./types.js";
|
|
4
5
|
|
|
5
6
|
const KEBAB_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
7
|
+
const FILTER_PAYLOAD_KEY = /^[a-z][a-zA-Z0-9]*$/;
|
|
6
8
|
const TENANT_ID_FIELD = "tenantId";
|
|
7
9
|
|
|
8
10
|
export function metricHasTenantColumn(metric: MetricDefinition): boolean {
|
|
@@ -32,6 +34,7 @@ function validateMetric(metric: MetricDefinition): void {
|
|
|
32
34
|
|
|
33
35
|
const info = extractTableInfo(metric.source);
|
|
34
36
|
validateColumns(metric, info, fail);
|
|
37
|
+
validateFilters(metric, info, fail);
|
|
35
38
|
validateTimeSettings(metric, info, fail);
|
|
36
39
|
validateShape(metric, fail);
|
|
37
40
|
if (metric.scopes.includes("tenant") && !metricHasTenantColumn(metric)) {
|
|
@@ -50,6 +53,37 @@ function validateColumns(metric: MetricDefinition, info: TableInfo, fail: Fail):
|
|
|
50
53
|
if (metric.timeField !== undefined) requireColumn(metric.timeField, "timeField");
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
function validateFilters(metric: MetricDefinition, info: TableInfo, fail: Fail): void {
|
|
57
|
+
const baseWhereColumns = new Set(Object.keys(metric.where ?? {}).map((f) => info.columnOf(f)));
|
|
58
|
+
for (const { payloadKey, column: field, resolver } of metricFilterEntries(metric)) {
|
|
59
|
+
if (resolver !== undefined && metric.scopes.includes("system")) {
|
|
60
|
+
fail(
|
|
61
|
+
`filter "${payloadKey}" uses a resolver, which runs with the tenant context and cannot be combined with scope "system"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!FILTER_PAYLOAD_KEY.test(payloadKey)) {
|
|
65
|
+
fail(`has filter key "${payloadKey}" that is not a camelCase identifier`);
|
|
66
|
+
}
|
|
67
|
+
if (RESERVED_PAYLOAD_KEYS.includes(payloadKey)) {
|
|
68
|
+
fail(`has filter key "${payloadKey}" that is reserved by the metric payload`);
|
|
69
|
+
}
|
|
70
|
+
if (!info.hasColumn(field))
|
|
71
|
+
fail(`filter "${payloadKey}" column "${field}" is not a column of ${info.name}`);
|
|
72
|
+
const column = info.columnOf(field);
|
|
73
|
+
if (column === info.columnOf(TENANT_ID_FIELD)) {
|
|
74
|
+
fail(`filter "${payloadKey}" targets the tenant column, which only the scope may set`);
|
|
75
|
+
}
|
|
76
|
+
if (baseWhereColumns.has(column)) {
|
|
77
|
+
fail(`filter "${payloadKey}" column "${field}" is already fixed by the metric where`);
|
|
78
|
+
}
|
|
79
|
+
if (filterValueKindOf(info.pgTypeOf(column)) === undefined) {
|
|
80
|
+
fail(
|
|
81
|
+
`filter "${payloadKey}" column "${field}" has type ${info.pgTypeOf(column)}, only uuid, text and numeric columns can be filtered`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
function validateTimeSettings(metric: MetricDefinition, info: TableInfo, fail: Fail): void {
|
|
54
88
|
const { timeField } = metric;
|
|
55
89
|
if (timeField === undefined) {
|
|
@@ -117,21 +117,35 @@ function renderBrandingHeader(
|
|
|
117
117
|
return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
type ResolvedBrandingFooter = {
|
|
121
|
+
readonly footerText: string | undefined;
|
|
122
|
+
readonly links: readonly { readonly label: string; readonly url: string }[];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function resolveBrandingFooter(
|
|
126
|
+
branding: MailBranding,
|
|
122
127
|
locale: string | undefined,
|
|
123
|
-
):
|
|
124
|
-
if (!branding) return "";
|
|
128
|
+
): ResolvedBrandingFooter {
|
|
125
129
|
const pick = (value: LocalizedText | undefined): string | undefined =>
|
|
126
130
|
resolveLocalized(value, locale, branding.defaultLocale);
|
|
127
131
|
const links = (branding.footerLinks ?? []).flatMap((link) => {
|
|
128
132
|
const url = pick(link.url);
|
|
129
133
|
if (url === undefined || !isHttpUrl(url)) return [];
|
|
130
|
-
return [
|
|
131
|
-
`<a href="${escapeHtmlAttr(url)}" style="color:#999">${escapeHtml(pick(link.label) ?? "")}</a>`,
|
|
132
|
-
];
|
|
134
|
+
return [{ label: pick(link.label) ?? "", url }];
|
|
133
135
|
});
|
|
134
|
-
|
|
136
|
+
return { footerText: pick(branding.footerText), links };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderBrandingFooter(
|
|
140
|
+
branding: MailBranding | undefined,
|
|
141
|
+
locale: string | undefined,
|
|
142
|
+
): string {
|
|
143
|
+
if (!branding) return "";
|
|
144
|
+
const { footerText, links: resolvedLinks } = resolveBrandingFooter(branding, locale);
|
|
145
|
+
const links = resolvedLinks.map(
|
|
146
|
+
(link) =>
|
|
147
|
+
`<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`,
|
|
148
|
+
);
|
|
135
149
|
const footerPartsHtml = [footerText ? escapeHtml(footerText) : "", links.join(" · ")].filter(
|
|
136
150
|
(part) => part !== "",
|
|
137
151
|
);
|
|
@@ -140,6 +154,18 @@ function renderBrandingFooter(
|
|
|
140
154
|
return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
function brandingFooterLines(
|
|
158
|
+
branding: MailBranding | undefined,
|
|
159
|
+
locale: string | undefined,
|
|
160
|
+
): readonly string[] {
|
|
161
|
+
if (!branding) return [];
|
|
162
|
+
const { footerText, links } = resolveBrandingFooter(branding, locale);
|
|
163
|
+
return [
|
|
164
|
+
...(footerText ? [footerText] : []),
|
|
165
|
+
...links.map((link) => (link.label ? `${link.label}: ${link.url}` : link.url)),
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
|
|
143
169
|
type Section =
|
|
144
170
|
| { readonly text: string }
|
|
145
171
|
| { readonly heading: string }
|
|
@@ -156,6 +182,29 @@ type EmailTemplateData = {
|
|
|
156
182
|
readonly body?: string;
|
|
157
183
|
};
|
|
158
184
|
|
|
185
|
+
function templateContent(variables: Readonly<Record<string, unknown>>): {
|
|
186
|
+
readonly header: string | undefined;
|
|
187
|
+
readonly sections: readonly Section[] | undefined;
|
|
188
|
+
readonly footer: string | undefined;
|
|
189
|
+
} {
|
|
190
|
+
const data = variables as EmailTemplateData; // @cast-boundary render-helper
|
|
191
|
+
// Without structured fields, title + body become the header and a single text section.
|
|
192
|
+
return {
|
|
193
|
+
header: data.header ?? data.title,
|
|
194
|
+
sections: data.sections ?? (data.body ? [{ text: data.body }] : undefined),
|
|
195
|
+
footer: data.footer,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Markdown stays as written: its source is already readable plain text.
|
|
200
|
+
function sectionText(section: Section): string {
|
|
201
|
+
if ("text" in section) return section.text;
|
|
202
|
+
if ("heading" in section) return section.heading;
|
|
203
|
+
if ("markdown" in section) return section.markdown;
|
|
204
|
+
if ("button" in section) return `${section.button.label}: ${section.button.url}`;
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
|
|
159
208
|
function renderSection(section: Section, primaryColor: string): string {
|
|
160
209
|
if ("text" in section) {
|
|
161
210
|
return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
|
|
@@ -183,11 +232,7 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
|
|
|
183
232
|
name: "simple",
|
|
184
233
|
|
|
185
234
|
async render(input) {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
// Fallback: if no structured fields, use title + body as header + single text section
|
|
189
|
-
const header = data.header ?? data.title;
|
|
190
|
-
const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
|
|
235
|
+
const { header, sections, footer } = templateContent(input.variables);
|
|
191
236
|
|
|
192
237
|
const parts: string[] = [];
|
|
193
238
|
parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
|
|
@@ -206,9 +251,9 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
|
|
|
206
251
|
}
|
|
207
252
|
}
|
|
208
253
|
|
|
209
|
-
if (
|
|
254
|
+
if (footer) {
|
|
210
255
|
parts.push(
|
|
211
|
-
`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(
|
|
256
|
+
`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(footer)}</p>`,
|
|
212
257
|
);
|
|
213
258
|
}
|
|
214
259
|
|
|
@@ -216,6 +261,14 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
|
|
|
216
261
|
parts.push("</div></body></html>");
|
|
217
262
|
return parts.join("");
|
|
218
263
|
},
|
|
264
|
+
|
|
265
|
+
async renderText(input) {
|
|
266
|
+
const { header, sections, footer } = templateContent(input.variables);
|
|
267
|
+
const brandingFooter = brandingFooterLines(branding, input.locale).join("\n");
|
|
268
|
+
return [header, ...(sections ?? []).map(sectionText), footer, brandingFooter]
|
|
269
|
+
.filter((block): block is string => block !== undefined && block !== "")
|
|
270
|
+
.join("\n\n");
|
|
271
|
+
},
|
|
219
272
|
};
|
|
220
273
|
}
|
|
221
274
|
|
|
@@ -44,6 +44,7 @@ registerMailTranslations("en", GDPR_MAIL_EN);
|
|
|
44
44
|
export type RenderedEmail = {
|
|
45
45
|
readonly subject: string;
|
|
46
46
|
readonly html: string;
|
|
47
|
+
readonly text: string;
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
export type RenderExportReadyEmailArgs = {
|
|
@@ -89,7 +90,13 @@ export function renderExportReadyEmail(args: RenderExportReadyEmailArgs): Render
|
|
|
89
90
|
<p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t(locale, "gdpr.mail.exportReady.button") })}</p>
|
|
90
91
|
<p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }))}</p>
|
|
91
92
|
${renderFallbackUrl({ url: args.downloadUrl, label: t(locale, "gdpr.mail.fallbackUrl") })}`;
|
|
92
|
-
|
|
93
|
+
const text = plainTextBody([
|
|
94
|
+
t(locale, "gdpr.mail.greeting"),
|
|
95
|
+
t(locale, "gdpr.mail.exportReady.intro", { app }),
|
|
96
|
+
`${t(locale, "gdpr.mail.exportReady.button")}: ${args.downloadUrl}`,
|
|
97
|
+
t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }),
|
|
98
|
+
]);
|
|
99
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): RenderedEmail {
|
|
@@ -99,7 +106,11 @@ export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): Rend
|
|
|
99
106
|
const body = `
|
|
100
107
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
101
108
|
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.exportFailed.intro", { app }))}</p>`;
|
|
102
|
-
|
|
109
|
+
const text = plainTextBody([
|
|
110
|
+
t(locale, "gdpr.mail.greeting"),
|
|
111
|
+
t(locale, "gdpr.mail.exportFailed.intro", { app }),
|
|
112
|
+
]);
|
|
113
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
export function renderDeletionRequestedEmail(
|
|
@@ -113,7 +124,12 @@ export function renderDeletionRequestedEmail(
|
|
|
113
124
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
114
125
|
<p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.intro", { app, when }))}</p>
|
|
115
126
|
<p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.cancel"))}</p>`;
|
|
116
|
-
|
|
127
|
+
const text = plainTextBody([
|
|
128
|
+
t(locale, "gdpr.mail.greeting"),
|
|
129
|
+
t(locale, "gdpr.mail.deletionRequested.intro", { app, when }),
|
|
130
|
+
t(locale, "gdpr.mail.deletionRequested.cancel"),
|
|
131
|
+
]);
|
|
132
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArgs): RenderedEmail {
|
|
@@ -124,7 +140,15 @@ export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArg
|
|
|
124
140
|
const body = `
|
|
125
141
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
126
142
|
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }))}</p>`;
|
|
127
|
-
|
|
143
|
+
const text = plainTextBody([
|
|
144
|
+
t(locale, "gdpr.mail.greeting"),
|
|
145
|
+
t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }),
|
|
146
|
+
]);
|
|
147
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function plainTextBody(paragraphs: readonly string[]): string {
|
|
151
|
+
return paragraphs.join("\n\n");
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
function wrapCell(bodyHtml: string): string {
|
|
@@ -57,13 +57,13 @@ export function makeDefaultExportReadyEmail(
|
|
|
57
57
|
): SendExportReadyEmailFn {
|
|
58
58
|
return async (args) => {
|
|
59
59
|
const transport = await resolveTransport(args.tenantId);
|
|
60
|
-
const { subject, html } = renderExportReadyEmail({
|
|
60
|
+
const { subject, html, text } = renderExportReadyEmail({
|
|
61
61
|
downloadUrl: args.downloadUrl,
|
|
62
62
|
expiresAt: args.expiresAt,
|
|
63
63
|
locale: localeFor(args.userLocale, defaults),
|
|
64
64
|
appName: defaults.appName,
|
|
65
65
|
});
|
|
66
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
66
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -73,11 +73,11 @@ export function makeDefaultExportFailedEmail(
|
|
|
73
73
|
): SendExportFailedEmailFn {
|
|
74
74
|
return async (args) => {
|
|
75
75
|
const transport = await resolveTransport(args.tenantId);
|
|
76
|
-
const { subject, html } = renderExportFailedEmail({
|
|
76
|
+
const { subject, html, text } = renderExportFailedEmail({
|
|
77
77
|
locale: localeFor(args.userLocale, defaults),
|
|
78
78
|
appName: defaults.appName,
|
|
79
79
|
});
|
|
80
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
80
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -87,12 +87,12 @@ export function makeDefaultDeletionRequestedEmail(
|
|
|
87
87
|
): SendDeletionRequestedEmailFn {
|
|
88
88
|
return async (args) => {
|
|
89
89
|
const transport = await resolveTransport(args.tenantId);
|
|
90
|
-
const { subject, html } = renderDeletionRequestedEmail({
|
|
90
|
+
const { subject, html, text } = renderDeletionRequestedEmail({
|
|
91
91
|
gracePeriodEnd: args.gracePeriodEnd,
|
|
92
92
|
locale: localeFor(args.userLocale, defaults),
|
|
93
93
|
appName: defaults.appName,
|
|
94
94
|
});
|
|
95
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
95
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -110,11 +110,11 @@ export function makeDefaultDeletionExecutedEmail(
|
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
112
112
|
const transport = await resolveTransport(tenantId);
|
|
113
|
-
const { subject, html } = renderDeletionExecutedEmail({
|
|
113
|
+
const { subject, html, text } = renderDeletionExecutedEmail({
|
|
114
114
|
executedAt: args.executedAt,
|
|
115
115
|
locale: localeFor(args.userLocale, defaults),
|
|
116
116
|
appName: defaults.appName,
|
|
117
117
|
});
|
|
118
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
118
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
119
119
|
};
|
|
120
120
|
}
|