@striae-org/striae 4.1.0 → 4.2.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.
Files changed (124) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +1 -1
  3. package/app/components/actions/case-export/core-export.ts +14 -8
  4. package/app/components/actions/case-export/data-processing.ts +1 -0
  5. package/app/components/actions/case-export/download-handlers.ts +7 -0
  6. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  7. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  8. package/app/components/actions/case-import/orchestrator.ts +78 -32
  9. package/app/components/actions/case-import/storage-operations.ts +97 -8
  10. package/app/components/actions/case-import/zip-processing.ts +159 -86
  11. package/app/components/actions/case-manage.ts +463 -8
  12. package/app/components/actions/confirm-export.ts +9 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +19 -8
  15. package/app/components/audit/user-audit.module.css +21 -0
  16. package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
  17. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  18. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  19. package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
  20. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  21. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  22. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  23. package/app/components/canvas/canvas.module.css +64 -54
  24. package/app/components/canvas/canvas.tsx +14 -16
  25. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  26. package/app/components/canvas/confirmation/confirmation.tsx +12 -14
  27. package/app/components/colors/colors.module.css +4 -3
  28. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  29. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  30. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  31. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  32. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  33. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  34. package/app/components/navbar/navbar.module.css +447 -0
  35. package/app/components/navbar/navbar.tsx +402 -0
  36. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  37. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  38. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  39. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  40. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  41. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  42. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  43. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  44. package/app/components/sidebar/cases/case-sidebar.tsx +68 -588
  45. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  46. package/app/components/sidebar/cases/cases-modal.tsx +82 -43
  47. package/app/components/sidebar/cases/cases.module.css +82 -21
  48. package/app/components/sidebar/files/files-modal.module.css +1 -0
  49. package/app/components/sidebar/files/files-modal.tsx +49 -52
  50. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  51. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
  52. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  53. package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
  54. package/app/components/sidebar/notes/notes.module.css +170 -1
  55. package/app/components/sidebar/sidebar-container.tsx +16 -28
  56. package/app/components/sidebar/sidebar.module.css +5 -69
  57. package/app/components/sidebar/sidebar.tsx +27 -125
  58. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  59. package/app/components/user/inactivity-warning.module.css +1 -0
  60. package/app/components/user/inactivity-warning.tsx +15 -2
  61. package/app/components/user/manage-profile.tsx +23 -10
  62. package/app/{tailwind.css → global.css} +1 -3
  63. package/app/hooks/useOverlayDismiss.ts +54 -4
  64. package/app/root.tsx +1 -1
  65. package/app/routes/auth/login.tsx +785 -774
  66. package/app/routes/striae/striae.module.css +10 -3
  67. package/app/routes/striae/striae.tsx +475 -30
  68. package/app/services/audit/audit.service.ts +173 -27
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  70. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  71. package/app/services/audit/builders/index.ts +1 -0
  72. package/app/types/audit.ts +4 -1
  73. package/app/types/case.ts +29 -0
  74. package/app/types/import.ts +3 -0
  75. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  76. package/app/utils/data/data-operations.ts +17 -861
  77. package/app/utils/data/index.ts +11 -1
  78. package/app/utils/data/operations/batch-operations.ts +113 -0
  79. package/app/utils/data/operations/case-operations.ts +168 -0
  80. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  81. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  82. package/app/utils/data/operations/index.ts +7 -0
  83. package/app/utils/data/operations/signing-operations.ts +225 -0
  84. package/app/utils/data/operations/types.ts +42 -0
  85. package/app/utils/data/operations/validation-operations.ts +48 -0
  86. package/app/utils/data/permissions.ts +16 -1
  87. package/app/utils/forensics/audit-export-signature.ts +5 -1
  88. package/app/utils/forensics/confirmation-signature.ts +3 -0
  89. package/app/utils/forensics/export-verification.ts +426 -22
  90. package/functions/api/_shared/firebase-auth.ts +2 -7
  91. package/functions/api/image/[[path]].ts +20 -23
  92. package/functions/api/pdf/[[path]].ts +27 -8
  93. package/package.json +7 -12
  94. package/scripts/deploy-primershear-emails.sh +2 -1
  95. package/worker-configuration.d.ts +3 -3
  96. package/workers/audit-worker/package.json +1 -1
  97. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  98. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  99. package/workers/data-worker/package.json +1 -1
  100. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  101. package/workers/data-worker/wrangler.jsonc.example +1 -1
  102. package/workers/image-worker/package.json +1 -1
  103. package/workers/image-worker/src/image-worker.example.ts +16 -5
  104. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  105. package/workers/image-worker/wrangler.jsonc.example +1 -1
  106. package/workers/keys-worker/package.json +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/package.json +1 -1
  110. package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
  111. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  112. package/workers/pdf-worker/src/report-types.ts +3 -3
  113. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  114. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  115. package/workers/user-worker/package.json +1 -1
  116. package/workers/user-worker/src/user-worker.example.ts +17 -0
  117. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  118. package/workers/user-worker/wrangler.jsonc.example +1 -1
  119. package/wrangler.toml.example +1 -1
  120. package/NOTICE +0 -13
  121. package/app/components/sidebar/notes/notes-modal.tsx +0 -53
  122. package/postcss.config.js +0 -6
  123. package/public/.well-known/keybase.txt +0 -56
  124. package/tailwind.config.ts +0 -22
