@striae-org/striae 5.4.5 → 5.5.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.
@@ -0,0 +1,439 @@
1
+ import type { User } from 'firebase/auth';
2
+ import { auditService } from '~/services/audit';
3
+ import type { ValidationAuditEntry } from '~/types';
4
+ import { getCaseData } from '~/utils/data';
5
+ import { canAccessCase } from '~/utils/data/permissions';
6
+ import { fetchPdfApi } from '~/utils/api';
7
+ import type { ToastType } from '~/components/toast/toast';
8
+
9
+ interface ExportAuditPdfParams {
10
+ user: User;
11
+ caseNumber: string;
12
+ userCompany?: string;
13
+ userFirstName?: string;
14
+ userLastName?: string;
15
+ userBadgeId?: string;
16
+ setIsExportingPDF: (isExporting: boolean) => void;
17
+ setToastType: (type: ToastType) => void;
18
+ setToastMessage: (message: string) => void;
19
+ setShowToast: (show: boolean) => void;
20
+ setToastDuration?: (duration: number) => void;
21
+ }
22
+
23
+ interface AuditTrailPdfPayload {
24
+ reportMode: 'audit-trail';
25
+ caseNumber: string;
26
+ exportedAt: string;
27
+ exportRangeStart: string;
28
+ exportRangeEnd: string;
29
+ chunkIndex: number;
30
+ totalChunks: number;
31
+ totalEntries: number;
32
+ includeRawJsonAppendix: boolean;
33
+ entries: ValidationAuditEntry[];
34
+ }
35
+
36
+ const MAX_AUDIT_ENTRIES_PER_PDF = 200;
37
+ const AUDIT_FETCH_WINDOW_DAYS = 30;
38
+
39
+ const formatShortDate = (date: Date): string => {
40
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
41
+ const day = date.getDate().toString().padStart(2, '0');
42
+ const year = date.getFullYear().toString();
43
+ return `${month}/${day}/${year}`;
44
+ };
45
+
46
+ const formatDateStamp = (date: Date): string => {
47
+ const year = date.getFullYear().toString();
48
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
49
+ const day = date.getDate().toString().padStart(2, '0');
50
+ return `${year}${month}${day}`;
51
+ };
52
+
53
+ const chunkEntries = (entries: ValidationAuditEntry[]): ValidationAuditEntry[][] => {
54
+ if (entries.length === 0) {
55
+ return [];
56
+ }
57
+
58
+ const chunks: ValidationAuditEntry[][] = [];
59
+ for (let offset = 0; offset < entries.length; offset += MAX_AUDIT_ENTRIES_PER_PDF) {
60
+ chunks.push(entries.slice(offset, offset + MAX_AUDIT_ENTRIES_PER_PDF));
61
+ }
62
+
63
+ return chunks;
64
+ };
65
+
66
+ const downloadPdfBlob = (blob: Blob, filename: string): void => {
67
+ const downloadUrl = URL.createObjectURL(blob);
68
+ const anchor = document.createElement('a');
69
+ anchor.href = downloadUrl;
70
+ anchor.download = filename;
71
+ document.body.appendChild(anchor);
72
+ anchor.click();
73
+ document.body.removeChild(anchor);
74
+ URL.revokeObjectURL(downloadUrl);
75
+ };
76
+
77
+ const normalizeIsoDate = (value?: string): string | null => {
78
+ if (!value) {
79
+ return null;
80
+ }
81
+
82
+ const parsed = new Date(value);
83
+ if (Number.isNaN(parsed.getTime())) {
84
+ return null;
85
+ }
86
+
87
+ return parsed.toISOString();
88
+ };
89
+
90
+ const toUtcDayStart = (value: string): Date => {
91
+ const parsed = new Date(value);
92
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate(), 0, 0, 0, 0));
93
+ };
94
+
95
+ const toUtcDayEnd = (value: string): Date => {
96
+ const parsed = new Date(value);
97
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate(), 23, 59, 59, 999));
98
+ };
99
+
100
+ const addUtcDays = (date: Date, days: number): Date => {
101
+ return new Date(Date.UTC(
102
+ date.getUTCFullYear(),
103
+ date.getUTCMonth(),
104
+ date.getUTCDate() + days,
105
+ date.getUTCHours(),
106
+ date.getUTCMinutes(),
107
+ date.getUTCSeconds(),
108
+ date.getUTCMilliseconds()
109
+ ));
110
+ };
111
+
112
+ const getAuditEntryIdentity = (entry: ValidationAuditEntry): string => {
113
+ return [
114
+ entry.timestamp,
115
+ entry.userId,
116
+ entry.action,
117
+ entry.result,
118
+ entry.details.caseNumber || '',
119
+ entry.details.fileName || '',
120
+ entry.details.confirmationId || ''
121
+ ].join('|');
122
+ };
123
+
124
+ const fetchAllCaseEntriesForExport = async (
125
+ user: User,
126
+ caseNumber: string,
127
+ caseCreatedAtIso: string,
128
+ nowIso: string
129
+ ): Promise<ValidationAuditEntry[]> => {
130
+ const rangeStart = toUtcDayStart(caseCreatedAtIso);
131
+ const rangeEnd = toUtcDayEnd(nowIso);
132
+
133
+ const mergedEntries = new Map<string, ValidationAuditEntry>();
134
+ let windowStart = new Date(rangeStart);
135
+
136
+ while (windowStart.getTime() <= rangeEnd.getTime()) {
137
+ const windowEndCandidate = addUtcDays(windowStart, AUDIT_FETCH_WINDOW_DAYS - 1);
138
+ const windowEnd = windowEndCandidate.getTime() > rangeEnd.getTime() ? new Date(rangeEnd) : windowEndCandidate;
139
+
140
+ const windowEntries = await auditService.getAuditEntriesForUser(user.uid, {
141
+ requestingUser: user,
142
+ caseNumber,
143
+ startDate: windowStart.toISOString(),
144
+ endDate: windowEnd.toISOString()
145
+ });
146
+
147
+ for (const entry of windowEntries) {
148
+ mergedEntries.set(getAuditEntryIdentity(entry), entry);
149
+ }
150
+
151
+ windowStart = addUtcDays(windowEnd, 1);
152
+ windowStart = new Date(Date.UTC(
153
+ windowStart.getUTCFullYear(),
154
+ windowStart.getUTCMonth(),
155
+ windowStart.getUTCDate(),
156
+ 0,
157
+ 0,
158
+ 0,
159
+ 0
160
+ ));
161
+ }
162
+
163
+ return Array.from(mergedEntries.values());
164
+ };
165
+
166
+ const extractErrorMessage = async (response: Response): Promise<string> => {
167
+ const contentType = response.headers.get('Content-Type') ?? '';
168
+
169
+ if (contentType.includes('application/json')) {
170
+ try {
171
+ const json = await response.json() as { error?: string; details?: string };
172
+ if (json.error) {
173
+ return json.details ? `${json.error}: ${json.details}` : json.error;
174
+ }
175
+ } catch {
176
+ // fall through to text fallback
177
+ }
178
+ }
179
+
180
+ try {
181
+ const text = await response.text();
182
+ return text.trim() || `HTTP ${response.status} ${response.statusText}`;
183
+ } catch {
184
+ return `HTTP ${response.status} ${response.statusText}`;
185
+ }
186
+ };
187
+
188
+ const buildPartFilename = (
189
+ caseNumber: string,
190
+ exportDate: Date,
191
+ partIndex: number,
192
+ totalParts: number
193
+ ): string => {
194
+ const sanitizedCaseNumber = caseNumber.trim().replace(/[^a-zA-Z0-9_-]+/g, '-');
195
+ const dateStamp = formatDateStamp(exportDate);
196
+
197
+ if (totalParts <= 1) {
198
+ return `audit-trail-${sanitizedCaseNumber}-${dateStamp}.pdf`;
199
+ }
200
+
201
+ return `audit-trail-${sanitizedCaseNumber}-${dateStamp}-part-${partIndex}-of-${totalParts}.pdf`;
202
+ };
203
+
204
+ const buildAuditTrailPayload = (
205
+ caseNumber: string,
206
+ entries: ValidationAuditEntry[],
207
+ exportRangeStart: string,
208
+ exportRangeEnd: string,
209
+ chunkIndex: number,
210
+ totalChunks: number,
211
+ totalEntries: number,
212
+ exportedAt: string
213
+ ): AuditTrailPdfPayload => {
214
+ return {
215
+ reportMode: 'audit-trail',
216
+ caseNumber,
217
+ exportedAt,
218
+ exportRangeStart,
219
+ exportRangeEnd,
220
+ chunkIndex,
221
+ totalChunks,
222
+ totalEntries,
223
+ includeRawJsonAppendix: true,
224
+ entries
225
+ };
226
+ };
227
+
228
+ export const exportAuditPDF = async ({
229
+ user,
230
+ caseNumber,
231
+ userCompany,
232
+ userFirstName,
233
+ userLastName,
234
+ userBadgeId,
235
+ setIsExportingPDF,
236
+ setToastType,
237
+ setToastMessage,
238
+ setShowToast,
239
+ setToastDuration
240
+ }: ExportAuditPdfParams): Promise<void> => {
241
+ setIsExportingPDF(true);
242
+ setToastType('loading');
243
+ setToastMessage('Preparing full case audit trail PDF export...');
244
+ if (setToastDuration) {
245
+ setToastDuration(0);
246
+ }
247
+ setShowToast(true);
248
+
249
+ const exportStartTime = Date.now();
250
+
251
+ try {
252
+ const accessCheck = await canAccessCase(user, caseNumber);
253
+ if (!accessCheck.allowed) {
254
+ throw new Error(accessCheck.reason || 'You do not have access to export this case audit trail.');
255
+ }
256
+
257
+ const caseData = await getCaseData(user, caseNumber);
258
+ const now = new Date();
259
+ const nowIso = now.toISOString();
260
+ const caseCreatedAtIso = normalizeIsoDate(caseData?.createdAt) || '1970-01-01T00:00:00.000Z';
261
+ const isBundledArchivedCase = Boolean(
262
+ caseData?.isReadOnly === true &&
263
+ caseData?.archived === true &&
264
+ caseData?.bundledAuditTrail?.source === 'archive-bundle'
265
+ );
266
+
267
+ const allEntries = isBundledArchivedCase
268
+ ? await auditService.getAuditEntriesForUser(user.uid, {
269
+ requestingUser: user,
270
+ caseNumber
271
+ })
272
+ : await fetchAllCaseEntriesForExport(user, caseNumber, caseCreatedAtIso, nowIso);
273
+
274
+ if (allEntries.length === 0) {
275
+ setToastType('warning');
276
+ setToastMessage(`No audit entries were found for case ${caseNumber}.`);
277
+ if (setToastDuration) {
278
+ setToastDuration(5000);
279
+ }
280
+ setShowToast(true);
281
+ return;
282
+ }
283
+
284
+ const sortedEntries = [...allEntries].sort(
285
+ (left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()
286
+ );
287
+ const exportRangeStartIso = isBundledArchivedCase
288
+ ? normalizeIsoDate(sortedEntries[0]?.timestamp) || caseCreatedAtIso
289
+ : caseCreatedAtIso;
290
+ const exportRangeEndIso = isBundledArchivedCase
291
+ ? normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso
292
+ : nowIso;
293
+
294
+ const chunks = chunkEntries(sortedEntries);
295
+ const totalChunks = chunks.length;
296
+ const exportedAtIso = nowIso;
297
+ const currentDate = formatShortDate(now);
298
+
299
+ let successfulParts = 0;
300
+ const failedParts: number[] = [];
301
+
302
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
303
+ const partNumber = chunkIndex + 1;
304
+ const chunkEntriesForPart = chunks[chunkIndex];
305
+ const filename = buildPartFilename(caseNumber, now, partNumber, totalChunks);
306
+ const partStartTime = Date.now();
307
+
308
+ const pdfData = {
309
+ reportMode: 'audit-trail' as const,
310
+ currentDate,
311
+ caseNumber,
312
+ userCompany,
313
+ userFirstName,
314
+ userLastName,
315
+ userBadgeId,
316
+ auditTrailReport: buildAuditTrailPayload(
317
+ caseNumber,
318
+ chunkEntriesForPart,
319
+ exportRangeStartIso,
320
+ exportRangeEndIso,
321
+ partNumber,
322
+ totalChunks,
323
+ sortedEntries.length,
324
+ exportedAtIso
325
+ )
326
+ };
327
+
328
+ setToastType('loading');
329
+ setToastMessage(`Generating audit PDF part ${partNumber} of ${totalChunks}...`);
330
+ setShowToast(true);
331
+
332
+ try {
333
+ const response = await fetchPdfApi(user, '/', {
334
+ method: 'POST',
335
+ headers: {
336
+ 'Content-Type': 'application/json'
337
+ },
338
+ body: JSON.stringify({ data: pdfData })
339
+ });
340
+
341
+ if (!response.ok) {
342
+ const errorMessage = await extractErrorMessage(response);
343
+ throw new Error(errorMessage);
344
+ }
345
+
346
+ const blob = await response.blob();
347
+ downloadPdfBlob(blob, filename);
348
+ successfulParts += 1;
349
+
350
+ try {
351
+ await auditService.logPDFGeneration(
352
+ user,
353
+ filename,
354
+ caseNumber,
355
+ 'success',
356
+ Date.now() - partStartTime,
357
+ blob.size,
358
+ [],
359
+ `audit-trail:${caseNumber}:part-${partNumber}`,
360
+ `audit-trail-${caseNumber}`
361
+ );
362
+ } catch (auditError) {
363
+ console.error('Failed to log audit PDF generation success:', auditError);
364
+ }
365
+ } catch (error) {
366
+ failedParts.push(partNumber);
367
+
368
+ try {
369
+ await auditService.logPDFGeneration(
370
+ user,
371
+ filename,
372
+ caseNumber,
373
+ 'failure',
374
+ Date.now() - partStartTime,
375
+ 0,
376
+ [error instanceof Error ? error.message : 'Unknown PDF generation error'],
377
+ `audit-trail:${caseNumber}:part-${partNumber}`,
378
+ `audit-trail-${caseNumber}`
379
+ );
380
+ } catch (auditError) {
381
+ console.error('Failed to log audit PDF generation failure:', auditError);
382
+ }
383
+ }
384
+ }
385
+
386
+ if (failedParts.length === 0) {
387
+ setToastType('success');
388
+ setToastMessage(
389
+ successfulParts > 1
390
+ ? `Exported ${successfulParts} audit PDF parts for case ${caseNumber}.`
391
+ : `Exported audit PDF for case ${caseNumber}.`
392
+ );
393
+ if (setToastDuration) {
394
+ setToastDuration(6000);
395
+ }
396
+ setShowToast(true);
397
+ return;
398
+ }
399
+
400
+ const failedPartLabel = failedParts.join(', ');
401
+ setToastType('warning');
402
+ setToastMessage(
403
+ successfulParts > 0
404
+ ? `Export completed with issues. Successful parts: ${successfulParts}/${totalChunks}. Failed parts: ${failedPartLabel}.`
405
+ : `Audit PDF export failed. Failed parts: ${failedPartLabel}.`
406
+ );
407
+ if (setToastDuration) {
408
+ setToastDuration(9000);
409
+ }
410
+ setShowToast(true);
411
+ } catch (error) {
412
+ const processingTime = Date.now() - exportStartTime;
413
+
414
+ try {
415
+ await auditService.logPDFGeneration(
416
+ user,
417
+ `audit-trail-${caseNumber}-failed-${Date.now()}.pdf`,
418
+ caseNumber,
419
+ 'failure',
420
+ processingTime,
421
+ 0,
422
+ [error instanceof Error ? error.message : 'Unknown audit PDF export error'],
423
+ `audit-trail:${caseNumber}`,
424
+ `audit-trail-${caseNumber}`
425
+ );
426
+ } catch (auditError) {
427
+ console.error('Failed to log audit PDF export error:', auditError);
428
+ }
429
+
430
+ setToastType('error');
431
+ setToastMessage(error instanceof Error ? error.message : 'Failed to export case audit trail PDF.');
432
+ if (setToastDuration) {
433
+ setToastDuration(7000);
434
+ }
435
+ setShowToast(true);
436
+ } finally {
437
+ setIsExportingPDF(false);
438
+ }
439
+ };
@@ -1,6 +1,8 @@
1
- import { useContext, useMemo } from 'react';
1
+ import { useCallback, useContext, useMemo, useState } from 'react';
2
2
  import { AuthContext } from '~/contexts/auth.context';
