@cosmicdrift/kumiko-bundled-features 0.88.0 → 0.90.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 +9 -6
- package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
- package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
- package/src/data-retention/__tests__/resolver.test.ts +3 -3
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
- package/src/data-retention/feature.ts +58 -7
- package/src/data-retention/presets.ts +5 -5
- package/src/data-retention/resolve-for-tenant.ts +9 -4
- package/src/data-retention/resolve-tenant-preset.ts +51 -0
- package/src/data-retention/run-retention-cleanup.ts +151 -0
- package/src/folders/__tests__/drift.test.ts +43 -0
- package/src/folders/__tests__/feature.test.ts +168 -0
- package/src/folders/__tests__/folders.integration.test.ts +290 -0
- package/src/folders/aggregate-id.ts +23 -0
- package/src/folders/constants.ts +40 -0
- package/src/folders/entity.ts +42 -0
- package/src/folders/executor.ts +11 -0
- package/src/folders/feature.ts +106 -0
- package/src/folders/handlers/clear-folder.write.ts +35 -0
- package/src/folders/handlers/set-folder.write.ts +82 -0
- package/src/folders/index.ts +23 -0
- package/src/folders/schemas.ts +18 -0
- package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
- package/src/folders/web/__tests__/tree.test.ts +58 -0
- package/src/folders/web/client-plugin.tsx +16 -0
- package/src/folders/web/folder-manager.tsx +323 -0
- package/src/folders/web/folder-section.tsx +198 -0
- package/src/folders/web/i18n.ts +55 -0
- package/src/folders/web/index.ts +6 -0
- package/src/folders/web/tree.ts +54 -0
- package/src/folders-user-data/hooks.ts +58 -0
- package/src/folders-user-data/index.ts +33 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
- package/src/legal-pages/feature.ts +22 -10
- package/src/mail-foundation/feature.ts +51 -6
- package/src/mail-foundation/index.ts +2 -0
- package/src/mail-transport-inmemory/feature.ts +6 -3
- package/src/mail-transport-smtp/feature.ts +11 -10
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
- package/src/managed-pages/feature.ts +17 -9
- package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
- package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
- package/src/user-data-rights/email-templates.ts +211 -0
- package/src/user-data-rights/feature.ts +96 -21
- package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
- package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
- package/src/user-data-rights/lib/default-mailers.ts +116 -0
- package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
- package/src/user-data-rights/run-export-jobs.ts +19 -8
- package/src/user-data-rights/run-forget-cleanup.ts +11 -1
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
1
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { USER_STATUS } from "../../user";
|
|
6
|
+
import {
|
|
7
|
+
type GdprMailDefaults,
|
|
8
|
+
isMailTransportAvailable,
|
|
9
|
+
makeDefaultDeletionRequestedEmail,
|
|
10
|
+
} from "../lib/default-mailers";
|
|
5
11
|
import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
6
12
|
|
|
7
13
|
// Atom 5b — Email-Notification beim deletion-requested-flip. Pattern:
|
|
@@ -14,12 +20,18 @@ import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
|
14
20
|
export type SendDeletionRequestedEmailFn = (args: {
|
|
15
21
|
readonly userId: string;
|
|
16
22
|
readonly userEmail: string;
|
|
23
|
+
/** Stored user.locale (free-form) — lets the default mailer render in the
|
|
24
|
+
* recipient's language. */
|
|
25
|
+
readonly userLocale: string | null;
|
|
17
26
|
readonly tenantId: string;
|
|
18
27
|
readonly gracePeriodEnd: string;
|
|
19
28
|
}) => Promise<void>;
|
|
20
29
|
|
|
21
30
|
export type RequestDeletionOptions = {
|
|
22
31
|
readonly sendDeletionRequestedEmail?: SendDeletionRequestedEmailFn;
|
|
32
|
+
/** Branding fuer die Default-Mail wenn kein sendDeletionRequestedEmail
|
|
33
|
+
* gesetzt ist + mail-foundation gemountet ist. */
|
|
34
|
+
readonly mailDefaults?: GdprMailDefaults;
|
|
23
35
|
};
|
|
24
36
|
|
|
25
37
|
// POST /api/user/request-deletion (S2.U5a) — DSGVO Art. 17 Forget-Antrag.
|
|
@@ -35,15 +47,29 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
|
|
|
35
47
|
handler: async (event, ctx) => {
|
|
36
48
|
const res = await startDeletionGracePeriod(ctx, event.user.id, event.user.tenantId);
|
|
37
49
|
if (!res.ok) return writeFailure(res.error);
|
|
38
|
-
const { gracePeriodEnd, userEmail } = res;
|
|
50
|
+
const { gracePeriodEnd, userEmail, userLocale } = res;
|
|
51
|
+
|
|
52
|
+
// App-Callback hat Vorrang; sonst greift die mail-foundation-Default-Mail
|
|
53
|
+
// (Request-Lane → ctx.config vorhanden, createTransportForTenant direkt).
|
|
54
|
+
// Ohne gemounteten mail-transport bleibt send undefined → keine Mail.
|
|
55
|
+
const send =
|
|
56
|
+
opts.sendDeletionRequestedEmail ??
|
|
57
|
+
(isMailTransportAvailable(ctx.registry)
|
|
58
|
+
? makeDefaultDeletionRequestedEmail(
|
|
59
|
+
(tenantId) =>
|
|
60
|
+
createTransportForTenant(ctx, tenantId, "user-data-rights:request-deletion"),
|
|
61
|
+
opts.mailDefaults,
|
|
62
|
+
)
|
|
63
|
+
: undefined);
|
|
39
64
|
|
|
40
65
|
// Best-effort Email-Notification. Send-Failure darf das Write nicht
|
|
41
66
|
// killen — siehe Type-Doc oben.
|
|
42
|
-
if (
|
|
67
|
+
if (send && userEmail.length > 0) {
|
|
43
68
|
try {
|
|
44
|
-
await
|
|
69
|
+
await send({
|
|
45
70
|
userId: event.user.id,
|
|
46
71
|
userEmail,
|
|
72
|
+
userLocale,
|
|
47
73
|
tenantId: event.user.tenantId,
|
|
48
74
|
gracePeriodEnd: gracePeriodEnd.toString(),
|
|
49
75
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Default send*Email-Callbacks fuer die GDPR-Notifications, backed by einem
|
|
2
|
+
// mail-transport (mail-foundation). Damit muss eine App keinen Callback-Code
|
|
3
|
+
// schreiben: sie mountet mail-foundation + einen mail-transport-* und setzt
|
|
4
|
+
// fuer Export-Mails appExportDownloadUrl — fertig. Uebergibt sie eigene
|
|
5
|
+
// send*Email-Opts, greifen diese Defaults nicht (Opt-Override im feature.ts).
|
|
6
|
+
//
|
|
7
|
+
// Jeder Callback rendert das Template (email-templates.ts) und versendet ueber
|
|
8
|
+
// den per-Tenant aufgeloesten EmailTransport. `resolveTransport` kommt im Job-
|
|
9
|
+
// Lane aus makeTenantMailTransportResolver, im Request-Lane direkt aus
|
|
10
|
+
// createTransportForTenant(ctx, ...).
|
|
11
|
+
|
|
12
|
+
import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
13
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import {
|
|
15
|
+
type GdprMailLocale,
|
|
16
|
+
normalizeGdprMailLocale,
|
|
17
|
+
renderDeletionExecutedEmail,
|
|
18
|
+
renderDeletionRequestedEmail,
|
|
19
|
+
renderExportFailedEmail,
|
|
20
|
+
renderExportReadyEmail,
|
|
21
|
+
} from "../email-templates";
|
|
22
|
+
import type { SendDeletionRequestedEmailFn } from "../handlers/request-deletion.write";
|
|
23
|
+
import type { SendExportFailedEmailFn, SendExportReadyEmailFn } from "../run-export-jobs";
|
|
24
|
+
import type { SendDeletionExecutedEmailFn } from "../run-forget-cleanup";
|
|
25
|
+
|
|
26
|
+
export type GdprMailDefaults = {
|
|
27
|
+
readonly locale?: GdprMailLocale;
|
|
28
|
+
readonly appName?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// mail-foundation ist eine Soft-Dep von user-data-rights. Die Default-Mailer
|
|
32
|
+
// greifen nur wenn mindestens ein mail-transport-* registriert ist — sonst
|
|
33
|
+
// kann ohnehin nichts versendet werden und die App bleibt beim bisherigen
|
|
34
|
+
// "kein Callback → keine Email"-Verhalten.
|
|
35
|
+
export function isMailTransportAvailable(registry: Registry): boolean {
|
|
36
|
+
return registry.getExtensionUsages("mailTransport").length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type TransportResolver = (tenantId: string) => Promise<EmailTransport>;
|
|
40
|
+
|
|
41
|
+
// Per-recipient locale wins (user.locale); mailDefaults.locale is the fallback
|
|
42
|
+
// for unknown/unsupported user.locale values; the template itself defaults to en.
|
|
43
|
+
function localeFor(
|
|
44
|
+
userLocale: string | null,
|
|
45
|
+
defaults: GdprMailDefaults,
|
|
46
|
+
): GdprMailLocale | undefined {
|
|
47
|
+
return normalizeGdprMailLocale(userLocale) ?? defaults.locale;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function makeDefaultExportReadyEmail(
|
|
51
|
+
resolveTransport: TransportResolver,
|
|
52
|
+
defaults: GdprMailDefaults = {},
|
|
53
|
+
): SendExportReadyEmailFn {
|
|
54
|
+
return async (args) => {
|
|
55
|
+
const transport = await resolveTransport(args.tenantId);
|
|
56
|
+
const { subject, html } = renderExportReadyEmail({
|
|
57
|
+
downloadUrl: args.downloadUrl,
|
|
58
|
+
expiresAt: args.expiresAt,
|
|
59
|
+
locale: localeFor(args.userLocale, defaults),
|
|
60
|
+
appName: defaults.appName,
|
|
61
|
+
});
|
|
62
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function makeDefaultExportFailedEmail(
|
|
67
|
+
resolveTransport: TransportResolver,
|
|
68
|
+
defaults: GdprMailDefaults = {},
|
|
69
|
+
): SendExportFailedEmailFn {
|
|
70
|
+
return async (args) => {
|
|
71
|
+
const transport = await resolveTransport(args.tenantId);
|
|
72
|
+
const { subject, html } = renderExportFailedEmail({
|
|
73
|
+
locale: localeFor(args.userLocale, defaults),
|
|
74
|
+
appName: defaults.appName,
|
|
75
|
+
});
|
|
76
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function makeDefaultDeletionRequestedEmail(
|
|
81
|
+
resolveTransport: TransportResolver,
|
|
82
|
+
defaults: GdprMailDefaults = {},
|
|
83
|
+
): SendDeletionRequestedEmailFn {
|
|
84
|
+
return async (args) => {
|
|
85
|
+
const transport = await resolveTransport(args.tenantId);
|
|
86
|
+
const { subject, html } = renderDeletionRequestedEmail({
|
|
87
|
+
gracePeriodEnd: args.gracePeriodEnd,
|
|
88
|
+
locale: localeFor(args.userLocale, defaults),
|
|
89
|
+
appName: defaults.appName,
|
|
90
|
+
});
|
|
91
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function makeDefaultDeletionExecutedEmail(
|
|
96
|
+
resolveTransport: TransportResolver,
|
|
97
|
+
defaults: GdprMailDefaults = {},
|
|
98
|
+
): SendDeletionExecutedEmailFn {
|
|
99
|
+
return async (args) => {
|
|
100
|
+
// Der User ist global, der Mail-Transport per-Tenant — wir senden ueber den
|
|
101
|
+
// Transport des ersten Memberships. Orphan-User (0 Memberships) liefert
|
|
102
|
+
// keine Tenant-Identitaet → kein Transport aufloesbar, Mail entfaellt.
|
|
103
|
+
const tenantId = args.tenantIds[0];
|
|
104
|
+
if (tenantId === undefined) {
|
|
105
|
+
// skip: orphan user has no tenant whose transport could send the mail
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const transport = await resolveTransport(tenantId);
|
|
109
|
+
const { subject, html } = renderDeletionExecutedEmail({
|
|
110
|
+
executedAt: args.executedAt,
|
|
111
|
+
locale: localeFor(args.userLocale, defaults),
|
|
112
|
+
appName: defaults.appName,
|
|
113
|
+
});
|
|
114
|
+
await transport.send({ to: args.userEmail, subject, html });
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Builds a per-tenant mail-transport resolver from a job/handler context, so
|
|
2
|
+
// the GDPR notification crons reach the SAME mounted mail-foundation transport
|
|
3
|
+
// the request path uses. Mirrors makeTenantStorageProviderResolver — the cron
|
|
4
|
+
// ctx carries `configResolver` (the per-request ConfigAccessor exists only in
|
|
5
|
+
// the HTTP dispatcher), so the resolver builds a per-tenant accessor from it.
|
|
6
|
+
// Without that bridge createTransportForTenant throws "ctx.config is missing"
|
|
7
|
+
// in the worker lane (the prod-bug class the file-provider resolver already fixed).
|
|
8
|
+
|
|
9
|
+
import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
10
|
+
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
11
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import type { ConfigResolver, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
14
|
+
import { createConfigAccessor } from "../../config";
|
|
15
|
+
|
|
16
|
+
export interface TenantMailResolverCtx {
|
|
17
|
+
readonly registry: Registry;
|
|
18
|
+
// Job-context carries configResolver (per-request ConfigAccessor exists only
|
|
19
|
+
// in the HTTP dispatcher); the resolver builds a per-tenant accessor from it.
|
|
20
|
+
// Undefined → the returned resolver throws (callers gate on mail-availability).
|
|
21
|
+
readonly configResolver: ConfigResolver | undefined;
|
|
22
|
+
readonly secrets: SecretsContext | undefined;
|
|
23
|
+
readonly db: DbConnection;
|
|
24
|
+
readonly userId: string;
|
|
25
|
+
readonly handlerName: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function makeTenantMailTransportResolver(
|
|
29
|
+
ctx: TenantMailResolverCtx,
|
|
30
|
+
): (tenantId: string) => Promise<EmailTransport> {
|
|
31
|
+
return async (tenantId) => {
|
|
32
|
+
if (!ctx.configResolver) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${ctx.handlerName}: ctx.configResolver missing — cannot resolve the mail transport for tenant ${tenantId}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const config = createConfigAccessor(
|
|
38
|
+
ctx.registry,
|
|
39
|
+
ctx.configResolver,
|
|
40
|
+
tenantId as Parameters<typeof createConfigAccessor>[2], // @cast-boundary engine-payload: TenantId brand
|
|
41
|
+
ctx.userId,
|
|
42
|
+
ctx.db,
|
|
43
|
+
);
|
|
44
|
+
return createTransportForTenant(
|
|
45
|
+
{ config, registry: ctx.registry, secrets: ctx.secrets, _userId: ctx.userId },
|
|
46
|
+
tenantId,
|
|
47
|
+
ctx.handlerName,
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -110,6 +110,9 @@ export const tokenCrud = createEventStoreExecutor(
|
|
|
110
110
|
export type SendExportReadyEmailFn = (args: {
|
|
111
111
|
readonly userId: string;
|
|
112
112
|
readonly userEmail: string;
|
|
113
|
+
/** Stored user.locale (free-form, e.g. "de"/"en"/"de-DE"/null) — lets the
|
|
114
|
+
* default mailer render in the recipient's language. */
|
|
115
|
+
readonly userLocale: string | null;
|
|
113
116
|
readonly tenantId: TenantId;
|
|
114
117
|
readonly jobId: string;
|
|
115
118
|
readonly downloadUrl: string;
|
|
@@ -120,6 +123,7 @@ export type SendExportReadyEmailFn = (args: {
|
|
|
120
123
|
export type SendExportFailedEmailFn = (args: {
|
|
121
124
|
readonly userId: string;
|
|
122
125
|
readonly userEmail: string;
|
|
126
|
+
readonly userLocale: string | null;
|
|
123
127
|
readonly tenantId: TenantId;
|
|
124
128
|
readonly jobId: string;
|
|
125
129
|
readonly errorMessage: string;
|
|
@@ -753,12 +757,17 @@ function countingStream(source: AsyncIterable<Uint8Array>): {
|
|
|
753
757
|
// (Alice in Tenant A+B) hat trotzdem nur 1 user-Row mit ihrer
|
|
754
758
|
// Heim-Tenant als tenantId. Lookup ohne tenantId-filter findet sie
|
|
755
759
|
// aus jedem Worker-Tenant-Context.
|
|
756
|
-
async function
|
|
760
|
+
async function lookupUserContact(
|
|
761
|
+
db: DbConnection,
|
|
762
|
+
userId: string,
|
|
763
|
+
): Promise<{ email: string; locale: string | null } | null> {
|
|
757
764
|
// @cast-boundary db-row.
|
|
758
765
|
const row = (await fetchOne(db, userTable, { id: userId })) as {
|
|
759
766
|
email: string | null;
|
|
767
|
+
locale: string | null;
|
|
760
768
|
} | null;
|
|
761
|
-
|
|
769
|
+
if (!row?.email) return null;
|
|
770
|
+
return { email: row.email, locale: row.locale ?? null };
|
|
762
771
|
}
|
|
763
772
|
|
|
764
773
|
// Atom 5 — Email-Notification beim done-flip. Best-effort:
|
|
@@ -776,8 +785,8 @@ async function fireExportReadyCallback(args: {
|
|
|
776
785
|
readonly appExportDownloadUrl: string | undefined;
|
|
777
786
|
readonly send: SendExportReadyEmailFn;
|
|
778
787
|
}): Promise<void> {
|
|
779
|
-
const
|
|
780
|
-
if (!
|
|
788
|
+
const contact = await lookupUserContact(args.db, args.job.userId);
|
|
789
|
+
if (!contact) {
|
|
781
790
|
// User-Row fehlt (z.B. forget-Pfad mid-export). Skip-Notification mit
|
|
782
791
|
// Operator-Alert via job-run-Log statt Throw — Job bleibt done, User
|
|
783
792
|
// hat ja seinen Token via export-status.query erreichbar (UI-Pfad).
|
|
@@ -802,7 +811,8 @@ async function fireExportReadyCallback(args: {
|
|
|
802
811
|
const downloadUrl = `${baseUrl}?token=${encodeURIComponent(args.plainToken)}`;
|
|
803
812
|
await args.send({
|
|
804
813
|
userId: args.job.userId,
|
|
805
|
-
userEmail,
|
|
814
|
+
userEmail: contact.email,
|
|
815
|
+
userLocale: contact.locale,
|
|
806
816
|
tenantId: args.job.requestedFromTenantId,
|
|
807
817
|
jobId: args.job.id,
|
|
808
818
|
downloadUrl,
|
|
@@ -818,8 +828,8 @@ async function fireExportFailedCallback(args: {
|
|
|
818
828
|
readonly errorMessage: string;
|
|
819
829
|
readonly send: SendExportFailedEmailFn;
|
|
820
830
|
}): Promise<void> {
|
|
821
|
-
const
|
|
822
|
-
if (!
|
|
831
|
+
const contact = await lookupUserContact(args.db, args.job.userId);
|
|
832
|
+
if (!contact) {
|
|
823
833
|
// biome-ignore lint/suspicious/noConsole: operator-visibility
|
|
824
834
|
console.warn(
|
|
825
835
|
`[user-data-rights:run-export-jobs] userId=${args.job.userId} hat kein userEmail — sendExportFailedEmail skipped`,
|
|
@@ -829,7 +839,8 @@ async function fireExportFailedCallback(args: {
|
|
|
829
839
|
}
|
|
830
840
|
await args.send({
|
|
831
841
|
userId: args.job.userId,
|
|
832
|
-
userEmail,
|
|
842
|
+
userEmail: contact.email,
|
|
843
|
+
userLocale: contact.locale,
|
|
833
844
|
tenantId: args.job.requestedFromTenantId,
|
|
834
845
|
jobId: args.job.id,
|
|
835
846
|
errorMessage: args.errorMessage,
|
|
@@ -70,6 +70,9 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
|
70
70
|
export type SendDeletionExecutedEmailFn = (args: {
|
|
71
71
|
readonly userId: string;
|
|
72
72
|
readonly userEmail: string;
|
|
73
|
+
/** Stored user.locale (free-form, cached PRE-tx alongside the email) — lets
|
|
74
|
+
* the default mailer render in the recipient's language. */
|
|
75
|
+
readonly userLocale: string | null;
|
|
73
76
|
readonly tenantIds: readonly TenantId[];
|
|
74
77
|
readonly executedAt: string;
|
|
75
78
|
}) => Promise<void>;
|
|
@@ -207,6 +210,7 @@ export async function runForgetCleanup(
|
|
|
207
210
|
await sendDeletionExecutedEmail({
|
|
208
211
|
userId: user.id,
|
|
209
212
|
userEmail: userResult.userEmailBeforeDelete,
|
|
213
|
+
userLocale: userResult.userLocaleBeforeDelete,
|
|
210
214
|
tenantIds: userResult.tenantIdsBeforeDelete,
|
|
211
215
|
executedAt: now.toString(),
|
|
212
216
|
});
|
|
@@ -231,6 +235,8 @@ interface ProcessUserResult {
|
|
|
231
235
|
* null wenn user-Row beim Pre-Tx-Lookup nicht (mehr) existiert oder
|
|
232
236
|
* email leer ist. */
|
|
233
237
|
readonly userEmailBeforeDelete: string | null;
|
|
238
|
+
/** user.locale VOR Tx gecacht — Default-Mailer rendert in Empfaenger-Sprache. */
|
|
239
|
+
readonly userLocaleBeforeDelete: string | null;
|
|
234
240
|
/** Tenant-Memberships VOR Tx — Email-Template kann das nutzen. */
|
|
235
241
|
readonly tenantIdsBeforeDelete: readonly TenantId[];
|
|
236
242
|
}
|
|
@@ -252,9 +258,12 @@ async function processUser(args: {
|
|
|
252
258
|
// Nach der Tx ist email = "deleted-{id}@{tenant}.example" oder NULL.
|
|
253
259
|
// Memory-cache laesst Atom-5b-Callback nach success-flip den
|
|
254
260
|
// ORIGINAL-email an App-Author-Callback geben.
|
|
255
|
-
const userPreTx = await fetchOne<{ email: string | null }>(db, userTable, {
|
|
261
|
+
const userPreTx = await fetchOne<{ email: string | null; locale: string | null }>(db, userTable, {
|
|
262
|
+
id: userId,
|
|
263
|
+
});
|
|
256
264
|
const userEmailBeforeDelete =
|
|
257
265
|
userPreTx?.email && userPreTx.email.length > 0 ? userPreTx.email : null;
|
|
266
|
+
const userLocaleBeforeDelete = userPreTx?.locale ?? null;
|
|
258
267
|
|
|
259
268
|
// Memberships fuer diesen User holen — alle Tenants in denen er Mitglied ist.
|
|
260
269
|
const memberships = await selectMany<{ tenantId: TenantId }>(db, tenantMembershipsTable, {
|
|
@@ -335,6 +344,7 @@ async function processUser(args: {
|
|
|
335
344
|
hookCallsAttempted,
|
|
336
345
|
errors,
|
|
337
346
|
userEmailBeforeDelete,
|
|
347
|
+
userLocaleBeforeDelete,
|
|
338
348
|
tenantIdsBeforeDelete,
|
|
339
349
|
};
|
|
340
350
|
}
|