@@ -2,6 +2,7 @@ import type { User } from 'firebase/auth';
2
2
  import {
3
3
  canCreateCase,
4
4
  getUserCases,
5
+ getUserData,
5
6
  validateUserSession,
6
7
  addUserCase,
7
8
  removeUserCase,
@@ -9,39 +10,101 @@ import {
9
10
  updateCaseData,
10
11
  deleteCaseData,
11
12
  duplicateCaseData,
12
- deleteFileAnnotations
13
+ deleteFileAnnotations,
14
+ signForensicManifest,
15
+ removeCaseConfirmationSummary
13
16
  } from '~/utils/data';
14
- import { type CaseData, type ReadOnlyCaseData, type FileData } from '~/types';
17
+ import { type CaseData, type ReadOnlyCaseData, type FileData, type AuditTrail, type CaseExportData } from '~/types';
15
18
  import { auditService } from '~/services/audit';
16
19
  import { fetchImageApi } from '~/utils/api';
20
+ import { exportCaseData, formatDateForFilename } from '~/components/actions/case-export';
21
+ import { getImageUrl } from './image-manage';
22
+ import {
23
+ calculateSHA256Secure,
24
+ createPublicSigningKeyFileName,
25
+ generateForensicManifestSecure,
26
+ getCurrentPublicSigningKeyDetails,
27
+ getVerificationPublicKey,
28
+ } from '~/utils/forensics';
29
+ import { signAuditExport } from '~/services/audit/audit-export-signing';
30
+ import { generateAuditSummary } from '~/services/audit/audit-query-helpers';
17
31
 
18
32
  /**
19
33
  * Delete a file without individual audit logging (for bulk operations)
20
34
  * This reduces API calls during bulk deletions
21
35
  */
22
- const deleteFileWithoutAudit = async (user: User, caseNumber: string, fileId: string): Promise<void> => {
36
+ interface DeleteFileWithoutAuditOptions {
37
+ skipCaseDataUpdate?: boolean;
38
+ skipValidation?: boolean;
39
+ }
40
+
41
+ interface DeleteFileWithoutAuditResult {
42
+ imageMissing: boolean;
43
+ fileName: string;
44
+ }
45
+
46
+ export interface DeleteCaseResult {
47
+ missingImages: string[];
48
+ }
49
+
50
+ function generateArchiveImageFilename(originalFilename: string, id: string): string {
51
+ const lastDotIndex = originalFilename.lastIndexOf('.');
52
+
53
+ if (lastDotIndex === -1) {
54
+ return `${originalFilename}-${id}`;
55
+ }
56
+
57
+ const basename = originalFilename.substring(0, lastDotIndex);
58
+ const extension = originalFilename.substring(lastDotIndex);
59
+
60
+ return `${basename}-${id}${extension}`;
61
+ }
62
+
63
+ const deleteFileWithoutAudit = async (
64
+ user: User,
65
+ caseNumber: string,
66
+ fileId: string,
67
+ options: DeleteFileWithoutAuditOptions = {}
68
+ ): Promise<DeleteFileWithoutAuditResult> => {
23
69
  // Get the case data to find file info
24
- const caseData = await getCaseData(user, caseNumber);
70
+ const caseData = await getCaseData(user, caseNumber, {
71
+ skipValidation: options.skipValidation === true
72
+ });
25
73
  if (!caseData) {
26
74
  throw new Error('Case not found');
27
75
  }
28
-
76
+
29
77
  const fileToDelete = (caseData.files || []).find((f: FileData) => f.id === fileId);
30
78
  if (!fileToDelete) {
31
79
  throw new Error('File not found in case');
32
80
  }
33
81
 
82
+ let imageMissing = false;
83
+
34
84
  // Delete image file and fail fast on non-404 failures so case deletion can be retried safely
35
85
  const imageResponse = await fetchImageApi(user, `/${encodeURIComponent(fileId)}`, {
36
86
  method: 'DELETE'
37
87
  });
38
88
 
89
+ if (!imageResponse.ok && imageResponse.status === 404) {
90
+ imageMissing = true;
91
+ }
92
+
39
93
  if (!imageResponse.ok && imageResponse.status !== 404) {
40
94
  throw new Error(`Failed to delete image: ${imageResponse.status} ${imageResponse.statusText}`);
41
95
  }
42
96
 
43
97
  // Delete annotation data (404s are handled by deleteFileAnnotations)
44
- await deleteFileAnnotations(user, caseNumber, fileId);
98
+ await deleteFileAnnotations(user, caseNumber, fileId, {
99
+ skipValidation: options.skipValidation === true
100
+ });
101
+
102
+ if (options.skipCaseDataUpdate === true) {
103
+ return {
104
+ imageMissing,
105
+ fileName: fileToDelete.originalFilename
106
+ };
107
+ }
45
108
 
46
109
  // Update case data to remove file reference
47
110
  const updatedData: CaseData = {
@@ -50,6 +113,11 @@ const deleteFileWithoutAudit = async (user: User, caseNumber: string, fileId: st
50
113
  };
51
114
 
52
115
  await updateCaseData(user, caseNumber, updatedData);
116
+
117
+ return {
118
+ imageMissing,
119
+ fileName: fileToDelete.originalFilename
120
+ };
53
121
  };
54
122
 
55
123
  const CASE_NUMBER_REGEX = /^[A-Za-z0-9-]+$/;
@@ -144,6 +212,11 @@ export const checkCaseIsReadOnly = async (user: User, caseNumber: string): Promi
144
212
  return false;
145
213
  }
146
214
 
215
+ // Archived cases are always treated as read-only.
216
+ if (caseData.archived) {
217
+ return true;
218
+ }
219
+
147
220
  // Use type guard to check for isReadOnly property safely
148
221
  return isReadOnlyCaseData(caseData) ? !!caseData.isReadOnly : false;
149
222
 
@@ -153,6 +226,34 @@ export const checkCaseIsReadOnly = async (user: User, caseNumber: string): Promi
153
226
  }
154
227
  };