3
3
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
+ import { exportAuditPDF } from '~/components/actions/export-audit-pdf';
5
+ import { Toast, type ToastType } from '~/components/toast/toast';
4
6
  import { AuditViewerHeader } from './viewer/audit-viewer-header';
5
7
  import { AuditUserInfoCard } from './viewer/audit-user-info-card';
6
8
  import { AuditActivitySummary } from './viewer/audit-activity-summary';
@@ -20,6 +22,12 @@ interface UserAuditViewerProps {
20
22
 
21
23
  export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAuditViewerProps) => {
22
24
  const { user } = useContext(AuthContext);
25
+ const [isExportingPdf, setIsExportingPdf] = useState(false);
26
+ const [showToast, setShowToast] = useState(false);
27
+ const [toastType, setToastType] = useState<ToastType>('success');
28
+ const [toastMessage, setToastMessage] = useState('');
29
+ const [toastDuration, setToastDuration] = useState(4000);
30
+
23
31
  const {
24
32
  filterAction,
25
33
  setFilterAction,
@@ -79,107 +87,157 @@ export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAudi
79
87
  });
80
88
 
81
89
  const userBadgeId = userData?.badgeId?.trim() || '';
90
+ const isCaseScopedViewer = Boolean(effectiveCaseNumber?.trim());
91
+
92
+ const handleExportPdf = useCallback(async () => {
93
+ if (!user || !effectiveCaseNumber || isExportingPdf) {
94
+ return;
95
+ }
96
+
97
+ const displayName = user.displayName?.trim() || '';
98
+ const [firstFromDisplayName, ...lastFromDisplayNameParts] = displayName.split(/\s+/).filter(Boolean);
99
+
100
+ await exportAuditPDF({
101
+ user,
102
+ caseNumber: effectiveCaseNumber,
103
+ userCompany: userData?.company,
104
+ userFirstName: firstFromDisplayName || userData?.firstName || '',
105
+ userLastName: lastFromDisplayNameParts.join(' ') || userData?.lastName || '',
106
+ userBadgeId,
107
+ setIsExportingPDF: setIsExportingPdf,
108
+ setToastType,
109
+ setToastMessage,
110
+ setShowToast,
111
+ setToastDuration
112
+ });
113
+ }, [
114
+ effectiveCaseNumber,
115
+ isExportingPdf,
116
+ user,
117
+ userBadgeId,
118
+ userData?.company,
119
+ userData?.firstName,
120
+ userData?.lastName
121
+ ]);
82
122
 
