@striae-org/striae 5.5.2 → 6.0.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 (34) hide show
  1. package/app/components/actions/case-export/download-handlers.ts +130 -62
  2. package/app/components/actions/case-manage/archive-package-builder.ts +299 -0
  3. package/app/components/actions/case-manage/delete-helpers.ts +61 -0
  4. package/app/components/actions/case-manage/index.ts +2 -0
  5. package/app/components/actions/case-manage/operations.ts +714 -0
  6. package/app/components/actions/case-manage/types.ts +21 -0
  7. package/app/components/actions/case-manage/utils.ts +34 -0
  8. package/app/components/actions/case-manage.ts +1 -1079
  9. package/app/components/navbar/case-import/case-import.module.css +2 -2
  10. package/app/components/navbar/case-import/case-import.tsx +0 -8
  11. package/app/components/navbar/case-import/components/CasePreviewSection.tsx +1 -1
  12. package/app/components/navbar/case-modals/all-cases-modal.tsx +13 -1
  13. package/app/components/navbar/navbar.tsx +8 -5
  14. package/app/components/sidebar/cases/case-sidebar.tsx +3 -2
  15. package/app/routes/striae/striae.tsx +36 -11
  16. package/app/types/export.ts +1 -0
  17. package/app/utils/forensics/SHA256.ts +2 -2
  18. package/app/utils/forensics/audit-export-signature.ts +1 -1
  19. package/app/utils/forensics/confirmation-signature.ts +1 -1
  20. package/app/utils/forensics/signature-utils.ts +7 -2
  21. package/package.json +1 -1
  22. package/workers/audit-worker/package.json +1 -1
  23. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  24. package/workers/data-worker/package.json +1 -1
  25. package/workers/data-worker/src/signature-utils.ts +7 -2
  26. package/workers/data-worker/src/signing-payload-utils.ts +4 -4
  27. package/workers/data-worker/wrangler.jsonc.example +1 -1
  28. package/workers/image-worker/package.json +1 -1
  29. package/workers/image-worker/wrangler.jsonc.example +1 -1
  30. package/workers/pdf-worker/package.json +1 -1
  31. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  32. package/workers/user-worker/package.json +1 -1
  33. package/workers/user-worker/wrangler.jsonc.example +1 -1
  34. package/wrangler.toml.example +1 -1