155
228
 
229
+ export interface CaseArchiveDetails {
230
+ archived: boolean;
231
+ archivedAt?: string;
232
+ archivedBy?: string;
233
+ archivedByDisplay?: string;
234
+ archiveReason?: string;
235
+ }
236
+
237
+ export const getCaseArchiveDetails = async (user: User, caseNumber: string): Promise<CaseArchiveDetails> => {
238
+ try {
239
+ const caseData = await getCaseData(user, caseNumber);
240
+ if (!caseData || !caseData.archived) {
241
+ return { archived: false };
242
+ }
243
+
244
+ return {
245
+ archived: true,
246
+ archivedAt: caseData.archivedAt,
247
+ archivedBy: caseData.archivedBy,
248
+ archivedByDisplay: caseData.archivedByDisplay,
249
+ archiveReason: caseData.archiveReason,
250
+ };
251
+ } catch (error) {
252
+ console.error('Error checking case archive details:', error);
253
+ return { archived: false };
254
+ }
255
+ };
256
+
156
257
  export const createNewCase = async (user: User, caseNumber: string): Promise<CaseData> => {
157
258
  const startTime = Date.now();
158
259
 
@@ -339,7 +440,7 @@ export const renameCase = async (
339
440
  }
340
441
  };
341
442
 
