@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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 (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,878 @@
1
+ // Async Export-Job Worker (S2.U3 Atom 3b) — pure Pipeline-Function.
2
+ //
3
+ // Spec: docs/plans/architecture/user-data-rights.md "Async Export-Pipeline".
4
+ // Cron-getriggert (siehe r.job-Wrap im feature.ts), iteriert ueber alle
5
+ // pending-Jobs in einem Pass, fuehrt pro Job aus:
6
+ //
7
+ // 1. Claim: `crud.update({status: running, startedAt})` mit
8
+ // optimistic-locking via version-Spalte. Wenn 2 Worker-Replicas
9
+ // denselben Job picken wollen, gewinnt der erste, der zweite
10
+ // kriegt VersionConflictError + skippt.
11
+ //
12
+ // 2. Bundle bauen: runUserExport({db, registry, userId, now}) →
13
+ // UserExportBundle (siehe run-user-export.ts). Cross-Tenant-
14
+ // Iteration ueber Memberships ist dort implementiert.
15
+ //
16
+ // 3. Profile-Resolution: `resolveProfileForTenant(requestedFromTenantId)`
17
+ // liefert effective compliance-profile. exportDownloadTtl wird
18
+ // fuer expiresAt-Berechnung gebraucht.
19
+ //
20
+ // 4. Storage-Provider builden: `buildStorageProvider(ctx, requestedFromTenantId)`
21
+ // — file-foundation's createFileProviderForTenant-Pattern.
22
+ // Pro Tenant unterschiedlicher Provider moeglich (S3 vs lokal).
23
+ //
24
+ // 5. ZIP-Stream: `createZipStream(bundleAsZipEntries(bundle))` →
25
+ // AsyncIterable<Uint8Array>. Wir wrappen das in einen tracking-
26
+ // Iterator der bytesWritten zaehlt.
27
+ //
28
+ // 6. Storage-Write: provider.writeStream(storageKey, zipStream).
29
+ // Atomar via tmp+rename (lokal) bzw. multipart-upload (S3).
30
+ //
31
+ // 7. Job=done: `crud.update({status: "done", completedAt, expiresAt,
32
+ // downloadStorageKey, bytesWritten})`.
33
+ //
34
+ // Bei Throw zwischen 2-7: Job=failed mit errorMessage. Storage-Write
35
+ // ist atomar (writeStream rollback bei Error im Source-Stream), keine
36
+ // halb-fertigen ZIPs am final-Pfad.
37
+ //
38
+ // **Stale-Detection:** vor dem Pickup-Pass laeuft ein separater
39
+ // Stale-Pass: Jobs in `running` mit `startedAt + exportStaleTimeoutMinutes
40
+ // < now` werden auf `failed` geflippt. Worker-Crashes / OOM-Kills /
41
+ // Replica-Restarts hinterlassen sonst stuck-running-Jobs.
42
+ //
43
+ // **Storage-Cleanup:** dritter Pass loescht abgelaufene downloadStorageKeys
44
+ // (`expiresAt + exportStorageCleanupGraceHours < now`) — abgelaufene ZIPs
45
+ // auf S3 sollen nicht ewig liegen.
46
+
47
+ import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
48
+ import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
49
+ import {
50
+ createEventStoreExecutor,
51
+ createTenantDb,
52
+ fetchOne,
53
+ } from "@cosmicdrift/kumiko-framework/db";
54
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
55
+ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
56
+ import {
57
+ createZipStream,
58
+ type FileStorageProvider,
59
+ type ZipEntry,
60
+ } from "@cosmicdrift/kumiko-framework/files";
61
+ import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
62
+ import { and, asc, eq, isNotNull, or } from "drizzle-orm";
63
+ import { resolveProfileForTenant } from "../compliance-profiles";
64
+ import { userTable } from "../user";
65
+ import { runUserExport, type UserExportBundle } from "./run-user-export";
66
+ import { exportDownloadTokenEntity, exportDownloadTokensTable } from "./schema/download-token";
67
+ import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "./schema/export-job";
68
+ import { generateDownloadToken } from "./token-helpers";
69
+
70
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
71
+
72
+ const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
73
+ entityName: "export-job",
74
+ });
75
+
76
+ // Atom 4a — separater ES-Executor fuer Download-Tokens. crud.create
77
+ // emittiert `exportDownloadToken.created`-Event in den event-store +
78
+ // projected synchron auf read_export_download_tokens. KEIN direct-INSERT
79
+ // (Memory `feedback_no_fake_dispatcher`).
80
+ //
81
+ // Exportiert weil Atom 4b's download-handler tokenCrud.update fuer
82
+ // Audit-Felder (useCount, lastUsedAt, IP, UA) nutzt — ES-konsistent.
83
+ export const tokenCrud = createEventStoreExecutor(
84
+ exportDownloadTokensTable,
85
+ exportDownloadTokenEntity,
86
+ { entityName: "export-download-token" },
87
+ );
88
+
89
+ /**
90
+ * Notification-Callback fuer den done-Pfad. Pattern matched
91
+ * auth-routes.PasswordResetConfig.sendResetEmail. Plain-Token bleibt
92
+ * ephemeral (nicht in DB/event-store/jobRunsTable). App-Author wired
93
+ * den Callback an seinen Email-Provider:
94
+ * - existing `delivery.notify` (multi-channel + delivery_attempts-Log)
95
+ * - `mailFoundation.send` direkt
96
+ * - Custom Resend/SES/etc.
97
+ *
98
+ * **Best-effort:** Throw vom Callback bubbelt zum r.job-Wrap; jobs-
99
+ * Feature persistiert den Worker-Run als failed in jobRunsTable. Der
100
+ * Failed-Pfad wird in `jobs/__tests__/jobs-feature.integration.ts`
101
+ * Scenario 2 gepinnt — wir verweisen darauf statt end-zu-end zu
102
+ * duplizieren. Operator sieht's im /jobs-Dashboard, kann via
103
+ * jobs:write:retry den Worker-Run erneut anstossen (Job selbst bleibt
104
+ * done; retry findet keinen pending mehr, aber Audit-Log zeigt den
105
+ * Failure).
106
+ *
107
+ * **Plain-Token-Recovery bei Email-Fail:** plain ist ephemeral. Wenn
108
+ * der Callback throwt, ist der plain verloren. Operator-Eingriff
109
+ * noetig (Token in DB nullen + Job auf pending → next Worker-Run
110
+ * generiert neuen Token). Atom 5b (Re-issue-handler) wird das
111
+ * automatisieren.
112
+ */
113
+ export type SendExportReadyEmailFn = (args: {
114
+ readonly userId: string;
115
+ readonly userEmail: string;
116
+ readonly tenantId: TenantId;
117
+ readonly jobId: string;
118
+ readonly downloadUrl: string;
119
+ readonly expiresAt: string;
120
+ readonly bytesWritten: number | null;
121
+ }) => Promise<void>;
122
+
123
+ export type SendExportFailedEmailFn = (args: {
124
+ readonly userId: string;
125
+ readonly userEmail: string;
126
+ readonly tenantId: TenantId;
127
+ readonly jobId: string;
128
+ readonly errorMessage: string;
129
+ }) => Promise<void>;
130
+
131
+ export interface RunExportJobsArgs {
132
+ readonly db: DbConnection;
133
+ readonly registry: Registry;
134
+ /**
135
+ * Per-Tenant Storage-Provider-Builder. App-Bootstrap liefert den
136
+ * file-foundation-Resolver via `createFileProviderForTenant` —
137
+ * Worker bekommt eine kleinere Surface ohne HandlerContext-Dep.
138
+ */
139
+ readonly buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
140
+ /** Now-Injection — Tests pinnen den Wert ohne Date-Mock. */
141
+ readonly now: Instant;
142
+
143
+ /** S2.U3 Atom 5 — Email-Notification beim done-flip. Optional;
144
+ * wenn nicht gesetzt, sendet der Worker keine notification (User
145
+ * kann aber via export-status.query polln + UI-Klick). */
146
+ readonly sendExportReadyEmail?: SendExportReadyEmailFn;
147
+ /** Optional Email beim failed-flip. */
148
+ readonly sendExportFailedEmail?: SendExportFailedEmailFn;
149
+ /** Base-URL fuer den Magic-Link. App-Author setzt das (z.B.
150
+ * "https://app.example.com/user-export/by-token"). Worker baut
151
+ * `${appExportDownloadUrl}?token=<plain>` und reicht das im
152
+ * Callback durch. Required wenn sendExportReadyEmail gesetzt. */
153
+ readonly appExportDownloadUrl?: string;
154
+ }
155
+
156
+ export interface ExportJobError {
157
+ readonly jobId: string;
158
+ readonly userId: string;
159
+ readonly phase: "claim" | "bundle" | "zip-write" | "complete";
160
+ readonly message: string;
161
+ }
162
+
163
+ export interface RunExportJobsResult {
164
+ /** Job-IDs die in diesem Pass von pending → done geflippt wurden. */
165
+ readonly completedJobIds: readonly string[];
166
+ /** Job-IDs die in diesem Pass von pending → failed geflippt wurden. */
167
+ readonly failedJobIds: readonly string[];
168
+ /** Job-IDs die als stuck-running auf failed geflippt wurden (Stale-Pass). */
169
+ readonly staleFailedJobIds: readonly string[];
170
+ /** Job-IDs deren downloadStorageKey im Cleanup-Pass geloescht wurde. */
171
+ readonly cleanedJobIds: readonly string[];
172
+ readonly errors: readonly ExportJobError[];
173
+ /**
174
+ * Plain-Tokens fuer in diesem Pass completed-Jobs. Map<jobId, plain>.
175
+ * Atom 5 (Notification) liest das + versendet plain per Email.
176
+ * NIEMALS persistiert — nur in-memory zwischen Worker-Run und
177
+ * Notification-Hook. NACH dem Run ist plain nur via DB-Hash
178
+ * verifizierbar (Atom 4b's Download-Endpoint).
179
+ */
180
+ readonly tokenByJobId: ReadonlyMap<string, string>;
181
+ }
182
+
183
+ export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJobsResult> {
184
+ const { db, registry, buildStorageProvider, now, sendExportReadyEmail, sendExportFailedEmail } =
185
+ args;
186
+
187
+ // Boot-Misconfig-Check: wer sendExportReadyEmail setzt aber URL
188
+ // vergisst, soll einen klaren Error sehen — VOR dem Pass-Loop, damit
189
+ // der Throw nicht vom per-Job-try/catch (Atom 5.fix3) geschluckt wird.
190
+ // Runtime-Callback-Failures sind best-effort, Boot-Misconfig ist hard-
191
+ // fail (App-Author-Bug, kein Network-Hiccup).
192
+ if (sendExportReadyEmail && args.appExportDownloadUrl === undefined) {
193
+ throw new Error(
194
+ "user-data-rights: sendExportReadyEmail gesetzt aber appExportDownloadUrl fehlt — beide muessen zusammen konfiguriert sein",
195
+ );
196
+ }
197
+
198
+ // Pass 1: Stale-Detection — running jobs die laenger als das tenant-
199
+ // spezifische exportStaleTimeoutMinutes haengen werden gefailed.
200
+ // Wird VOR dem pickup-pass ausgefuehrt damit ein neuer Worker-Run
201
+ // den vorhergehenden Crash erstmal als failed markiert.
202
+ const staleFailedJobIds = await staleDetectionPass({ db, now });
203
+
204
+ // Pass 2: Pickup pending jobs + Process them.
205
+ const completedJobIds: string[] = [];
206
+ const failedJobIds: string[] = [];
207
+ const errors: ExportJobError[] = [];
208
+ const tokenByJobId = new Map<string, string>();
209
+
210
+ const pendingJobs = await fetchPendingJobs(db);
211
+ for (const job of pendingJobs) {
212
+ const outcome = await processJob({
213
+ db,
214
+ registry,
215
+ buildStorageProvider,
216
+ now,
217
+ job,
218
+ });
219
+ if (outcome.kind === "done") {
220
+ completedJobIds.push(job.id);
221
+ // Worker hat plain im Memory; weitergeben fuer downstream-callbacks
222
+ // + Atom-5-Tests (RunExportJobsResult.tokenByJobId). Plain landet
223
+ // NICHT in Logs/Telemetry/jobRunsTable.
224
+ tokenByJobId.set(job.id, outcome.tokenPlain);
225
+
226
+ // Atom 5: Email-Notification beim done-flip.
227
+ //
228
+ // Best-effort (Atom 5.fix3): Throw fuer Job A darf den Batch nicht
229
+ // abwuergen — Job-Status ist bereits done committed, restliche
230
+ // pending-Jobs muessen noch verarbeitet werden. Throw waere ein
231
+ // Bug: r.job-Wrap markiert den Run failed, retry findet keinen
232
+ // pending-Job mehr (Status=done) → silent miss + ZIP laeuft nach
233
+ // TTL ab ohne dass der User die Email je bekommt. console.warn ist
234
+ // die einzige Operator-Sichtbarkeit (runExportJobs-args fuehren
235
+ // AppContext.log nicht durch — pure-function-Pattern).
236
+ if (sendExportReadyEmail) {
237
+ try {
238
+ await fireExportReadyCallback({
239
+ db,
240
+ job,
241
+ plainToken: outcome.tokenPlain,
242
+ bytesWritten: outcome.bytesWritten,
243
+ expiresAt: outcome.expiresAt,
244
+ appExportDownloadUrl: args.appExportDownloadUrl,
245
+ send: sendExportReadyEmail,
246
+ });
247
+ } catch (err) {
248
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
249
+ console.warn(
250
+ `[user-data-rights:run-export-jobs] sendExportReadyEmail failed jobId=${job.id} userId=${job.userId} err=${err instanceof Error ? err.message : String(err)}`,
251
+ );
252
+ }
253
+ }
254
+ } else if (outcome.kind === "failed") {
255
+ failedJobIds.push(job.id);
256
+ errors.push(outcome.error);
257
+
258
+ // Atom 5: Email-Notification beim failed-flip — User soll wissen
259
+ // dass er neuen Export anfordern muss. Best-effort analog
260
+ // sendExportReadyEmail (Atom 5.fix3) — Email-Send-Throw darf den
261
+ // Batch nicht killen.
262
+ if (sendExportFailedEmail) {
263
+ try {
264
+ await fireExportFailedCallback({
265
+ db,
266
+ job,
267
+ errorMessage: outcome.error.message,
268
+ send: sendExportFailedEmail,
269
+ });
270
+ } catch (err) {
271
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
272
+ console.warn(
273
+ `[user-data-rights:run-export-jobs] sendExportFailedEmail failed jobId=${job.id} userId=${job.userId} err=${err instanceof Error ? err.message : String(err)}`,
274
+ );
275
+ }
276
+ }
277
+ }
278
+ // "skipped" (claim race lost) wird still passiert — kein error,
279
+ // anderer Worker hat den Job bereits.
280
+ }
281
+
282
+ // Pass 3: Storage-Cleanup — abgelaufene done-Jobs (expiresAt+grace < now)
283
+ // bekommen ihren downloadStorageKey aus dem Storage-Provider geloescht
284
+ // + im DB-Row genullt.
285
+ const cleanedJobIds = await storageCleanupPass({
286
+ db,
287
+ buildStorageProvider,
288
+ now,
289
+ });
290
+
291
+ return {
292
+ completedJobIds,
293
+ failedJobIds,
294
+ staleFailedJobIds,
295
+ cleanedJobIds,
296
+ errors,
297
+ tokenByJobId,
298
+ };
299
+ }
300
+
301
+ interface JobRow {
302
+ readonly id: string;
303
+ readonly version: number;
304
+ readonly userId: string;
305
+ readonly requestedFromTenantId: TenantId;
306
+ }
307
+
308
+ async function fetchPendingJobs(db: DbRunner): Promise<readonly JobRow[]> {
309
+ // @cast-boundary db-row.
310
+ return (await db
311
+ .select({
312
+ id: exportJobsTable["id"],
313
+ version: exportJobsTable["version"],
314
+ userId: exportJobsTable["userId"],
315
+ requestedFromTenantId: exportJobsTable["requestedFromTenantId"],
316
+ })
317
+ .from(exportJobsTable)
318
+ .where(eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Pending))
319
+ .orderBy(asc(exportJobsTable["requestedAt"]))) as readonly JobRow[];
320
+ }
321
+
322
+ type ProcessOutcome =
323
+ | {
324
+ kind: "done";
325
+ tokenPlain: string;
326
+ expiresAt: Instant;
327
+ bytesWritten: number;
328
+ }
329
+ | { kind: "failed"; error: ExportJobError }
330
+ | { kind: "skipped" }; // claim-race-loss (anderer Worker)
331
+
332
+ async function processJob(args: {
333
+ db: DbConnection;
334
+ registry: Registry;
335
+ buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
336
+ now: Instant;
337
+ job: JobRow;
338
+ }): Promise<ProcessOutcome> {
339
+ const { db, registry, buildStorageProvider, now, job } = args;
340
+ const executor = createSystemUser(job.requestedFromTenantId);
341
+ // crud.update braucht TenantDb fuer Stream-Identity (Memory
342
+ // feedback_event_store_tenant_consistency). system-mode bypassed
343
+ // den auto-tenant-filter; Stream wird ueber requestedFromTenantId
344
+ // konsistent gehalten.
345
+ const tdb = systemTenantDb(db, job.requestedFromTenantId);
346
+
347
+ // Phase 1: Claim — version-bumped update, scheitert via VersionConflict
348
+ // wenn paralleler Worker schon claimed hat.
349
+ //
350
+ // **Path-pre-claim** (Atom 4a.fix): downloadStorageKey wird HIER schon
351
+ // persistiert (deterministischer Pfad aus job.id). Begründung: wenn
352
+ // Worker zwischen ZIP-write und done-flip crashed, hat der Job-Row
353
+ // bereits den Pfad → storageCleanupPass kann den orphan-ZIP via
354
+ // failed-Job-Cleanup-Pass loeschen (sonst forever-leak im S3).
355
+ const storageKey = buildExportStorageKey(job);
356
+ const claimResult = await crud.update(
357
+ {
358
+ id: job.id,
359
+ version: job.version,
360
+ changes: {
361
+ status: EXPORT_JOB_STATUS.Running,
362
+ startedAt: now,
363
+ downloadStorageKey: storageKey,
364
+ },
365
+ },
366
+ executor,
367
+ tdb,
368
+ );
369
+ if (!claimResult.isSuccess) {
370
+ // Race-Loss: anderer Worker hat den Job claimed. Skip silently.
371
+ return { kind: "skipped" };
372
+ }
373
+
374
+ // Phase 2-6: Bundle bauen + ZIP-streamen + Storage-Write.
375
+ // Errors bis zur completion werden als job=failed materialisiert.
376
+ try {
377
+ const bundle = await runUserExport({
378
+ db,
379
+ registry,
380
+ userId: job.userId,
381
+ now,
382
+ });
383
+
384
+ const profile = await resolveProfileForTenant({
385
+ db,
386
+ tenantId: job.requestedFromTenantId,
387
+ });
388
+ const ttl = profile.profile.userRights.exportDownloadTtl;
389
+
390
+ // Per-Tenant-Provider-Cache: Cross-Tenant-fileRefs (Alice Member von
391
+ // Tenant A + B → File-Refs aus beiden) brauchen pro Tenant separat
392
+ // einen Provider-Build (S3-Bucket pro Tenant, andere config). Job-
393
+ // Tenant ist die Identitaet fuer den write-Pfad; read-Pfade gehen
394
+ // ueber den fileRef.tenantId.
395
+ const providerCache = new Map<TenantId, FileStorageProvider>();
396
+ const cachedProvider = async (tenantId: TenantId): Promise<FileStorageProvider> => {
397
+ let p = providerCache.get(tenantId);
398
+ if (!p) {
399
+ p = await buildStorageProvider(tenantId);
400
+ providerCache.set(tenantId, p);
401
+ }
402
+ return p;
403
+ };
404
+
405
+ const writeProvider = await cachedProvider(job.requestedFromTenantId);
406
+ // writeStream + readStream sind im FileStorageProvider-Type required
407
+ // (Atom 3c.fix Type-Honesty) — keine Runtime-Optional-Checks mehr noetig.
408
+
409
+ const tracker = countingStream(
410
+ createZipStream(bundleToZipEntries(bundle, now, cachedProvider)),
411
+ );
412
+
413
+ await writeProvider.writeStream(storageKey, tracker.stream, {
414
+ mimeType: "application/zip",
415
+ });
416
+
417
+ // Phase 7: Token VOR done-flip (Atom 4a.fix Sequencing).
418
+ //
419
+ // Wenn Token-Create failt, bleibt Job in `running` → catch-Pfad
420
+ // flippt auf failed (monotone Status-Transition). Wenn Token-Create
421
+ // VOR und done-flip NACH: tightes Race-Window in dem ein Worker-
422
+ // Crash zwischen den zwei calls einen Token + ZIP orphans. Beides
423
+ // catched die Stale-Detection im naechsten Pass — Job=failed,
424
+ // Storage-Cleanup-Pass clearted den ZIP (path-pre-claim oben).
425
+ //
426
+ // Plain-Token bleibt im Worker-Memory + wird via RunExportJobsResult
427
+ // an Atom 5 (Notification) weitergegeben. NUR der hash landet in DB.
428
+ // ES via tokenCrud.create — kein direct-INSERT.
429
+ const expiresAt = addDurationSpec(now, ttl);
430
+ const { plain: tokenPlain, hash: tokenHash } = await generateDownloadToken();
431
+ const tokenCreateResult = await tokenCrud.create(
432
+ {
433
+ jobId: job.id,
434
+ tokenHash,
435
+ issuedAt: now,
436
+ expiresAt,
437
+ // Atom 4a.fix: useCount explizit 0 statt default null. 4b's
438
+ // Verify-Pfad incrementiert via `useCount + 1` ohne COALESCE-
439
+ // Defensiv-Code.
440
+ useCount: 0,
441
+ },
442
+ executor,
443
+ tdb,
444
+ );
445
+ if (!tokenCreateResult.isSuccess) {
446
+ // Token-Creation failed VOR done-flip → Job bleibt running.
447
+ // Catch-Pfad flippt auf failed mit klarer Diagnose. Storage-
448
+ // Cleanup-Pass clearted den orphan-ZIP (path bereits via claim
449
+ // persistiert).
450
+ throw new Error(
451
+ `Job ${job.id}: Token-Creation failed before done-flip. ` +
452
+ `${(tokenCreateResult as { error?: { code?: string } }).error?.code ?? "unknown"}`, // @cast-boundary engine-payload
453
+ );
454
+ }
455
+
456
+ // Phase 8: Job=done. expiresAt wurde oben fuer Token gesetzt; identisch
457
+ // hier persistiert (denormalisiert in beiden Tabellen).
458
+ const doneResult = await crud.update(
459
+ {
460
+ id: job.id,
461
+ version: job.version + 1, // +1 weil wir bereits via claim einen update gemacht haben
462
+ changes: {
463
+ status: EXPORT_JOB_STATUS.Done,
464
+ completedAt: now,
465
+ // downloadStorageKey ist beim claim bereits gesetzt — kein
466
+ // Re-Set hier noetig (waere identisch).
467
+ expiresAt,
468
+ bytesWritten: tracker.bytes,
469
+ },
470
+ },
471
+ executor,
472
+ tdb,
473
+ );
474
+ if (!doneResult.isSuccess) {
475
+ // Sehr unerwartet — wir haben gerade claimed (version+1), niemand
476
+ // sollte den Job zwischenzeitlich aendern. Materialisieren als
477
+ // failed damit der Operator das sieht. Token-Row bleibt orphan
478
+ // bis Atom-spaeter-Cleanup; Storage-ZIP wird via failed-Cleanup
479
+ // gecleared.
480
+ throw new Error(
481
+ `Job ${job.id}: failed to flip status=done after successful Token-Create. ` +
482
+ `${(doneResult as { error?: { code?: string } }).error?.code ?? "unknown"}`, // @cast-boundary engine-payload
483
+ );
484
+ }
485
+
486
+ return { kind: "done", tokenPlain, expiresAt, bytesWritten: tracker.bytes };
487
+ } catch (e) {
488
+ const message = e instanceof Error ? e.message : String(e);
489
+ // Best-effort failure-flip. Wenn auch das failed (DB down etc.),
490
+ // bleibt der Job in running stehen — Stale-Detection im naechsten
491
+ // Worker-Run faengt ihn dann auf.
492
+ await crud
493
+ .update(
494
+ {
495
+ id: job.id,
496
+ version: job.version + 1,
497
+ changes: {
498
+ status: EXPORT_JOB_STATUS.Failed,
499
+ completedAt: now,
500
+ errorMessage: message,
501
+ },
502
+ },
503
+ executor,
504
+ tdb,
505
+ )
506
+ .catch(() => {
507
+ /* swallow — stale-detection kommt im nachsten Pass */
508
+ });
509
+ return {
510
+ kind: "failed",
511
+ error: {
512
+ jobId: job.id,
513
+ userId: job.userId,
514
+ phase: "zip-write",
515
+ message,
516
+ },
517
+ };
518
+ }
519
+ }
520
+
521
+ async function staleDetectionPass(args: {
522
+ db: DbConnection;
523
+ now: Instant;
524
+ }): Promise<readonly string[]> {
525
+ const { db, now } = args;
526
+
527
+ // **Kein Coarse-Filter** — der vorherige `startedAt <= now-1h` Filter
528
+ // war fragile: profile.exportStaleTimeoutMinutes hat Default 30min und
529
+ // erlaubt per-Tenant-Override auf z.B. 5min. Ein 60min-Coarse-Filter
530
+ // wuerde 30-60min-alte stale-Jobs UNERKANNT lassen. Cron laeuft alle
531
+ // 60s, zu jedem Zeitpunkt sind nur wenige Jobs in `running` —
532
+ // alle fetchen + profile-resolve im Loop ist bezahlbar + korrekt.
533
+ // @cast-boundary db-row.
534
+ const candidates = (await db
535
+ .select({
536
+ id: exportJobsTable["id"],
537
+ version: exportJobsTable["version"],
538
+ userId: exportJobsTable["userId"],
539
+ requestedFromTenantId: exportJobsTable["requestedFromTenantId"],
540
+ startedAt: exportJobsTable["startedAt"],
541
+ })
542
+ .from(exportJobsTable)
543
+ .where(eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Running))) as readonly {
544
+ id: string;
545
+ version: number;
546
+ userId: string;
547
+ requestedFromTenantId: TenantId;
548
+ startedAt: Instant | null;
549
+ }[];
550
+
551
+ const failed: string[] = [];
552
+ for (const c of candidates) {
553
+ const profile = await resolveProfileForTenant({
554
+ db,
555
+ tenantId: c.requestedFromTenantId,
556
+ });
557
+ const cutoffMs =
558
+ now.epochMilliseconds - profile.profile.userRights.exportStaleTimeoutMinutes * 60 * 1000;
559
+ const startedMs = c.startedAt?.epochMilliseconds ?? Number.MAX_SAFE_INTEGER;
560
+ if (startedMs < cutoffMs) {
561
+ const result = await crud.update(
562
+ {
563
+ id: c.id,
564
+ version: c.version,
565
+ changes: {
566
+ status: EXPORT_JOB_STATUS.Failed,
567
+ completedAt: now,
568
+ errorMessage: "stale: worker crashed mid-run",
569
+ },
570
+ },
571
+ createSystemUser(c.requestedFromTenantId),
572
+ systemTenantDb(db, c.requestedFromTenantId),
573
+ );
574
+ if (result.isSuccess) failed.push(c.id);
575
+ // Race-loss (Worker hat das Job parallel completed) → skip
576
+ }
577
+ }
578
+ return failed;
579
+ }
580
+
581
+ async function storageCleanupPass(args: {
582
+ db: DbConnection;
583
+ buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
584
+ now: Instant;
585
+ }): Promise<readonly string[]> {
586
+ const { db, buildStorageProvider, now } = args;
587
+
588
+ // Zwei Cleanup-Pfade:
589
+ //
590
+ // 1. **Done-Jobs** (TTL-expired): expiresAt + grace < now → cleanup.
591
+ // Per-Tenant-Grace aus Profile (default 24h post-expiry damit
592
+ // gleichzeitige Re-Downloads bei Connection-Abbrueche moeglich).
593
+ //
594
+ // 2. **Failed-Jobs mit downloadStorageKey** (Atom 4a.fix orphan-cleanup):
595
+ // Worker hat ZIP geschrieben, dann ist VOR done-flip etwas
596
+ // schiefgegangen (Token-Create-Fail, done-flip-Fail, Stale-
597
+ // Detection mid-write). Job=failed mit gesetztem downloadStorageKey =
598
+ // ZIP-Orphan in Storage, kein User-Pfad zum download.
599
+ //
600
+ // **Strategie failed-Jobs: SOFORTIGE Cleanup ohne Grace.**
601
+ //
602
+ // Trade-off (DSGVO vs Audit-Forensik):
603
+ // - DSGVO: User-Daten-ZIP einer failed-export-Anfrage hat keinen
604
+ // legitimen Aufbewahrungs-Grund — hier ueberwiegt das Recht auf
605
+ // Loeschung
606
+ // - Audit-Forensik: forensische Untersuchung ("was ist im ZIP gelandet,
607
+ // warum failed der Job") wuerde von einer Grace-Periode profitieren —
608
+ // ABER: Token wurde nie ausgegeben (kein User hat das ZIP gesehen),
609
+ // also keine User-Schaden-Forensik. Operator-Audit hat job.errorMessage
610
+ // + Stack-Trace, das reicht
611
+ // → Trade-off zugunsten DSGVO entschieden. Wenn ein Operator forensik
612
+ // braucht, muss er das vor dem Cleanup-Pass capturen (out-of-band).
613
+ //
614
+ // **SQL-Filter:** WHERE-clause auf downloadStorageKey IS NOT NULL filtert
615
+ // bereits in der DB statt im Loop. Bei skalierender DB-Historie (10k+
616
+ // done-jobs nach 30 Tagen) reduziert das den Worker-Roundtrip drastisch.
617
+ //
618
+ // @cast-boundary db-row.
619
+ const candidates = (await db
620
+ .select({
621
+ id: exportJobsTable["id"],
622
+ version: exportJobsTable["version"],
623
+ status: exportJobsTable["status"],
624
+ requestedFromTenantId: exportJobsTable["requestedFromTenantId"],
625
+ downloadStorageKey: exportJobsTable["downloadStorageKey"],
626
+ expiresAt: exportJobsTable["expiresAt"],
627
+ })
628
+ .from(exportJobsTable)
629
+ .where(
630
+ and(
631
+ // Beide Pfade: status in (done, failed) + downloadStorageKey gesetzt.
632
+ // Filter im Loop verfeinert (done braucht expiresAt+grace, failed
633
+ // sofort).
634
+ or(
635
+ eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Done),
636
+ eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Failed),
637
+ ),
638
+ isNotNull(exportJobsTable["downloadStorageKey"]),
639
+ ),
640
+ )) as readonly {
641
+ id: string;
642
+ version: number;
643
+ status: string;
644
+ requestedFromTenantId: TenantId;
645
+ downloadStorageKey: string | null;
646
+ expiresAt: Instant | null;
647
+ }[];
648
+
649
+ const cleaned: string[] = [];
650
+ for (const c of candidates) {
651
+ if (!c.downloadStorageKey) continue;
652
+
653
+ // Done-Jobs brauchen expiresAt+grace-Check. Failed-Jobs gehen direkt
654
+ // durch (kein User-Pfad → sofort cleanup).
655
+ if (c.status === EXPORT_JOB_STATUS.Done) {
656
+ if (!c.expiresAt) continue;
657
+ const profile = await resolveProfileForTenant({
658
+ db,
659
+ tenantId: c.requestedFromTenantId,
660
+ });
661
+ const cleanupAfter =
662
+ c.expiresAt.epochMilliseconds +
663
+ profile.profile.userRights.exportStorageCleanupGraceHours * 60 * 60 * 1000;
664
+ if (now.epochMilliseconds < cleanupAfter) continue;
665
+ }
666
+ // Failed-Job-Branch: kein TTL-Check, sofort cleanup.
667
+
668
+ // Storage-Datei loeschen + DB-Spalte nullen.
669
+ try {
670
+ const provider = await buildStorageProvider(c.requestedFromTenantId);
671
+ await provider.delete(c.downloadStorageKey);
672
+ } catch {
673
+ // best-effort; wenn Storage-Delete failed, retry beim naechsten Pass
674
+ continue;
675
+ }
676
+ const result = await crud.update(
677
+ {
678
+ id: c.id,
679
+ version: c.version,
680
+ changes: { downloadStorageKey: null },
681
+ },
682
+ createSystemUser(c.requestedFromTenantId),
683
+ systemTenantDb(db, c.requestedFromTenantId),
684
+ );
685
+ if (result.isSuccess) cleaned.push(c.id);
686
+ }
687
+ return cleaned;
688
+ }
689
+
690
+ // Wrapper: crud.update braucht TenantDb. ExportJob ist tenant-agnostisch
691
+ // (1 Job pro userId), aber der event-store-Stream-Lookup erwartet einen
692
+ // Tenant-Context. system-mode TenantDb bypassed den auto-tenant-filter
693
+ // — wir nutzen `requestedFromTenantId` als Stream-Identity damit der
694
+ // Stream-Counter konsistent bleibt (Memory feedback_event_store_tenant_consistency).
695
+ function systemTenantDb(db: DbConnection, tenantId: TenantId) {
696
+ return createTenantDb(db, tenantId, "system");
697
+ }
698
+
699
+ function buildExportStorageKey(job: JobRow): string {
700
+ // Tenant-prefix damit der Storage-Layout pro-Tenant separat liegt.
701
+ // job.id ist UUID → URL-safe.
702
+ return `${job.requestedFromTenantId}/exports/${job.id}.zip`;
703
+ }
704
+
705
+ /**
706
+ * Konvertiert das UserExportBundle in eine `AsyncIterable<ZipEntry>`
707
+ * fuer createZipStream.
708
+ *
709
+ * Bundle-Layout (Atom 3c):
710
+ * bundle.json — Top-Level-Bundle-Metadata
711
+ * + tenant-sections + flat
712
+ * fileRefs[] mit zipPath
713
+ * pro Datei
714
+ * files/<tenantId>/<fileRefId>-<name> — File-Binaries, Pfad
715
+ * identisch zu fileRef.zipPath
716
+ * im JSON. Bytes via
717
+ * provider.readStream — kein
718
+ * Memory-Spike, auch grosse
719
+ * PDFs streamen durch.
720
+ *
721
+ * `getProvider` ist ein async-cached Resolver pro tenantId. Caller
722
+ * (processJob) baut die Caching-Map damit jeder Tenant nur EINMAL
723
+ * via `buildStorageProvider` materialisiert wird.
724
+ */
725
+ async function* bundleToZipEntries(
726
+ bundle: UserExportBundle,
727
+ mtime: Instant,
728
+ getProvider: (tenantId: TenantId) => Promise<FileStorageProvider>,
729
+ ): AsyncIterable<ZipEntry> {
730
+ // bundle.json zuerst — Reader-Tools koennen das frueh parsen.
731
+ const bundleJson = JSON.stringify(bundle, null, 2);
732
+ yield {
733
+ path: "bundle.json",
734
+ data: oneShot(new TextEncoder().encode(bundleJson)),
735
+ mtime,
736
+ };
737
+
738
+ // File-Binaries: pro fileRef einen ZIP-Entry. data ist der readStream
739
+ // direkt — der ZIP-Builder konsumiert chunk-fuer-chunk, kein Upfront-
740
+ // Memory-Spike pro File. Bei 50 PDFs à 10MB werden trotzdem max. ~64KB
741
+ // (default highWaterMark) gleichzeitig im Heap gehalten.
742
+ for (const ref of bundle.fileRefs) {
743
+ const provider = await getProvider(ref.tenantId);
744
+ // readStream ist required im FileStorageProvider-Type (Atom 3c.fix
745
+ // Type-Honesty) — kein Runtime-Optional-Check noetig.
746
+ yield {
747
+ path: ref.zipPath,
748
+ data: provider.readStream(ref.storageKey),
749
+ mtime,
750
+ };
751
+ }
752
+ }
753
+
754
+ async function* oneShot(bytes: Uint8Array): AsyncIterable<Uint8Array> {
755
+ yield bytes;
756
+ }
757
+
758
+ /**
759
+ * Wraps an `AsyncIterable<Uint8Array>` und zaehlt total-bytes. Nach
760
+ * `for await` ist `tracker.bytes` der finale Wert. Verwendet fuer
761
+ * bytesWritten im Job-Row.
762
+ */
763
+ function countingStream(source: AsyncIterable<Uint8Array>): {
764
+ stream: AsyncIterable<Uint8Array>;
765
+ readonly bytes: number;
766
+ } {
767
+ const tracker = { bytes: 0 };
768
+ const stream: AsyncIterable<Uint8Array> = {
769
+ async *[Symbol.asyncIterator]() {
770
+ for await (const chunk of source) {
771
+ tracker.bytes += chunk.byteLength;
772
+ yield chunk;
773
+ }
774
+ },
775
+ };
776
+ return {
777
+ stream,
778
+ get bytes() {
779
+ return tracker.bytes;
780
+ },
781
+ };
782
+ }
783
+
784
+ // Atom 5 — userEmail-Lookup fuer notification-callback.
785
+ //
786
+ // **Direct-DB statt queryAs:** Worker-AppContext hat kein queryAs
787
+ // (anders als HandlerContext im request-Pfad). Pattern matched
788
+ // runUserExport's Cross-Tenant-Iteration die ebenso direkt
789
+ // tenantMembershipsTable liest. Cross-feature-API via dispatcher
790
+ // haette einen JobContext-Wrapper gebraucht den der framework noch
791
+ // nicht hat — wenn das mal kommt, ist der hier Refactor-Kandidat.
792
+ //
793
+ // **tenant-agnostic:** userTable.id ist UNIQUE (PK). Cross-Tenant
794
+ // (Alice in Tenant A+B) hat trotzdem nur 1 user-Row mit ihrer
795
+ // Heim-Tenant als tenantId. Lookup ohne tenantId-filter findet sie
796
+ // aus jedem Worker-Tenant-Context.
797
+ async function lookupUserEmail(db: DbConnection, userId: string): Promise<string | null> {
798
+ // @cast-boundary db-row.
799
+ const row = (await fetchOne(db, userTable, eq(userTable["id"], userId))) as {
800
+ email: string | null;
801
+ } | null;
802
+ return row?.email ?? null;
803
+ }
804
+
805
+ // Atom 5 — Email-Notification beim done-flip. Best-effort:
806
+ // Throw vom Callback bubbelt zum r.job-handler hoch + Worker-Run wird
807
+ // als failed in jobRunsTable gemerkt. Operator kann via jobs:write:retry
808
+ // den Worker-Run erneut anstossen — der Job selbst bleibt done, das
809
+ // retry findet keinen pending-Job mehr aber die Audit-Log zeigt den
810
+ // Failure.
811
+ async function fireExportReadyCallback(args: {
812
+ readonly db: DbConnection;
813
+ readonly job: JobRow;
814
+ readonly plainToken: string;
815
+ readonly expiresAt: Instant;
816
+ readonly bytesWritten: number;
817
+ readonly appExportDownloadUrl: string | undefined;
818
+ readonly send: SendExportReadyEmailFn;
819
+ }): Promise<void> {
820
+ const userEmail = await lookupUserEmail(args.db, args.job.userId);
821
+ if (!userEmail) {
822
+ // User-Row fehlt (z.B. forget-Pfad mid-export). Skip-Notification mit
823
+ // Operator-Alert via job-run-Log statt Throw — Job bleibt done, User
824
+ // hat ja seinen Token via export-status.query erreichbar (UI-Pfad).
825
+ // **console.warn statt ctx.log:** runExportJobs-args fuehren AppContext.log
826
+ // aktuell nicht durch (Worker-pure-function-Pattern). console.warn ist
827
+ // die einzige Operator-Sichtbarkeit fuer den missing-user-edge-case.
828
+ // Wenn jobs-Feature spaeter ctx.log threadet oder Worker-args erweitert
829
+ // werden, hier Refactor-Kandidat.
830
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for missing-user edge-case
831
+ console.warn(
832
+ `[user-data-rights:run-export-jobs] userId=${args.job.userId} hat kein userEmail — sendExportReadyEmail skipped`,
833
+ );
834
+ // skip: missing user-email row; console.warn above provides operator visibility
835
+ return;
836
+ }
837
+ const baseUrl = args.appExportDownloadUrl;
838
+ if (!baseUrl) {
839
+ throw new Error(
840
+ "user-data-rights: sendExportReadyEmail gesetzt aber appExportDownloadUrl fehlt — beide muessen zusammen konfiguriert sein",
841
+ );
842
+ }
843
+ const downloadUrl = `${baseUrl}?token=${encodeURIComponent(args.plainToken)}`;
844
+ await args.send({
845
+ userId: args.job.userId,
846
+ userEmail,
847
+ tenantId: args.job.requestedFromTenantId,
848
+ jobId: args.job.id,
849
+ downloadUrl,
850
+ expiresAt: args.expiresAt.toString(),
851
+ bytesWritten: args.bytesWritten,
852
+ });
853
+ }
854
+
855
+ // Atom 5 — Email-Notification beim failed-flip.
856
+ async function fireExportFailedCallback(args: {
857
+ readonly db: DbConnection;
858
+ readonly job: JobRow;
859
+ readonly errorMessage: string;
860
+ readonly send: SendExportFailedEmailFn;
861
+ }): Promise<void> {
862
+ const userEmail = await lookupUserEmail(args.db, args.job.userId);
863
+ if (!userEmail) {
864
+ // biome-ignore lint/suspicious/noConsole: operator-visibility
865
+ console.warn(
866
+ `[user-data-rights:run-export-jobs] userId=${args.job.userId} hat kein userEmail — sendExportFailedEmail skipped`,
867
+ );
868
+ // skip: missing user-email row; console.warn above provides operator visibility
869
+ return;
870
+ }
871
+ await args.send({
872
+ userId: args.job.userId,
873
+ userEmail,
874
+ tenantId: args.job.requestedFromTenantId,
875
+ jobId: args.job.id,
876
+ errorMessage: args.errorMessage,
877
+ });
878
+ }