@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.
Files changed (52) hide show
  1. package/package.json +9 -6
  2. package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
  3. package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
  4. package/src/data-retention/__tests__/resolver.test.ts +3 -3
  5. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
  6. package/src/data-retention/feature.ts +58 -7
  7. package/src/data-retention/presets.ts +5 -5
  8. package/src/data-retention/resolve-for-tenant.ts +9 -4
  9. package/src/data-retention/resolve-tenant-preset.ts +51 -0
  10. package/src/data-retention/run-retention-cleanup.ts +151 -0
  11. package/src/folders/__tests__/drift.test.ts +43 -0
  12. package/src/folders/__tests__/feature.test.ts +168 -0
  13. package/src/folders/__tests__/folders.integration.test.ts +290 -0
  14. package/src/folders/aggregate-id.ts +23 -0
  15. package/src/folders/constants.ts +40 -0
  16. package/src/folders/entity.ts +42 -0
  17. package/src/folders/executor.ts +11 -0
  18. package/src/folders/feature.ts +106 -0
  19. package/src/folders/handlers/clear-folder.write.ts +35 -0
  20. package/src/folders/handlers/set-folder.write.ts +82 -0
  21. package/src/folders/index.ts +23 -0
  22. package/src/folders/schemas.ts +18 -0
  23. package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
  24. package/src/folders/web/__tests__/tree.test.ts +58 -0
  25. package/src/folders/web/client-plugin.tsx +16 -0
  26. package/src/folders/web/folder-manager.tsx +323 -0
  27. package/src/folders/web/folder-section.tsx +198 -0
  28. package/src/folders/web/i18n.ts +55 -0
  29. package/src/folders/web/index.ts +6 -0
  30. package/src/folders/web/tree.ts +54 -0
  31. package/src/folders-user-data/hooks.ts +58 -0
  32. package/src/folders-user-data/index.ts +33 -0
  33. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
  34. package/src/legal-pages/feature.ts +22 -10
  35. package/src/mail-foundation/feature.ts +51 -6
  36. package/src/mail-foundation/index.ts +2 -0
  37. package/src/mail-transport-inmemory/feature.ts +6 -3
  38. package/src/mail-transport-smtp/feature.ts +11 -10
  39. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
  40. package/src/managed-pages/feature.ts +17 -9
  41. package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
  42. package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
  43. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
  44. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
  45. package/src/user-data-rights/email-templates.ts +211 -0
  46. package/src/user-data-rights/feature.ts +96 -21
  47. package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
  48. package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
  49. package/src/user-data-rights/lib/default-mailers.ts +116 -0
  50. package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
  51. package/src/user-data-rights/run-export-jobs.ts +19 -8
  52. 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 (opts.sendDeletionRequestedEmail && userEmail.length > 0) {
67
+ if (send && userEmail.length > 0) {
43
68
  try {
44
- await opts.sendDeletionRequestedEmail({
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 lookupUserEmail(db: DbConnection, userId: string): Promise<string | null> {
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
- return row?.email ?? null;
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 userEmail = await lookupUserEmail(args.db, args.job.userId);
780
- if (!userEmail) {
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 userEmail = await lookupUserEmail(args.db, args.job.userId);
822
- if (!userEmail) {
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, { id: userId });
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
  }