342
- export const deleteCase = async (user: User, caseNumber: string): Promise<void> => {
443
+ export const deleteCase = async (user: User, caseNumber: string): Promise<DeleteCaseResult> => {
343
444
  const startTime = Date.now();
344
445
 
345
446
  try {
@@ -370,6 +471,7 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
370
471
  const files = caseData.files;
371
472
  const deletedFiles: Array<{id: string, originalFilename: string, fileSize: number}> = [];
372
473
  const failedFiles: Array<{id: string, originalFilename: string, error: string}> = [];
474
+ const missingImages: string[] = [];
373
475
 
374
476
  console.log(`🗑️ Deleting ${files.length} files in batches of ${BATCH_SIZE}...`);
375
477
 
@@ -387,7 +489,16 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
387
489
  try {
388
490
  // Delete file without individual audit logging to reduce API calls
389
491
  // We'll do bulk audit logging at the end
390
- await deleteFileWithoutAudit(user, caseNumber, file.id);
492
+ const deleteResult = await deleteFileWithoutAudit(user, caseNumber, file.id, {
493
+ // Archived cases are immutable; during deletion we can skip per-file case-data mutations.
494
+ skipCaseDataUpdate: !!caseData.archived,
495
+ skipValidation: !!caseData.archived
496
+ });
497
+
498
+ if (deleteResult.imageMissing) {
499
+ missingImages.push(deleteResult.fileName);
500
+ }
501
+
391
502
  deletedFiles.push({
392
503
  id: file.id,
393
504
  originalFilename: file.originalFilename,
@@ -452,6 +563,36 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
452
563
  `Case deletion aborted: failed to delete ${failedFiles.length} file(s): ${failedFiles.map(f => f.originalFilename).join(', ')}`
453
564
  );
454
565
  }
566
+
567
+ // Remove case from user data first (so user loses access immediately)
568
+ await removeUserCase(user, caseNumber);
569
+
570
+ // Delete case data using centralized function (skip validation since user no longer has access)
571
+ await deleteCaseData(user, caseNumber, { skipValidation: true });
572
+
573
+ // Clean up confirmation status metadata for this case
574
+ try {
575
+ await removeCaseConfirmationSummary(user, caseNumber);
576
+ } catch (summaryError) {
577
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
578
+ }
579
+
580
+ // Add a small delay before audit logging to reduce rate limiting
581
+ await new Promise(resolve => setTimeout(resolve, 100));
582
+
583
+ // Log successful case deletion with file details
584
+ const endTime = Date.now();
585
+ await auditService.logCaseDeletion(
586
+ user,
587
+ caseNumber,
588
+ caseName,
589
+ `User-requested deletion via case actions (${fileCount} files deleted)` +
590
+ (missingImages.length > 0 ? `; ${missingImages.length} image(s) were already missing` : ''),
591
+ false // No backup created for standard deletions
592
+ );
593
+
594
+ console.log(`✅ Case deleted: ${caseNumber} (${fileCount} files) (${endTime - startTime}ms)`);
595
+ return { missingImages };
455
596
  }
456
597
 
457
598
  // Remove case from user data first (so user loses access immediately)
@@ -460,6 +601,13 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
460
601
  // Delete case data using centralized function (skip validation since user no longer has access)
461
602
  await deleteCaseData(user, caseNumber, { skipValidation: true });
462
603
 
604
+ // Clean up confirmation status metadata for this case
605
+ try {
606
+ await removeCaseConfirmationSummary(user, caseNumber);
607
+ } catch (summaryError) {
608
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
609
+ }
610
+
463
611
  // Add a small delay before audit logging to reduce rate limiting
464
612
  await new Promise(resolve => setTimeout(resolve, 100));
465
613
 
@@ -474,6 +622,7 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
474
622
  );
475
623
 
476
624
  console.log(`✅ Case deleted: ${caseNumber} (${fileCount} files) (${endTime - startTime}ms)`);
625
+ return { missingImages: [] };
477
626
 
478
627
  } catch (error) {
479
628
  // Log failed case deletion
@@ -504,6 +653,312 @@ export const deleteCase = async (user: User, caseNumber: string): Promise<void>
504
653
  }
505
654
 
506
655
  console.error('Error deleting case:', error);
656
+ throw error;
657
+ }
658
+ };
659
+
660
+ const getVerificationPublicSigningKey = (preferredKeyId?: string): { keyId: string | null; publicKeyPem: string } => {
661
+ const preferredKey = preferredKeyId ? getVerificationPublicKey(preferredKeyId) : null;
662
+ const currentDetails = getCurrentPublicSigningKeyDetails();
663
+ const resolvedPem = preferredKey ?? currentDetails.publicKeyPem;
664
+ const resolvedKeyId = preferredKey ? preferredKeyId ?? null : currentDetails.keyId;
665
+
666
+ if (!resolvedPem || resolvedPem.trim().length === 0) {
667
+ throw new Error('No public signing key is configured for archive packaging.');
668
+ }
669
+
670
+ return {
671
+ keyId: resolvedKeyId,
672
+ publicKeyPem: resolvedPem.endsWith('\n') ? resolvedPem : `${resolvedPem}\n`,
673
+ };
674
+ };
675
+
676
+ const fetchImageAsBlob = async (user: User, fileData: FileData, caseNumber: string): Promise<Blob | null> => {
677
+ try {
678
+ const imageUrl = await getImageUrl(user, fileData, caseNumber, 'Archive Package');
679
+
680
+ if (!imageUrl) {
681
+ return null;
682
+ }
683
+
684
+ const response = await fetch(imageUrl);
685
+ if (!response.ok) {
686
+ return null;
687
+ }
688
+
689
+ return await response.blob();
690
+ } catch (error) {
691
+ console.error('Failed to fetch image for archive package:', error);
692
+ return null;
693
+ }
694
+ };
695
+
696
+ export const archiveCase = async (
697
+ user: User,
698
+ caseNumber: string,
699
+ archiveReason?: string
700
+ ): Promise<void> => {
701
+ const startTime = Date.now();
702
+
703
+ try {
704
+ if (!validateCaseNumber(caseNumber)) {
705
+ throw new Error('Invalid case number');
706
+ }
707
+
708
+ const sessionValidation = await validateUserSession(user);
709
+ if (!sessionValidation.valid) {
710
+ throw new Error(`Session validation failed: ${sessionValidation.reason}`);
711
+ }
712
+
713
+ const caseData = await getCaseData(user, caseNumber);
714
+ if (!caseData) {
715
+ throw new Error('Case not found');
716
+ }
717
+
718
+ if (caseData.archived) {
719
+ throw new Error('This case is already archived.');
720
+ }
721
+
722
+ const archivedAt = new Date().toISOString();
723
+ let archivedByDisplay = user.uid;
724
+
725
+ try {
726
+ const userData = await getUserData(user);
727
+ const fullName = [userData?.firstName?.trim(), userData?.lastName?.trim()]
728
+ .filter(Boolean)
729
+ .join(' ')
730
+ .trim();
731
+ const badgeId = userData?.badgeId?.trim();
732
+
733
+ if (fullName && badgeId) {
734
+ archivedByDisplay = `${fullName}, ${badgeId}`;
735
+ } else if (fullName) {
736
+ archivedByDisplay = fullName;
737
+ } else if (badgeId) {
738
+ archivedByDisplay = badgeId;
739
+ }
740
+ } catch (userDataError) {
741
+ console.warn('Failed to resolve user profile details for archive display value:', userDataError);
742
+ }
743
+
744
+ const archiveData: CaseData = {
745
+ ...caseData,
746
+ archived: true,
747
+ archivedAt,
748
+ archivedBy: user.uid,
749
+ archivedByDisplay,
750
+ archiveReason: archiveReason?.trim() || undefined,
751
+ isReadOnly: true,
752
+ } as CaseData;
753
+
754
+ const exportData = await exportCaseData(user, caseNumber, { includeMetadata: true });
755
+ const archivedExportData: CaseExportData = {
756
+ ...exportData,
757
+ metadata: {
758
+ ...exportData.metadata,
759
+ archived: true,
760
+ archivedAt,
761
+ archivedBy: user.uid,
762
+ archivedByDisplay,
763
+ archiveReason: archiveReason?.trim() || undefined,
764
+ },
765
+ };
766
+ const caseJsonContent = JSON.stringify(archivedExportData, null, 2);
767
+
768
+ const JSZip = (await import('jszip')).default;
769
+ const zip = new JSZip();
770
+ zip.file(`${caseNumber}_data.json`, caseJsonContent);
771
+
772
+ const imageFolder = zip.folder('images');
773
+ const imageBlobs: Record<string, Blob> = {};
774
+ if (imageFolder && exportData.files) {
775
+ for (const fileEntry of exportData.files) {
776
+ const imageBlob = await fetchImageAsBlob(user, fileEntry.fileData, caseNumber);
777
+ if (!imageBlob) {
778
+ continue;
779
+ }
780
+
781
+ const exportFileName = generateArchiveImageFilename(
782
+ fileEntry.fileData.originalFilename,
783
+ fileEntry.fileData.id
784
+ );
785
+ imageFolder.file(exportFileName, imageBlob);
786
+ imageBlobs[exportFileName] = imageBlob;
787
+ }
788
+ }
789
+
790
+ const forensicManifest = await generateForensicManifestSecure(caseJsonContent, imageBlobs);
791
+ const manifestSigningResponse = await signForensicManifest(user, caseNumber, forensicManifest);
792
+
793
+ const signingKey = getVerificationPublicSigningKey(manifestSigningResponse.signature.keyId);
794
+ const publicKeyFileName = createPublicSigningKeyFileName(signingKey.keyId);
795
+ zip.file(publicKeyFileName, signingKey.publicKeyPem);
796
+
797
+ zip.file(
798
+ 'FORENSIC_MANIFEST.json',
799
+ JSON.stringify(
800
+ {
801
+ ...forensicManifest,
802
+ manifestVersion: manifestSigningResponse.manifestVersion,
803
+ signature: manifestSigningResponse.signature,
804
+ },
805
+ null,
806
+ 2
807
+ )
808
+ );
809
+
810
+ const auditEntries = await auditService.getAuditEntriesForUser(user.uid, { caseNumber });
811
+ const auditTrail: AuditTrail = {
812
+ caseNumber,
813
+ workflowId: `${caseNumber}-archive-${Date.now()}`,
814
+ entries: auditEntries,
815
+ summary: generateAuditSummary(auditEntries),
816
+ };
817
+
818
+ const auditTrailPayload = {
819
+ metadata: {
820
+ exportTimestamp: new Date().toISOString(),
821
+ exportVersion: '1.0',
822
+ totalEntries: auditTrail.summary.totalEvents,
823
+ application: 'Striae',
824
+ exportType: 'trail' as const,
825
+ scopeType: 'case' as const,
826
+ scopeIdentifier: caseNumber,
827
+ },
828
+ auditTrail,
829
+ };
830
+
831
+ const auditTrailRawContent = JSON.stringify(auditTrailPayload, null, 2);
832
+ const auditTrailHash = await calculateSHA256Secure(auditTrailRawContent);
833
+ const signedAuditExportPayload = await signAuditExport(
834
+ {
835
+ exportFormat: 'json',
836
+ exportType: 'trail',
837
+ generatedAt: auditTrailPayload.metadata.exportTimestamp,
838
+ totalEntries: auditTrail.summary.totalEvents,
839
+ hash: auditTrailHash.toUpperCase(),
840
+ },
841
+ {
842
+ user,
843
+ scopeType: 'case',
844
+ scopeIdentifier: caseNumber,
845
+ caseNumber,
846
+ }
847
+ );
848
+
849
+ const signedAuditTrail = {
850
+ metadata: {
851
+ ...auditTrailPayload.metadata,
852
+ hash: auditTrailHash.toUpperCase(),
853
+ signatureVersion: signedAuditExportPayload.signatureMetadata.signatureVersion,
854
+ signatureMetadata: signedAuditExportPayload.signatureMetadata,
855
+ signature: signedAuditExportPayload.signature,
856
+ },
857
+ auditTrail,
858
+ };
859
+
860
+ zip.file('audit/case-audit-trail.json', JSON.stringify(signedAuditTrail, null, 2));
861
+ zip.file('audit/case-audit-signature.json', JSON.stringify(signedAuditExportPayload, null, 2));
862
+
863
+ zip.file(
864
+ 'README.txt',
865
+ [
866
+ 'Striae Archived Case Package',
867
+ '===========================',
868
+ '',
869
+ `Case Number: ${caseNumber}`,
870
+ `Archived At: ${archivedAt}`,
871
+ `Archived By: ${archivedByDisplay}`,
872
+ `Archive Reason: ${archiveReason?.trim() || 'Not provided'}`,
873
+ '',
874
+ 'Package Contents',
875
+ '- Case data JSON export with all image references',
876
+ '- images/ folder with exported image files',
877
+ '- Full case audit trail export and signed audit metadata',
878
+ '- Forensic manifest with server-side signature',
879
+ `- ${publicKeyFileName} for verification`,
880
+ '',
881
+ 'This package is intended for read-only review and verification workflows.',
882
+ ].join('\n')
883
+ );
884
+
885
+ const zipBlob = await zip.generateAsync({
886
+ type: 'blob',
887
+ compression: 'DEFLATE',
888
+ compressionOptions: { level: 6 },
889
+ });
890
+
891
+ await updateCaseData(user, caseNumber, archiveData);
892
+
893
+ // Clean up confirmation status metadata for this archived case
894
+ try {
895
+ await removeCaseConfirmationSummary(user, caseNumber);
896
+ } catch (summaryError) {
897
+ console.warn(`Failed to remove confirmation summary for case ${caseNumber}:`, summaryError);
898
+ }
899
+
900
+ await auditService.logCaseArchive(
901
+ user,
902
+ caseNumber,
903
+ caseNumber,
904
+ archiveReason?.trim() || 'No reason provided',
905
+ 'success',
906
+ [],
907
+ archiveData.files?.length || 0,
908
+ archivedAt,
909
+ Date.now() - startTime
910
+ );
911
+
912
+ const downloadUrl = URL.createObjectURL(zipBlob);
913
+ const archiveFileName = `striae-case-${caseNumber}-archive-${formatDateForFilename(new Date())}.zip`;
914
+ const anchor = document.createElement('a');
915
+ anchor.href = downloadUrl;
916
+ anchor.download = archiveFileName;
917
+ anchor.click();
918
+ URL.revokeObjectURL(downloadUrl);
919
+
920
+ await auditService.logEvent({
921
+ userId: user.uid,
922
+ userEmail: user.email || '',
923
+ action: 'case-export',
924
+ result: 'success',
925
+ fileName: archiveFileName,
926
+ fileType: 'case-package',
927
+ caseNumber,
928
+ workflowPhase: 'case-export',
929
+ caseDetails: {
930
+ newCaseName: caseNumber,
931
+ totalFiles: exportData.files?.length || 0,
932
+ totalAnnotations: exportData.summary?.totalBoxAnnotations || 0,
933
+ lastModified: archivedAt,
934
+ },
935
+ securityChecks: {
936
+ selfConfirmationPrevented: true,
937
+ fileIntegrityValid: true,
938
+ manifestSignaturePresent: true,
939
+ manifestSignatureValid: true,
940
+ manifestSignatureKeyId: manifestSigningResponse.signature.keyId,
941
+ },
942
+ performanceMetrics: {
943
+ processingTimeMs: Date.now() - startTime,
944
+ fileSizeBytes: zipBlob.size,
945
+ validationStepsCompleted: 4,
946
+ validationStepsFailed: 0,
947
+ },
948
+ });
949
+ } catch (error) {
950
+ await auditService.logCaseArchive(
951
+ user,
952
+ caseNumber,
953
+ caseNumber,
954
+ archiveReason?.trim() || 'No reason provided',
955
+ 'failure',
956
+ [error instanceof Error ? error.message : 'Unknown archive error'],
957
+ undefined,
958
+ undefined,
959
+ Date.now() - startTime
960
+ );
961
+
507
962
  throw error;
508
963
  }
509
964
  };
@@ -202,7 +202,13 @@ export async function exportConfirmationData(
202
202
  }
203
203
 
204
204
  // Get user metadata for export (same as case exports)
205
- let userMetadata = {
205
+ let userMetadata: {
206
+ exportedBy: string;
207
+ exportedByUid: string;
208
+ exportedByName: string;
209
+ exportedByCompany: string;
210
+ exportedByBadgeId?: string;
211
+ } = {
206
212
  exportedBy: user.email || 'Unknown User',
207
213
  exportedByUid: user.uid,
208
214
  exportedByName: user.displayName || 'N/A',
@@ -216,7 +222,8 @@ export async function exportConfirmationData(
216
222
  exportedBy: user.email || 'Unknown User',
217
223
  exportedByUid: userData.uid,
218
224
  exportedByName: `${userData.firstName} ${userData.lastName}`.trim(),
219
- exportedByCompany: userData.company
225
+ exportedByCompany: userData.company,
226
+ ...(userData.badgeId ? { exportedByBadgeId: userData.badgeId } : {})
220
227
  };
221
228
  }
222
229
  } catch (error) {