83
123
  if (!isOpen) return null;
84
124
 
85
125
  return (
86
- <div
87
- className={styles.overlay}
88
- aria-label="Close audit trail dialog"
89
- {...overlayProps}
90
- >
91
- <div className={styles.modal}>
92
- <AuditViewerHeader
93
- title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
94
- onClose={requestClose}
95
- />
96
-
97
- <div className={styles.content}>
98
- {loading && (
99
- <div className={styles.loading}>
100
- <div className={styles.spinner}></div>
101
- <p>Loading your audit trail...this may take a while for longer time ranges</p>
102
- </div>
103
- )}
104
-
105
- {error && (
106
- <div className={styles.error}>
107
- <p>Error: {error}</p>
108
- <button onClick={loadAuditData} className={styles.retryButton}>
109
- Retry
110
- </button>
111
- </div>
112
- )}
113
-
114
- {!loading && !error && (
115
- <>
116
- {isArchivedReadOnlyCase && (
117
- <div className={bundledAuditWarning ? styles.archivedWarning : styles.archivedNotice}>
118
- <p>
119
- {bundledAuditWarning || 'Viewing bundled audit trail data from this imported archived case package.'}
120
- </p>
121
- </div>
122
- )}
123
-
124
- {/* User Information Section */}
125
- {user && (
126
- <AuditUserInfoCard user={user} userData={userData} userBadgeId={userBadgeId} />
127
- )}
128
-
129
- {/* Summary Section */}
130
- <AuditActivitySummary
131
- caseNumber={caseNumber}
132
- filterCaseNumber={filterCaseNumber}
133
- dateRangeDisplay={dateRangeDisplay}
134
- summary={auditSummary}
135
- />
136
-
137
- {/* Filters */}
138
- <AuditFiltersPanel
139
- dateRange={dateRange}
140
- customStartDate={customStartDate}
141
- customEndDate={customEndDate}
142
- customStartDateInput={customStartDateInput}
143
- customEndDateInput={customEndDateInput}
144
- caseNumber={caseNumber}
145
- filterCaseNumber={filterCaseNumber}
146
- caseNumberInput={caseNumberInput}
147
- filterBadgeId={filterBadgeId}
148
- badgeIdInput={badgeIdInput}
149
- filterAction={filterAction}
150
- filterResult={filterResult}
151
- onDateRangeChange={handleDateRangeChange}
152
- onCustomStartDateInputChange={setCustomStartDateInput}
153
- onCustomEndDateInputChange={setCustomEndDateInput}
154
- onApplyCustomDateRange={handleApplyCustomDateRange}
155
- onClearCustomDateRange={handleClearCustomDateRange}
156
- onCaseNumberInputChange={setCaseNumberInput}
157
- onApplyCaseFilter={handleApplyCaseFilter}
158
- onClearCaseFilter={handleClearCaseFilter}
159
- onBadgeIdInputChange={setBadgeIdInput}
160
- onApplyBadgeFilter={handleApplyBadgeFilter}
161
- onClearBadgeFilter={handleClearBadgeFilter}
162
- onFilterActionChange={setFilterAction}
163
- onFilterResultChange={setFilterResult}
164
- />
165
-
166
- {/* Entries List */}
167
- <AuditEntriesList entries={filteredEntries} />
168
-
169
- </>
170
- )}
171
-
172
- {auditEntries.length === 0 && !loading && !error && (
173
- <div className={styles.noData}>
174
- <p>
175
- {isArchivedReadOnlyCase
176
- ? 'No bundled audit trail entries are available for this imported archived case.'
177
- : 'No audit trail available. Your activities will appear here as you use Striae.'}
178
- </p>
179
- </div>
180
- )}
126
+ <>
127
+ <Toast
128
+ message={toastMessage}
129
+ type={toastType}
130
+ isVisible={showToast}
131
+ onClose={() => setShowToast(false)}
132
+ duration={toastDuration}
133
+ />
134
+ <div
135
+ className={styles.overlay}
136
+ aria-label="Close audit trail dialog"
137
+ {...overlayProps}
138
+ >
139
+ <div className={styles.modal}>
140
+ <AuditViewerHeader
141
+ title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
142
+ onClose={requestClose}
143
+ onExportPdf={isCaseScopedViewer ? () => void handleExportPdf() : undefined}
144
+ canExportPdf={isCaseScopedViewer && auditEntries.length > 0 && !loading && !error}
145
+ isExportingPdf={isExportingPdf}
146
+ />
147
+
148
+ <div className={styles.content}>
149
+ {loading && (
150
+ <div className={styles.loading}>
151
+ <div className={styles.spinner}></div>
152
+ <p>Loading your audit trail...this may take a while for longer time ranges</p>
153
+ </div>
154
+ )}
155
+
156
+ {error && (
157
+ <div className={styles.error}>
158
+ <p>Error: {error}</p>
159
+ <button onClick={loadAuditData} className={styles.retryButton}>
160
+ Retry
161
+ </button>
162
+ </div>
163
+ )}
164
+
165
+ {!loading && !error && (
166
+ <>
167
+ {isArchivedReadOnlyCase && (
168
+ <div className={bundledAuditWarning ? styles.archivedWarning : styles.archivedNotice}>
169
+ <p>
170
+ {bundledAuditWarning || 'Viewing bundled audit trail data from this imported archived case package.'}
171
+ </p>
172
+ </div>
173
+ )}
174
+
175
+ {/* User Information Section */}
176
+ {user && (
177
+ <AuditUserInfoCard user={user} userData={userData} userBadgeId={userBadgeId} />
178
+ )}
179
+
180
+ {/* Summary Section */}
181
+ <AuditActivitySummary
182
+ caseNumber={caseNumber}
183
+ filterCaseNumber={filterCaseNumber}
184
+ dateRangeDisplay={dateRangeDisplay}
185
+ summary={auditSummary}
186
+ />
187
+
188
+ {isCaseScopedViewer && (
189
+ <div className={styles.exportScopeNote}>
190
+ Export PDF always includes full case history from case creation through now, regardless of current filters.
191
+ </div>
192
+ )}
193
+
194
+ {/* Filters */}
195
+ <AuditFiltersPanel
196
+ dateRange={dateRange}
197
+ customStartDate={customStartDate}
198
+ customEndDate={customEndDate}
199
+ customStartDateInput={customStartDateInput}
200
+ customEndDateInput={customEndDateInput}
201
+ caseNumber={caseNumber}
202
+ filterCaseNumber={filterCaseNumber}
203
+ caseNumberInput={caseNumberInput}
204
+ filterBadgeId={filterBadgeId}
205
+ badgeIdInput={badgeIdInput}
206
+ filterAction={filterAction}
207
+ filterResult={filterResult}
208
+ onDateRangeChange={handleDateRangeChange}
209
+ onCustomStartDateInputChange={setCustomStartDateInput}
210
+ onCustomEndDateInputChange={setCustomEndDateInput}
211
+ onApplyCustomDateRange={handleApplyCustomDateRange}
212
+ onClearCustomDateRange={handleClearCustomDateRange}
213
+ onCaseNumberInputChange={setCaseNumberInput}
214
+ onApplyCaseFilter={handleApplyCaseFilter}
215
+ onClearCaseFilter={handleClearCaseFilter}
216
+ onBadgeIdInputChange={setBadgeIdInput}
217
+ onApplyBadgeFilter={handleApplyBadgeFilter}
218
+ onClearBadgeFilter={handleClearBadgeFilter}
219
+ onFilterActionChange={setFilterAction}
220
+ onFilterResultChange={setFilterResult}
221
+ />
222
+
223
+ {/* Entries List */}
224
+ <AuditEntriesList entries={filteredEntries} />
225
+
226
+ </>
227
+ )}
228
+
229
+ {auditEntries.length === 0 && !loading && !error && (
230
+ <div className={styles.noData}>
231
+ <p>
232
+ {isArchivedReadOnlyCase
233
+ ? 'No bundled audit trail entries are available for this imported archived case.'
234
+ : 'No audit trail available. Your activities will appear here as you use Striae.'}
235
+ </p>
236
+ </div>
237
+ )}
238
+ </div>
181
239
  </div>
182
240
  </div>
183
- </div>
241
+ </>
184
242
  );
