@striae-org/striae 5.4.5 → 5.5.1

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,438 @@
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 = normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso;
291
+
292
+ const chunks = chunkEntries(sortedEntries);
293
+ const totalChunks = chunks.length;
294
+ const exportedAtIso = nowIso;
295
+ const currentDate = formatShortDate(now);
296
+
297
+ let successfulParts = 0;
298
+ const failedParts: number[] = [];
299
+
300
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
301
+ const partNumber = chunkIndex + 1;
302
+ const chunkEntriesForPart = chunks[chunkIndex];
303
+ const filename = buildPartFilename(caseNumber, now, partNumber, totalChunks);
304
+ const partStartTime = Date.now();
305
+
306
+ const pdfData = {
307
+ reportMode: 'audit-trail' as const,
308
+ currentDate,
309
+ caseNumber,
310
+ userCompany,
311
+ userFirstName,
312
+ userLastName,
313
+ userBadgeId,
314
+ userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
315
+ auditTrailReport: buildAuditTrailPayload(
316
+ caseNumber,
317
+ chunkEntriesForPart,
318
+ exportRangeStartIso,
319
+ exportRangeEndIso,
320
+ partNumber,
321
+ totalChunks,
322
+ sortedEntries.length,
323
+ exportedAtIso
324
+ )
325
+ };
326
+
327
+ setToastType('loading');
328
+ setToastMessage(`Generating audit PDF part ${partNumber} of ${totalChunks}...`);
329
+ setShowToast(true);
330
+
331
+ try {
332
+ const response = await fetchPdfApi(user, '/', {
333
+ method: 'POST',
334
+ headers: {
335
+ 'Content-Type': 'application/json'
336
+ },
337
+ body: JSON.stringify({ data: pdfData })
338
+ });
339
+
340
+ if (!response.ok) {
341
+ const errorMessage = await extractErrorMessage(response);
342
+ throw new Error(errorMessage);
343
+ }
344
+
345
+ const blob = await response.blob();
346
+ downloadPdfBlob(blob, filename);
347
+ successfulParts += 1;
348
+
349
+ try {
350
+ await auditService.logPDFGeneration(
351
+ user,
352
+ filename,
353
+ caseNumber,
354
+ 'success',
355
+ Date.now() - partStartTime,
356
+ blob.size,
357
+ [],
358
+ `audit-trail:${caseNumber}:part-${partNumber}`,
359
+ `audit-trail-${caseNumber}`
360
+ );
361
+ } catch (auditError) {
362
+ console.error('Failed to log audit PDF generation success:', auditError);
363
+ }
364
+ } catch (error) {
365
+ failedParts.push(partNumber);
366
+
367
+ try {
368
+ await auditService.logPDFGeneration(
369
+ user,
370
+ filename,
371
+ caseNumber,
372
+ 'failure',
373
+ Date.now() - partStartTime,
374
+ 0,
375
+ [error instanceof Error ? error.message : 'Unknown PDF generation error'],
376
+ `audit-trail:${caseNumber}:part-${partNumber}`,
377
+ `audit-trail-${caseNumber}`
378
+ );
379
+ } catch (auditError) {
380
+ console.error('Failed to log audit PDF generation failure:', auditError);
381
+ }
382
+ }
383
+ }
384
+
385
+ if (failedParts.length === 0) {
386
+ setToastType('success');
387
+ setToastMessage(
388
+ successfulParts > 1
389
+ ? `Exported ${successfulParts} audit PDF parts for case ${caseNumber}.`
390
+ : `Exported audit PDF for case ${caseNumber}.`
391
+ );
392
+ if (setToastDuration) {
393
+ setToastDuration(6000);
394
+ }
395
+ setShowToast(true);
396
+ return;
397
+ }
398
+
399
+ const failedPartLabel = failedParts.join(', ');
400
+ setToastType('warning');
401
+ setToastMessage(
402
+ successfulParts > 0
403
+ ? `Export completed with issues. Successful parts: ${successfulParts}/${totalChunks}. Failed parts: ${failedPartLabel}.`
404
+ : `Audit PDF export failed. Failed parts: ${failedPartLabel}.`
405
+ );
406
+ if (setToastDuration) {
407
+ setToastDuration(9000);
408
+ }
409
+ setShowToast(true);
410
+ } catch (error) {
411
+ const processingTime = Date.now() - exportStartTime;
412
+
413
+ try {
414
+ await auditService.logPDFGeneration(
415
+ user,
416
+ `audit-trail-${caseNumber}-failed-${Date.now()}.pdf`,
417
+ caseNumber,
418
+ 'failure',
419
+ processingTime,
420
+ 0,
421
+ [error instanceof Error ? error.message : 'Unknown audit PDF export error'],
422
+ `audit-trail:${caseNumber}`,
423
+ `audit-trail-${caseNumber}`
424
+ );
425
+ } catch (auditError) {
426
+ console.error('Failed to log audit PDF export error:', auditError);
427
+ }
428
+
429
+ setToastType('error');
430
+ setToastMessage(error instanceof Error ? error.message : 'Failed to export case audit trail PDF.');
431
+ if (setToastDuration) {
432
+ setToastDuration(7000);
433
+ }
434
+ setShowToast(true);
435
+ } finally {
436
+ setIsExportingPDF(false);
437
+ }
438
+ };