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