@cosmicdrift/kumiko-samples 0.371.1 → 0.372.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/auth-mfa/db/queries.ts +5 -7
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +2 -1
- package/packages/bundled-features/src/config/web/index.ts +2 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +75 -34
- package/packages/bundled-features/src/crypto-shredding/resolve-file-bytes-deleter.ts +29 -0
- package/packages/bundled-features/src/data-retention/changes.json +6 -0
- package/packages/bundled-features/src/data-retention/index.ts +6 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +37 -213
- package/packages/bundled-features/src/delivery/changes.json +13 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +7 -2
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +2 -2
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +42 -23
- package/packages/bundled-features/src/delivery/recipient-address-seal.ts +72 -0
- package/packages/bundled-features/src/delivery/testing.ts +4 -1
- package/packages/bundled-features/src/delivery/unsubscribe.ts +23 -2
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +2 -2
- package/packages/bundled-features/src/files/changes.json +7 -0
- package/packages/bundled-features/src/personal-access-tokens/changes.json +12 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +15 -9
- package/packages/bundled-features/src/shared/index.ts +14 -0
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +206 -0
- package/packages/bundled-features/src/shared/purge-row-file-refs.ts +214 -0
- package/packages/bundled-features/src/template-resolver/changes.json +6 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -4
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +2 -1
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +1 -9
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +46 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +5 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +9 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/features/rental/entities.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +27 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +9 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +22 -1
- package/samples/apps/use-all-bundled/package.json +3 -1
- package/samples/recipes/custom-handlers/src/feature.ts +3 -5
- package/samples/recipes/embedded-entity-form/src/feature.ts +1 -1
- package/samples/recipes/pdf-audit-export/README.md +1 -1
- package/samples/recipes/user-data-rights/README.md +22 -0
- package/samples/recipes/user-data-rights/src/retained-docs.ts +35 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type DbRunner, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { EntityDefinition, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
4
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
|
+
|
|
6
|
+
type EntityFields = EntityDefinition["fields"];
|
|
7
|
+
type EntityTable = Parameters<typeof selectMany>[1];
|
|
8
|
+
|
|
9
|
+
export type FileFieldPlan = {
|
|
10
|
+
/** type file|image: the fileRef-id lives directly in the entity column. */
|
|
11
|
+
readonly singleFields: readonly string[];
|
|
12
|
+
/** type files|images: resolved via file_refs (bound by entityType/entityId/fieldName). */
|
|
13
|
+
readonly multiFields: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SingleFileHolder = {
|
|
17
|
+
readonly table: EntityTable;
|
|
18
|
+
readonly tableHasTenantId: boolean;
|
|
19
|
+
readonly singleFields: readonly string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type FileRefPurgeOutcome = "purged" | "missing_file_storage" | "file_delete_failed";
|
|
23
|
+
|
|
24
|
+
const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
|
|
25
|
+
|
|
26
|
+
// Same set as boot-validator/entity-handler.ts FILE_FIELD_TYPES; the single/multi split
|
|
27
|
+
// matters here because only plural fields resolve through file_refs.
|
|
28
|
+
export function planFileFields(
|
|
29
|
+
fields: EntityFields,
|
|
30
|
+
fieldNames?: readonly string[],
|
|
31
|
+
): FileFieldPlan {
|
|
32
|
+
const singleFields: string[] = [];
|
|
33
|
+
const multiFields: string[] = [];
|
|
34
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
35
|
+
if (fieldNames !== undefined && !fieldNames.includes(name)) continue;
|
|
36
|
+
if (field.type === "file" || field.type === "image") singleFields.push(name);
|
|
37
|
+
else if (field.type === "files" || field.type === "images") multiFields.push(name);
|
|
38
|
+
}
|
|
39
|
+
return singleFields.length > 0 || multiFields.length > 0
|
|
40
|
+
? { singleFields, multiFields }
|
|
41
|
+
: NO_FILE_FIELDS;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Every implicit entity table with file/image fields: an unbound fileRef
|
|
45
|
+
// (POST /files without entityType/entityId) can sit in single fields of
|
|
46
|
+
// several entity types, so the sharing check must look beyond the purged
|
|
47
|
+
// row's own table.
|
|
48
|
+
export function collectSingleFileHolders(registry: Registry): readonly SingleFileHolder[] {
|
|
49
|
+
const holders: SingleFileHolder[] = [];
|
|
50
|
+
for (const proj of registry.getAllProjections().values()) {
|
|
51
|
+
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
|
|
52
|
+
const entity = registry.getEntity(proj.source);
|
|
53
|
+
if (!entity) continue;
|
|
54
|
+
const { singleFields } = planFileFields(entity.fields);
|
|
55
|
+
if (singleFields.length === 0) continue;
|
|
56
|
+
holders.push({
|
|
57
|
+
table: proj.table,
|
|
58
|
+
tableHasTenantId: propertyOf(proj.table, "tenantId") !== undefined,
|
|
59
|
+
singleFields,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return holders;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Every fileRef-id a row's file-fields reference: single fields hold the id in
|
|
66
|
+
// their own column, plural fields resolve via file_refs (tenantId, entityType,
|
|
67
|
+
// entityId, fieldName).
|
|
68
|
+
async function collectFileRefIds(
|
|
69
|
+
db: DbRunner,
|
|
70
|
+
tenantId: TenantId,
|
|
71
|
+
entityName: string,
|
|
72
|
+
row: Record<string, unknown>,
|
|
73
|
+
plan: FileFieldPlan,
|
|
74
|
+
): Promise<readonly string[]> {
|
|
75
|
+
const ids = new Set<string>();
|
|
76
|
+
for (const field of plan.singleFields) {
|
|
77
|
+
const value = row[field];
|
|
78
|
+
if (typeof value === "string" && value.length > 0) ids.add(value);
|
|
79
|
+
}
|
|
80
|
+
if (plan.multiFields.length > 0) {
|
|
81
|
+
const refRows = await selectMany<{ id: string }>(db, fileRefsTable, {
|
|
82
|
+
tenantId,
|
|
83
|
+
entityType: entityName,
|
|
84
|
+
entityId: String(row["id"]),
|
|
85
|
+
fieldName: { in: plan.multiFields },
|
|
86
|
+
});
|
|
87
|
+
for (const r of refRows) ids.add(r.id);
|
|
88
|
+
}
|
|
89
|
+
return Array.from(ids);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A fileRef is bound to a DIFFERENT entity/record (or, when only some fields are
|
|
93
|
+
// purged, to another field of this row) than the one being purged — its binding,
|
|
94
|
+
// not the row's own single-field column, decides ownership.
|
|
95
|
+
function isFileRefBoundElsewhere(
|
|
96
|
+
fileRef: Record<string, unknown>,
|
|
97
|
+
entityName: string,
|
|
98
|
+
rowId: string,
|
|
99
|
+
fieldNames: readonly string[] | undefined,
|
|
100
|
+
): boolean {
|
|
101
|
+
const entityType = fileRef["entityType"];
|
|
102
|
+
const entityId = fileRef["entityId"];
|
|
103
|
+
const fieldName = fileRef["fieldName"];
|
|
104
|
+
if (typeof entityType === "string" && entityType !== entityName) return true;
|
|
105
|
+
if (typeof entityId === "string" && entityId !== rowId) return true;
|
|
106
|
+
if (
|
|
107
|
+
fieldNames !== undefined &&
|
|
108
|
+
typeof fieldName === "string" &&
|
|
109
|
+
!fieldNames.includes(fieldName)
|
|
110
|
+
) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A single-field fileRef-id can be shared by more than one row, in the SAME
|
|
117
|
+
// table or in another entity's table; with a partial field set, also by another
|
|
118
|
+
// field of the same row. Purging its bytes would corrupt the holder that stays.
|
|
119
|
+
async function isFileRefSharedByOtherHolder(
|
|
120
|
+
db: DbRunner,
|
|
121
|
+
holders: readonly SingleFileHolder[],
|
|
122
|
+
ownTable: EntityTable,
|
|
123
|
+
tenantId: TenantId,
|
|
124
|
+
rowId: string,
|
|
125
|
+
fileRefId: string,
|
|
126
|
+
fieldNames: readonly string[] | undefined,
|
|
127
|
+
): Promise<boolean> {
|
|
128
|
+
for (const holder of holders) {
|
|
129
|
+
for (const field of holder.singleFields) {
|
|
130
|
+
const where: WhereObject = { [field]: fileRefId };
|
|
131
|
+
if (holder.table === ownTable) {
|
|
132
|
+
const fieldIsPurged = fieldNames === undefined || fieldNames.includes(field);
|
|
133
|
+
where["id"] = fieldIsPurged ? { ne: rowId } : rowId;
|
|
134
|
+
}
|
|
135
|
+
if (holder.tableHasTenantId) where["tenantId"] = tenantId;
|
|
136
|
+
const others = await selectMany(db, holder.table, where, { limit: 1 });
|
|
137
|
+
if (others.length > 0) return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Deletes the un-shared fileRefs a row's file-fields reference: bytes (incl.
|
|
144
|
+
// derivatives) first, then the fileRef rows — bytes before rows so a crash
|
|
145
|
+
// mid-way leaves an orphaned fileRef pointing at already-gone bytes (a later
|
|
146
|
+
// run's delete is idempotent) rather than a gone fileRef whose bytes leak forever.
|
|
147
|
+
// The entity row itself is the caller's business. `fieldNames` narrows the purge
|
|
148
|
+
// to those fields; without it every file-field of the entity is purged.
|
|
149
|
+
export async function purgeRowFileRefs(args: {
|
|
150
|
+
readonly db: DbRunner;
|
|
151
|
+
readonly tenantId: TenantId;
|
|
152
|
+
readonly entityName: string;
|
|
153
|
+
readonly entity: EntityDefinition;
|
|
154
|
+
readonly table: EntityTable;
|
|
155
|
+
readonly fileHolders: readonly SingleFileHolder[];
|
|
156
|
+
readonly row: Record<string, unknown>;
|
|
157
|
+
readonly fieldNames?: readonly string[];
|
|
158
|
+
/** Returns the keys whose delete failed; undefined = no storage reachable. */
|
|
159
|
+
readonly deleteBytes: ((storageKey: string) => Promise<readonly string[]>) | undefined;
|
|
160
|
+
readonly fileRefForget: (fileRefId: string) => Promise<boolean>;
|
|
161
|
+
readonly onBytesDeleteFailed?: (failedKeys: readonly string[]) => void;
|
|
162
|
+
}): Promise<FileRefPurgeOutcome> {
|
|
163
|
+
const rowId = String(args.row["id"]);
|
|
164
|
+
const plan = planFileFields(args.entity.fields, args.fieldNames);
|
|
165
|
+
const fileRefIds = await collectFileRefIds(
|
|
166
|
+
args.db,
|
|
167
|
+
args.tenantId,
|
|
168
|
+
args.entityName,
|
|
169
|
+
args.row,
|
|
170
|
+
plan,
|
|
171
|
+
);
|
|
172
|
+
if (fileRefIds.length === 0) return "purged";
|
|
173
|
+
|
|
174
|
+
const fileRefRows = await selectMany<Record<string, unknown>>(args.db, fileRefsTable, {
|
|
175
|
+
id: { in: fileRefIds },
|
|
176
|
+
tenantId: args.tenantId,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const toDelete: Record<string, unknown>[] = [];
|
|
180
|
+
for (const fileRef of fileRefRows) {
|
|
181
|
+
if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId, args.fieldNames)) continue;
|
|
182
|
+
const shared = await isFileRefSharedByOtherHolder(
|
|
183
|
+
args.db,
|
|
184
|
+
args.fileHolders,
|
|
185
|
+
args.table,
|
|
186
|
+
args.tenantId,
|
|
187
|
+
rowId,
|
|
188
|
+
String(fileRef["id"]),
|
|
189
|
+
args.fieldNames,
|
|
190
|
+
);
|
|
191
|
+
if (shared) continue;
|
|
192
|
+
toDelete.push(fileRef);
|
|
193
|
+
}
|
|
194
|
+
if (toDelete.length === 0) return "purged";
|
|
195
|
+
|
|
196
|
+
if (!args.deleteBytes) return "missing_file_storage";
|
|
197
|
+
|
|
198
|
+
let anyFailed = false;
|
|
199
|
+
for (const fileRef of toDelete) {
|
|
200
|
+
const key = fileRef["storageKey"];
|
|
201
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
202
|
+
const failedKeys = await args.deleteBytes(key);
|
|
203
|
+
if (failedKeys.length > 0) {
|
|
204
|
+
anyFailed = true;
|
|
205
|
+
args.onBytesDeleteFailed?.(failedKeys);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (anyFailed) return "file_delete_failed";
|
|
209
|
+
|
|
210
|
+
for (const fileRef of toDelete) {
|
|
211
|
+
if (!(await args.fileRefForget(String(fileRef["id"])))) return "file_delete_failed";
|
|
212
|
+
}
|
|
213
|
+
return "purged";
|
|
214
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.372.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Text examples of template variables follow the template's language",
|
|
6
|
+
"detail": "The `example` of a text or richText templateVariable is now read like `label`: an i18n key resolves to the translation of the edited template's locale, plain text stays as it is. Preview, the sample values bar and send-test therefore show German examples for a German template. Example values of other types (link, number, enum-like text values) stay as declared; put the explanation of such a value into the variable's label. Apps: register `templateVariable.text({ example: \"<app>:mail.example.x\" })` and add the key with de, en and es via `r.translations`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -57,11 +57,27 @@ const SAMPLE_LINK = "https://example.com";
|
|
|
57
57
|
const SAMPLE_DATE = "2026-01-15";
|
|
58
58
|
const SAMPLE_LIST_ROWS = 3;
|
|
59
59
|
|
|
60
|
-
// A variable label is an i18n key or plain text; an unknown
|
|
60
|
+
// A variable label, and the example of a text variable, is an i18n key or plain text; an unknown
|
|
61
|
+
// key reads as itself.
|
|
61
62
|
type LabelText = (label: string) => string;
|
|
62
63
|
|
|
64
|
+
function isTranslatableExample(
|
|
65
|
+
def: TemplateScalarVariableDef,
|
|
66
|
+
example: unknown,
|
|
67
|
+
): example is string {
|
|
68
|
+
return (def.type === "text" || def.type === "richText") && typeof example === "string";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function exampleFor(
|
|
72
|
+
def: TemplateScalarVariableDef,
|
|
73
|
+
example: unknown,
|
|
74
|
+
labelText: LabelText,
|
|
75
|
+
): unknown {
|
|
76
|
+
return isTranslatableExample(def, example) ? labelText(example) : example;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function sampleScalar(name: string, def: TemplateScalarVariableDef, labelText: LabelText): unknown {
|
|
64
|
-
if (def.example !== undefined) return def.example;
|
|
80
|
+
if (def.example !== undefined) return exampleFor(def, def.example, labelText);
|
|
65
81
|
const label = def.label === undefined ? name : labelText(def.label);
|
|
66
82
|
switch (def.type) {
|
|
67
83
|
case "text":
|
|
@@ -85,8 +101,12 @@ function sampleRow(
|
|
|
85
101
|
given: Readonly<Record<string, unknown>> = {},
|
|
86
102
|
): Record<string, unknown> {
|
|
87
103
|
const row: Record<string, unknown> = {};
|
|
88
|
-
for (const [name, def] of Object.entries(item))
|
|
89
|
-
|
|
104
|
+
for (const [name, def] of Object.entries(item)) {
|
|
105
|
+
row[name] = Object.hasOwn(given, name)
|
|
106
|
+
? exampleFor(def, given[name], labelText)
|
|
107
|
+
: sampleScalar(name, def, labelText);
|
|
108
|
+
}
|
|
109
|
+
return { ...given, ...row };
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
function sampleList(def: TemplateListVariableDef, labelText: LabelText): Record<string, unknown>[] {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
|
|
1
2
|
// @runtime client
|
|
2
3
|
// Client feature factory for the text-block content tree. Apps mount it via
|
|
3
4
|
// createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
|
|
@@ -399,7 +400,7 @@ function TextBlockEditor({
|
|
|
399
400
|
<Form
|
|
400
401
|
onSubmit={onSubmit}
|
|
401
402
|
testId="text-block-editor"
|
|
402
|
-
title={title || slug ||
|
|
403
|
+
title={title || slug || EMPTY_VALUE_PLACEHOLDER}
|
|
403
404
|
subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
|
|
404
405
|
actions={
|
|
405
406
|
canWrite ? (
|
|
@@ -63,7 +63,6 @@ function repeatRowListAt(doc: ProseMirrorNode, position: number | undefined): st
|
|
|
63
63
|
export function createVariableView(
|
|
64
64
|
variableLabels: Readonly<Record<string, string>>,
|
|
65
65
|
systemVariables: readonly string[],
|
|
66
|
-
labelOnly = false,
|
|
67
66
|
): NodeViewRenderer {
|
|
68
67
|
return ({ node, editor, getPos }) => {
|
|
69
68
|
const name = String(node.attrs["name"]);
|
|
@@ -79,9 +78,6 @@ export function createVariableView(
|
|
|
79
78
|
const hasOwnLabel = label !== undefined && label !== "" && label !== name;
|
|
80
79
|
dom.append(document.createTextNode(hasOwnLabel ? label : name));
|
|
81
80
|
if (hasOwnLabel) dom.title = name;
|
|
82
|
-
if (!labelOnly && hasOwnLabel) {
|
|
83
|
-
dom.append(createElementWithClass("small", "font-mono text-[10px] opacity-75", name));
|
|
84
|
-
}
|
|
85
81
|
return { dom };
|
|
86
82
|
};
|
|
87
83
|
}
|
|
@@ -218,11 +214,7 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
|
|
|
218
214
|
case DocumentVariable.name:
|
|
219
215
|
return DocumentVariable.extend({
|
|
220
216
|
addNodeView: () =>
|
|
221
|
-
createVariableView(
|
|
222
|
-
labels.variableLabels ?? {},
|
|
223
|
-
labels.systemVariables ?? [],
|
|
224
|
-
labels.blockLabels === true,
|
|
225
|
-
),
|
|
217
|
+
createVariableView(labels.variableLabels ?? {}, labels.systemVariables ?? []),
|
|
226
218
|
});
|
|
227
219
|
case DocumentResource.name:
|
|
228
220
|
return DocumentResource.extend({ addNodeView: () => createResourceView(labels) });
|
|
@@ -96,13 +96,13 @@ function LineEditorBody({
|
|
|
96
96
|
undoRedo: false,
|
|
97
97
|
}),
|
|
98
98
|
DocumentVariable.extend({
|
|
99
|
-
addNodeView: () => createVariableView(variableLabels, systemVariables
|
|
99
|
+
addNodeView: () => createVariableView(variableLabels, systemVariables),
|
|
100
100
|
}),
|
|
101
101
|
UndoRedo,
|
|
102
102
|
BlockLineBreaks,
|
|
103
103
|
...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
|
|
104
104
|
],
|
|
105
|
-
[variableLabels, systemVariables,
|
|
105
|
+
[variableLabels, systemVariables, placeholder],
|
|
106
106
|
);
|
|
107
107
|
|
|
108
108
|
const editor = useEditor({
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.372.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Account forget-cleanup removes userOwned files uploaded by someone else",
|
|
6
|
+
"detail": "runForgetCleanup now purges the bytes, derivatives and file_refs rows behind every file field whose subject is the deleted user, not only files the user uploaded; a failing byte delete keeps the user in DeletionRequested so the next run retries."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.369.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
selectMany,
|
|
51
51
|
WritePurposes,
|
|
52
52
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
53
|
+
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
53
54
|
import {
|
|
54
55
|
type EscapeHatchAuditSink,
|
|
55
56
|
EXT_USER_DATA,
|
|
@@ -75,7 +76,12 @@ import {
|
|
|
75
76
|
resolveRetentionPolicyForTenant,
|
|
76
77
|
resolveTenantRetentionPreset,
|
|
77
78
|
} from "../data-retention/index.ts";
|
|
78
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
type DeleteFileBytes,
|
|
81
|
+
decryptStoredPii,
|
|
82
|
+
purgeFilesReachingSubject,
|
|
83
|
+
runInSubTransaction,
|
|
84
|
+
} from "../shared/index.ts";
|
|
79
85
|
import { tenantMembershipsTable } from "../tenant/index.ts";
|
|
80
86
|
import { USER_STATUS, userTable } from "../user/index.ts";
|
|
81
87
|
import { selectUsersDueForForgetCleanup } from "./db/queries/forget-cleanup.ts";
|
|
@@ -450,6 +456,25 @@ async function processUser(args: {
|
|
|
450
456
|
);
|
|
451
457
|
}
|
|
452
458
|
|
|
459
|
+
// Same ordering rationale: the files are found through the entity rows a
|
|
460
|
+
// hook may hard-delete, and a file whose uploader differs from the owner
|
|
461
|
+
// is never reached by the fileRef hook (it keys on insertedById).
|
|
462
|
+
await purgeFilesReachingSubject({
|
|
463
|
+
registry,
|
|
464
|
+
db: tx,
|
|
465
|
+
subject: { kind: "user", userId },
|
|
466
|
+
subjectKey: subjectIdToKey({ kind: "user", userId }),
|
|
467
|
+
resolveDeleteBytes: resolveForgetFileBytesDeleter(buildStorageProvider),
|
|
468
|
+
missingStorage: {
|
|
469
|
+
mode: "skip",
|
|
470
|
+
warn: (tenantId) =>
|
|
471
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility, same as the fileRef delete hook
|
|
472
|
+
console.warn(
|
|
473
|
+
`[user-data-rights:run-forget-cleanup] no file provider resolvable for tenantId=${tenantId} - file binaries of userId=${userId} are NOT deleted (rows kept). Mount file-foundation + a file-provider-* feature so erasure can reach the binaries.`,
|
|
474
|
+
),
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
453
478
|
for (const tenantId of tenantList) {
|
|
454
479
|
currentTenantId = tenantId;
|
|
455
480
|
// Refine the app-level model to THIS tenant: "single-user" only if the
|
|
@@ -576,6 +601,26 @@ async function processUser(args: {
|
|
|
576
601
|
};
|
|
577
602
|
}
|
|
578
603
|
|
|
604
|
+
// A resolution failure collapses to "no storage" like in the fileRef delete hook; a failing
|
|
605
|
+
// byte delete throws through the sweep so the per-user sub-tx rolls back and the next run retries.
|
|
606
|
+
function resolveForgetFileBytesDeleter(
|
|
607
|
+
buildStorageProvider: RunForgetCleanupArgs["buildStorageProvider"],
|
|
608
|
+
): (tenantId: TenantId) => Promise<DeleteFileBytes | undefined> {
|
|
609
|
+
return async (tenantId) => {
|
|
610
|
+
if (!buildStorageProvider) return undefined;
|
|
611
|
+
try {
|
|
612
|
+
const provider = await buildStorageProvider(tenantId);
|
|
613
|
+
return (
|
|
614
|
+
provider &&
|
|
615
|
+
((key) => deleteStoredFileAndDerivatives(key, provider, "user-data-rights:forget-cleanup"))
|
|
616
|
+
);
|
|
617
|
+
} catch {
|
|
618
|
+
// skip: provider unresolvable (not configured) -> files skipped with a warn, like the hook
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
579
624
|
// Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
|
|
580
625
|
// Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
|
|
581
626
|
// tenant-agnostische Hooks (z.B. user) operieren auf der globalen
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
|
|
1
2
|
// @runtime client
|
|
2
3
|
// Export section (Art. 20) — the one part of user-data-rights' privacy-center
|
|
3
4
|
// screen that stays custom (fw#2312). Registered as an EditExtensionSection
|
|
@@ -57,7 +58,7 @@ function failureKey(error: unknown): string {
|
|
|
57
58
|
// Roher ISO-Instant → nur der Datums-Teil ist relevant; reiner String-Slice,
|
|
58
59
|
// kein Date-API (no-date-api-Guard) und universell. Leer/null → "—".
|
|
59
60
|
export function formatDate(iso: string | null | undefined): string {
|
|
60
|
-
if (!iso) return
|
|
61
|
+
if (!iso) return EMPTY_VALUE_PLACEHOLDER;
|
|
61
62
|
const tIndex = iso.indexOf("T");
|
|
62
63
|
return tIndex > 0 ? iso.slice(0, tIndex) : iso;
|
|
63
64
|
}
|
|
@@ -102,7 +102,7 @@ export const demoFeature = defineFeature("styleguide", (r) => {
|
|
|
102
102
|
if (!source) return failNotFound("item", event.payload.id);
|
|
103
103
|
return itemExecutor.create(
|
|
104
104
|
{
|
|
105
|
-
name: `${source["name"]
|
|
105
|
+
name: `${source["name"]} (Copy)`,
|
|
106
106
|
description: source["description"],
|
|
107
107
|
quantity: source["quantity"],
|
|
108
108
|
rating: source["rating"],
|
|
@@ -14,6 +14,7 @@ type BoardLease = {
|
|
|
14
14
|
readonly beginn: string;
|
|
15
15
|
readonly ende?: string;
|
|
16
16
|
readonly miete: number;
|
|
17
|
+
readonly vorgaengerIndex?: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
const BOARD_LEASES: readonly BoardLease[] = [
|
|
@@ -101,6 +102,7 @@ const BOARD_LEASES: readonly BoardLease[] = [
|
|
|
101
102
|
beginn: "2026-01-01",
|
|
102
103
|
ende: "2026-08-31",
|
|
103
104
|
miete: 700,
|
|
105
|
+
vorgaengerIndex: 0,
|
|
104
106
|
},
|
|
105
107
|
{
|
|
106
108
|
mieter: "Karl Positionsende",
|
|
@@ -229,6 +231,9 @@ export const seedRental: SeedFn = async (stack) => {
|
|
|
229
231
|
grundmiete: eur(lease.miete),
|
|
230
232
|
kuendigungsfrist: "3 Monate",
|
|
231
233
|
zahltag: 3,
|
|
234
|
+
...(lease.vorgaengerIndex !== undefined && {
|
|
235
|
+
vorgaenger: boardLeaseId(lease.vorgaengerIndex),
|
|
236
|
+
}),
|
|
232
237
|
},
|
|
233
238
|
TestUsers.admin,
|
|
234
239
|
db,
|
|
@@ -226,6 +226,8 @@ const tenantInvoiceMeta = (subject: string) => ({
|
|
|
226
226
|
...invoiceIssuedMeta(subject),
|
|
227
227
|
senderName: "Nordlicht Immobilien GmbH",
|
|
228
228
|
replyTo: "buchhaltung@nordlicht-immobilien.de",
|
|
229
|
+
headerChip: "{{noticeLabel}}",
|
|
230
|
+
tone: "{{noticeTone}}",
|
|
229
231
|
});
|
|
230
232
|
|
|
231
233
|
// The platform default has a due date; the tenant drafts below drop it, which
|
|
@@ -54,6 +54,15 @@ const INVOICE_ISSUED_VARIABLES = {
|
|
|
54
54
|
label: "billing:mail.variable.paymentReference",
|
|
55
55
|
example: "RE-2026-0142 / Yilmaz",
|
|
56
56
|
}),
|
|
57
|
+
// A text example given as i18n key follows the edited template's language.
|
|
58
|
+
noticeLabel: templateVariable.text({
|
|
59
|
+
label: "billing:mail.variable.noticeLabel",
|
|
60
|
+
example: "billing:mail.example.noticeLabel",
|
|
61
|
+
}),
|
|
62
|
+
noticeTone: templateVariable.text({
|
|
63
|
+
label: "billing:mail.variable.noticeTone",
|
|
64
|
+
example: "success",
|
|
65
|
+
}),
|
|
57
66
|
} as const;
|
|
58
67
|
|
|
59
68
|
const PAYMENT_REMINDER_VARIABLES = {
|
|
@@ -20,6 +20,9 @@ export const billingTranslations: TranslationsByLocale = {
|
|
|
20
20
|
"billing:mail.variable.lines.amount": "Betrag",
|
|
21
21
|
"billing:mail.variable.lines": "Rechnungspositionen",
|
|
22
22
|
"billing:mail.variable.paymentReference": "Zahlungsreferenz",
|
|
23
|
+
"billing:mail.variable.noticeLabel": "Kennzeichen der Rechnung",
|
|
24
|
+
"billing:mail.example.noticeLabel": "Neue Rechnung",
|
|
25
|
+
"billing:mail.variable.noticeTone": "Farbe der Meldung (success, warning oder danger)",
|
|
23
26
|
},
|
|
24
27
|
en: {
|
|
25
28
|
"billing:feature.label": "Invoices",
|
|
@@ -37,5 +40,8 @@ export const billingTranslations: TranslationsByLocale = {
|
|
|
37
40
|
"billing:mail.variable.lines.amount": "Amount",
|
|
38
41
|
"billing:mail.variable.lines": "Invoice lines",
|
|
39
42
|
"billing:mail.variable.paymentReference": "Payment reference",
|
|
43
|
+
"billing:mail.variable.noticeLabel": "Invoice label",
|
|
44
|
+
"billing:mail.example.noticeLabel": "New invoice",
|
|
45
|
+
"billing:mail.variable.noticeTone": "Notice colour (success, warning or danger)",
|
|
40
46
|
},
|
|
41
47
|
};
|
|
@@ -19,6 +19,7 @@ export const leaseEntity = createEntity({
|
|
|
19
19
|
liegenschaft: createTextField({ ...DEMO_DATA, searchable: true, sortable: true }),
|
|
20
20
|
beginn: createDateField({ required: true, sortable: true, filterable: true }),
|
|
21
21
|
ende: createDateField({ sortable: true }),
|
|
22
|
+
vorgaenger: { type: "reference", entity: "lease", labelField: "einheit" },
|
|
22
23
|
status: createSelectField({
|
|
23
24
|
options: LEASE_STATUSES,
|
|
24
25
|
optionTones: { active: "ok", terminated: "bad" },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import { defineFeature, i18nKey } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
leaseListEmptyScreen,
|
|
31
32
|
leaseListScreen,
|
|
32
33
|
leaseListShortScreen,
|
|
34
|
+
leaseSuccessionListScreen,
|
|
33
35
|
partyEditScreen,
|
|
34
36
|
positionEditScreen,
|
|
35
37
|
recordPositionsScreen,
|
|
@@ -69,6 +71,28 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
69
71
|
|
|
70
72
|
r.crud("lease", leaseEntity, { write: open, read: open });
|
|
71
73
|
r.crud("leasePosition", leasePositionEntity, { write: open, read: open });
|
|
74
|
+
// Only the interactive create path is guarded; recordPositions and adjustRent
|
|
75
|
+
// write through the executor and compute their own date ranges.
|
|
76
|
+
r.hook({
|
|
77
|
+
type: "validation",
|
|
78
|
+
target: "lease-position:create",
|
|
79
|
+
handler: (data) => {
|
|
80
|
+
const { gueltigVon, gueltigBis } = data;
|
|
81
|
+
if (
|
|
82
|
+
typeof gueltigVon === "string" &&
|
|
83
|
+
typeof gueltigBis === "string" &&
|
|
84
|
+
gueltigBis < gueltigVon
|
|
85
|
+
) {
|
|
86
|
+
// The "(root)" issue has no rendered field, so the form also shows its
|
|
87
|
+
// error banner next to the field error.
|
|
88
|
+
return [
|
|
89
|
+
{ field: "gueltigBis", error: "valid_until_before_valid_from" },
|
|
90
|
+
{ field: "(root)", error: "position_period_invalid" },
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
72
96
|
r.crud("leaseParty", leasePartyEntity, { write: open, read: open });
|
|
73
97
|
|
|
74
98
|
r.queryHandler({
|
|
@@ -78,7 +102,7 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
78
102
|
const row = await leaseExecutor.detail({ id: query.payload.id }, query.user, ctx.db);
|
|
79
103
|
if (!row) return failNotFound("lease", query.payload.id);
|
|
80
104
|
const lease = parseLeaseRow(row);
|
|
81
|
-
const countChildren = async (executor:
|
|
105
|
+
const countChildren = async (executor: EventStoreExecutor): Promise<number> => {
|
|
82
106
|
const page = await executor.list(
|
|
83
107
|
{ filter: { field: "lease", op: "eq", value: lease.id }, limit: 1, totalCount: true },
|
|
84
108
|
query.user,
|
|
@@ -98,6 +122,7 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
98
122
|
einheit: lease.einheit ?? "",
|
|
99
123
|
liegenschaft: lease.liegenschaft ?? "",
|
|
100
124
|
ende: "",
|
|
125
|
+
vorgaenger: lease.vorgaenger ?? "",
|
|
101
126
|
kontostand: "0,00 €",
|
|
102
127
|
notizen: "",
|
|
103
128
|
partyCount: await countChildren(partyExecutor),
|
|
@@ -241,6 +266,7 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
241
266
|
r.screen(leaseHubScreen);
|
|
242
267
|
r.screen(leaseDetailGapsScreen);
|
|
243
268
|
r.screen(leaseListEmptyScreen);
|
|
269
|
+
r.screen(leaseSuccessionListScreen);
|
|
244
270
|
r.screen(rentalDashboardScreen);
|
|
245
271
|
r.screen(adjustRentScreen);
|
|
246
272
|
r.screen(recordPositionsScreen);
|
|
@@ -7,6 +7,7 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
7
7
|
|
|
8
8
|
"screen:lease-list.title": "Mietverträge",
|
|
9
9
|
"screen:lease-list-short.title": "Mietverträge (gekündigt)",
|
|
10
|
+
"screen:lease-succession-list.title": "Nachfolge",
|
|
10
11
|
"screen:lease-edit.title": "Mietvertrag",
|
|
11
12
|
"screen:lease-detail.title": "Mietvertrag",
|
|
12
13
|
"screen:lease-hub.title": "Mietvertrag Hub",
|
|
@@ -56,6 +57,7 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
56
57
|
"rental:entity:lease:field:liegenschaft": "Liegenschaft",
|
|
57
58
|
"rental:entity:lease:field:beginn": "Beginn",
|
|
58
59
|
"rental:entity:lease:field:ende": "Ende",
|
|
60
|
+
"rental:entity:lease:field:vorgaenger": "Vorgänger",
|
|
59
61
|
"rental:entity:lease:field:status": "Status",
|
|
60
62
|
"rental:entity:lease:field:status:option:active": "Aktiv",
|
|
61
63
|
"rental:entity:lease:field:status:option:terminated": "Gekündigt",
|
|
@@ -71,6 +73,9 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
71
73
|
"rental:entity:leasePosition:field:betrag": "Betrag",
|
|
72
74
|
"rental:entity:leasePosition:field:gueltigVon": "Gültig von",
|
|
73
75
|
"rental:entity:leasePosition:field:gueltigBis": "Gültig bis",
|
|
76
|
+
"errors.validation.position_period_invalid": "Der Zeitraum der Position ist ungültig.",
|
|
77
|
+
"errors.validation.valid_until_before_valid_from":
|
|
78
|
+
"Gültig bis darf nicht vor Gültig von liegen.",
|
|
74
79
|
|
|
75
80
|
"rental:entity:leaseParty:field:lease": "Mietvertrag",
|
|
76
81
|
"rental:entity:leaseParty:field:name": "Name",
|
|
@@ -119,6 +124,7 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
119
124
|
|
|
120
125
|
"screen:lease-list.title": "Leases",
|
|
121
126
|
"screen:lease-list-short.title": "Leases (terminated)",
|
|
127
|
+
"screen:lease-succession-list.title": "Succession",
|
|
122
128
|
"screen:lease-edit.title": "Lease",
|
|
123
129
|
"screen:lease-detail.title": "Lease",
|
|
124
130
|
"screen:lease-hub.title": "Lease hub",
|
|
@@ -166,6 +172,7 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
166
172
|
"rental:entity:lease:field:liegenschaft": "Property",
|
|
167
173
|
"rental:entity:lease:field:beginn": "Start",
|
|
168
174
|
"rental:entity:lease:field:ende": "End",
|
|
175
|
+
"rental:entity:lease:field:vorgaenger": "Predecessor",
|
|
169
176
|
"rental:entity:lease:field:status": "Status",
|
|
170
177
|
"rental:entity:lease:field:status:option:active": "Active",
|
|
171
178
|
"rental:entity:lease:field:status:option:terminated": "Terminated",
|
|
@@ -181,6 +188,8 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
181
188
|
"rental:entity:leasePosition:field:betrag": "Amount",
|
|
182
189
|
"rental:entity:leasePosition:field:gueltigVon": "Valid from",
|
|
183
190
|
"rental:entity:leasePosition:field:gueltigBis": "Valid until",
|
|
191
|
+
"errors.validation.position_period_invalid": "The position period is invalid.",
|
|
192
|
+
"errors.validation.valid_until_before_valid_from": "Valid until must not be before valid from.",
|
|
184
193
|
|
|
185
194
|
"rental:entity:leaseParty:field:lease": "Lease",
|
|
186
195
|
"rental:entity:leaseParty:field:name": "Name",
|