185
243
  };
@@ -72,6 +72,12 @@
72
72
  transform: translateY(0);
73
73
  }
74
74
 
75
+ .exportButton:disabled {
76
+ opacity: 0.65;
77
+ cursor: not-allowed;
78
+ box-shadow: none;
79
+ }
80
+
75
81
  .title {
76
82
  margin: 0;
77
83
  color: var(--textTitle);
@@ -178,6 +184,17 @@
178
184
  border: 1px solid color-mix(in lab, var(--textLight) 20%, transparent);
179
185
  }
180
186
 
187
+ .exportScopeNote {
188
+ margin: -12px 0 16px;
189
+ padding: 10px 12px;
190
+ border-radius: 6px;
191
+ border: 1px solid color-mix(in lab, var(--primary) 30%, transparent);
192
+ background: color-mix(in lab, var(--primary) 10%, transparent);
193
+ color: var(--textBody);
194
+ font-size: 0.85rem;
195
+ line-height: 1.45;
196
+ }
197
+
181
198
  /* Common heading styles */
182
199
  .summary h3,
183
200
  .entriesList h3 {
@@ -3,16 +3,32 @@ import styles from '../user-audit.module.css';
3
3
  interface AuditViewerHeaderProps {
4
4
  title: string;
5
5
  onClose: () => void;
6
+ onExportPdf?: () => void;
7
+ canExportPdf?: boolean;
8
+ isExportingPdf?: boolean;
6
9
  }
7
10
 
8
11
  export const AuditViewerHeader = ({
9
12
  title,
10
13
  onClose,
14
+ onExportPdf,
15
+ canExportPdf = false,
16
+ isExportingPdf = false,
11
17
  }: AuditViewerHeaderProps) => {
12
18
  return (
13
19
  <div className={styles.header}>
14
20
  <h2 className={styles.title}>{title}</h2>
15
21
  <div className={styles.headerActions}>
22
+ {onExportPdf && (
23
+ <button
24
+ type="button"
25
+ className={styles.exportButton}
26
+ onClick={onExportPdf}
27
+ disabled={!canExportPdf || isExportingPdf}
28
+ >
29
+ {isExportingPdf ? 'Exporting PDF...' : 'Export PDF'}
30
+ </button>
31
+ )}
16
32
  <button className={styles.closeButton} onClick={onClose}>
17
33
  ×
18
34
  </button>
@@ -730,7 +730,13 @@ textarea:focus {
730
730
  position: sticky;
731
731
  bottom: 0;
732
732
  padding: 0.75rem 0 0.25rem;
733
- background: linear-gradient(to top, white 72%, rgba(255, 255, 255, 0));
733
+ background: linear-gradient(
734
+ to bottom,
735
+ rgba(255, 255, 255, 0),
736
+ white 28%,
737
+ white 72%,
738
+ rgba(255, 255, 255, 0)
739
+ );
734
740
  }
735
741
 
736
742
  .saveButton:hover {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "5.4.5",
3
+ "version": "5.5.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -115,8 +115,8 @@
115
115
  "@types/qrcode": "^1.5.6",
116
116
  "@types/react": "^19.2.14",
117
117
  "@types/react-dom": "^19.2.3",
118
- "@typescript-eslint/eslint-plugin": "^8.58.0",
119
- "@typescript-eslint/parser": "^8.58.0",
118
+ "@typescript-eslint/eslint-plugin": "^8.58.1",
119
+ "@typescript-eslint/parser": "^8.58.1",
120
120
  "eslint": "^9.39.4",
121
121
  "eslint-import-resolver-typescript": "^4.4.4",
122
122
  "eslint-plugin-import": "^2.32.0",
@@ -126,9 +126,9 @@
126
126
  "firebase-admin": "^13.7.0",
127
127
  "modern-normalize": "^3.0.1",
128
128
  "typescript": "^5.9.3",
129
- "vite": "^6.4.1",
129
+ "vite": "^7.3.2",
130
130
  "vite-tsconfig-paths": "^6.1.1",
131
- "wrangler": "^4.80.0"
131
+ "wrangler": "^4.81.0"
132
132
  },
133
133
  "overrides": {
134
134
  "@tootallnate/once": "3.0.1",
@@ -136,7 +136,7 @@
136
136
  "undici": "7.24.1"
137
137
  },
138
138
  "engines": {
139
- "node": ">=20.0.0"
139
+ "node": ">=20.19.0"
140
140
  },
141
141
  "packageManager": "npm@11.11.0"
142
142
  }
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.80.0"
12
+ "wrangler": "^4.81.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -7,7 +7,7 @@
7
7
  "name": "AUDIT_WORKER_NAME",
8
8
  "account_id": "ACCOUNT_ID",
9
9
  "main": "src/audit-worker.ts",
10
- "compatibility_date": "2026-04-03",
10
+ "compatibility_date": "2026-04-08",
11
11
  "compatibility_flags": [
12
12
  "nodejs_compat"
13
13
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.80.0"
12
+ "wrangler": "^4.81.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -5,7 +5,7 @@
5
5
  "name": "DATA_WORKER_NAME",
6
6
  "account_id": "ACCOUNT_ID",
7
7
  "main": "src/data-worker.ts",
8
- "compatibility_date": "2026-04-03",
8
+ "compatibility_date": "2026-04-08",
9
9
  "compatibility_flags": [
10
10
  "nodejs_compat"
11
11
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.80.0"
12
+ "wrangler": "^4.81.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-04-03",
5
+ "compatibility_date": "2026-04-08",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "devDependencies": {
12
12
  "@cloudflare/puppeteer": "^1.0.6",
13
- "wrangler": "^4.80.0"
13
+ "wrangler": "^4.81.0"
14
14
  },
15
15
  "overrides": {
16
16
  "undici": "7.24.1",
@@ -0,0 +1,231 @@
1
+ import type { AuditTrailReportPayload, PDFGenerationData, ReportPdfOptions } from './report-types';
2
+ import { buildRepeatedChromePdfOptions, escapeHtml } from './report-layout';
3
+
4
+ const safeText = (value: unknown): string => escapeHtml(String(value ?? ''));
5
+
6
+ const formatTimestamp = (timestamp: string): string => {
7
+ const parsed = new Date(timestamp);
8
+ if (Number.isNaN(parsed.getTime())) {
9
+ return timestamp;
10
+ }
11
+
12
+ return parsed.toISOString();
13
+ };
14
+
15
+ const renderEntryDetailsSummary = (entry: Record<string, unknown>): string => {
16
+ const details = (entry.details ?? {}) as Record<string, unknown>;
17
+ const caseNumber = typeof details.caseNumber === 'string' ? details.caseNumber : '';
18
+ const workflowPhase = typeof details.workflowPhase === 'string' ? details.workflowPhase : '';
19
+ const fileName = typeof details.fileName === 'string' ? details.fileName : '';
20
+
21
+ const summaryRows = [
22
+ caseNumber ? `<div><strong>Case:</strong> ${safeText(caseNumber)}</div>` : '',
23
+ workflowPhase ? `<div><strong>Phase:</strong> ${safeText(workflowPhase)}</div>` : '',
24
+ fileName ? `<div><strong>File:</strong> ${safeText(fileName)}</div>` : ''
25
+ ].filter(Boolean);
26
+
27
+ if (summaryRows.length === 0) {
28
+ return '<div class="entry-meta-muted">No structured summary fields available.</div>';
29
+ }
30
+
31
+ return summaryRows.join('');
32
+ };
33
+
34
+ const renderRawJsonAppendix = (entry: unknown): string => {
35
+ if (entry === null || entry === undefined) {
36
+ return '';
37
+ }
38
+
39
+ let rawJson: string;
40
+ try {
41
+ rawJson = JSON.stringify(entry, null, 2) ?? '';
42
+ } catch {
43
+ return '';
44
+ }
45
+
46
+ if (!rawJson) {
47
+ return '';
48
+ }
49
+
50
+ return `
51
+ <div class="entry-raw-json">
52
+ <div class="entry-raw-label">Raw JSON Entry</div>
53
+ <pre>${escapeHtml(rawJson)}</pre>
54
+ </div>
55
+ `;
56
+ };
57
+
58
+ export const isAuditTrailReportMode = (data: PDFGenerationData): boolean =>
59
+ data.reportMode === 'audit-trail';
60
+
61
+ export const getAuditTrailPayload = (data: PDFGenerationData): AuditTrailReportPayload => {
62
+ const payload = data.auditTrailReport;
63
+
64
+ if (!payload) {
65
+ throw new Error('Audit trail report payload is required when reportMode is audit-trail');
66
+ }
67
+
68
+ return payload;
69
+ };
70
+
71
+ export const renderAuditTrailReport = (data: PDFGenerationData): string => {
72
+ const payload = getAuditTrailPayload(data);
73
+ const entries = payload.entries || [];
74
+
75
+ const entrySections = entries.map((entry, index) => {
76
+ const entryRecord = entry as Record<string, unknown>;
77
+ const timestamp = typeof entryRecord.timestamp === 'string' ? entryRecord.timestamp : 'unknown';
78
+ const action = typeof entryRecord.action === 'string' ? entryRecord.action : 'unknown';
79
+ const result = typeof entryRecord.result === 'string' ? entryRecord.result : 'unknown';
80
+ const userEmail = typeof entryRecord.userEmail === 'string' ? entryRecord.userEmail : 'unknown';
81
+ const userId = typeof entryRecord.userId === 'string' ? entryRecord.userId : 'unknown';
82
+
83
+ return `
84
+ <section class="entry-section">
85
+ <h3 class="entry-title">Entry ${index + 1} of ${entries.length}</h3>
86
+ <div class="entry-core-grid">
87
+ <div><strong>Timestamp:</strong> ${safeText(formatTimestamp(timestamp))}</div>
88
+ <div><strong>Action:</strong> ${safeText(action)}</div>
89
+ <div><strong>Result:</strong> ${safeText(result)}</div>
90
+ <div><strong>User Email:</strong> ${safeText(userEmail)}</div>
91
+ <div><strong>User ID:</strong> ${safeText(userId)}</div>
92
+ </div>
93
+ <div class="entry-meta">
94
+ ${renderEntryDetailsSummary(entryRecord)}
95
+ </div>
96
+ ${renderRawJsonAppendix(entry)}
97
+ </section>
98
+ `;
99
+ }).join('');
100
+
101
+ return `
102
+ <!DOCTYPE html>
103
+ <html lang="en">
104
+ <head>
105
+ <meta charset="utf-8" />
106
+ <style>
107
+ html, body {
108
+ margin: 0;
109
+ padding: 0;
110
+ width: 100%;
111
+ font-family: Arial, sans-serif;
112
+ color: #1f2933;
113
+ background: #ffffff;
114
+ }
115
+ body {
116
+ box-sizing: border-box;
117
+ }
118
+ .report-body {
119
+ width: 100%;
120
+ box-sizing: border-box;
121
+ }
122
+ .summary {
123
+ border: 1px solid #d0d7de;
124
+ border-radius: 8px;
125
+ padding: 16px;
126
+ margin-bottom: 16px;
127
+ page-break-inside: avoid;
128
+ }
129
+ .summary h1 {
130
+ margin: 0 0 8px;
131
+ font-size: 22px;
132
+ }
133
+ .summary-grid {
134
+ display: grid;
135
+ grid-template-columns: repeat(2, minmax(0, 1fr));
136
+ gap: 6px 12px;
137
+ font-size: 12px;
138
+ }
139
+ .entry-section {
140
+ border: 1px solid #d0d7de;
141
+ border-radius: 8px;
142
+ padding: 12px;
143
+ margin-bottom: 12px;
144
+ page-break-inside: avoid;
145
+ }
146
+ .entry-title {
147
+ margin: 0 0 8px;
148
+ font-size: 14px;
149
+ }
150
+ .entry-core-grid {
151
+ display: grid;
152
+ grid-template-columns: repeat(2, minmax(0, 1fr));
153
+ gap: 6px 12px;
154
+ font-size: 11px;
155
+ }
156
+ .entry-meta {
157
+ margin-top: 10px;
158
+ font-size: 11px;
159
+ color: #334155;
160
+ }
161
+ .entry-meta-muted {
162
+ color: #64748b;
163
+ font-style: italic;
164
+ }
165
+ .entry-raw-json {
166
+ margin-top: 12px;
167
+ border-top: 1px dashed #c5ced8;
168
+ padding-top: 10px;
169
+ }
170
+ .entry-raw-label {
171
+ font-size: 10px;
172
+ letter-spacing: 0.05em;
173
+ text-transform: uppercase;
174
+ color: #475569;
175
+ margin-bottom: 6px;
176
+ font-weight: 700;
177
+ }
178
+ .entry-raw-json pre {
179
+ margin: 0;
180
+ padding: 10px;
181
+ border: 1px solid #d0d7de;
182
+ border-radius: 6px;
183
+ background: #f8fafc;
184
+ color: #0f172a;
185
+ font-size: 10px;
186
+ line-height: 1.5;
187
+ white-space: pre-wrap;
188
+ word-break: break-word;
189
+ }
190
+ </style>
191
+ </head>
192
+ <body>
193
+ <div class="report-body">
194
+ <section class="summary">
195
+ <h1>Case Audit Trail Report</h1>
196
+ <div class="summary-grid">
197
+ <div><strong>Case Number:</strong> ${safeText(payload.caseNumber)}</div>
198
+ <div><strong>Exported At:</strong> ${safeText(formatTimestamp(payload.exportedAt))}</div>
199
+ <div><strong>Range Start:</strong> ${safeText(formatTimestamp(payload.exportRangeStart))}</div>
200
+ <div><strong>Range End:</strong> ${safeText(formatTimestamp(payload.exportRangeEnd))}</div>
201
+ <div><strong>Total Entries (All Parts):</strong> ${safeText(payload.totalEntries)}</div>
202
+ <div><strong>This Part:</strong> ${safeText(payload.chunkIndex)} of ${safeText(payload.totalChunks)}</div>
203
+ <div><strong>Entries in Part:</strong> ${safeText(entries.length)}</div>
204
+ <div><strong>Raw JSON Appendix:</strong> Included when available</div>
205
+ </div>
206
+ </section>
207
+ ${entrySections}
208
+ </div>
209
+ </body>
210
+ </html>
211
+ `;
212
+ };
213
+
214
+ export const getAuditTrailPdfOptions = (data: PDFGenerationData): Partial<ReportPdfOptions> => {
215
+ const payload = getAuditTrailPayload(data);
216
+
217
+ return {
218
+ format: 'letter',
219
+ ...buildRepeatedChromePdfOptions({
220
+ headerLeft: data.currentDate,
221
+ headerCenter: 'Case Audit Trail Report',
222
+ headerRight: `Case ${payload.caseNumber}`,
223
+ headerDetailLeft: `Entries ${payload.totalEntries}`,
224
+ headerDetailRight: `Part ${payload.chunkIndex}/${payload.totalChunks}`,
225
+ footerLeft: 'Striae Audit Export',
226
+ footerCenter: payload.exportedAt,
227
+ footerRight: `Case ${payload.caseNumber}`,
228
+ includePageNumbers: true
229
+ })
230
+ };
231
+ };
@@ -480,17 +480,19 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
480
480
  `;
481
481
  };
482
482
 
483
- export const getPdfOptions: ReportPdfOptionsBuilder = (data: PDFGenerationData) => buildRepeatedChromePdfOptions({
484
- headerLeft: data.currentDate,
485
- headerRight: data.caseNumber,
486
- headerDetailLeft: [data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')
487
- ? `Left Case / Item: ${[data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')}`
488
- : undefined,
489
- headerDetailRight: [data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')
490
- ? `Right Case / Item: ${[data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')}`
491
- : undefined,
492
- footerLeft: 'Notes formatted by Striae',
493
- footerCenter: data.userCompany,
494
- footerRight: data.notesUpdatedFormatted ? `Notes updated ${data.notesUpdatedFormatted}` : undefined,
495
- footerLeftImageSrc: ICON_256,
496
- });
483
+ export const getPdfOptions: ReportPdfOptionsBuilder = (data: PDFGenerationData) => {
484
+ return buildRepeatedChromePdfOptions({
485
+ headerLeft: data.currentDate,
486
+ headerRight: data.caseNumber,
487
+ headerDetailLeft: [data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')
488
+ ? `Left Case / Item: ${[data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')}`
489
+ : undefined,
490
+ headerDetailRight: [data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')
491
+ ? `Right Case / Item: ${[data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')}`
492
+ : undefined,
493
+ footerLeft: 'Notes formatted by Striae',
494
+ footerCenter: data.userCompany,
495
+ footerRight: data.notesUpdatedFormatted ? `Notes updated ${data.notesUpdatedFormatted}` : undefined,
496
+ footerLeftImageSrc: ICON_256,
497
+ });
498
+ };
@@ -1,4 +1,5 @@
1
1
  import type { PDFGenerationData, PDFGenerationRequest, ReportModule, ReportPdfOptions } from './report-types';
2
+ import { getAuditTrailPdfOptions, isAuditTrailReportMode, renderAuditTrailReport } from './audit-trail-report';
2
3
 
3
4
  interface Env {
4
5
  PDF_WORKER_AUTH: string;
@@ -112,6 +113,13 @@ function resolveReportRequest(payload: unknown): PDFGenerationRequest {
112
113
  }
113
114
 
114
115
  async function renderReport(reportFormat: string, data: PDFGenerationData): Promise<{ html: string; pdfOptions: ReportPdfOptions }> {
116
+ if (isAuditTrailReportMode(data)) {
117
+ return {
118
+ html: renderAuditTrailReport(data),
119
+ pdfOptions: resolvePdfOptions(getAuditTrailPdfOptions(data)),
120
+ };
121
+ }
122
+
115
123
  const loader = reportModuleLoaders[reportFormat];
116
124
 
117
125
  if (!loader) {
@@ -58,6 +58,20 @@ export interface PDFGenerationData {
58
58
  userFirstName?: string;
59
59
  userLastName?: string;
60
60
  userBadgeId?: string;
61
+ reportMode?: 'audit-trail';
62
+ auditTrailReport?: AuditTrailReportPayload;
63
+ }
64
+
65
+ export interface AuditTrailReportPayload {
66
+ caseNumber: string;
67
+ exportedAt: string;
68
+ exportRangeStart: string;
69
+ exportRangeEnd: string;
70
+ chunkIndex: number;
71
+ totalChunks: number;
72
+ totalEntries: number;
73
+ includeRawJsonAppendix: boolean;
74
+ entries: unknown[];
61
75
  }
62
76
 
63
77
  export interface PDFGenerationRequest {
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-04-03",
5
+ "compatibility_date": "2026-04-08",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@cloudflare/puppeteer": "^1.0.6",
12
- "wrangler": "^4.80.0"
12
+ "wrangler": "^4.81.0"
13
13
  },
14
14
  "overrides": {
15
15
  "undici": "7.24.1",
@@ -2,7 +2,7 @@
2
2
  "name": "USER_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/user-worker.ts",
5
- "compatibility_date": "2026-04-03",
5
+ "compatibility_date": "2026-04-08",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -1,6 +1,6 @@
1
1
  #:schema node_modules/wrangler/config-schema.json
2
2
  name = "PAGES_PROJECT_NAME"
3
- compatibility_date = "2026-04-03"
3
+ compatibility_date = "2026-04-08"
4
4
  compatibility_flags = ["nodejs_compat"]
5
5
  pages_build_output_dir = "./build/client"
6
6