@@ -0,0 +1,714 @@
1
+ import type { User } from 'firebase/auth';
2
+ import {
3
+ canCreateCase,
4
+ getUserCases,
5
+ getUserData,
6
+ validateUserSession,
7
+ addUserCase,
8
+ removeUserCase,
9
+ getCaseData,
10
+ updateCaseData,
11
+ deleteCaseData,
12
+ duplicateCaseData,
13
+ deleteFileAnnotations,
14
+ moveCaseConfirmationSummary,
15
+ removeCaseConfirmationSummary
16
+ } from '~/utils/data';
17
+ import { type CaseData, type CaseExportData, type ValidationAuditEntry } from '~/types';
18
+ import { auditService } from '~/services/audit';
19
+ import { exportCaseData, formatDateForFilename } from '~/components/actions/case-export';
20
+ import { buildArchivePackage } from './archive-package-builder';
21
+ import { deleteFileWithoutAudit } from './delete-helpers';
22
+ import { isReadOnlyCaseData, sortCaseNumbers, validateCaseNumber } from './utils';
23
+ import { type CaseArchiveDetails, type DeleteCaseResult } from './types';
24
+
25
+ /**
26
+ * Delete a file without individual audit logging (for bulk operations)
27
+ * This reduces API calls during bulk deletions
28
+ */
29
+ export type { DeleteCaseResult, CaseArchiveDetails };
30
+ export { validateCaseNumber };
31
+
32
+ export const listCases = async (user: User): Promise<string[]> => {
33
+ try {
34
+ // Use centralized function to get user cases
35
+ const userCases = await getUserCases(user);
36
+ const caseNumbers = userCases.map(c => c.caseNumber);
37
+ return sortCaseNumbers(caseNumbers);
38
+
39
+ } catch (error) {
40
+ console.error('Error listing cases:', error);
41
+ return [];
42
+ }
43
+ };
44
+
45
+ export const checkExistingCase = async (user: User, caseNumber: string): Promise<CaseData | null> => {
46
+ try {
47
+ // Try to get case data - if user doesn't have access, it means case doesn't exist for them
48
+ const caseData = await getCaseData(user, caseNumber);
49
+
50
+ if (!caseData) {
51
+ return null;
52
+ }
53
+
54
+ // Imported review cases are read-only and should not be treated as regular cases.
55
+ // Archived cases remain regular case records even if legacy data includes isReadOnly.
56
+ if ('isReadOnly' in caseData && caseData.isReadOnly && !caseData.archived) {
57
+ return null;
58
+ }
59
+
60
+ // Verify the case number matches (extra safety check)
61
+ if (caseData.caseNumber === caseNumber) {
62
+ return caseData;
63
+ }
64
+
65
+ return null;
66
+
67
+ } catch (error) {
68
+ // If access denied, treat as case doesn't exist for this user
69
+ if (error instanceof Error && error.message.includes('Access denied')) {
70
+ return null;
71
+ }
72
+ console.error('Error checking existing case:', error);
73
+ return null;
74
+ }
75
+ };
76
+
77
+ export const checkCaseIsReadOnly = async (user: User, caseNumber: string): Promise<boolean> => {
78
+ try {
79
+ const caseData = await getCaseData(user, caseNumber);
80
+ if (!caseData) {
81
+ // Case doesn't exist, so it's not read-only
82
+ return false;
83
+ }
84
+
85
+ // Use type guard to check for isReadOnly property safely
86
+ return isReadOnlyCaseData(caseData) ? !!caseData.isReadOnly : false;
87
+
88
+ } catch (error) {
89
+ console.error('Error checking if case is read-only:', error);
90
+ return false;
91
+ }
92
+ };
93
+
94
+ export const getCaseArchiveDetails = async (user: User, caseNumber: string): Promise<CaseArchiveDetails> => {
95
+ try {
96
+ const caseData = await getCaseData(user, caseNumber);
97
+ if (!caseData || !caseData.archived) {
98
+ return { archived: false };
99
+ }
100
+
101
+ return {
102
+ archived: true,
103
+ archivedAt: caseData.archivedAt,
104
+ archivedBy: caseData.archivedBy,
105
+ archivedByDisplay: caseData.archivedByDisplay,
106
+ archiveReason: caseData.archiveReason,
107
+ };
108
+ } catch (error) {
109
+ console.error('Error checking case archive details:', error);
110
+ return { archived: false };
111
+ }
112
+ };
113
+
114
+ export const createNewCase = async (user: User, caseNumber: string): Promise<CaseData> => {
115
+ const startTime = Date.now();
116
+
117
+ try {
118
+ // Validate user session first
119
+ const sessionValidation = await validateUserSession(user);
120
+ if (!sessionValidation.valid) {
121
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
122
+ }
123
+
124
+ // Check if user can create a new case
125
+ const permission = await canCreateCase(user);
126
+ if (!permission.canCreate) {
127
+ throw new Error(permission.reason || 'You cannot create more cases.');
128
+ }
129
+
130
+ const newCase: CaseData = {
131
+ createdAt: new Date().toISOString(),
132
+ caseNumber,
133
+ files: []
134
+ };
135
+
136
+ const caseMetadata = {
137
+ createdAt: newCase.createdAt,
138
+ caseNumber: newCase.caseNumber
139
+ };
140
+
141
+ // Add case to user data first (so user has permission to create case data)
142
+ await addUserCase(user, caseMetadata);
143
+
144
+ // Create case file using centralized function
145
+ await updateCaseData(user, caseNumber, newCase);
146
+
147
+ // Log successful case creation
148
+ const endTime = Date.now();
149
+ await auditService.logCaseCreation(
150
+ user,
151
+ caseNumber,
152
+ caseNumber // Using case number as case name for now
153
+ );
154
+
155
+ console.log(`✅ Case created: ${caseNumber} (${endTime - startTime}ms)`);
156
+ return newCase;
157
+
158
+ } catch (error) {
159
+ // Log failed case creation
160
+ const endTime = Date.now();
161
+ try {
162
+ await auditService.logEvent({
163
+ userId: user.uid,
164
+ userEmail: user.email || '',
165
+ action: 'case-create',
166
+ result: 'failure',
167
+ fileName: `${caseNumber}.case`,
168
+ fileType: 'case-package',
169
+ validationErrors: [error instanceof Error ? error.message : 'Unknown error'],
170
+ caseNumber,
171
+ caseDetails: {
172
+ newCaseName: caseNumber
173
+ },
174
+ performanceMetrics: {
175
+ processingTimeMs: endTime - startTime,
176
+ fileSizeBytes: 0
177
+ }
178
+ });
179
+ } catch (auditError) {
180
+ console.error('Failed to log case creation failure:', auditError);
181
+ }
182
+
183
+ console.error('Error creating new case:', error);
184
+ throw error;
185
+ }
186
+ };
187
+
188
+ export const renameCase = async (
189
+ user: User,
190
+ oldCaseNumber: string,
191
+ newCaseNumber: string
192
+ ): Promise<void> => {
193
+ const startTime = Date.now();
194
+
195
+ try {
196
+ // Validate case numbers
197
+ if (!validateCaseNumber(oldCaseNumber) || !validateCaseNumber(newCaseNumber)) {
198
+ throw new Error('Invalid case number format');
199
+ }
200
+
201
+ // Check if new case exists
202
+ const existingCase = await checkExistingCase(user, newCaseNumber);
203
+ if (existingCase) {
204
+ throw new Error('New case number already exists');
205
+ }
206
+
207
+ // Get the old case data to find all files that need annotation cleanup
208
+ const oldCaseData = await getCaseData(user, oldCaseNumber);
209
+ if (!oldCaseData) {
210
+ throw new Error('Old case not found');
211
+ }
212
+
213
+ // 1) Create new case number in USER DB's entry (KV storage)
214
+ const newCaseMetadata = {
215
+ createdAt: new Date().toISOString(),
216
+ caseNumber: newCaseNumber
217
+ };
218
+ await addUserCase(user, newCaseMetadata);
219
+
220
+ // 2) Copy R2 case data from old case number to new case number in R2
221
+ await duplicateCaseData(user, oldCaseNumber, newCaseNumber);
222
+
223
+ // 3) Delete individual file annotations from the old case (before losing access)
224
+ if (oldCaseData.files && oldCaseData.files.length > 0) {
225
+ // Process annotation deletions in batches to avoid rate limiting
226
+ const BATCH_SIZE = 5;
227
+ const files = oldCaseData.files;
228
+
229
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
230
+ const batch = files.slice(i, i + BATCH_SIZE);
231
+
232
+ // Delete annotation files in this batch
233
+ await Promise.all(
234
+ batch.map(async file => {
235
+ try {
236
+ await deleteFileAnnotations(user, oldCaseNumber, file.id);
237
+ } catch (error) {
238
+ // Continue if annotation file doesn't exist or fails to delete
239
+ console.warn(`Failed to delete annotations for ${file.originalFilename}:`, error);
240
+ }
241
+ })
242
+ );
243
+
244
+ // Add delay between batches to reduce rate limiting
245
+ if (i + BATCH_SIZE < files.length) {
246
+ await new Promise(resolve => setTimeout(resolve, 150));
247
+ }
248
+ }
249
+ }
250
+
251
+ // 4) Delete R2 case data with old case number
252
+ await deleteCaseData(user, oldCaseNumber);
253
+
254
+ // 5) Move confirmation summary metadata to the new case number
255
+ await moveCaseConfirmationSummary(user, oldCaseNumber, newCaseNumber);
256
+
257
+ // 6) Delete old case number in user's KV entry
258
+ await removeUserCase(user, oldCaseNumber);
259
+
260
+ // Log successful case rename under the original case number context
261
+ const endTime = Date.now();
262
+ await auditService.logCaseRename(
263
+ user,
264
+ oldCaseNumber,
265
+ oldCaseNumber,
266
+ newCaseNumber
267
+ );
268
+
269
+ // Log creation of the new case number as a rename-derived case
270
+ await auditService.logCaseCreation(
271
+ user,
272
+ newCaseNumber,
273
+ newCaseNumber,
274
+ oldCaseNumber
275
+ );
276
+
277
+ console.log(`✅ Case renamed: ${oldCaseNumber} → ${newCaseNumber} (${endTime - startTime}ms)`);
278
+
279
+ } catch (error) {
280
+ // Log failed case rename
281
+ const endTime = Date.now();
282
+ try {
283
+ await auditService.logEvent({
284
+ userId: user.uid,
285
+ userEmail: user.email || '',
286
+ action: 'case-rename',
287
+ result: 'failure',
288
+ fileName: `${oldCaseNumber}.case`,
289
+ fileType: 'case-package',
290
+ validationErrors: [error instanceof Error ? error.message : 'Unknown error'],
291
+ caseNumber: oldCaseNumber,
292
+ caseDetails: {
293
+ oldCaseName: oldCaseNumber,
294
+ newCaseName: newCaseNumber,
295
+ lastModified: new Date().toISOString()
296
+ },
297
+ performanceMetrics: {
298
+ processingTimeMs: endTime - startTime,
299
+ fileSizeBytes: 0
300
+ }
301
+ });
302
+ } catch (auditError) {
303
+ console.error('Failed to log case rename failure:', auditError);
304
+ }
305
+
306
+ console.error('Error renaming case:', error);
307
+ throw error;
308
+ }
309
+ };
310
+
311
+ export const deleteCase = async (user: User, caseNumber: string): Promise<DeleteCaseResult> => {
312
+ const startTime = Date.now();
313
+
314
+ try {
315
+ if (!validateCaseNumber(caseNumber)) {
316
+ throw new Error('Invalid case number');
317
+ }
318
+
319
+ // Validate user session
320
+ const sessionValidation = await validateUserSession(user);
321
+ if (!sessionValidation.valid) {
322
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
323
+ }
324
+
325
+ // Get case data using centralized function
326
+ const caseData = await getCaseData(user, caseNumber);
327
+ if (!caseData) {
328
+ throw new Error('Case not found');
329
+ }
330
+
331
+ // Store case info for audit logging
332
+ const fileCount = caseData.files?.length || 0;
333
+ const caseName = caseData.caseNumber || caseNumber;
334
+
335
+ // Process file deletions in batches to reduce audit rate limiting
336
+ if (caseData.files && caseData.files.length > 0) {
337
+ const BATCH_SIZE = 3; // Reduced batch size for better stability
338
+ const BATCH_DELAY = 300; // Increased delay between batches
339
+ const files = caseData.files;
340
+ const deletedFiles: Array<{id: string, originalFilename: string, fileSize: number}> = [];
341
+ const failedFiles: Array<{id: string, originalFilename: string, error: string}> = [];
342
+ const missingImages: string[] = [];
343
+
344
+ console.log(`🗑️ Deleting ${files.length} files in batches of ${BATCH_SIZE}...`);
345
+
346
+ // Process files in batches
347
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
348
+ const batch = files.slice(i, i + BATCH_SIZE);
349
+ const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
350
+ const totalBatches = Math.ceil(files.length / BATCH_SIZE);
351
+
352
+ console.log(`📦 Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`);
353
+
354
+ // Delete files in this batch with individual error handling
355
+ await Promise.allSettled(
356
+ batch.map(async file => {
357
+ try {
358
+ // Delete file without individual audit logging to reduce API calls
359
+ // We'll do bulk audit logging at the end
360
+ const deleteResult = await deleteFileWithoutAudit(user, caseNumber, file.id, {
361
+ // Archived cases are immutable; during deletion we can skip per-file case-data mutations.
362
+ skipCaseDataUpdate: !!caseData.archived,
363
+ skipValidation: !!caseData.archived
364
+ });
365
+
366
+ if (deleteResult.imageMissing) {
367
+ missingImages.push(deleteResult.fileName);
368
+ }
369
+
370
+ deletedFiles.push({
371
+ id: file.id,
372
+ originalFilename: file.originalFilename,
373
+ fileSize: 0 // We don't track file size, use 0
374
+ });
375
+ } catch (error) {
376
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
377
+ console.error(`❌ Failed to delete file ${file.originalFilename}:`, errorMessage);
378
+ failedFiles.push({
379
+ id: file.id,
380
+ originalFilename: file.originalFilename,
381
+ error: errorMessage
382
+ });
383
+ }
384
+ })
385
+ );
386
+
387
+ // Add delay between batches to reduce rate limiting
388
+ if (i + BATCH_SIZE < files.length) {
389
+ console.log(`⏱️ Waiting ${BATCH_DELAY}ms before next batch...`);
390
+ await new Promise(resolve => setTimeout(resolve, BATCH_DELAY));
391
+ }
392
+ }
393
+
394
+ // Single consolidated audit entry for all file operations
395
+ try {
396
+ const endTime = Date.now();
397
+ const successCount = deletedFiles.length;
398
+ const failureCount = failedFiles.length;
399
+
400
+ await auditService.logEvent({
401
+ userId: user.uid,
402
+ userEmail: user.email || '',
403
+ action: 'file-delete',
404
+ result: failureCount === 0 ? 'success' : 'failure',
405
+ fileName: `Bulk deletion: ${successCount} succeeded, ${failureCount} failed`,
406
+ fileType: 'case-package',
407
+ caseNumber,
408
+ caseDetails: {
409
+ newCaseName: `${caseNumber} - Bulk file deletion`,
410
+ deleteReason: `Case deletion: processed ${files.length} files (${successCount} deleted, ${failureCount} failed)`,
411
+ backupCreated: false,
412
+ lastModified: new Date().toISOString()
413
+ },
414
+ performanceMetrics: {
415
+ processingTimeMs: endTime - startTime,
416
+ fileSizeBytes: deletedFiles.reduce((total, file) => total + file.fileSize, 0)
417
+ },
418
+ // Include details of failed files if any
419
+ ...(failedFiles.length > 0 && {
420
+ validationErrors: failedFiles.map(f => `${f.originalFilename}: ${f.error}`)
421
+ })
422
+ });
423
+
424
+ console.log(`✅ Batch deletion complete: ${successCount} files deleted, ${failureCount} failed`);
425
+ } catch (auditError) {
426
+ console.error('⚠️ Failed to log batch file deletion (continuing with case deletion):', auditError);
427
+ }
428
+
429
+ if (failedFiles.length > 0) {
430
+ throw new Error(
431
+ `Case deletion aborted: failed to delete ${failedFiles.length} file(s): ${failedFiles.map(f => f.originalFilename).join(', ')}`
432
+ );
433
+ }
434
+
435
+ // Remove case from user data first (so user loses access immediately)
436
+ await removeUserCase(user, caseNumber);
437
+
438
+ // Delete case data using centralized function (skip validation since user no longer has access)
439
+ await deleteCaseData(user, caseNumber, { skipValidation: true });
440
+
441
+ // Clean up confirmation status metadata for this case
442
+ try {
443
+ await removeCaseConfirmationSummary(user, caseNumber);
444
+ } catch (summaryError) {
445
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
446
+ }
447
+
448
+ // Add a small delay before audit logging to reduce rate limiting
449
+ await new Promise(resolve => setTimeout(resolve, 100));
450
+
451
+ // Log successful case deletion with file details
452
+ const endTime = Date.now();
453
+ await auditService.logCaseDeletion(
454
+ user,
455
+ caseNumber,
456
+ caseName,
457
+ `User-requested deletion via case actions (${fileCount} files deleted)` +
458
+ (missingImages.length > 0 ? `; ${missingImages.length} image(s) were already missing` : ''),
459
+ false // No backup created for standard deletions
460
+ );
461
+
462
+ console.log(`✅ Case deleted: ${caseNumber} (${fileCount} files) (${endTime - startTime}ms)`);
463
+ return { missingImages };
464
+ }
465
+
466
+ // Remove case from user data first (so user loses access immediately)
467
+ await removeUserCase(user, caseNumber);
468
+
469
+ // Delete case data using centralized function (skip validation since user no longer has access)
470
+ await deleteCaseData(user, caseNumber, { skipValidation: true });
471
+
472
+ // Clean up confirmation status metadata for this case
473
+ try {
474
+ await removeCaseConfirmationSummary(user, caseNumber);
475
+ } catch (summaryError) {
476
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
477
+ }
478
+
479
+ // Add a small delay before audit logging to reduce rate limiting
480
+ await new Promise(resolve => setTimeout(resolve, 100));
481
+
482
+ // Log successful case deletion with file details
483
+ const endTime = Date.now();
484
+ await auditService.logCaseDeletion(
485
+ user,
486
+ caseNumber,
487
+ caseName,
488
+ `User-requested deletion via case actions (${fileCount} files deleted)`,
489
+ false // No backup created for standard deletions
490
+ );
491
+
492
+ console.log(`✅ Case deleted: ${caseNumber} (${fileCount} files) (${endTime - startTime}ms)`);
493
+ return { missingImages: [] };
494
+
495
+ } catch (error) {
496
+ // Log failed case deletion
497
+ const endTime = Date.now();
498
+ try {
499
+ await auditService.logEvent({
500
+ userId: user.uid,
501
+ userEmail: user.email || '',
502
+ action: 'case-delete',
503
+ result: 'failure',
504
+ fileName: `${caseNumber}.case`,
505
+ fileType: 'case-package',
506
+ validationErrors: [error instanceof Error ? error.message : 'Unknown error'],
507
+ caseNumber,
508
+ caseDetails: {
509
+ newCaseName: caseNumber,
510
+ deleteReason: 'Failed deletion attempt',
511
+ backupCreated: false,
512
+ lastModified: new Date().toISOString()
513
+ },
514
+ performanceMetrics: {
515
+ processingTimeMs: endTime - startTime,
516
+ fileSizeBytes: 0
517
+ }
518
+ });
519
+ } catch (auditError) {
520
+ console.error('Failed to log case deletion failure:', auditError);
521
+ }
522
+
523
+ console.error('Error deleting case:', error);
524
+ throw error;
525
+ }
526
+ };
527
+
528
+ export const archiveCase = async (
529
+ user: User,
530
+ caseNumber: string,
531
+ archiveReason?: string
532
+ ): Promise<void> => {
533
+ const startTime = Date.now();
534
+
535
+ try {
536
+ if (!validateCaseNumber(caseNumber)) {
537
+ throw new Error('Invalid case number');
538
+ }
539
+
540
+ const sessionValidation = await validateUserSession(user);
541
+ if (!sessionValidation.valid) {
542
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
543
+ }
544
+
545
+ const caseData = await getCaseData(user, caseNumber);
546
+ if (!caseData) {
547
+ throw new Error('Case not found');
548
+ }
549
+
550
+ if (caseData.archived) {
551
+ throw new Error('This case is already archived.');
552
+ }
553
+
554
+ const archivedAt = new Date().toISOString();
555
+ let archivedByDisplay = user.uid;
556
+
557
+ try {
558
+ const userData = await getUserData(user);
559
+ const fullName = [userData?.firstName?.trim(), userData?.lastName?.trim()]
560
+ .filter(Boolean)
561
+ .join(' ')
562
+ .trim();
563
+ const badgeId = userData?.badgeId?.trim();
564
+
565
+ if (fullName && badgeId) {
566
+ archivedByDisplay = `${fullName}, ${badgeId}`;
567
+ } else if (fullName) {
568
+ archivedByDisplay = fullName;
569
+ } else if (badgeId) {
570
+ archivedByDisplay = badgeId;
571
+ }
572
+ } catch (userDataError) {
573
+ console.warn('Failed to resolve user profile details for archive display value:', userDataError);
574
+ }
575
+
576
+ const archiveData: CaseData = {
577
+ ...caseData,
578
+ archived: true,
579
+ archivedAt,
580
+ archivedBy: user.uid,
581
+ archivedByDisplay,
582
+ archiveReason: archiveReason?.trim() || undefined,
583
+ isReadOnly: false,
584
+ } as CaseData;
585
+
586
+ const exportData = await exportCaseData(user, caseNumber, { includeMetadata: true });
587
+ const archivedExportData: CaseExportData = {
588
+ ...exportData,
589
+ metadata: {
590
+ ...exportData.metadata,
591
+ archived: true,
592
+ archivedAt,
593
+ archivedBy: user.uid,
594
+ archivedByDisplay,
595
+ archiveReason: archiveReason?.trim() || undefined,
596
+ },
597
+ };
598
+ const caseJsonContent = JSON.stringify(archivedExportData, null, 2);
599
+ const archiveAuditEntry: ValidationAuditEntry = {
600
+ timestamp: archivedAt,
601
+ userId: user.uid,
602
+ userEmail: user.email || '',
603
+ action: 'case-archive',
604
+ result: 'success',
605
+ details: {
606
+ fileName: `${caseNumber}.case`,
607
+ fileType: 'case-package',
608
+ validationErrors: [],
609
+ caseNumber,
610
+ workflowPhase: 'casework',
611
+ caseDetails: {
612
+ newCaseName: caseNumber,
613
+ archiveReason: archiveReason?.trim() || 'No reason provided',
614
+ totalFiles: archiveData.files?.length || 0,
615
+ lastModified: archivedAt,
616
+ },
617
+ performanceMetrics: {
618
+ processingTimeMs: Date.now() - startTime,
619
+ fileSizeBytes: 0,
620
+ },
621
+ },
622
+ };
623
+
624
+ const archivePackage = await buildArchivePackage({
625
+ user,
626
+ caseNumber,
627
+ caseJsonContent,
628
+ files: exportData.files,
629
+ auditConfig: {
630
+ startDate: caseData.createdAt,
631
+ endDate: archivedAt,
632
+ additionalEntries: [archiveAuditEntry],
633
+ },
634
+ readmeConfig: {
635
+ archivedAt,
636
+ archivedByDisplay,
637
+ archiveReason: archiveReason?.trim() || undefined,
638
+ },
639
+ });
640
+
641
+ await updateCaseData(user, caseNumber, archiveData);
642
+
643
+ // Clean up confirmation status metadata for this archived case
644
+ try {
645
+ await removeCaseConfirmationSummary(user, caseNumber);
646
+ } catch (summaryError) {
647
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
648
+ }
649
+
650
+ await auditService.logCaseArchive(
651
+ user,
652
+ caseNumber,
653
+ caseNumber,
654
+ archiveReason?.trim() || 'No reason provided',
655
+ 'success',
656
+ [],
657
+ archiveData.files?.length || 0,
658
+ archivedAt,
659
+ Date.now() - startTime
660
+ );
661
+
662
+ const downloadUrl = URL.createObjectURL(archivePackage.zipBlob);
663
+ const archiveFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}-encrypted.zip`;
664
+ const anchor = document.createElement('a');
665
+ anchor.href = downloadUrl;
666
+ anchor.download = archiveFileName;
667
+ anchor.click();
668
+ URL.revokeObjectURL(downloadUrl);
669
+
670
+ await auditService.logEvent({
671
+ userId: user.uid,
672
+ userEmail: user.email || '',
673
+ action: 'case-export',
674
+ result: 'success',
675
+ fileName: archiveFileName,
676
+ fileType: 'case-package',
677
+ caseNumber,
678
+ workflowPhase: 'case-export',
679
+ caseDetails: {
680
+ newCaseName: caseNumber,
681
+ totalFiles: exportData.files?.length || 0,
682
+ totalAnnotations: exportData.summary?.totalBoxAnnotations || 0,
683
+ lastModified: archivedAt,
684
+ },
685
+ securityChecks: {
686
+ selfConfirmationPrevented: true,
687
+ fileIntegrityValid: true,
688
+ manifestSignaturePresent: true,
689
+ manifestSignatureValid: true,
690
+ manifestSignatureKeyId: archivePackage.manifestSignatureKeyId,
691
+ },
692
+ performanceMetrics: {
693
+ processingTimeMs: Date.now() - startTime,
694
+ fileSizeBytes: archivePackage.zipBlob.size,
695
+ validationStepsCompleted: 4,
696
+ validationStepsFailed: 0,
697
+ },
698
+ });
699
+ } catch (error) {
700
+ await auditService.logCaseArchive(
701
+ user,
702
+ caseNumber,
703
+ caseNumber,
704
+ archiveReason?.trim() || 'No reason provided',
705
+ 'failure',
706
+ [error instanceof Error ? error.message : 'Unknown archive error'],
707
+ undefined,
708
+ undefined,
709
+ Date.now() - startTime
710
+ );
711
+
712
+ throw error;
713
+ }
714
+ };