@cosmicdrift/kumiko-bundled-features 0.359.0 → 0.360.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +1 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +4 -3
- package/dist/jobs/job-run-logger.js +14 -5
- package/dist/renderer-pdf/feature.d.ts +4 -0
- package/dist/renderer-pdf/feature.js +32 -0
- package/dist/renderer-pdf/index.d.ts +2 -0
- package/dist/renderer-pdf/index.js +2 -0
- package/dist/renderer-pdf/render-table-pdf.d.ts +54 -0
- package/dist/renderer-pdf/render-table-pdf.js +274 -0
- package/dist/user-profile/feature.d.ts +6 -1
- package/dist/user-profile/feature.js +15 -11
- package/dist/user-profile/index.d.ts +1 -1
- package/package.json +20 -10
- package/src/jobs/changes.json +6 -0
- package/src/renderer-pdf/changes.json +8 -0
- package/src/user-profile/changes.json +6 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnonymousExtraRoute } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { type PublicPageWrapLayout } from "../../page-render/index.js";
|
|
3
3
|
import type { ConsentLocale } from "./consent-text.js";
|
|
4
4
|
export type ContractTerminationRoutesOptions = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Public § 312k pages (/legal/kuendigen, /legal/cancel): form → review → confirm,
|
|
2
2
|
// no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
|
|
3
|
+
import { getSessionCookies } from "@cosmicdrift/kumiko-framework/api";
|
|
3
4
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
4
5
|
import { getCookie } from "hono/cookie";
|
|
5
6
|
import { redirectToCanonicalPath, securePageHeaders, slashVariantOf, wrapInLayout, } from "../../page-render/index.js";
|
|
@@ -181,7 +182,6 @@ function resultPage(ctx, receivedAtIso, requestId) {
|
|
|
181
182
|
}
|
|
182
183
|
const RATE_LIMITED_CODE = "rate_limited";
|
|
183
184
|
const TENANT_HEADER = "x-tenant";
|
|
184
|
-
const TENANT_COOKIE = "kumiko_tenant";
|
|
185
185
|
// Exactly what the anonymous tenant resolution reads. The resolver and its
|
|
186
186
|
// trust rules still decide; the re-entry only sees what a direct /api/write
|
|
187
187
|
// from this client would see. Never the session cookie or Authorization (the
|
|
@@ -200,9 +200,10 @@ function reentryHeaders(c) {
|
|
|
200
200
|
if (value !== undefined)
|
|
201
201
|
headers.set(name, value);
|
|
202
202
|
}
|
|
203
|
-
const
|
|
203
|
+
const tenantCookieName = getSessionCookies(c).names.tenant;
|
|
204
|
+
const tenantCookie = getCookie(c, tenantCookieName);
|
|
204
205
|
if (tenantCookie !== undefined) {
|
|
205
|
-
headers.set("cookie", `${
|
|
206
|
+
headers.set("cookie", `${tenantCookieName}=${encodeURIComponent(tenantCookie)}`);
|
|
206
207
|
}
|
|
207
208
|
return headers;
|
|
208
209
|
}
|
|
@@ -101,6 +101,8 @@ async function encryptStartedPayload(payload, triggeredById) {
|
|
|
101
101
|
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
102
102
|
// framework's other fixed advisory-lock keys.
|
|
103
103
|
const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
|
|
104
|
+
// 'tjrl': serialises the finished-row write + prune per tenant run key.
|
|
105
|
+
const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
|
|
104
106
|
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
105
107
|
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
106
108
|
// clears it. `subject` is null for a job that declares no subjectFields.
|
|
@@ -188,11 +190,18 @@ async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
|
|
|
188
190
|
// skip: retry pending, the row stays active until the final attempt
|
|
189
191
|
return;
|
|
190
192
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
// One transaction: a reader never sees the new finished row next to the
|
|
194
|
+
// one it replaces. The per-key lock keeps two runs of the same subject that
|
|
195
|
+
// finish together from each missing the other's uncommitted row and leaving
|
|
196
|
+
// both behind.
|
|
197
|
+
await transaction(db, async (tx) => {
|
|
198
|
+
await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_RUN_LOCK_NAMESPACE, JSON.stringify([key.tenantId, key.jobName, key.subject]));
|
|
199
|
+
await upsertOnConflict(tx, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } });
|
|
200
|
+
await deleteMany(tx, tenantJobRunsTable, {
|
|
201
|
+
...key,
|
|
202
|
+
status: { in: ["completed", "failed"] },
|
|
203
|
+
bullJobId: { ne: bullJobId },
|
|
204
|
+
});
|
|
196
205
|
});
|
|
197
206
|
}
|
|
198
207
|
async function dropTenantRunQueued(db, bullJobId) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type RenderRequest, type RenderResponse } from "../renderer-foundation/index.js";
|
|
3
|
+
export declare function renderTablePdfRequest(req: RenderRequest): Promise<RenderResponse>;
|
|
4
|
+
export declare function createRendererPdfFeature(): FeatureDefinition;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { RENDERER_EXTENSION, RendererError, } from "../renderer-foundation/index.js";
|
|
3
|
+
import { pdfTableDocumentSchema, renderTablePdf } from "./render-table-pdf.js";
|
|
4
|
+
export async function renderTablePdfRequest(req) {
|
|
5
|
+
if (req.kind !== "document-pdf") {
|
|
6
|
+
throw new RendererError(`renderer-pdf supports only kind="document-pdf", got "${req.kind}"`, "invalid_payload");
|
|
7
|
+
}
|
|
8
|
+
const parsed = pdfTableDocumentSchema.safeParse(req.payload.variables);
|
|
9
|
+
if (!parsed.success) {
|
|
10
|
+
throw new RendererError(`renderer-pdf: payload.variables is not a table document: ${parsed.error.message}`, "invalid_payload");
|
|
11
|
+
}
|
|
12
|
+
const { pdfBytes, pageCount } = await renderTablePdf(parsed.data, {
|
|
13
|
+
...(req.options?.format && { format: req.options.format }),
|
|
14
|
+
...(req.options?.marginMm && { marginMm: req.options.marginMm }),
|
|
15
|
+
});
|
|
16
|
+
return { kind: "document-pdf", pdfBytes, pageCount, sizeBytes: pdfBytes.byteLength };
|
|
17
|
+
}
|
|
18
|
+
export function createRendererPdfFeature() {
|
|
19
|
+
return defineFeature("renderer-pdf", (r) => {
|
|
20
|
+
r.describe('Renderer plugin for `kind="document-pdf"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.');
|
|
21
|
+
r.uiHints({
|
|
22
|
+
displayLabel: "Renderer · PDF tables",
|
|
23
|
+
category: "notifications",
|
|
24
|
+
recommended: false,
|
|
25
|
+
});
|
|
26
|
+
r.requires("renderer-foundation");
|
|
27
|
+
r.useExtension(RENDERER_EXTENSION, "pdf", {
|
|
28
|
+
kinds: ["document-pdf"],
|
|
29
|
+
render: (req) => renderTablePdfRequest(req),
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export declare const pdfTableDocumentSchema: z.ZodObject<{
|
|
3
|
+
title: z.ZodString;
|
|
4
|
+
subtitle: z.ZodOptional<z.ZodString>;
|
|
5
|
+
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
6
|
+
label: z.ZodString;
|
|
7
|
+
value: z.ZodString;
|
|
8
|
+
}, z.core.$strip>>>;
|
|
9
|
+
columns: z.ZodArray<z.ZodObject<{
|
|
10
|
+
header: z.ZodString;
|
|
11
|
+
weight: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
align: z.ZodOptional<z.ZodEnum<{
|
|
13
|
+
left: "left";
|
|
14
|
+
right: "right";
|
|
15
|
+
}>>;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
|
18
|
+
emptyText: z.ZodOptional<z.ZodString>;
|
|
19
|
+
footerText: z.ZodOptional<z.ZodString>;
|
|
20
|
+
pageLabel: z.ZodOptional<z.ZodString>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
export type PdfTableColumn = {
|
|
23
|
+
readonly header: string;
|
|
24
|
+
readonly weight?: number;
|
|
25
|
+
readonly align?: "left" | "right";
|
|
26
|
+
};
|
|
27
|
+
export type PdfTableDocument = {
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly subtitle?: string;
|
|
30
|
+
readonly meta?: readonly {
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly value: string;
|
|
33
|
+
}[];
|
|
34
|
+
readonly columns: readonly PdfTableColumn[];
|
|
35
|
+
readonly rows: readonly (readonly string[])[];
|
|
36
|
+
readonly emptyText?: string;
|
|
37
|
+
readonly footerText?: string;
|
|
38
|
+
/** Template with `{page}` and `{pages}`; default `"{page} / {pages}"`. */
|
|
39
|
+
readonly pageLabel?: string;
|
|
40
|
+
};
|
|
41
|
+
export type PdfTableOptions = {
|
|
42
|
+
readonly format?: "A4" | "Letter";
|
|
43
|
+
readonly marginMm?: {
|
|
44
|
+
readonly top?: number;
|
|
45
|
+
readonly right?: number;
|
|
46
|
+
readonly bottom?: number;
|
|
47
|
+
readonly left?: number;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export type PdfTableResult = {
|
|
51
|
+
readonly pdfBytes: Uint8Array;
|
|
52
|
+
readonly pageCount: number;
|
|
53
|
+
};
|
|
54
|
+
export declare function renderTablePdf(doc: PdfTableDocument, options?: PdfTableOptions): Promise<PdfTableResult>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { RendererError } from "../renderer-foundation/index.js";
|
|
4
|
+
export const pdfTableDocumentSchema = z.object({
|
|
5
|
+
title: z.string(),
|
|
6
|
+
subtitle: z.string().optional(),
|
|
7
|
+
meta: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
|
8
|
+
columns: z
|
|
9
|
+
.array(z.object({
|
|
10
|
+
header: z.string(),
|
|
11
|
+
weight: z.number().positive().optional(),
|
|
12
|
+
align: z.enum(["left", "right"]).optional(),
|
|
13
|
+
}))
|
|
14
|
+
.min(1),
|
|
15
|
+
rows: z.array(z.array(z.string())),
|
|
16
|
+
emptyText: z.string().optional(),
|
|
17
|
+
footerText: z.string().optional(),
|
|
18
|
+
pageLabel: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
const POINTS_PER_MM = 72 / 25.4;
|
|
21
|
+
const PAGE_SIZE_POINTS = {
|
|
22
|
+
A4: { width: 595.28, height: 841.89 },
|
|
23
|
+
Letter: { width: 612, height: 792 },
|
|
24
|
+
};
|
|
25
|
+
const DEFAULT_MARGIN_MM = { top: 18, right: 15, bottom: 18, left: 15 };
|
|
26
|
+
const TITLE_SIZE = 16;
|
|
27
|
+
const SUBTITLE_SIZE = 11;
|
|
28
|
+
const BODY_SIZE = 9;
|
|
29
|
+
const FOOTER_SIZE = 8;
|
|
30
|
+
const LINE_HEIGHT_FACTOR = 1.3;
|
|
31
|
+
const CELL_PADDING_X = 4;
|
|
32
|
+
const CELL_PADDING_Y = 3;
|
|
33
|
+
const FOOTER_RESERVE = 16;
|
|
34
|
+
const META_LABEL_GAP = 10;
|
|
35
|
+
const BLOCK_GAP = 10;
|
|
36
|
+
const REPLACEMENT_CHAR = "?";
|
|
37
|
+
const ELLIPSIS = "...";
|
|
38
|
+
const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
|
|
39
|
+
const MUTED_COLOR = rgb(0.4, 0.4, 0.4);
|
|
40
|
+
const HEADER_FILL = rgb(0.92, 0.93, 0.95);
|
|
41
|
+
const RULE_COLOR = rgb(0.8, 0.8, 0.82);
|
|
42
|
+
function lineHeightFor(size) {
|
|
43
|
+
return size * LINE_HEIGHT_FACTOR;
|
|
44
|
+
}
|
|
45
|
+
function createEncodableText(font) {
|
|
46
|
+
const supported = new Set(font.getCharacterSet());
|
|
47
|
+
return (text) => {
|
|
48
|
+
let result = "";
|
|
49
|
+
for (const char of text.replace(/\t/g, " ")) {
|
|
50
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
51
|
+
result += codePoint === 0x0a || supported.has(codePoint) ? char : REPLACEMENT_CHAR;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function breakLongWord(word, font, size, maxWidth) {
|
|
57
|
+
const pieces = [];
|
|
58
|
+
let current = "";
|
|
59
|
+
for (const char of word) {
|
|
60
|
+
if (current !== "" && font.widthOfTextAtSize(current + char, size) > maxWidth) {
|
|
61
|
+
pieces.push(current);
|
|
62
|
+
current = char;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
current += char;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (current !== "")
|
|
69
|
+
pieces.push(current);
|
|
70
|
+
return pieces;
|
|
71
|
+
}
|
|
72
|
+
function wrapText(text, font, size, maxWidth) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
for (const paragraph of text.split(/\r\n|\n|\r/)) {
|
|
75
|
+
let current = "";
|
|
76
|
+
for (const word of paragraph.split(" ").filter((w) => w !== "")) {
|
|
77
|
+
const candidate = current === "" ? word : `${current} ${word}`;
|
|
78
|
+
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
|
|
79
|
+
current = candidate;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (current !== "")
|
|
83
|
+
lines.push(current);
|
|
84
|
+
const pieces = font.widthOfTextAtSize(word, size) <= maxWidth
|
|
85
|
+
? [word]
|
|
86
|
+
: breakLongWord(word, font, size, maxWidth);
|
|
87
|
+
lines.push(...pieces.slice(0, -1));
|
|
88
|
+
current = pieces[pieces.length - 1] ?? "";
|
|
89
|
+
}
|
|
90
|
+
lines.push(current);
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
function fitSingleLine(text, font, size, maxWidth) {
|
|
95
|
+
if (font.widthOfTextAtSize(text, size) <= maxWidth)
|
|
96
|
+
return text;
|
|
97
|
+
let fitted = text;
|
|
98
|
+
while (fitted !== "" && font.widthOfTextAtSize(fitted + ELLIPSIS, size) > maxWidth) {
|
|
99
|
+
fitted = fitted.slice(0, -1);
|
|
100
|
+
}
|
|
101
|
+
return fitted + ELLIPSIS;
|
|
102
|
+
}
|
|
103
|
+
function resolveColumnWidths(columns, totalWidth) {
|
|
104
|
+
const totalWeight = columns.reduce((sum, column) => sum + (column.weight ?? 1), 0);
|
|
105
|
+
return columns.map((column) => (totalWidth * (column.weight ?? 1)) / totalWeight);
|
|
106
|
+
}
|
|
107
|
+
function assertRowShape(doc) {
|
|
108
|
+
doc.rows.forEach((row, index) => {
|
|
109
|
+
if (row.length !== doc.columns.length) {
|
|
110
|
+
throw new RendererError(`row ${index} has ${row.length} cells but the table has ${doc.columns.length} columns`, "invalid_payload");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
export async function renderTablePdf(doc, options) {
|
|
115
|
+
if (doc.columns.length === 0) {
|
|
116
|
+
throw new RendererError("table needs at least one column", "invalid_payload");
|
|
117
|
+
}
|
|
118
|
+
assertRowShape(doc);
|
|
119
|
+
const pdf = await PDFDocument.create({ updateMetadata: false });
|
|
120
|
+
const fonts = {
|
|
121
|
+
regular: await pdf.embedFont(StandardFonts.Helvetica),
|
|
122
|
+
bold: await pdf.embedFont(StandardFonts.HelveticaBold),
|
|
123
|
+
};
|
|
124
|
+
const clean = createEncodableText(fonts.regular);
|
|
125
|
+
const pageSize = PAGE_SIZE_POINTS[options?.format ?? "A4"];
|
|
126
|
+
const margin = {
|
|
127
|
+
top: (options?.marginMm?.top ?? DEFAULT_MARGIN_MM.top) * POINTS_PER_MM,
|
|
128
|
+
right: (options?.marginMm?.right ?? DEFAULT_MARGIN_MM.right) * POINTS_PER_MM,
|
|
129
|
+
bottom: (options?.marginMm?.bottom ?? DEFAULT_MARGIN_MM.bottom) * POINTS_PER_MM,
|
|
130
|
+
left: (options?.marginMm?.left ?? DEFAULT_MARGIN_MM.left) * POINTS_PER_MM,
|
|
131
|
+
};
|
|
132
|
+
const contentWidth = pageSize.width - margin.left - margin.right;
|
|
133
|
+
const contentBottom = margin.bottom + FOOTER_RESERVE;
|
|
134
|
+
const columnWidths = resolveColumnWidths(doc.columns, contentWidth);
|
|
135
|
+
const bodyLineHeight = lineHeightFor(BODY_SIZE);
|
|
136
|
+
const rowSpaceOnEmptyPage = pageSize.height - margin.top - contentBottom;
|
|
137
|
+
if (rowSpaceOnEmptyPage < 4 * bodyLineHeight) {
|
|
138
|
+
throw new RendererError("margins leave no room for table rows", "invalid_payload");
|
|
139
|
+
}
|
|
140
|
+
const headerLines = doc.columns.map((column, index) => wrapText(clean(column.header), fonts.bold, BODY_SIZE, (columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X));
|
|
141
|
+
const headerHeight = Math.max(...headerLines.map((lines) => lines.length)) * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
142
|
+
let page = pdf.addPage([pageSize.width, pageSize.height]);
|
|
143
|
+
let cursorY = pageSize.height - margin.top;
|
|
144
|
+
function drawLines(lines, font, size, left, width, topY, align, color = TEXT_COLOR) {
|
|
145
|
+
lines.forEach((line, index) => {
|
|
146
|
+
const lineWidth = font.widthOfTextAtSize(line, size);
|
|
147
|
+
const x = align === "right" ? left + width - lineWidth : left;
|
|
148
|
+
page.drawText(line, {
|
|
149
|
+
x,
|
|
150
|
+
y: topY - size - index * lineHeightFor(size),
|
|
151
|
+
size,
|
|
152
|
+
font,
|
|
153
|
+
color,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function drawIntroBlock() {
|
|
158
|
+
const titleLines = wrapText(clean(doc.title), fonts.bold, TITLE_SIZE, contentWidth);
|
|
159
|
+
drawLines(titleLines, fonts.bold, TITLE_SIZE, margin.left, contentWidth, cursorY, "left");
|
|
160
|
+
cursorY -= titleLines.length * lineHeightFor(TITLE_SIZE);
|
|
161
|
+
if (doc.subtitle !== undefined) {
|
|
162
|
+
const subtitleLines = wrapText(clean(doc.subtitle), fonts.regular, SUBTITLE_SIZE, contentWidth);
|
|
163
|
+
drawLines(subtitleLines, fonts.regular, SUBTITLE_SIZE, margin.left, contentWidth, cursorY, "left", MUTED_COLOR);
|
|
164
|
+
cursorY -= subtitleLines.length * lineHeightFor(SUBTITLE_SIZE);
|
|
165
|
+
}
|
|
166
|
+
const meta = doc.meta ?? [];
|
|
167
|
+
if (meta.length > 0) {
|
|
168
|
+
cursorY -= BLOCK_GAP / 2;
|
|
169
|
+
const labels = meta.map((entry) => clean(entry.label));
|
|
170
|
+
const labelWidth = Math.max(...labels.map((label) => fonts.bold.widthOfTextAtSize(label, BODY_SIZE))) +
|
|
171
|
+
META_LABEL_GAP;
|
|
172
|
+
meta.forEach((entry, index) => {
|
|
173
|
+
const valueLines = wrapText(clean(entry.value), fonts.regular, BODY_SIZE, contentWidth - labelWidth);
|
|
174
|
+
drawLines([labels[index] ?? ""], fonts.bold, BODY_SIZE, margin.left, labelWidth, cursorY, "left");
|
|
175
|
+
drawLines(valueLines, fonts.regular, BODY_SIZE, margin.left + labelWidth, contentWidth - labelWidth, cursorY, "left");
|
|
176
|
+
cursorY -= valueLines.length * bodyLineHeight;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
cursorY -= BLOCK_GAP;
|
|
180
|
+
}
|
|
181
|
+
function drawTableHeader() {
|
|
182
|
+
page.drawRectangle({
|
|
183
|
+
x: margin.left,
|
|
184
|
+
y: cursorY - headerHeight,
|
|
185
|
+
width: contentWidth,
|
|
186
|
+
height: headerHeight,
|
|
187
|
+
color: HEADER_FILL,
|
|
188
|
+
});
|
|
189
|
+
let x = margin.left;
|
|
190
|
+
doc.columns.forEach((column, index) => {
|
|
191
|
+
const width = columnWidths[index] ?? 0;
|
|
192
|
+
drawLines(headerLines[index] ?? [], fonts.bold, BODY_SIZE, x + CELL_PADDING_X, width - 2 * CELL_PADDING_X, cursorY - CELL_PADDING_Y, column.align ?? "left");
|
|
193
|
+
x += width;
|
|
194
|
+
});
|
|
195
|
+
cursorY -= headerHeight;
|
|
196
|
+
}
|
|
197
|
+
function startContinuationPage() {
|
|
198
|
+
page = pdf.addPage([pageSize.width, pageSize.height]);
|
|
199
|
+
cursorY = pageSize.height - margin.top;
|
|
200
|
+
drawTableHeader();
|
|
201
|
+
}
|
|
202
|
+
function drawRowSlice(cellLines, from, count) {
|
|
203
|
+
const height = count * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
204
|
+
let x = margin.left;
|
|
205
|
+
doc.columns.forEach((column, index) => {
|
|
206
|
+
const width = columnWidths[index] ?? 0;
|
|
207
|
+
drawLines((cellLines[index] ?? []).slice(from, from + count), fonts.regular, BODY_SIZE, x + CELL_PADDING_X, width - 2 * CELL_PADDING_X, cursorY - CELL_PADDING_Y, column.align ?? "left");
|
|
208
|
+
x += width;
|
|
209
|
+
});
|
|
210
|
+
page.drawLine({
|
|
211
|
+
start: { x: margin.left, y: cursorY - height },
|
|
212
|
+
end: { x: margin.left + contentWidth, y: cursorY - height },
|
|
213
|
+
thickness: 0.5,
|
|
214
|
+
color: RULE_COLOR,
|
|
215
|
+
});
|
|
216
|
+
cursorY -= height;
|
|
217
|
+
}
|
|
218
|
+
function drawRow(cellLines) {
|
|
219
|
+
const lineCount = Math.max(...cellLines.map((lines) => lines.length));
|
|
220
|
+
const rowHeight = lineCount * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
221
|
+
const emptyPageSpace = pageSize.height - margin.top - headerHeight - contentBottom;
|
|
222
|
+
if (rowHeight > cursorY - contentBottom && rowHeight <= emptyPageSpace) {
|
|
223
|
+
startContinuationPage();
|
|
224
|
+
}
|
|
225
|
+
let drawn = 0;
|
|
226
|
+
while (drawn < lineCount) {
|
|
227
|
+
const fitting = Math.floor((cursorY - contentBottom - 2 * CELL_PADDING_Y) / bodyLineHeight);
|
|
228
|
+
if (fitting < 1) {
|
|
229
|
+
startContinuationPage();
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const count = Math.min(fitting, lineCount - drawn);
|
|
233
|
+
drawRowSlice(cellLines, drawn, count);
|
|
234
|
+
drawn += count;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
drawIntroBlock();
|
|
238
|
+
drawTableHeader();
|
|
239
|
+
if (doc.rows.length === 0 && doc.emptyText !== undefined) {
|
|
240
|
+
drawRow([
|
|
241
|
+
wrapText(clean(doc.emptyText), fonts.regular, BODY_SIZE, contentWidth - 2 * CELL_PADDING_X),
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
for (const row of doc.rows) {
|
|
245
|
+
drawRow(row.map((cell, index) => wrapText(clean(cell), fonts.regular, BODY_SIZE, (columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X)));
|
|
246
|
+
}
|
|
247
|
+
const pages = pdf.getPages();
|
|
248
|
+
const pageLabelTemplate = doc.pageLabel ?? "{page} / {pages}";
|
|
249
|
+
const footerBaseline = margin.bottom;
|
|
250
|
+
pages.forEach((footerPage, index) => {
|
|
251
|
+
const label = clean(pageLabelTemplate
|
|
252
|
+
.replaceAll("{page}", String(index + 1))
|
|
253
|
+
.replaceAll("{pages}", String(pages.length)));
|
|
254
|
+
const labelWidth = fonts.regular.widthOfTextAtSize(label, FOOTER_SIZE);
|
|
255
|
+
if (doc.footerText !== undefined) {
|
|
256
|
+
footerPage.drawText(fitSingleLine(clean(doc.footerText.replace(/\s+/g, " ")), fonts.regular, FOOTER_SIZE, contentWidth - labelWidth - 2 * CELL_PADDING_X), {
|
|
257
|
+
x: margin.left,
|
|
258
|
+
y: footerBaseline,
|
|
259
|
+
size: FOOTER_SIZE,
|
|
260
|
+
font: fonts.regular,
|
|
261
|
+
color: MUTED_COLOR,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
footerPage.drawText(label, {
|
|
265
|
+
x: margin.left + contentWidth - labelWidth,
|
|
266
|
+
y: footerBaseline,
|
|
267
|
+
size: FOOTER_SIZE,
|
|
268
|
+
font: fonts.regular,
|
|
269
|
+
color: MUTED_COLOR,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
const pdfBytes = await pdf.save();
|
|
273
|
+
return { pdfBytes, pageCount: pages.length };
|
|
274
|
+
}
|
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
export
|
|
2
|
+
export type UserProfileFeatureOptions = {
|
|
3
|
+
/** Default true. false drops the change-email handler and the email
|
|
4
|
+
* section — for apps whose login email is managed elsewhere (SSO, admin-set). */
|
|
5
|
+
readonly allowEmailChange?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function createUserProfileFeature(options?: UserProfileFeatureOptions): FeatureDefinition;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { CHANGE_EMAIL_SECTION_EXTENSION_NAME, CHANGE_PASSWORD_SECTION_EXTENSION_NAME, PROFILE_SCREEN_ID, UserDataRightsHandlers, UserProfileQueries, } from "./constants.js";
|
|
3
3
|
import { changeEmailWrite } from "./handlers/change-email.write.js";
|
|
4
|
-
export function createUserProfileFeature() {
|
|
4
|
+
export function createUserProfileFeature(options) {
|
|
5
|
+
const allowEmailChange = options?.allowEmailChange ?? true;
|
|
5
6
|
return defineFeature("user-profile", (r) => {
|
|
6
7
|
r.describe("Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
|
|
7
8
|
"the signed-in user's own `me` row) plus its `change-email` write handler " +
|
|
8
9
|
"(re-auth via current password, uniqueness check, resets emailVerified and " +
|
|
9
|
-
"expects the app to trigger the verification flow
|
|
10
|
+
"expects the app to trigger the verification flow; opt out with " +
|
|
11
|
+
"`allowEmailChange: false`, which drops handler and section). Change-password and " +
|
|
10
12
|
"change-email stay custom `EditExtensionSection` components (re-auth flows a " +
|
|
11
13
|
"declarative action can't express); account deletion (user-data-rights " +
|
|
12
14
|
"request/cancel with grace period) is declarative fields + actions. Apps " +
|
|
@@ -24,9 +26,7 @@ export function createUserProfileFeature() {
|
|
|
24
26
|
r.requires("auth-email-password");
|
|
25
27
|
r.requires("user-data-rights");
|
|
26
28
|
r.requires("user-data-rights-defaults");
|
|
27
|
-
const handlers = {
|
|
28
|
-
changeEmail: r.writeHandler(changeEmailWrite),
|
|
29
|
-
};
|
|
29
|
+
const handlers = allowEmailChange ? { changeEmail: r.writeHandler(changeEmailWrite) } : {};
|
|
30
30
|
// Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
|
|
31
31
|
// one entry per i18nKey()-marked dot-form label below — kept inline (not
|
|
32
32
|
// imported from ./i18n) so feature.ts, part of the server barrel, stays
|
|
@@ -83,12 +83,16 @@ export function createUserProfileFeature() {
|
|
|
83
83
|
},
|
|
84
84
|
layout: {
|
|
85
85
|
sections: [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
...(allowEmailChange
|
|
87
|
+
? [
|
|
88
|
+
{
|
|
89
|
+
kind: "extension",
|
|
90
|
+
title: i18nKey("profile.email.title"),
|
|
91
|
+
component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
|
|
92
|
+
entityName: "user",
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
: []),
|
|
92
96
|
{
|
|
93
97
|
kind: "extension",
|
|
94
98
|
title: i18nKey("profile.password.title"),
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { USER_PROFILE_FEATURE, UserDataRightsHandlers, UserProfileErrors, UserProfileHandlers, UserProfileQueries, } from "./constants.js";
|
|
2
|
-
export { createUserProfileFeature } from "./feature.js";
|
|
2
|
+
export { createUserProfileFeature, type UserProfileFeatureOptions } from "./feature.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.360.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>",
|
|
@@ -398,6 +398,10 @@
|
|
|
398
398
|
"types": "./dist/renderer-simple/index.d.ts",
|
|
399
399
|
"default": "./dist/renderer-simple/index.js"
|
|
400
400
|
},
|
|
401
|
+
"./renderer-pdf": {
|
|
402
|
+
"types": "./dist/renderer-pdf/index.d.ts",
|
|
403
|
+
"default": "./dist/renderer-pdf/index.js"
|
|
404
|
+
},
|
|
401
405
|
"./files-provider-s3": {
|
|
402
406
|
"types": "./dist/files-provider-s3/index.d.ts",
|
|
403
407
|
"default": "./dist/files-provider-s3/index.js"
|
|
@@ -516,12 +520,12 @@
|
|
|
516
520
|
}
|
|
517
521
|
},
|
|
518
522
|
"dependencies": {
|
|
519
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
520
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
521
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
522
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
523
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
524
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
523
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.360.0",
|
|
524
|
+
"@cosmicdrift/kumiko-framework": "0.360.0",
|
|
525
|
+
"@cosmicdrift/kumiko-headless": "0.360.0",
|
|
526
|
+
"@cosmicdrift/kumiko-renderer": "0.360.0",
|
|
527
|
+
"@cosmicdrift/kumiko-renderer-web": "0.360.0",
|
|
528
|
+
"@cosmicdrift/kumiko-types": "0.360.0",
|
|
525
529
|
"@mollie/api-client": "^4.5.0",
|
|
526
530
|
"@node-rs/argon2": "^2.0.2",
|
|
527
531
|
"@types/mailparser": "^3.4.6",
|
|
@@ -535,6 +539,7 @@
|
|
|
535
539
|
"mailparser": "^3.9.32",
|
|
536
540
|
"marked": "^18.0.3",
|
|
537
541
|
"nodemailer": "^10.0.13",
|
|
542
|
+
"pdf-lib": "^1.17.1",
|
|
538
543
|
"qrcode": "^1.5.4",
|
|
539
544
|
"react": "^19.2.6",
|
|
540
545
|
"sharp": "^0.35.5",
|
|
@@ -924,6 +929,10 @@
|
|
|
924
929
|
"types": "./dist/renderer-simple/index.d.ts",
|
|
925
930
|
"default": "./dist/renderer-simple/index.js"
|
|
926
931
|
},
|
|
932
|
+
"./renderer-pdf": {
|
|
933
|
+
"types": "./dist/renderer-pdf/index.d.ts",
|
|
934
|
+
"default": "./dist/renderer-pdf/index.js"
|
|
935
|
+
},
|
|
927
936
|
"./files-provider-s3": {
|
|
928
937
|
"types": "./dist/files-provider-s3/index.d.ts",
|
|
929
938
|
"default": "./dist/files-provider-s3/index.js"
|
|
@@ -1050,8 +1059,9 @@
|
|
|
1050
1059
|
],
|
|
1051
1060
|
"devDependencies": {
|
|
1052
1061
|
"@testing-library/user-event": "^14.6.1",
|
|
1053
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1054
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1055
|
-
"jsqr": "^1.4.0"
|
|
1062
|
+
"@cosmicdrift/kumiko-locale-de": "0.360.0",
|
|
1063
|
+
"@cosmicdrift/kumiko-locale-es": "0.360.0",
|
|
1064
|
+
"jsqr": "^1.4.0",
|
|
1065
|
+
"unpdf": "^1.8.1"
|
|
1056
1066
|
}
|
|
1057
1067
|
}
|
package/src/jobs/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Finished tenant job run replaces the older one atomically",
|
|
6
|
+
"detail": "recordTenantRunOutcome wrote the new completed or failed row and pruned older finished rows of the same tenant, job and subject in two separate statements, so the store briefly held two finished rows per subject, and two runs of one subject finishing together could each miss the other's row and leave both. Both writes now run in one transaction under a per-key advisory lock (namespace tjrl). jobs:query:tenant-runs already showed only the newest finished row, so its result does not change."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Pure-JS PDF renderer for table documents",
|
|
6
|
+
"detail": "renderTablePdf(doc, options?) renders a PdfTableDocument (title, subtitle, meta lines, weighted columns, wrapped cells, header row repeated per page, footerText and a pageLabel template with {page} and {pages}) to PDF bytes with pdf-lib. Characters outside WinAnsi are replaced by \"?\". Identical input yields identical bytes. createRendererPdfFeature() registers it as plugin \"pdf\" for kind \"document-pdf\"; payload.variables is validated against pdfTableDocumentSchema, anything else fails with RendererError invalid_payload."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "change-email can be switched off",
|
|
6
|
+
"detail": "createUserProfileFeature({ allowEmailChange: false }) neither registers the change-email write handler nor shows the email section on the profile screen. Without the option nothing changes."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.243.1",
|
|
4
10
|
"type": "fix",
|